125 lines
5.1 KiB
TypeScript
125 lines
5.1 KiB
TypeScript
import React, { useState } from 'react';
|
|
|
|
interface DataPoint {
|
|
x: number;
|
|
y: number;
|
|
isOutlier?: boolean;
|
|
}
|
|
|
|
const ScatterplotInteractiveWidget: React.FC = () => {
|
|
const [showLine, setShowLine] = useState(false);
|
|
const [showResiduals, setShowResiduals] = useState(false);
|
|
const [hasOutlier, setHasOutlier] = useState(false);
|
|
|
|
// Base Data (approx linear y = 1.5x + 10)
|
|
const basePoints: DataPoint[] = [
|
|
{x: 1, y: 12}, {x: 2, y: 14}, {x: 3, y: 13}, {x: 4, y: 17},
|
|
{x: 5, y: 18}, {x: 6, y: 19}, {x: 7, y: 22}, {x: 8, y: 21}
|
|
];
|
|
|
|
const points: DataPoint[] = hasOutlier
|
|
? [...basePoints, {x: 7, y: 5, isOutlier: true}]
|
|
: basePoints;
|
|
|
|
// Simple Linear Regression Calculation
|
|
const n = points.length;
|
|
const sumX = points.reduce((a, p) => a + p.x, 0);
|
|
const sumY = points.reduce((a, p) => a + p.y, 0);
|
|
const sumXY = points.reduce((a, p) => a + p.x * p.y, 0);
|
|
const sumXX = points.reduce((a, p) => a + p.x * p.x, 0);
|
|
|
|
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
const intercept = (sumY - slope * sumX) / n;
|
|
|
|
const predict = (x: number) => slope * x + intercept;
|
|
|
|
// Scales
|
|
const width = 400;
|
|
const height = 250;
|
|
const maxX = 9;
|
|
const maxY = 25;
|
|
|
|
const toX = (val: number) => (val / maxX) * width;
|
|
const toY = (val: number) => height - (val / maxY) * height;
|
|
|
|
return (
|
|
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
|
|
<div className="flex flex-wrap gap-4 mb-6 justify-center">
|
|
<button
|
|
onClick={() => setShowLine(!showLine)}
|
|
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${showLine ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'}`}
|
|
>
|
|
{showLine ? 'Hide Line' : 'Show Line of Best Fit'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowResiduals(!showResiduals)}
|
|
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${showResiduals ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-600'}`}
|
|
>
|
|
{showResiduals ? 'Hide Residuals' : 'Show Residuals'}
|
|
</button>
|
|
<button
|
|
onClick={() => setHasOutlier(!hasOutlier)}
|
|
className={`px-4 py-2 rounded-full font-bold text-sm transition-all border ${hasOutlier ? 'bg-rose-100 text-rose-700 border-rose-300' : 'bg-white text-slate-600 border-slate-300'}`}
|
|
>
|
|
{hasOutlier ? 'Remove Outlier' : 'Add Outlier'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative border-b border-l border-slate-300 bg-slate-50 rounded-tr-lg mb-4 h-[250px]">
|
|
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
|
|
{/* Line of Best Fit */}
|
|
{showLine && (
|
|
<line
|
|
x1={toX(0)} y1={toY(predict(0))}
|
|
x2={toX(maxX)} y2={toY(predict(maxX))}
|
|
stroke="#4f46e5" strokeWidth="2" strokeDasharray={hasOutlier ? "5,5" : ""}
|
|
/>
|
|
)}
|
|
|
|
{/* Residuals */}
|
|
{showLine && showResiduals && points.map((p, i) => (
|
|
<line
|
|
key={`res-${i}`}
|
|
x1={toX(p.x)} y1={toY(p.y)}
|
|
x2={toX(p.x)} y2={toY(predict(p.x))}
|
|
stroke={p.y > predict(p.x) ? "#10b981" : "#f43f5e"} strokeWidth="1.5" opacity="0.6"
|
|
/>
|
|
))}
|
|
|
|
{/* Points */}
|
|
{points.map((p, i) => (
|
|
<g key={i}>
|
|
<circle
|
|
cx={toX(p.x)} cy={toY(p.y)}
|
|
r={p.isOutlier ? 6 : 4}
|
|
fill={p.isOutlier ? "#f43f5e" : "#475569"}
|
|
stroke="white" strokeWidth="2"
|
|
className="transition-all duration-300"
|
|
/>
|
|
{p.isOutlier && (
|
|
<text x={toX(p.x)+10} y={toY(p.y)} className="text-xs font-bold fill-rose-600">Outlier</text>
|
|
)}
|
|
</g>
|
|
))}
|
|
</svg>
|
|
{/* Axes Labels */}
|
|
<div className="absolute -bottom-6 w-full text-center text-xs font-bold text-slate-400">Variable X</div>
|
|
<div className="absolute -left-8 top-1/2 -rotate-90 text-xs font-bold text-slate-400">Variable Y</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 flex justify-between items-center text-sm">
|
|
<div>
|
|
<span className="font-bold text-slate-500 block text-xs uppercase">Slope (m)</span>
|
|
<span className="font-mono font-bold text-indigo-700 text-lg">{slope.toFixed(2)}</span>
|
|
</div>
|
|
{hasOutlier && (
|
|
<div className="text-rose-600 font-bold bg-rose-50 px-3 py-1 rounded border border-rose-200">
|
|
Outlier pulls the line down!
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ScatterplotInteractiveWidget; |