feat(lessons): add lessons from client db

This commit is contained in:
shafin-r
2026-03-01 20:24:14 +06:00
parent 2eaf77e13c
commit 2a00c44157
152 changed files with 74587 additions and 222 deletions

View File

@ -0,0 +1,87 @@
import React, { useState } from 'react';
const BoxPlotAnatomyWidget: React.FC = () => {
const [q1, setQ1] = useState(20);
const [q3, setQ3] = useState(60);
const [med, setMed] = useState(40);
const min = 10;
const max = 90;
// Enforce constraints
const handleMedChange = (val: number) => {
setMed(Math.max(q1, Math.min(q3, val)));
};
const handleQ1Change = (val: number) => {
const newQ1 = Math.min(val, med);
setQ1(Math.max(min, newQ1));
};
const handleQ3Change = (val: number) => {
const newQ3 = Math.max(val, med);
setQ3(Math.min(max, newQ3));
};
const scale = (val: number) => ((val) / 100) * 100; // 0-100 domain mapped to %
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 select-none">
<div className="relative h-40 mt-8 mb-4">
{/* Axis Line */}
<div className="absolute top-1/2 left-[10%] right-[10%] h-0.5 bg-slate-300 -translate-y-1/2"></div>
{/* Range Line (Whiskers) */}
<div className="absolute top-1/2 bg-slate-800 h-0.5 -translate-y-1/2 transition-all"
style={{ left: `${scale(min)}%`, right: `${100 - scale(max)}%` }}></div>
{/* Endpoints (Min/Max) */}
<div className="absolute top-1/2 h-4 w-0.5 bg-slate-800 -translate-y-1/2" style={{ left: `${scale(min)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-slate-500">Min</span>
</div>
<div className="absolute top-1/2 h-4 w-0.5 bg-slate-800 -translate-y-1/2" style={{ left: `${scale(max)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-slate-500">Max</span>
</div>
{/* The Box */}
<div className="absolute top-1/2 -translate-y-1/2 h-16 bg-amber-100 border-2 border-amber-500 rounded-sm transition-all"
style={{ left: `${scale(q1)}%`, width: `${scale(q3) - scale(q1)}%` }}>
{/* Median Line */}
<div className="absolute top-0 bottom-0 w-1 bg-amber-600 left-[50%] -translate-x-1/2 transition-all"
style={{ left: `${((med - q1) / (q3 - q1)) * 100}%` }}>
</div>
</div>
{/* Labels below for Q1, Med, Q3 */}
<div className="absolute top-[70%] text-xs font-bold text-amber-700 -translate-x-1/2 transition-all" style={{ left: `${scale(q1)}%` }}>Q1</div>
<div className="absolute top-[70%] text-xs font-bold text-amber-900 -translate-x-1/2 transition-all" style={{ left: `${scale(med)}%` }}>Median</div>
<div className="absolute top-[70%] text-xs font-bold text-amber-700 -translate-x-1/2 transition-all" style={{ left: `${scale(q3)}%` }}>Q3</div>
</div>
<div className="grid grid-cols-3 gap-6 mt-8">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Q1 (25th %)</label>
<input type="range" min={min} max={max} value={q1} onChange={e => handleQ1Change(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"/>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Median (50th %)</label>
<input type="range" min={min} max={max} value={med} onChange={e => handleMedChange(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-700"/>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Q3 (75th %)</label>
<input type="range" min={min} max={max} value={q3} onChange={e => handleQ3Change(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-500"/>
</div>
</div>
<div className="mt-6 p-4 bg-amber-50 border border-amber-100 rounded-lg text-center">
<div className="text-sm font-mono text-amber-900">
IQR (Box Width) = <span className="font-bold">{q3 - q1}</span>
</div>
<p className="text-xs text-amber-700/70 mt-1">The middle 50% of data lies inside the box.</p>
</div>
</div>
);
};
export default BoxPlotAnatomyWidget;

View File

@ -0,0 +1,111 @@
import React, { useState } from 'react';
const BoxPlotComparisonWidget: React.FC = () => {
// Box Plot A is fixed
const statsA = { min: 10, q1: 18, med: 24, q3: 30, max: 42 };
// Box Plot B is adjustable
const [shift, setShift] = useState(0); // Shift median
const [spread, setSpread] = useState(1); // Scale spread
const statsB = {
min: 10 + shift - (5 * (spread - 1)), // Just approximating visual expansion
q1: 16 + shift - (2 * (spread - 1)),
med: 26 + shift,
q3: 34 + shift + (2 * (spread - 1)),
max: 38 + shift + (4 * (spread - 1))
};
const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to %
const BoxPlot = ({ stats, color, label }: { stats: any, color: string, label: string }) => {
const leftW = scaleX(stats.min);
const rightW = scaleX(stats.max);
const boxL = scaleX(stats.q1);
const boxR = scaleX(stats.q3);
const med = scaleX(stats.med);
return (
<div className="relative h-16 w-full mb-8 group">
<div className="absolute left-0 top-0 text-xs font-bold text-slate-400">{label}</div>
{/* Main Line (Whisker to Whisker) */}
<div className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }} />
{/* Whiskers */}
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${leftW}%` }} />
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${rightW}%` }} />
{/* Box */}
<div className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{ left: `${boxL}%`, width: `${boxR - boxL}%`, borderColor: 'currentColor' }}>
</div>
{/* Median Line */}
<div className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2" style={{ left: `${med}%` }} />
{/* Labels on Hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none">
Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:{stats.max.toFixed(0)}
</div>
</div>
);
};
const iqrA = statsA.q3 - statsA.q1;
const iqrB = statsB.q3 - statsB.q1;
const rangeA = statsA.max - statsA.min;
const rangeB = statsB.max - statsB.min;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 relative h-48 border-b border-slate-200">
<BoxPlot stats={statsA} color="text-indigo-500" label="Dataset A (Fixed)" />
<BoxPlot stats={statsB} color="text-rose-500" label="Dataset B (Adjustable)" />
{/* Axis */}
<div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2">
<span>0</span><span>10</span><span>20</span><span>30</span><span>40</span><span>50</span><span>60</span>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Center (Median B)</label>
<input type="range" min="-15" max="15" value={shift} onChange={e => setShift(parseInt(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Adjust Spread (IQR B)</label>
<input type="range" min="0.5" max="2" step="0.1" value={spread} onChange={e => setSpread(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">Median Comparison</div>
<div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{statsA.med}</span>
<span className="text-slate-400">{statsA.med > statsB.med ? '>' : statsA.med < statsB.med ? '<' : '='}</span>
<span className="text-rose-600 font-bold">{statsB.med}</span>
</div>
</div>
<div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">IQR Comparison</div>
<div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{iqrA.toFixed(0)}</span>
<span className="text-slate-400">{iqrA > iqrB ? '>' : iqrA < iqrB ? '<' : '='}</span>
<span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span>
</div>
</div>
<div className="col-span-2 text-xs text-slate-500 text-center">
The box length represents the IQR (Middle 50%). The whiskers represent the full Range.
</div>
</div>
</div>
</div>
);
};
export default BoxPlotComparisonWidget;

View File

@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from 'react';
const CircleTheoremsWidget: React.FC = () => {
// C is the point on the major arc
const [angleC, setAngleC] = useState(230); // Position in degrees on the circle
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef(false);
const R = 120;
const center = { x: 200, y: 180 };
// Fixed points A and B at the bottom
const angleA = 330; // 30 deg below x axis
const angleB = 210; // 150 deg below x axis? No, let's place them symmetrically
// Let's place A and B to define a nice arc
// A at -30 deg (330), B at 210 is too far.
// Let's put A at 320 (-40) and B at 220 (-140).
// Wait, standard unit circle angles.
// A at 340 (-20), B at 200. Arc is 140 deg at bottom.
// Major arc is top. C moves on top.
const posA = { x: center.x + R * Math.cos(340 * Math.PI/180), y: center.y - R * Math.sin(340 * Math.PI/180) }; // SVG y inverted logic?
// Let's just use standard math cos/sin and add to center.y
// SVG y is positive down.
const getPos = (deg: number) => ({
x: center.x + R * Math.cos(deg * Math.PI / 180),
y: center.y + R * Math.sin(deg * Math.PI / 180)
});
const A = getPos(30); // Bottom Right
const B = getPos(150); // Bottom Left
// Central angle is 120 degrees (150 - 30).
const centralAngleValue = 120;
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI;
if (deg < 0) deg += 360;
// Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150.
// Bad zone is between 30 and 150 (the minor arc).
// Let's allow C anywhere except the minor arc to avoid crossing lines weirdly.
if (deg > 40 && deg < 140) return; // Simple constraint
setAngleC(deg);
};
const C = getPos(angleC);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<h3 className="font-bold text-slate-700 mb-2">Central vs. Inscribed Angle</h3>
<div className="text-sm text-slate-500 mb-4 text-center max-w-md">
Drag point <strong className="text-emerald-600">C</strong> along the top arc. Notice that the inscribed angle stays constant!
</div>
<svg
ref={svgRef}
width="400"
height="350"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
className="select-none"
>
{/* Circle */}
<circle cx={center.x} cy={center.y} r={R} stroke="#cbd5e1" strokeWidth="2" fill="transparent" />
{/* Central Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`} stroke="#e2e8f0" strokeWidth="2" fill="transparent" strokeDasharray="5,5"/>
{/* Central Angle Wedge */}
{/* 30 to 150 */}
<path d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`} fill="rgba(99, 102, 241, 0.1)" stroke="none" />
<text x={center.x} y={center.y + 40} textAnchor="middle" className="text-sm font-bold fill-indigo-600">{centralAngleValue}°</text>
<text x={center.x} y={center.y + 60} textAnchor="middle" className="text-xs fill-indigo-400 uppercase">Central</text>
{/* Inscribed Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`} stroke="#059669" strokeWidth="3" fill="transparent" strokeLinejoin="round" />
{/* Points */}
<circle cx={center.x} cy={center.y} r="4" fill="#64748b" /> {/* Center */}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">O</text>
<circle cx={A.x} cy={A.y} r="5" fill="#475569" />
<text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">A</text>
<circle cx={B.x} cy={B.y} r="5" fill="#475569" />
<text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">B</text>
{/* Draggable C */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing">
<circle cx={C.x} cy={C.y} r="15" fill="transparent" /> {/* Hit area */}
<circle cx={C.x} cy={C.y} r="8" fill="#059669" stroke="white" strokeWidth="2" className="shadow-lg" />
<text x={C.x} y={C.y - 15} textAnchor="middle" className="text-sm font-bold fill-emerald-700">C</text>
</g>
{/* Inscribed Angle Label */}
{/* Simple approximation for label placement: slightly "in" from C towards center */}
<text x={C.x + (center.x - C.x)*0.2} y={C.y + (center.y - C.y)*0.2 + 5} textAnchor="middle" className="text-lg font-bold fill-emerald-600">
{centralAngleValue / 2}°
</text>
</svg>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center">
<p className="font-mono text-lg text-slate-800">
Inscribed Angle = <span className="text-emerald-600">½</span> × Central Angle
</p>
<p className="font-mono text-md text-slate-600 mt-1">
{centralAngleValue / 2}° = ½ × {centralAngleValue}°
</p>
</div>
</div>
);
};
export default CircleTheoremsWidget;

View File

@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { MousePointerClick } from 'lucide-react';
export type SegmentType = 'ic' | 'dc' | 'modifier' | 'conjunction' | 'punct' | 'subject' | 'verb';
export interface Segment {
text: string;
type: SegmentType;
label?: string;
}
export interface ClauseExample {
title: string;
segments: Segment[];
}
interface ClauseBreakdownWidgetProps {
examples: ClauseExample[];
accentColor?: string;
}
const TYPE_STYLES: Record<SegmentType, { bg: string; text: string; border: string; ring: string }> = {
ic: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', ring: '#93c5fd' },
dc: { bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-300', ring: '#86efac' },
modifier: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', ring: '#fdba74' },
conjunction: { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300', ring: '#c4b5fd' },
subject: { bg: 'bg-sky-100', text: 'text-sky-800', border: 'border-sky-300', ring: '#7dd3fc' },
verb: { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300', ring: '#fda4af' },
punct: { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-300', ring: '#d1d5db' },
};
const TYPE_LABELS: Record<SegmentType, string> = {
ic: 'Independent Clause',
dc: 'Dependent Clause',
modifier: 'Modifier',
conjunction: 'Conjunction',
subject: 'Subject',
verb: 'Verb / Predicate',
punct: 'Punctuation',
};
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
const TAB_ACTIVE: Record<string, string> = {
purple: 'border-b-2 border-purple-600 text-purple-700 bg-white',
teal: 'border-b-2 border-teal-600 text-teal-700 bg-white',
fuchsia: 'border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white',
amber: 'border-b-2 border-amber-600 text-amber-700 bg-white',
};
export default function ClauseBreakdownWidget({ examples, accentColor = 'purple' }: ClauseBreakdownWidgetProps) {
const [activeTab, setActiveTab] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const example = examples[activeTab];
const switchTab = (i: number) => { setActiveTab(i); setSelected(null); };
const selectedSeg = selected !== null ? example.segments[selected] : null;
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
// Unique labeled segment types for the legend
const legendTypes = Array.from(
new Set(example.segments.filter(s => s.label).map(s => s.type))
);
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{examples.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{examples.map((ex, i) => (
<button
key={i}
onClick={() => switchTab(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeTab ? tabActive : 'text-gray-500 hover:text-gray-700'
}`}
>
{ex.title}
</button>
))}
</div>
)}
{examples.length === 1 && (
<div className="px-5 pt-4 pb-1">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{example.title}</p>
</div>
)}
{/* Instruction */}
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<p className="text-xs text-gray-400 italic">Click any colored part to see its grammatical role</p>
</div>
{/* Sentence display */}
<div className="px-5 pt-2 pb-3">
<div className="text-base leading-10 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
{example.segments.map((seg, i) => {
if (!seg.label) {
// Punctuation / unlabeled — plain unstyled text, not clickable
return <span key={i} className="text-gray-700">{seg.text}</span>;
}
const style = TYPE_STYLES[seg.type];
const isSelected = selected === i;
return (
<span
key={i}
onClick={() => setSelected(isSelected ? null : i)}
className={`inline cursor-pointer rounded px-1 py-0.5 mx-0.5 transition-all ${style.bg} ${style.text} ${
isSelected
? `border-2 ${style.border} font-semibold`
: `border ${style.border} hover:opacity-80`
}`}
style={isSelected ? { outline: `2.5px solid ${style.ring}`, outlineOffset: '1px' } : {}}
>
{seg.text}
</span>
);
})}
</div>
{/* Selection indicator */}
{selectedSeg ? (
<div
className={`mt-3 rounded-xl border-2 px-4 py-3 flex items-start gap-3 ${TYPE_STYLES[selectedSeg.type].bg} ${TYPE_STYLES[selectedSeg.type].border}`}
>
<div
className="w-2.5 h-2.5 rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
/>
<div className="flex-1 min-w-0">
<p className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}>
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
</p>
<p className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}>
"{selectedSeg.text.trim()}"
</p>
</div>
</div>
) : (
<p className="mt-2 text-xs text-gray-400 italic px-1">No element selected click a colored span above.</p>
)}
</div>
{/* Legend */}
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
{legendTypes.map(type => {
const style = TYPE_STYLES[type];
return (
<span
key={type}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_STYLES[type].ring }} />
{TYPE_LABELS[type]}
</span>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
import React, { useState } from 'react';
// Example: x^2 + y^2 - 6x + 8y - 11 = 0
// Center (3, -4), Radius 6
// Steps:
// 1. Group: (x^2 - 6x) + (y^2 + 8y) = 11
// 2. Add magic numbers: (x^2 - 6x + 9) + (y^2 + 8y + 16) = 11 + 9 + 16
// 3. Factor: (x - 3)^2 + (y + 4)^2 = 36
const CompletingSquareWidget: React.FC = () => {
const [step, setStep] = useState(0);
const [inputs, setInputs] = useState({
magicX: '',
magicY: '',
factorX: '',
factorY: '',
totalR: ''
});
// Removed explicit generic Record<string, boolean> to prevent parsing error
const [errors, setErrors] = useState<any>({});
const correct = {
magicX: '9', // (-6/2)^2
magicY: '16', // (8/2)^2
factorX: '3', // h
factorY: '4', // -k (actually displayed as + 4)
totalR: '36' // 11 + 9 + 16
};
const handleChange = (field: string, value: string) => {
setInputs(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev: any) => ({ ...prev, [field]: false }));
}
};
const validateStep1 = () => {
const isXCorrect = inputs.magicX === correct.magicX;
const isYCorrect = inputs.magicY === correct.magicY;
setErrors({
magicX: !isXCorrect,
magicY: !isYCorrect
});
if (isXCorrect && isYCorrect) setStep(1);
};
const validateStep2 = () => {
const isFXCorrect = inputs.factorX === correct.factorX;
const isFYCorrect = inputs.factorY === correct.factorY;
const isRCorrect = inputs.totalR === correct.totalR;
setErrors({
factorX: !isFXCorrect,
factorY: !isFYCorrect,
totalR: !isRCorrect
});
if (isFXCorrect && isFYCorrect && isRCorrect) setStep(2);
};
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 w-full max-w-2xl mx-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center">
<span className="bg-indigo-100 text-indigo-700 text-xs px-2 py-1 rounded uppercase tracking-wide mr-2">Interactive</span>
Convert to Standard Form
</h3>
<div className="mb-6 p-4 bg-slate-50 rounded-lg text-center font-mono text-lg text-slate-700">
x² + y² - 6x + 8y - 11 = 0
</div>
<div className="space-y-6">
{/* Step 0: Group and Move */}
<div className={`transition-opacity duration-500 ${step >= 0 ? 'opacity-100' : 'opacity-50'}`}>
<p className="text-sm font-semibold text-slate-500 mb-2">Step 1: Group terms & move constant</p>
<div className="font-mono text-lg flex flex-wrap items-center gap-2">
<span>(x² - 6x + <input
type="text"
placeholder="?"
value={inputs.magicX}
onChange={(e) => handleChange('magicX', e.target.value)}
disabled={step > 0}
className={`w-12 text-center border-b-2 bg-transparent outline-none ${errors.magicX ? 'border-red-500 text-red-600' : 'border-slate-300'}`}
/>)</span>
<span>+</span>
<span>(y² + 8y + <input
type="text"
placeholder="?"
value={inputs.magicY}
onChange={(e) => handleChange('magicY', e.target.value)}
disabled={step > 0}
className={`w-12 text-center border-b-2 bg-transparent outline-none ${errors.magicY ? 'border-red-500 text-red-600' : 'border-slate-300'}`}
/>)</span>
<span>=</span>
<span>11 + <span className="text-indigo-600">{inputs.magicX || '?'}</span> + <span className="text-indigo-600">{inputs.magicY || '?'}</span></span>
</div>
{step === 0 && (
<div className="mt-2 text-xs text-slate-500">
Hint: Take half the coefficient of the linear term (-6 and 8), then square it.
</div>
)}
{step === 0 && (
<button
onClick={validateStep1}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors"
>
Check Magic Numbers
</button>
)}
</div>
{/* Step 2: Factor */}
{step >= 1 && (
<div className="animate-fade-in-up">
<p className="text-sm font-semibold text-slate-500 mb-2">Step 2: Factor & Sum</p>
<div className="font-mono text-lg flex flex-wrap items-center gap-2">
<span>(x - <input
type="text"
value={inputs.factorX}
onChange={(e) => handleChange('factorX', e.target.value)}
disabled={step > 1}
className={`w-10 text-center border-b-2 bg-transparent outline-none ${errors.factorX ? 'border-red-500' : 'border-slate-300'}`}
/>)²</span>
<span>+</span>
<span>(y + <input
type="text"
value={inputs.factorY}
onChange={(e) => handleChange('factorY', e.target.value)}
disabled={step > 1}
className={`w-10 text-center border-b-2 bg-transparent outline-none ${errors.factorY ? 'border-red-500' : 'border-slate-300'}`}
/>)²</span>
<span>=</span>
<input
type="text"
value={inputs.totalR}
onChange={(e) => handleChange('totalR', e.target.value)}
disabled={step > 1}
className={`w-16 text-center border-b-2 bg-transparent outline-none ${errors.totalR ? 'border-red-500' : 'border-slate-300'}`}
/>
</div>
{step === 1 && (
<button
onClick={validateStep2}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm font-medium transition-colors"
>
Check Final Equation
</button>
)}
</div>
)}
{/* Step 3: Success */}
{step === 2 && (
<div className="animate-fade-in-up p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
<p className="font-bold mb-1">🎉 Awesome work!</p>
<p className="text-sm">You've successfully converted the equation. The center is (3, -4) and radius is 6 (36).</p>
</div>
)}
</div>
</div>
);
};
export default CompletingSquareWidget;

View File

@ -0,0 +1,227 @@
import React, { useState } from "react";
const CompositeAreaWidget: React.FC = () => {
const [mode, setMode] = useState<"add" | "subtract">("add");
const [width, setWidth] = useState(10);
const [height, setHeight] = useState(6);
// Scale for display
const scale = 20;
const displayW = width * scale;
const displayH = height * scale;
const radius = width / 2; // Semicircle on top (width is diameter)
const displayR = radius * scale;
// Areas
const rectArea = width * height;
const semiArea = 0.5 * Math.PI * radius * radius;
const totalArea = mode === "add" ? rectArea + semiArea : rectArea - semiArea;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<div className="flex gap-4 mb-8">
<button
onClick={() => setMode("add")}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${
mode === "add"
? "bg-orange-600 text-white shadow-md transform scale-105"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Add Semicircle (Composite)
</button>
<button
onClick={() => setMode("subtract")}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${
mode === "subtract"
? "bg-rose-600 text-white shadow-md transform scale-105"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Subtract Semicircle (Hole)
</button>
</div>
<div
className="relative mb-8 flex items-end justify-center"
style={{ height: "300px", width: "100%" }}
>
<svg
width="400"
height="300"
className="overflow-visible transition-all duration-500"
>
<defs>
<pattern
id="grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#f1f5f9"
strokeWidth="1"
/>
</pattern>
</defs>
<g transform={`translate(${200 - displayW / 2}, ${250})`}>
{/* Rectangle */}
<rect
x="0"
y={-displayH}
width={displayW}
height={displayH}
fill={
mode === "add"
? "rgba(255,237,213, 1)"
: "rgba(254, 226, 226, 1)"
}
stroke={mode === "add" ? "#f97316" : "#e11d48"}
strokeWidth="2"
/>
{mode === "add" && (
// Semicircle on TOP
<path
d={`M 0 ${-displayH} A ${displayR} ${displayR} 0 0 1 ${displayW} ${-displayH} Z`}
fill="rgba(255,237,213, 1)"
stroke="#f97316"
strokeWidth="2"
transform={`translate(0,0)`}
/>
)}
{mode === "add" && (
// Hide the seam line
<line
x1="2"
y1={-displayH}
x2={displayW - 2}
y2={-displayH}
stroke="rgba(255,237,213, 1)"
strokeWidth="4"
/>
)}
{mode === "subtract" && (
// Semicircle Cutting INTO top
<path
d={`M 0 ${-displayH} A ${displayR} ${displayR} 0 0 0 ${displayW} ${-displayH} Z`}
fill="white"
stroke="#e11d48"
strokeWidth="2"
strokeDasharray="4,4"
/>
)}
{/* Labels */}
<text
x={displayW / 2}
y={-displayH / 2}
textAnchor="middle"
className="font-bold fill-slate-500 opacity-50 text-xl"
>
Rect
</text>
{mode === "add" && (
<text
x={displayW / 2}
y={-displayH - displayR / 2}
textAnchor="middle"
className="font-bold fill-orange-600 text-sm"
>
Semicircle
</text>
)}
{mode === "subtract" && (
<text
x={displayW / 2}
y={-displayH + displayR / 2}
textAnchor="middle"
className="font-bold fill-rose-600 text-sm"
>
Hole
</text>
)}
</g>
</svg>
</div>
<div className="grid grid-cols-2 gap-8 w-full max-w-2xl">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">
Width (Diameter)
</label>
<input
type="range"
min="4"
max="14"
step="2"
value={width}
onChange={(e) => setWidth(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">
{width}
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">
Height
</label>
<input
type="range"
min="4"
max="12"
step="1"
value={height}
onChange={(e) => setHeight(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">
{height}
</div>
</div>
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200 w-full max-w-2xl">
<div className="flex justify-between items-center mb-4">
<span className="font-bold text-slate-700">Calculation</span>
<span
className={`px-2 py-1 rounded text-xs font-bold uppercase ${mode === "add" ? "bg-orange-100 text-orange-800" : "bg-rose-100 text-rose-800"}`}
>
{mode === "add" ? "Sum" : "Difference"}
</span>
</div>
<div className="font-mono text-lg space-y-2">
<div className="flex justify-between">
<span className="text-slate-500">Rectangle Area (w×h)</span>
<span>
{width} × {height} = <strong>{rectArea}</strong>
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Semicircle Area (½πr²)</span>
<span>
½ × π × {radius}² <strong>{semiArea.toFixed(1)}</strong>
</span>
</div>
<div className="border-t border-slate-300 my-2 pt-2 flex justify-between font-bold text-xl">
<span>Total Area</span>
<span
className={mode === "add" ? "text-orange-600" : "text-rose-600"}
>
{totalArea.toFixed(1)}
</span>
</div>
</div>
</div>
</div>
);
};
export default CompositeAreaWidget;

View File

@ -0,0 +1,255 @@
import React, { useState } from "react";
const CompositeSolidsWidget: React.FC = () => {
const [isMerged, setIsMerged] = useState(false);
const [w, setW] = useState(60);
const [h, setH] = useState(80);
const [d, setD] = useState(60);
// Surface Area Calcs
const singleSA = 2 * (w * h + w * d + h * d);
const hiddenFaceArea = d * h;
const totalSeparateSA = singleSA * 2;
const mergedSA = totalSeparateSA - 2 * hiddenFaceArea;
// Helper to generate a face style
const getFaceStyle = (
width: number,
height: number,
transform: string,
color: string,
) => ({
width: `${width}px`,
height: `${height}px`,
position: "absolute" as const,
left: "50%",
top: "50%",
marginLeft: `-${width / 2}px`,
marginTop: `-${height / 2}px`,
transform: transform,
backgroundColor: color,
border: "1px solid rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
backfaceVisibility: "hidden" as const, // Hide backfaces for cleaner look if opaque
transition: "all 0.5s",
});
// Prism Component
const Prism = ({
positionX,
baseHue,
highlightSide, // 'left' or 'right' indicates the face to highlight red
}: {
positionX: number;
baseHue: "indigo" | "sky";
highlightSide?: "left" | "right";
}) => {
// Define shades based on hue
// Lighting: Top is lightest, Front is base, Side is darkest
const colors =
baseHue === "indigo"
? { top: "#818cf8", front: "#6366f1", side: "#4f46e5" } // Indigo 400, 500, 600
: { top: "#38bdf8", front: "#0ea5e9", side: "#0284c7" }; // Sky 400, 500, 600
const hiddenColor = "#f43f5e"; // Rose 500
return (
<div
className="absolute top-0 left-0 transition-all duration-700 ease-in-out transform-style-3d"
style={{ transform: `translateX(${positionX}px)` }}
>
{/* Front (w x h) */}
<div
style={getFaceStyle(w, h, `translateZ(${d / 2}px)`, colors.front)}
/>
{/* Back (w x h) - usually hidden but good for completeness */}
<div
style={getFaceStyle(
w,
h,
`rotateY(180deg) translateZ(${d / 2}px)`,
colors.front,
)}
/>
{/* Right (d x h) */}
<div
style={getFaceStyle(
d,
h,
`rotateY(90deg) translateZ(${w / 2}px)`,
highlightSide === "right" ? hiddenColor : colors.side,
)}
>
{highlightSide === "right" && (
<span className="text-white font-bold text-xs rotate-90 tracking-widest">
FACE
</span>
)}
</div>
{/* Left (d x h) */}
<div
style={getFaceStyle(
d,
h,
`rotateY(-90deg) translateZ(${w / 2}px)`,
highlightSide === "left" ? hiddenColor : colors.side,
)}
>
{highlightSide === "left" && (
<span className="text-white font-bold text-xs -rotate-90 tracking-widest">
FACE
</span>
)}
</div>
{/* Top (w x d) */}
<div
style={getFaceStyle(
w,
d,
`rotateX(90deg) translateZ(${h / 2}px)`,
colors.top,
)}
/>
{/* Bottom (w x d) */}
<div
style={getFaceStyle(
w,
d,
`rotateX(-90deg) translateZ(${h / 2}px)`,
colors.side,
)}
/>
</div>
);
};
// Gap Logic
const gap = isMerged ? 0 : 40;
const posLeft = -(w / 2 + gap / 2);
const posRight = w / 2 + gap / 2;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<div className="flex justify-between w-full items-center mb-8">
<h3 className="text-lg font-bold text-slate-800">
The "Hidden Face" Trap
</h3>
<button
onClick={() => setIsMerged(!isMerged)}
className={`px-6 py-2 rounded-full font-bold shadow-sm transition-all text-sm ${isMerged ? "bg-slate-200 text-slate-700 hover:bg-slate-300" : "bg-indigo-600 text-white hover:bg-indigo-700"}`}
>
{isMerged ? "Separate Prisms" : "Glue Together"}
</button>
</div>
{/* 3D Scene */}
<div className="relative h-72 w-full flex items-center justify-center perspective-1000 overflow-visible mb-8">
{/* Container rotated for Isometric-ish view */}
<div
className="relative transform-style-3d transition-transform duration-700"
style={{ transform: "rotateX(-15deg) rotateY(35deg)" }}
>
{/* Left Prism (Indigo) - Right face hidden */}
<Prism positionX={posLeft} baseHue="indigo" highlightSide="right" />
{/* Right Prism (Sky) - Left face hidden */}
<Prism positionX={posRight} baseHue="sky" highlightSide="left" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">
Dimensions
</h4>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Width (w)</span> <span>{w}</span>
</div>
<input
type="range"
min="40"
max="80"
value={w}
onChange={(e) => setW(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Height (h)</span> <span>{h}</span>
</div>
<input
type="range"
min="40"
max="100"
value={h}
onChange={(e) => setH(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
<div>
<div className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Depth (d)</span> <span>{d}</span>
</div>
<input
type="range"
min="40"
max="80"
value={d}
onChange={(e) => setD(parseInt(e.target.value))}
className="w-full h-1 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div
className={`p-5 rounded-xl border transition-colors ${isMerged ? "bg-indigo-50 border-indigo-200" : "bg-slate-50 border-slate-200"}`}
>
<div className="text-xs uppercase font-bold text-slate-500 mb-2">
Total Surface Area
</div>
<div className="text-4xl font-mono font-bold text-slate-800 tracking-tight">
{isMerged ? mergedSA : totalSeparateSA}
</div>
<div className="text-sm mt-2 text-slate-600 font-medium">
{isMerged
? "⬇ Area decreased (Faces Hidden)"
: "Sum of 2 separated prisms"}
</div>
</div>
<div
className={`p-4 rounded-lg border flex justify-between items-center transition-colors ${isMerged ? "bg-rose-50 border-rose-200 opacity-50" : "bg-rose-50 border-rose-200"}`}
>
<span className="text-xs font-bold text-rose-800 uppercase">
Hidden Area Calculation
</span>
<div className="text-right">
<div className="font-mono font-bold text-rose-600 text-lg">
2 × ({d}×{h})
</div>
<div className="text-xs text-rose-700/70 font-bold">
= {2 * hiddenFaceArea} lost
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompositeSolidsWidget;

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react';
const ConfidenceIntervalWidget: React.FC = () => {
const [meanA, setMeanA] = useState(46);
const [moeA, setMoeA] = useState(4);
const [meanB, setMeanB] = useState(52);
const [moeB, setMoeB] = useState(5);
const minA = meanA - moeA;
const maxA = meanA + moeA;
const minB = meanB - moeB;
const maxB = meanB + moeB;
// Overlap Logic
const overlap = Math.max(0, Math.min(maxA, maxB) - Math.max(minA, minB));
const isOverlapping = overlap > 0;
// Visual Scale (Range 30 to 70)
const scale = (val: number) => ((val - 30) / 40) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8 relative h-32 bg-slate-50 rounded-lg border border-slate-100">
{/* Grid lines */}
{[35, 40, 45, 50, 55, 60, 65].map(v => (
<div key={v} className="absolute top-0 bottom-0 border-r border-slate-200 text-xs text-slate-300 pt-2" style={{ left: `${scale(v)}%` }}>
<span className="absolute -bottom-5 -translate-x-1/2">{v}</span>
</div>
))}
{/* Interval A */}
<div className="absolute top-8 h-4 bg-indigo-500/20 border-l-2 border-r-2 border-indigo-500 rounded flex items-center justify-center group"
style={{ left: `${scale(minA)}%`, width: `${scale(maxA) - scale(minA)}%` }}>
<div className="w-1.5 h-1.5 bg-indigo-600 rounded-full"></div> {/* Point Estimate */}
<div className="absolute -top-6 text-xs font-bold text-indigo-600 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
Group A: {minA} to {maxA}
</div>
</div>
{/* Interval B */}
<div className="absolute top-20 h-4 bg-emerald-500/20 border-l-2 border-r-2 border-emerald-500 rounded flex items-center justify-center group"
style={{ left: `${scale(minB)}%`, width: `${scale(maxB) - scale(minB)}%` }}>
<div className="w-1.5 h-1.5 bg-emerald-600 rounded-full"></div>
<div className="absolute -top-6 text-xs font-bold text-emerald-600 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
Group B: {minB} to {maxB}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6">
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-100">
<h4 className="font-bold text-indigo-900 mb-2">Group A</h4>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Mean</span>
<input type="range" min="35" max="65" value={meanA} onChange={e => setMeanA(parseInt(e.target.value))} className="w-24 accent-indigo-600"/>
<span className="font-bold">{meanA}%</span>
</div>
<div className="flex justify-between text-sm">
<span>Margin of Error</span>
<input type="range" min="1" max="10" value={moeA} onChange={e => setMoeA(parseInt(e.target.value))} className="w-24 accent-indigo-600"/>
<span className="font-bold">±{moeA}</span>
</div>
</div>
</div>
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-100">
<h4 className="font-bold text-emerald-900 mb-2">Group B</h4>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Mean</span>
<input type="range" min="35" max="65" value={meanB} onChange={e => setMeanB(parseInt(e.target.value))} className="w-24 accent-emerald-600"/>
<span className="font-bold">{meanB}%</span>
</div>
<div className="flex justify-between text-sm">
<span>Margin of Error</span>
<input type="range" min="1" max="10" value={moeB} onChange={e => setMoeB(parseInt(e.target.value))} className="w-24 accent-emerald-600"/>
<span className="font-bold">±{moeB}</span>
</div>
</div>
</div>
</div>
<div className={`p-4 rounded-xl border-l-4 ${isOverlapping ? 'bg-amber-50 border-amber-400 text-amber-900' : 'bg-green-50 border-green-500 text-green-900'}`}>
<h4 className="font-bold text-lg mb-1">
{isOverlapping ? "⚠️ Conclusion: Inconclusive" : "✅ Conclusion: Strong Evidence"}
</h4>
<p className="text-sm">
{isOverlapping
? `The intervals overlap (between ${Math.max(minA, minB)} and ${Math.min(maxA, maxB)}). We cannot rule out that the true means are equal.`
: "The intervals do not overlap. It is highly likely that there is a real difference between the groups."}
</p>
</div>
</div>
);
};
export default ConfidenceIntervalWidget;

View File

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { CheckCircle2, RotateCcw, ChevronRight } from 'lucide-react';
export interface VocabOption {
id: string;
definition: string;
isCorrect: boolean;
elimReason: string; // why wrong (for eliminated options) or why right (for correct option)
}
export interface VocabExercise {
sentence: string;
word: string; // the target word — will be highlighted
question: string;
options: VocabOption[];
}
interface ContextEliminationWidgetProps {
exercises: VocabExercise[];
accentColor?: string;
}
export default function ContextEliminationWidget({ exercises, accentColor = 'rose' }: ContextEliminationWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [eliminated, setEliminated] = useState<Set<string>>(new Set());
const [revealed, setRevealed] = useState(false);
const [triedCorrect, setTriedCorrect] = useState(false);
const exercise = exercises[activeEx];
const wrongIds = exercise.options.filter(o => !o.isCorrect).map(o => o.id);
const allWrongEliminated = wrongIds.every(id => eliminated.has(id));
const eliminate = (id: string) => {
const opt = exercise.options.find(o => o.id === id)!;
if (opt.isCorrect) {
setTriedCorrect(true);
setTimeout(() => setTriedCorrect(false), 1500);
} else {
const newElim = new Set([...eliminated, id]);
setEliminated(newElim);
if (wrongIds.every(wid => newElim.has(wid))) {
setRevealed(true);
}
}
};
const reset = () => { setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); };
const switchEx = (i: number) => { setActiveEx(i); setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); };
// Highlight the target word in the sentence
const renderSentence = () => {
const idx = exercise.sentence.toLowerCase().indexOf(exercise.word.toLowerCase());
if (idx === -1) return <>{exercise.sentence}</>;
return (
<>
{exercise.sentence.slice(0, idx)}
<mark className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}>
{exercise.sentence.slice(idx, idx + exercise.word.length)}
</mark>
{exercise.sentence.slice(idx + exercise.word.length)}
</>
);
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((_, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeEx
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700'
}`}
>
Word {i + 1}
</button>
))}
</div>
)}
{/* Sentence in context */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}>
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}>Sentence in Context</p>
<p className="text-gray-700 italic leading-relaxed text-sm">{renderSentence()}</p>
</div>
{/* Question + instruction */}
<div className="px-5 pt-4 pb-2">
<p className="font-medium text-gray-800 text-sm mb-1">{exercise.question}</p>
<p className="text-xs text-gray-400 italic">
{revealed
? 'You found it! The correct definition is highlighted.'
: 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'}
</p>
</div>
{/* Tried to eliminate correct option flash */}
{triedCorrect && (
<div className="mx-5 mb-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 font-medium">
Can't eliminate that one — it fits the context too well!
</div>
)}
{/* Options */}
<div className="px-5 py-3 space-y-2">
{exercise.options.map(opt => {
const isElim = eliminated.has(opt.id);
const isAnswer = opt.isCorrect && revealed;
let wrapCls = 'border-gray-200 bg-white';
if (isAnswer) wrapCls = 'border-green-400 bg-green-50';
else if (isElim) wrapCls = 'border-gray-100 bg-gray-50';
return (
<div
key={opt.id}
className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? 'opacity-50' : ''}`}
>
<div className="flex items-start gap-3">
<span className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? 'text-gray-400' : isAnswer ? 'text-green-700' : 'text-gray-500'}`}>
{opt.id}.
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${
isElim ? 'text-gray-400 line-through' :
isAnswer ? 'text-green-800 font-semibold' :
'text-gray-700'
}`}>
{opt.definition}
</p>
{isElim && (
<p className="text-xs text-gray-400 mt-0.5 italic">{opt.elimReason}</p>
)}
{isAnswer && (
<p className="text-xs text-green-700 mt-1">✓ {opt.elimReason}</p>
)}
</div>
<div className="shrink-0">
{isAnswer && <CheckCircle2 className="w-5 h-5 text-green-500" />}
{!isElim && !isAnswer && !revealed && (
<button
onClick={() => eliminate(opt.id)}
className="text-xs font-semibold text-red-500 hover:text-red-700 hover:bg-red-50 px-2.5 py-1 rounded-lg transition-colors border border-red-200 hover:border-red-300"
>
Eliminate ✗
</button>
)}
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-5 flex items-center gap-3">
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors">
<RotateCcw className="w-3.5 h-3.5" /> Reset
</button>
{revealed && activeEx < exercises.length - 1 && (
<button
onClick={() => switchEx(activeEx + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}
>
Next word <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
import React, { useRef, useState, useEffect } from 'react';
import { scaleToSvg, scaleFromSvg, round, calculateDistanceSquared } from '../utils/math';
import { CircleState, Point } from '../types';
interface CoordinatePlaneProps {
circle: CircleState;
point?: Point | null;
onPointClick?: (p: Point) => void;
interactive?: boolean;
showDistance?: boolean;
mode?: 'view' | 'place_point';
}
const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
circle,
point,
onPointClick,
showDistance = false,
mode = 'view'
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
// Viewport settings
const width = 400;
const height = 400;
const range = 10; // -10 to 10
const tickSpacing = 1;
// Scales
const toX = (val: number) => scaleToSvg(val, -range, range, 0, width);
const toY = (val: number) => scaleToSvg(val, range, -range, 0, height); // Inverted Y for SVG
const fromX = (px: number) => scaleFromSvg(px, -range, range, 0, width);
const fromY = (px: number) => scaleFromSvg(px, range, -range, 0, height);
const cx = toX(circle.h);
const cy = toY(circle.k);
// Radius in pixels (assuming uniform aspect ratio)
const rPx = toX(circle.r) - toX(0);
const handleMouseMove = (e: React.MouseEvent) => {
if (mode !== 'place_point' || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const rawX = e.clientX - rect.left;
const rawY = e.clientY - rect.top;
// Snap to nearest 0.5 for cleaner UX
const graphX = Math.round(fromX(rawX) * 2) / 2;
const graphY = Math.round(fromY(rawY) * 2) / 2;
setHoverPoint({ x: graphX, y: graphY });
};
const handleClick = () => {
if (mode === 'place_point' && hoverPoint && onPointClick) {
onPointClick(hoverPoint);
}
};
// Generate grid lines
const ticks = [];
for (let i = -range; i <= range; i += tickSpacing) {
if (i === 0) continue; // Skip axes (drawn separately)
ticks.push(i);
}
const dSquared = point ? calculateDistanceSquared(point.x, point.y, circle.h, circle.k) : 0;
const isInside = dSquared < circle.r * circle.r;
const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01;
const pointColor = isOn ? 'text-yellow-600' : isInside ? 'text-green-600' : 'text-red-600';
const pointFill = isOn ? '#ca8a04' : isInside ? '#16a34a' : '#dc2626';
return (
<div className="flex flex-col items-center">
<div className="relative shadow-lg rounded-xl overflow-hidden bg-white border border-slate-200">
<svg
ref={svgRef}
width={width}
height={height}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverPoint(null)}
onClick={handleClick}
className={`${mode === 'place_point' ? 'cursor-crosshair' : 'cursor-default'}`}
>
{/* Grid Background */}
{ticks.map(t => (
<React.Fragment key={t}>
<line x1={toX(t)} y1={0} x2={toX(t)} y2={height} stroke="#e2e8f0" strokeWidth="1" />
<line x1={0} y1={toY(t)} x2={width} y2={toY(t)} stroke="#e2e8f0" strokeWidth="1" />
</React.Fragment>
))}
{/* Axes */}
<line x1={toX(0)} y1={0} x2={toX(0)} y2={height} stroke="#64748b" strokeWidth="2" />
<line x1={0} y1={toY(0)} x2={width} y2={toY(0)} stroke="#64748b" strokeWidth="2" />
{/* Circle */}
<circle
cx={cx}
cy={cy}
r={Math.abs(rPx)}
fill="rgba(99, 102, 241, 0.1)"
stroke="#4f46e5"
strokeWidth="3"
className="transition-all duration-300 ease-out"
/>
{/* Center Point */}
<circle cx={cx} cy={cy} r={4} fill="#4f46e5" />
<text x={cx + 8} y={cy - 8} fontSize="12" fill="#4f46e5" fontWeight="bold">Center ({circle.h}, {circle.k})</text>
{/* Radius Line (only if distance line is not active to avoid clutter) */}
{!point && (
<line
x1={cx}
y1={cy}
x2={cx + rPx}
y2={cy}
stroke="#4f46e5"
strokeWidth="2"
strokeDasharray="5,5"
/>
)}
{!point && (
<text x={cx + rPx/2} y={cy - 5} fontSize="12" fill="#4f46e5">r = {circle.r}</text>
)}
{/* Placed Point */}
{point && (
<>
<line
x1={cx}
y1={cy}
x2={toX(point.x)}
y2={toY(point.y)}
stroke="#94a3b8"
strokeWidth="2"
strokeDasharray="4,4"
/>
<circle cx={toX(point.x)} cy={toY(point.y)} r={6} fill={pointFill} stroke="white" strokeWidth="2" />
<text x={toX(point.x) + 8} y={toY(point.y) - 8} fontSize="12" fontWeight="bold" fill={pointFill}>
({point.x}, {point.y})
</text>
</>
)}
{/* Hover Ghost Point */}
{mode === 'place_point' && hoverPoint && !point && (
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r={4} fill="rgba(0,0,0,0.3)" />
)}
</svg>
<div className="absolute bottom-2 left-2 text-xs text-slate-400 bg-white/80 px-2 py-1 rounded">
1 unit = {width / (range * 2)}px
</div>
</div>
{/* Info Panel below graph */}
{point && showDistance && (
<div className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${
isOn ? 'border-yellow-500 bg-yellow-50' :
isInside ? 'border-green-500 bg-green-50' :
'border-red-500 bg-red-50'
}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-slate-700">Distance Check:</span>
<span className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${
isOn ? 'bg-yellow-200 text-yellow-800' :
isInside ? 'bg-green-200 text-green-800' :
'bg-red-200 text-red-800'
}`}>
{isOn ? 'On Circle' : isInside ? 'Inside' : 'Outside'}
</span>
</div>
<div className="font-mono text-sm space-y-1">
<p>d² = (x - h)² + (y - k)²</p>
<p>d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²</p>
<p>d² = {round(calculateDistanceSquared(point.x, point.y, circle.h, circle.k))} <span className="mx-2 text-slate-400">vs</span> r² = {circle.r * circle.r}</p>
</div>
</div>
)}
</div>
);
};
export default CoordinatePlane;

View File

@ -0,0 +1,443 @@
import React, { useState } from 'react';
import { CheckCircle2, XCircle, RotateCcw } from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────────────
export type Verdict = 'supported' | 'contradicted' | 'neither';
export interface ChartSeries {
name: string;
data: { label: string; value: number }[];
}
export interface ChartData {
type: 'bar' | 'line';
title: string;
yLabel?: string;
xLabel?: string;
source?: string;
unit?: string; // e.g. '%', '°C', 'min'
series: ChartSeries[];
}
export interface DataClaim {
text: string;
verdict: Verdict;
explanation: string;
}
export interface DataExercise {
title: string;
chart: ChartData;
claims: DataClaim[];
}
// ── Chart palette ──────────────────────────────────────────────────────────
const PALETTE = ['#3b82f6', '#8b5cf6', '#f97316', '#10b981', '#ef4444', '#ec4899'];
// ── BarChart ───────────────────────────────────────────────────────────────
function BarChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null);
const labels = chart.series[0].data.map(d => d.label);
const allValues = chart.series.flatMap(s => s.data.map(d => d.value));
const maxVal = Math.max(...allValues);
// Round up max to nearest 10 for cleaner y-axis
const yMax = Math.ceil(maxVal / 10) * 10;
const yTicks = [0, yMax * 0.25, yMax * 0.5, yMax * 0.75, yMax];
const chartH = 180; // px height of bar area
return (
<div className="px-2">
<p className="text-xs font-semibold text-gray-600 text-center mb-4">{chart.title}</p>
<div className="flex gap-2">
{/* Y-axis */}
<div className="flex flex-col-reverse justify-between items-end pr-1" style={{ height: chartH, minWidth: 32 }}>
{yTicks.map(t => (
<span key={t} className="text-[10px] text-gray-400 leading-none">{t}{chart.unit ?? ''}</span>
))}
</div>
{/* Bar groups */}
<div className="flex-1 flex items-end gap-2 border-b border-l border-gray-300" style={{ height: chartH }}>
{labels.map((label, pi) => (
<div key={pi} className="flex-1 flex flex-col items-center gap-0">
{/* Bar group */}
<div className="w-full flex items-end gap-0.5" style={{ height: chartH - 2 }}>
{chart.series.map((s, si) => {
const val = s.data[pi].value;
const heightPct = (val / yMax) * 100;
const isHov = hovered?.si === si && hovered?.pi === pi;
return (
<div
key={si}
className="relative flex-1 rounded-t-sm transition-all duration-150 cursor-pointer"
style={{
height: `${heightPct}%`,
backgroundColor: isHov
? PALETTE[si % PALETTE.length] + 'dd'
: PALETTE[si % PALETTE.length] + 'cc',
outline: isHov ? `2px solid ${PALETTE[si % PALETTE.length]}` : 'none',
}}
onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)}
>
{/* Value label on hover */}
{isHov && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
>
{val}{chart.unit ?? ''}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{/* X-axis labels */}
<div className="flex gap-2 ml-10 mt-1">
{labels.map((label, i) => (
<div key={i} className="flex-1 text-center text-[10px] text-gray-500 leading-tight">{label}</div>
))}
</div>
{chart.xLabel && <p className="text-[10px] text-gray-400 text-center mt-1">{chart.xLabel}</p>}
{/* Legend */}
{chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-3 justify-center">
{chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} />
{s.name}
</div>
))}
</div>
)}
{/* Hover info bar */}
{hovered && (
<div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}>
{chart.series[hovered.si].name}
</span>
{' — '}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''}
</span>
</div>
)}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>}
</div>
);
}
// ── LineChart ──────────────────────────────────────────────────────────────
function LineChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null);
const W = 480, H = 200;
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const allValues = chart.series.flatMap(s => s.data.map(d => d.value));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const spread = maxVal - minVal || 1;
// Add 10% padding on y-axis
const yPad = spread * 0.15;
const yMin = minVal - yPad;
const yMax = maxVal + yPad;
const yRange = yMax - yMin;
const labels = chart.series[0].data.map(d => d.label);
const xStep = cW / (labels.length - 1);
const xPos = (i: number) => PAD.left + i * xStep;
const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH;
// Y-axis ticks: 5 evenly spaced
const yTicks = Array.from({ length: 5 }, (_, i) => minVal + ((maxVal - minVal) / 4) * i);
return (
<div>
<p className="text-xs font-semibold text-gray-600 text-center mb-2">{chart.title}</p>
<div className="overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ maxHeight: 220 }}>
{/* Grid lines */}
{yTicks.map((t, i) => {
const y = yPos(t);
return (
<g key={i}>
<line x1={PAD.left} x2={W - PAD.right} y1={y} y2={y} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 4} y={y + 3.5} textAnchor="end" fontSize="9" fill="#9ca3af">
{t % 1 === 0 ? t : t.toFixed(2)}{chart.unit ?? ''}
</text>
</g>
);
})}
{/* Lines + dots */}
{chart.series.map((s, si) => {
const color = PALETTE[si % PALETTE.length];
const pts = s.data.map((d, i) => `${xPos(i)},${yPos(d.value)}`).join(' ');
return (
<g key={si}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2.5" strokeLinejoin="round" />
{s.data.map((d, pi) => {
const isHov = hovered?.si === si && hovered?.pi === pi;
const cx = xPos(pi);
const cy = yPos(d.value);
return (
<g key={pi}>
<circle
cx={cx} cy={cy} r={isHov ? 7 : 5}
fill={color} stroke="white" strokeWidth="2"
style={{ cursor: 'pointer', transition: 'r 0.1s' }}
onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)}
/>
{isHov && (
<>
<rect
x={cx - 28} y={cy - 26} width="56" height="18"
rx="4" fill="#1f2937"
/>
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="10" fill="white" fontWeight="bold">
{d.value}{chart.unit ?? ''}
</text>
</>
)}
</g>
);
})}
</g>
);
})}
{/* X-axis labels */}
{labels.map((label, i) => (
<text key={i} x={xPos(i)} y={H - PAD.bottom + 14} textAnchor="middle" fontSize="9.5" fill="#6b7280">
{label}
</text>
))}
{/* Axes */}
<line x1={PAD.left} x2={PAD.left} y1={PAD.top} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" />
<line x1={PAD.left} x2={W - PAD.right} y1={H - PAD.bottom} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" />
{/* Y-axis label */}
{chart.yLabel && (
<text
x={12} y={H / 2}
transform={`rotate(-90, 12, ${H / 2})`}
textAnchor="middle" fontSize="9" fill="#9ca3af"
>
{chart.yLabel}
</text>
)}
</svg>
</div>
{/* Legend */}
{chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-1 justify-center">
{chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600">
<div className="w-5 h-0.5" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} />
{s.name}
</div>
))}
</div>
)}
{/* Hover tooltip */}
{hovered && (
<div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}>
{chart.series[hovered.si].name}
</span>
{' · '}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''}
</span>
</div>
)}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>}
</div>
);
}
// ── Main widget ────────────────────────────────────────────────────────────
const VERDICT_LABELS: Record<Verdict, string> = {
supported: 'Supported by data',
contradicted: 'Contradicted by data',
neither: 'Neither proven nor disproven',
};
interface DataClaimWidgetProps {
exercises: DataExercise[];
accentColor?: string;
}
// Pre-resolved accent classes to avoid Tailwind purge issues
const ACCENT_CLASSES: Record<string, { tab: string; header: string; label: string; btn: string }> = {
amber: { tab: 'border-b-2 border-amber-600 text-amber-700', header: 'bg-amber-50', label: 'text-amber-600', btn: 'bg-amber-600 hover:bg-amber-700' },
teal: { tab: 'border-b-2 border-teal-600 text-teal-700', header: 'bg-teal-50', label: 'text-teal-600', btn: 'bg-teal-600 hover:bg-teal-700' },
purple: { tab: 'border-b-2 border-purple-600 text-purple-700', header: 'bg-purple-50', label: 'text-purple-600', btn: 'bg-purple-600 hover:bg-purple-700' },
fuchsia: { tab: 'border-b-2 border-fuchsia-600 text-fuchsia-700', header: 'bg-fuchsia-50', label: 'text-fuchsia-600', btn: 'bg-fuchsia-600 hover:bg-fuchsia-700' },
};
export default function DataClaimWidget({ exercises, accentColor = 'amber' }: DataClaimWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [answers, setAnswers] = useState<Record<number, Verdict>>({});
const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx];
const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined);
const score = submitted ? exercise.claims.filter((c, i) => answers[i] === c.verdict).length : 0;
const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber;
const reset = () => { setAnswers({}); setSubmitted(false); };
const switchEx = (i: number) => { setActiveEx(i); setAnswers({}); setSubmitted(false); };
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tabs */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((ex, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : 'text-gray-500 hover:text-gray-700'
}`}
>
{ex.title}
</button>
))}
</div>
)}
{/* Chart */}
<div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}>Data Source</p>
{exercise.chart.type === 'bar'
? <BarChart chart={exercise.chart} />
: <LineChart chart={exercise.chart} />
}
</div>
{/* Claims */}
<div className="px-5 py-4">
<p className="text-sm text-gray-600 mb-4">
For each claim, decide if the data{' '}
<strong className="text-green-700">supports</strong>,{' '}
<strong className="text-red-600">contradicts</strong>, or{' '}
<strong className="text-gray-600">neither proves nor disproves</strong> it:
</p>
<div className="space-y-4">
{exercise.claims.map((claim, i) => {
const userAnswer = answers[i];
const isCorrect = submitted && userAnswer === claim.verdict;
const isWrong = submitted && userAnswer !== undefined && userAnswer !== claim.verdict;
return (
<div
key={i}
className={`rounded-xl border p-4 transition-all ${
submitted
? isCorrect ? 'border-green-300 bg-green-50'
: isWrong ? 'border-red-200 bg-red-50'
: 'border-gray-200'
: 'border-gray-200'
}`}
>
<p className="text-sm text-gray-800 mb-3">
<span className="font-bold text-gray-400 mr-2">Claim {i + 1}:</span>
{claim.text}
</p>
<div className="flex flex-wrap gap-2">
{(['supported', 'contradicted', 'neither'] as Verdict[]).map(v => {
const isSelected = userAnswer === v;
const isCorrectOpt = submitted && v === claim.verdict;
let cls = 'border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50';
if (isSelected && !submitted) cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`;
if (submitted) {
if (isCorrectOpt) cls = 'border-green-400 bg-green-100 text-green-800 font-semibold';
else if (isSelected) cls = 'border-red-300 bg-red-100 text-red-700';
else cls = 'border-gray-100 text-gray-400';
}
return (
<button
key={v}
disabled={submitted}
onClick={() => setAnswers(prev => ({ ...prev, [i]: v }))}
className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`}
>
{VERDICT_LABELS[v]}
</button>
);
})}
</div>
{submitted && (
<div className="mt-3 pt-2 border-t border-gray-100 flex gap-2">
{isCorrect
? <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
}
<p className="text-xs text-gray-600 leading-relaxed">
{!isCorrect && (
<span className="font-semibold text-red-700">Answer: {VERDICT_LABELS[claim.verdict]}. </span>
)}
{claim.explanation}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="px-5 pb-5">
{!submitted ? (
<button
disabled={!allAnswered}
onClick={() => setSubmitted(true)}
className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
allAnswered ? c.btn : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Check all answers
</button>
) : (
<div className="flex items-center gap-4">
<p className="text-sm font-semibold text-gray-700">
{score}/{exercise.claims.length} correct
</p>
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors">
<RotateCcw className="w-3.5 h-3.5" /> Try again
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
const DataModifierWidget: React.FC = () => {
const initialData = [10, 12, 13, 15, 16, 18, 20];
const [data, setData] = useState<number[]>(initialData);
const calculateStats = (arr: number[]) => {
const sorted = [...arr].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const mean = sum / sorted.length;
const min = sorted[0];
const max = sorted[sorted.length - 1];
const range = max - min;
// Median
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
// SD (Population)
const variance = sorted.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / sorted.length;
const sd = Math.sqrt(variance);
return { mean, median, range, sd, sorted };
};
const stats = calculateStats(data);
// Operations
const reset = () => setData(initialData);
const addConstant = (k: number) => {
setData(prev => prev.map(n => n + k));
};
const multiplyConstant = (k: number) => {
setData(prev => prev.map(n => n * k));
};
const addOutlier = (val: number) => {
setData(prev => [...prev, val]);
};
// Visual scaling
const minDisplay = Math.min(0, ...data) - 5;
const maxDisplay = Math.max(Math.max(...data), 100) + 10;
const getX = (val: number) => ((val - minDisplay) / (maxDisplay - minDisplay)) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-6">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-3">
<h4 className="font-bold text-slate-700 mb-2">Apply Transformation</h4>
<button onClick={() => addConstant(5)} className="w-full py-2 px-4 bg-amber-100 hover:bg-amber-200 text-amber-900 rounded-lg font-bold text-sm text-left transition-colors">
+ Add 5 (Shift Right)
</button>
<button onClick={() => multiplyConstant(2)} className="w-full py-2 px-4 bg-amber-100 hover:bg-amber-200 text-amber-900 rounded-lg font-bold text-sm text-left transition-colors">
× Multiply by 2 (Scale)
</button>
<button onClick={() => addOutlier(80)} className="w-full py-2 px-4 bg-rose-100 hover:bg-rose-200 text-rose-900 rounded-lg font-bold text-sm text-left transition-colors border border-rose-200">
Add Outlier (80)
</button>
<button onClick={reset} className="w-full py-2 px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg font-bold text-sm text-left transition-colors mt-4">
Reset Data
</button>
</div>
{/* Visualization */}
<div className="flex-1">
{/* Stats Panel */}
<div className="grid grid-cols-4 gap-2 mb-6 text-center">
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Mean</div>
<div className="font-mono font-bold text-lg text-indigo-600">{stats.mean.toFixed(1)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Median</div>
<div className="font-mono font-bold text-lg text-emerald-600">{stats.median.toFixed(1)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">Range</div>
<div className="font-mono font-bold text-lg text-slate-700">{stats.range.toFixed(0)}</div>
</div>
<div className="p-2 bg-slate-50 border border-slate-200 rounded">
<div className="text-xs uppercase font-bold text-slate-500">SD</div>
<div className="font-mono font-bold text-lg text-slate-700">{stats.sd.toFixed(1)}</div>
</div>
</div>
{/* Dot Plot */}
<div className="h-32 relative border-b border-slate-300">
{stats.sorted.map((val, idx) => (
<div
key={idx}
className="absolute w-4 h-4 rounded-full bg-indigo-500 shadow-sm border border-white transform -translate-x-1/2"
style={{ left: `${getX(val)}%`, bottom: '10px' }}
title={`Value: ${val}`}
/>
))}
{/* Mean Marker */}
<div className="absolute top-0 bottom-0 w-0.5 bg-indigo-300 border-l border-dashed border-indigo-500 opacity-60" style={{ left: `${getX(stats.mean)}%` }}>
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-bold text-indigo-600 bg-white px-1 rounded shadow-sm"></span>
</div>
{/* Median Marker */}
<div className="absolute top-0 bottom-0 w-0.5 bg-emerald-300 border-l border-dashed border-emerald-500 opacity-60" style={{ left: `${getX(stats.median)}%` }}>
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-xs font-bold text-emerald-600 bg-white px-1 rounded shadow-sm">M</span>
</div>
</div>
<div className="mt-8 text-sm text-slate-500 leading-relaxed bg-slate-50 p-3 rounded">
{data.length > 7 ? (
<span className="text-rose-600 font-bold">Notice how the Mean is pulled towards the outlier, while the Median barely moves!</span>
) : (
"Experiment with adding constants and multipliers to see which stats change."
)}
</div>
</div>
</div>
</div>
);
};
export default DataModifierWidget;

View File

@ -0,0 +1,235 @@
import React, { useState } from 'react';
import { ChevronRight, RotateCcw, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
export interface TreeNode {
id: string;
question: string;
hint?: string;
yesLabel?: string;
noLabel?: string;
yes?: TreeNode;
no?: TreeNode;
result?: string;
resultType?: 'correct' | 'warning' | 'info';
ruleRef?: string;
}
export interface TreeScenario {
label: string; // Short tab label, e.g. "Sentence 1"
sentence: string; // The sentence to analyze
tree: TreeNode;
}
interface DecisionTreeWidgetProps {
scenarios: TreeScenario[];
accentColor?: string;
}
type Answers = Record<string, 'yes' | 'no'>;
/** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */
function getPath(root: TreeNode, answers: Answers): Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> {
const path: Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> = [];
let current: TreeNode | undefined = root;
while (current) {
const ans = answers[current.id] ?? null;
path.push({ node: current, answer: ans });
if (ans === null) break; // not answered yet — this is the active node
if (current.result !== undefined) break; // leaf
current = ans === 'yes' ? current.yes : current.no;
}
return path;
}
const RESULT_STYLES = {
correct: {
bg: 'bg-green-50',
border: 'border-green-300',
text: 'text-green-800',
icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />,
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-300',
text: 'text-amber-800',
icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />,
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-300',
text: 'text-blue-800',
icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />,
},
};
export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }: DecisionTreeWidgetProps) {
const [activeScenario, setActiveScenario] = useState(0);
const [answers, setAnswers] = useState<Answers>({});
const scenario = scenarios[activeScenario];
const path = getPath(scenario.tree, answers);
const lastStep = path[path.length - 1];
const isLeaf = lastStep.node.result !== undefined;
const isComplete = isLeaf && lastStep.answer === null; // reached leaf, no more choices needed
// Actually leaf nodes don't have yes/no — they just show result when we arrive
const atLeaf = lastStep.node.result !== undefined;
const handleAnswer = (nodeId: string, ans: 'yes' | 'no') => {
setAnswers(prev => {
// Remove all answers for nodes that come AFTER this one in the current path
const pathIds = path.map(p => p.node.id);
const idx = pathIds.indexOf(nodeId);
const newAnswers: Answers = {};
for (let i = 0; i < idx; i++) {
newAnswers[pathIds[i]] = prev[pathIds[i]]!;
}
newAnswers[nodeId] = ans;
return newAnswers;
});
};
const resetScenario = () => setAnswers({});
const switchScenario = (i: number) => {
setActiveScenario(i);
setAnswers({});
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Scenario tab strip */}
{scenarios.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{scenarios.map((sc, i) => (
<button
key={i}
onClick={() => switchScenario(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeScenario
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700'
}`}
>
{sc.label}
</button>
))}
</div>
)}
{/* Sentence under analysis */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}>
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}>Analyze this sentence</p>
<p className="text-gray-800 font-medium italic leading-relaxed">"{scenario.sentence}"</p>
</div>
{/* Breadcrumb path */}
{path.length > 1 && (
<div className="px-5 py-2.5 border-b border-gray-100 bg-gray-50 flex flex-wrap items-center gap-1 text-xs text-gray-500">
{path.map((step, i) => {
if (i === path.length - 1) return null; // last step shown below, not in crumb
const isAnswered = step.answer !== null;
return (
<React.Fragment key={step.node.id}>
<button
onClick={() => {
// Reset from this node forward
const pathIds = path.map(p => p.node.id);
const idx = pathIds.indexOf(step.node.id);
setAnswers(prev => {
const newAnswers: Answers = {};
for (let j = 0; j < idx; j++) newAnswers[pathIds[j]] = prev[pathIds[j]]!;
return newAnswers;
});
}}
className={`px-2 py-0.5 rounded transition-colors ${
isAnswered ? 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : 'text-gray-400'
}`}
>
{step.node.question.length > 40 ? step.node.question.slice(0, 40) + '…' : step.node.question}
{step.answer && (
<span className={`ml-1 font-semibold ${step.answer === 'yes' ? 'text-green-600' : 'text-red-500'}`}>
{step.answer === 'yes' ? (step.node.yesLabel ?? 'Yes') : (step.node.noLabel ?? 'No')}
</span>
)}
</button>
<ChevronRight className="w-3 h-3 shrink-0" />
</React.Fragment>
);
})}
</div>
)}
{/* Active node */}
<div className="px-5 py-5">
{atLeaf ? (
/* Leaf result */
(() => {
const node = lastStep.node;
const rType = node.resultType ?? 'correct';
const s = RESULT_STYLES[rType];
return (
<div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}>
<div className="flex gap-3">
{s.icon}
<div>
<p className={`font-semibold ${s.text} leading-snug`}>{node.result}</p>
{node.ruleRef && (
<p className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}>
{node.ruleRef}
</p>
)}
</div>
</div>
</div>
);
})()
) : (
/* Decision question */
(() => {
const node = lastStep.node;
return (
<div>
<p className="font-semibold text-gray-800 text-base leading-snug mb-1">{node.question}</p>
{node.hint && <p className="text-sm text-gray-500 mb-4">{node.hint}</p>}
{!node.hint && <div className="mb-4" />}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleAnswer(node.id, 'yes')}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors"
>
{node.yesLabel ?? 'Yes'}
</button>
<button
onClick={() => handleAnswer(node.id, 'no')}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors"
>
{node.noLabel ?? 'No'}
</button>
</div>
</div>
);
})()
)}
</div>
{/* Footer */}
<div className="px-5 pb-4 flex items-center gap-3">
<button
onClick={resetScenario}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Try again
</button>
{atLeaf && scenarios.length > 1 && activeScenario < scenarios.length - 1 && (
<button
onClick={() => switchScenario(activeScenario + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}
>
Next sentence <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
const DiscriminantWidget: React.FC = () => {
const [a, setA] = useState(1);
const [b, setB] = useState(-4);
const [c, setC] = useState(3); // Default x^2 - 4x + 3 (Roots 1, 3)
const discriminant = b*b - 4*a*c;
const rootsCount = discriminant > 0 ? 2 : discriminant === 0 ? 1 : 0;
// Viewport
const range = 10;
const size = 300;
const scale = size / (range * 2);
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const generatePath = () => {
let dStr = "";
for (let x = -range; x <= range; x += 0.5) {
const y = a * x*x + b*x + c;
if (Math.abs(y) > range) continue;
const px = toPx(x);
const py = toPx(y, true);
dStr += dStr ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return dStr;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="font-mono text-lg font-bold text-slate-800">
Δ = b² - 4ac = <span className={discriminant > 0 ? "text-green-600" : discriminant < 0 ? "text-rose-600" : "text-amber-600"}>{discriminant}</span>
</div>
<div className={`px-3 py-1 rounded text-sm font-bold uppercase text-white ${discriminant > 0 ? "bg-green-500" : discriminant < 0 ? "bg-rose-500" : "bg-amber-500"}`}>
{rootsCount} Real Solution{rootsCount !== 1 ? 's' : ''}
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-4">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">a = {a}</label>
<input type="range" min="-3" max="3" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value) || 0.1)} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">b = {b}</label>
<input type="range" min="-10" max="10" step="1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">c = {c}</label>
<input type="range" min="-10" max="10" step="1" value={c} onChange={e => setC(parseFloat(e.target.value))} className="w-full h-1 bg-slate-200 rounded accent-slate-600"/>
</div>
<div className="text-xs text-slate-500 mt-4">
<p>If Δ &gt; 0: Crosses axis twice</p>
<p>If Δ = 0: Touches axis once (Vertex on axis)</p>
<p>If Δ &lt; 0: Never touches axis</p>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[200px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" preserveAspectRatio="xMidYMid slice">
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
<path d={generatePath()} fill="none" stroke="#4f46e5" strokeWidth="3" />
</svg>
</div>
</div>
</div>
</div>
</div>
);
};
export default DiscriminantWidget;

View File

@ -0,0 +1,255 @@
import React, { useState } from "react";
import {
CheckCircle2,
XCircle,
RotateCcw,
ChevronRight,
MousePointerClick,
} from "lucide-react";
export interface EvidenceExercise {
question: string;
passage: string[]; // array of sentences rendered as a flowing paragraph
evidenceIndex: number; // 0-based index of the correct sentence
explanation: string;
}
interface EvidenceHunterWidgetProps {
exercises: EvidenceExercise[];
accentColor?: string;
}
// Tailwind needs complete class strings — map accent to concrete classes
const ACCENT: Record<
string,
{
tab: string;
header: string;
label: string;
hover: string;
selected: string;
btn: string;
next: string;
}
> = {
teal: {
tab: "border-b-2 border-teal-600 text-teal-700",
header: "bg-teal-50",
label: "text-teal-500",
hover: "hover:bg-teal-50 hover:border-teal-400",
selected: "bg-teal-100 border-teal-500",
btn: "bg-teal-600 hover:bg-teal-700",
next: "text-teal-700 hover:text-teal-900",
},
fuchsia: {
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
header: "bg-fuchsia-50",
label: "text-fuchsia-500",
hover: "hover:bg-fuchsia-50 hover:border-fuchsia-400",
selected: "bg-fuchsia-100 border-fuchsia-500",
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
next: "text-fuchsia-700 hover:text-fuchsia-900",
},
purple: {
tab: "border-b-2 border-purple-600 text-purple-700",
header: "bg-purple-50",
label: "text-purple-500",
hover: "hover:bg-purple-50 hover:border-purple-400",
selected: "bg-purple-100 border-purple-500",
btn: "bg-purple-600 hover:bg-purple-700",
next: "text-purple-700 hover:text-purple-900",
},
amber: {
tab: "border-b-2 border-amber-600 text-amber-700",
header: "bg-amber-50",
label: "text-amber-500",
hover: "hover:bg-amber-50 hover:border-amber-400",
selected: "bg-amber-100 border-amber-500",
btn: "bg-amber-600 hover:bg-amber-700",
next: "text-amber-700 hover:text-amber-900",
},
};
export default function EvidenceHunterWidget({
exercises,
accentColor = "teal",
}: EvidenceHunterWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx];
const isCorrect = submitted && selected === exercise.evidenceIndex;
const c = ACCENT[accentColor] ?? ACCENT.teal;
const reset = () => {
setSelected(null);
setSubmitted(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setSelected(null);
setSubmitted(false);
};
return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */}
{exercises.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
{exercises.map((_, i) => (
<button
key={i}
onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : "text-gray-500 hover:text-gray-700"
}`}
>
Passage {i + 1}
</button>
))}
</div>
)}
{/* Question */}
<div className={`px-5 py-4 border-b border-gray-200 ${c.header}`}>
<p
className={`text-xs font-bold uppercase tracking-wider mb-1.5 ${c.label}`}
>
Question
</p>
<p className="text-gray-800 font-semibold leading-snug text-base">
{exercise.question}
</p>
</div>
{/* Passage — flowing text with inline clickable sentences */}
<div className="px-5 pt-5 pb-3">
<div className="flex items-center gap-2 mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Passage
</p>
{!submitted && (
<span className="flex items-center gap-1 text-xs text-gray-400 italic">
<MousePointerClick className="w-3 h-3" /> click the sentence that
answers the question
</span>
)}
</div>
{/* Render as a flowing paragraph with clickable sentence spans */}
<div className="text-sm text-gray-700 leading-8 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
{exercise.passage.map((sentence, i) => {
// Determine highlight class for this sentence
let spanCls = `inline cursor-pointer rounded px-0.5 py-0.5 border border-transparent transition-all ${c.hover}`;
if (submitted) {
if (i === exercise.evidenceIndex) {
spanCls =
"inline rounded px-0.5 py-0.5 border bg-green-100 border-green-400 text-green-800 font-medium cursor-default";
} else if (i === selected) {
spanCls =
"inline rounded px-0.5 py-0.5 border bg-red-100 border-red-300 text-red-600 cursor-default line-through";
} else {
spanCls =
"inline rounded px-0.5 py-0.5 border border-transparent text-gray-400 cursor-default";
}
} else if (selected === i) {
spanCls = `inline rounded px-0.5 py-0.5 border cursor-pointer ${c.selected} font-medium`;
}
return (
<React.Fragment key={i}>
<span
onClick={() => {
if (!submitted) setSelected(i);
}}
className={spanCls}
title={
submitted ? undefined : `Click to select sentence ${i + 1}`
}
>
{sentence}
</span>
{i < exercise.passage.length - 1 ? " " : ""}
</React.Fragment>
);
})}
</div>
{/* Selection indicator */}
{!submitted && selected !== null && (
<p className="mt-2 text-xs text-gray-500 italic">
Selected:{" "}
<span className="font-semibold text-gray-700">
"{exercise.passage[selected].slice(0, 60)}
{exercise.passage[selected].length > 60 ? "" : ""}"
</span>
</p>
)}
{!submitted && selected === null && (
<p className="mt-2 text-xs text-gray-400 italic">
No sentence selected yet.
</p>
)}
</div>
{/* Submit / result */}
<div className="px-5 pb-5">
{!submitted ? (
<button
disabled={selected === null}
onClick={() => setSubmitted(true)}
className={`mt-2 px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
selected !== null
? c.btn
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
Check my answer
</button>
) : (
<div
className={`mt-3 rounded-xl border p-4 ${isCorrect ? "bg-green-50 border-green-300" : "bg-amber-50 border-amber-300"}`}
>
<div className="flex gap-2 mb-2">
{isCorrect ? (
<CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
) : (
<XCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
)}
<p
className={`font-semibold text-sm ${isCorrect ? "text-green-800" : "text-amber-800"}`}
>
{isCorrect
? "Correct — that's the key sentence."
: `Not quite. The highlighted sentence above is the correct one.`}
</p>
</div>
<p className="text-sm text-gray-700 leading-relaxed">
{exercise.explanation}
</p>
</div>
)}
<div className="flex items-center gap-3 mt-3">
{submitted && (
<button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Try again
</button>
)}
{submitted && activeEx < exercises.length - 1 && (
<button
onClick={() => switchEx(activeEx + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold transition-colors ${c.next}`}
>
Next passage <ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
const ExponentialExplorer: React.FC = () => {
const [a, setA] = useState(2); // Initial Value
const [b, setB] = useState(1.5); // Growth Factor
const [k, setK] = useState(0); // Horizontal Asymptote shift
const width = 300;
const height = 300;
const range = 5; // x range -5 to 5
// Mapping
const toPx = (v: number, isY = false) => {
const scale = width / (range * 2);
const center = width / 2;
return isY ? center - v * scale : center + v * scale;
};
const generatePath = () => {
let d = "";
for (let x = -range; x <= range; x += 0.1) {
const y = a * Math.pow(b, x) + k;
if (y > range * 2 || y < -range * 2) continue; // Clip
const px = toPx(x);
const py = toPx(y, true);
d += d ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center">
<div className="text-xs font-bold text-violet-400 uppercase mb-1">Standard Form</div>
<div className="text-xl font-mono font-bold text-violet-900">
y = <span className="text-indigo-600">{a}</span> · <span className="text-emerald-600">{b}</span><sup>x</sup> {k >= 0 ? '+' : ''} <span className="text-rose-600">{k}</span>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Initial Value (a) <span>{a}</span>
</label>
<input type="range" min="0.5" max="5" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Growth Factor (b) <span>{b}</span>
</label>
<input type="range" min="0.1" max="3" step="0.1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"/>
<p className="text-xs text-slate-400 mt-1">{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}</p>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span>
</label>
<input type="range" min="-3" max="3" step="1" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" />
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* Asymptote */}
<line x1="0" y1={toPx(k, true)} x2="300" y2={toPx(k, true)} stroke="#e11d48" strokeWidth="1" strokeDasharray="4,4" />
<text x="10" y={toPx(k, true) - 5} className="text-xs font-bold fill-rose-500">y = {k}</text>
{/* Function */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Intercept */}
<circle cx={toPx(0)} cy={toPx(a+k, true)} r="4" fill="#4f46e5" stroke="white" strokeWidth="2" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default ExponentialExplorer;

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
const FactoringWidget: React.FC = () => {
// ax^2 + bx + c
const [a, setA] = useState(1);
const [b, setB] = useState(5);
const [c, setC] = useState(6);
const product = a * c;
const sum = b;
// We won't solve it for them immediately, let them guess or think
// But we will show if it's factorable over integers
// Simple check for nice numbers
const getFactors = () => {
// Find two numbers p, q such that p*q = product and p+q = sum
// Brute force range reasonable for typical SAT (up to +/- 100)
for (let i = -100; i <= 100; i++) {
if (i === 0) continue;
const q = product / i;
if (Math.abs(q - Math.round(q)) < 0.001) { // is integer
if (i + q === sum) return [i, q].sort((x,y) => x-y);
}
}
return null;
};
const solution = getFactors();
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 items-center">
{/* Input Side */}
<div className="w-full md:w-1/2 space-y-4">
<h4 className="font-bold text-violet-900 mb-2">Polynomial: <span className="font-mono text-lg">{a === 1 ? '' : a}x² {b >= 0 ? '+' : ''}{b}x {c >= 0 ? '+' : ''}{c}</span></h4>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-xs font-bold text-slate-400">a</label>
<input type="number" value={a} onChange={e => setA(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
<div>
<label className="text-xs font-bold text-slate-400">b (Sum)</label>
<input type="number" value={b} onChange={e => setB(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
<div>
<label className="text-xs font-bold text-slate-400">c</label>
<input type="number" value={c} onChange={e => setC(parseInt(e.target.value) || 0)} className="w-full p-2 border rounded font-mono font-bold text-center" />
</div>
</div>
<div className="p-4 bg-slate-50 rounded-lg text-sm text-slate-600">
<p><strong>AC Method (Diamond):</strong></p>
<p>Find two numbers that multiply to <strong>a·c</strong> and add to <strong>b</strong>.</p>
<p className="mt-2 font-mono text-center">
Product (ac) = {a} × {c} = <strong>{product}</strong> <br/>
Sum (b) = <strong>{sum}</strong>
</p>
</div>
</div>
{/* Visual Side */}
<div className="flex-1 flex flex-col items-center justify-center">
<div className="relative w-48 h-48">
{/* X Shape */}
<div className="absolute top-0 left-0 w-full h-full">
<svg width="100%" height="100%" viewBox="0 0 200 200">
<line x1="20" y1="20" x2="180" y2="180" stroke="#cbd5e1" strokeWidth="4" />
<line x1="180" y1="20" x2="20" y2="180" stroke="#cbd5e1" strokeWidth="4" />
</svg>
</div>
{/* Top (Product) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-4 bg-violet-100 px-3 py-1 rounded border border-violet-300 text-violet-800 font-bold shadow-sm">
{product}
</div>
{/* Bottom (Sum) */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-4 bg-indigo-100 px-3 py-1 rounded border border-indigo-300 text-indigo-800 font-bold shadow-sm">
{sum}
</div>
{/* Left (Factor 1) */}
<div className="absolute left-0 top-1/2 -translate-x-6 -translate-y-1/2 bg-white px-3 py-2 rounded border-2 border-emerald-400 text-emerald-700 font-bold shadow-md min-w-[3rem] text-center">
{solution ? solution[0] : "?"}
</div>
{/* Right (Factor 2) */}
<div className="absolute right-0 top-1/2 translate-x-6 -translate-y-1/2 bg-white px-3 py-2 rounded border-2 border-emerald-400 text-emerald-700 font-bold shadow-md min-w-[3rem] text-center">
{solution ? solution[1] : "?"}
</div>
</div>
<div className="mt-8 text-center">
{solution ? (
<div className="text-emerald-700 font-bold bg-emerald-50 px-4 py-2 rounded-lg border border-emerald-200">
Factors Found: {solution[0]} and {solution[1]}
{a === 1 && (
<div className="text-sm mt-1 font-mono text-slate-600">
(x {solution[0] >= 0 ? '+' : ''}{solution[0]})(x {solution[1] >= 0 ? '+' : ''}{solution[1]})
</div>
)}
</div>
) : (
<div className="text-rose-600 font-bold text-sm bg-rose-50 px-4 py-2 rounded-lg border border-rose-200">
No integer factors found. (Prime)
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default FactoringWidget;

View File

@ -0,0 +1,114 @@
import React, { useState } from 'react';
const FrequencyMeanWidget: React.FC = () => {
// Data: Value -> Frequency
const [counts, setCounts] = useState({ 0: 3, 1: 5, 2: 6, 3: 4, 4: 2 });
const handleChange = (val: number, delta: number) => {
setCounts(prev => ({
...prev,
[val]: Math.max(0, (prev[val as keyof typeof prev] || 0) + delta)
}));
};
const values = [0, 1, 2, 3, 4];
const totalStudents = values.reduce((sum, v) => sum + counts[v as keyof typeof counts], 0);
const totalBooks = values.reduce((sum, v) => sum + v * counts[v as keyof typeof counts], 0);
const mean = totalStudents > 0 ? (totalBooks / totalStudents).toFixed(2) : '0';
// Calculate Median position
let cumulative = 0;
let medianVal = 0;
const mid = (totalStudents + 1) / 2;
if (totalStudents > 0) {
for (const v of values) {
cumulative += counts[v as keyof typeof counts];
if (cumulative >= mid) {
medianVal = v;
break;
}
}
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Table Control */}
<div>
<h4 className="font-bold text-slate-700 mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500"></span>
Edit Frequencies
</h4>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm text-center">
<thead className="bg-slate-50 text-slate-500 font-bold uppercase">
<tr>
<th className="p-3 border-b border-r border-slate-200">Books Read</th>
<th className="p-3 border-b border-slate-200">Students (Freq)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{values.map(v => (
<tr key={v} className="group hover:bg-amber-50/50 transition-colors">
<td className="p-3 border-r border-slate-100 font-mono font-bold text-slate-700">{v}</td>
<td className="p-2 flex justify-center items-center gap-3">
<button
onClick={() => handleChange(v, -1)}
className="w-6 h-6 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold flex items-center justify-center transition-colors"
>-</button>
<span className="w-4 font-mono font-bold text-slate-800">{counts[v as keyof typeof counts]}</span>
<button
onClick={() => handleChange(v, 1)}
className="w-6 h-6 rounded bg-amber-100 hover:bg-amber-200 text-amber-700 font-bold flex items-center justify-center transition-colors"
>+</button>
</td>
</tr>
))}
<tr className="bg-slate-50 font-bold text-slate-800">
<td className="p-3 border-r border-slate-200">TOTAL</td>
<td className="p-3">{totalStudents}</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Visualization & Stats */}
<div className="flex flex-col justify-between">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 mb-6">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-3">Dot Plot Preview</h4>
<div className="flex justify-between items-end h-32 px-2 pb-2 border-b border-slate-300">
{values.map(v => (
<div key={v} className="flex flex-col-reverse items-center gap-1 w-8">
{Array.from({length: counts[v as keyof typeof counts]}).map((_, i) => (
<div key={i} className="w-3 h-3 rounded-full bg-amber-500 shadow-sm"></div>
))}
</div>
))}
</div>
<div className="flex justify-between px-2 pt-2 text-xs font-mono font-bold text-slate-500">
{values.map(v => <span key={v} className="w-8 text-center">{v}</span>)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<p className="text-xs font-bold text-indigo-400 uppercase">Weighted Mean</p>
<p className="text-2xl font-bold text-indigo-700">{mean}</p>
<p className="text-[10px] text-indigo-400 mt-1">Total Books ({totalBooks}) / Students ({totalStudents})</p>
</div>
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
<p className="text-xs font-bold text-emerald-400 uppercase">Median</p>
<p className="text-2xl font-bold text-emerald-700">{medianVal}</p>
<p className="text-[10px] text-emerald-400 mt-1">Middle Position (~{mid.toFixed(1)})</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default FrequencyMeanWidget;

View File

@ -0,0 +1,84 @@
import React, { useState } from 'react';
const GrowthComparisonWidget: React.FC = () => {
const [linearRate, setLinearRate] = useState(10); // +10 per step
const [expRate, setExpRate] = useState(10); // +10% per step
const start = 100;
const steps = 10;
// Generate Data
const data = Array.from({ length: steps + 1 }, (_, i) => {
return {
x: i,
lin: start + linearRate * i,
exp: start * Math.pow(1 + expRate/100, i)
};
});
const maxY = Math.max(data[steps].lin, data[steps].exp);
// Scales
const width = 100;
const height = 60;
const getX = (i: number) => (i / steps) * width;
const getY = (val: number) => height - (val / maxY) * height; // Inverted Y
const linPath = `M ${data.map(d => `${getX(d.x)},${getY(d.lin)}`).join(' L ')}`;
const expPath = `M ${data.map(d => `${getX(d.x)},${getY(d.exp)}`).join(' L ')}`;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-8 mb-6">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Linear Rate (+)</label>
<input
type="range" min="5" max="50" value={linearRate}
onChange={e => setLinearRate(Number(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-2"
/>
<div className="text-right font-mono font-bold text-indigo-700">+{linearRate} / step</div>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Exponential Rate (%)</label>
<input
type="range" min="2" max="30" value={expRate}
onChange={e => setExpRate(Number(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<div className="text-right font-mono font-bold text-rose-700">+{expRate}% / step</div>
</div>
</div>
<div className="relative h-64 w-full bg-slate-50 rounded-lg border border-slate-200 mb-6 overflow-hidden">
<svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" className="w-full h-full p-4 overflow-visible">
{/* Grid */}
<line x1="0" y1={height} x2={width} y2={height} stroke="#cbd5e1" strokeWidth="0.5" />
<line x1="0" y1="0" x2="0" y2={height} stroke="#cbd5e1" strokeWidth="0.5" />
{/* Paths */}
<path d={linPath} fill="none" stroke="#4f46e5" strokeWidth="1" />
<path d={expPath} fill="none" stroke="#e11d48" strokeWidth="1" />
{/* Points */}
{data.map((d, i) => (
<g key={i}>
<circle cx={getX(d.x)} cy={getY(d.lin)} r="1" fill="#4f46e5" />
<circle cx={getX(d.x)} cy={getY(d.exp)} r="1" fill="#e11d48" />
</g>
))}
</svg>
{/* Labels */}
<div className="absolute top-2 right-2 text-xs font-bold bg-white/80 p-2 rounded shadow-sm">
<div className="text-indigo-600">Linear Final: {Math.round(data[steps].lin)}</div>
<div className="text-rose-600">Exp Final: {Math.round(data[steps].exp)}</div>
</div>
</div>
<p className="text-sm text-slate-500">
Exponential growth eventually overtakes Linear growth, even if the linear rate seems larger at first!
</p>
</div>
);
};
export default GrowthComparisonWidget;

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
const HistogramBuilderWidget: React.FC = () => {
const [mode, setMode] = useState<'count' | 'percent'>('count');
// Data: [60, 70), [70, 80), [80, 90), [90, 100)
const data = [
{ bin: '60-70', count: 4, label: '60s' },
{ bin: '70-80', count: 9, label: '70s' },
{ bin: '80-90', count: 6, label: '80s' },
{ bin: '90-100', count: 1, label: '90s' },
];
const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20
const maxCount = Math.max(...data.map(d => d.count));
const maxPercent = maxCount / total; // 0.45
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-slate-700">Test Scores Distribution</h3>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setMode('count')}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'count' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Frequency (Count)
</button>
<button
onClick={() => setMode('percent')}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'percent' ? 'bg-white shadow-sm text-rose-600' : 'text-slate-500 hover:text-slate-700'}`}
>
Relative Freq (%)
</button>
</div>
</div>
<div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1">
{/* Y Axis Labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2">
<span>{mode === 'count' ? maxCount + 1 : ((maxPercent + 0.05)*100).toFixed(0) + '%'}</span>
<span>{mode === 'count' ? Math.round((maxCount+1)/2) : (((maxPercent + 0.05)/2)*100).toFixed(0) + '%'}</span>
<span>0</span>
</div>
{data.map((d, i) => {
const heightRatio = d.count / maxCount; // Normalize to max height of graph area roughly
// Actually map 0 to maxScale
const maxScale = mode === 'count' ? maxCount + 1 : (maxPercent + 0.05);
const val = mode === 'count' ? d.count : d.count / total;
const hPercent = (val / maxScale) * 100;
return (
<div key={i} className="flex-1 flex flex-col justify-end group relative h-full">
{/* Tooltip */}
<div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap">
{d.bin}: {mode === 'count' ? d.count : `${(d.count/total*100).toFixed(0)}%`}
</div>
{/* Bar */}
<div
className={`w-full transition-all duration-500 rounded-t ${mode === 'count' ? 'bg-indigo-500 group-hover:bg-indigo-600' : 'bg-rose-500 group-hover:bg-rose-600'}`}
style={{ height: `${hPercent}%` }}
></div>
{/* Bin Label */}
<div className="absolute -bottom-6 w-full text-center text-xs font-bold text-slate-500">
{d.label}
</div>
</div>
);
})}
</div>
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-sm text-slate-600">
<strong>Key Takeaway:</strong> Notice that the <span className="font-bold text-slate-800">shape</span> of the distribution stays exactly the same.
Only the <span className="font-bold text-slate-800">Y-axis scale</span> changes.
</p>
</div>
</div>
);
};
export default HistogramBuilderWidget;

View File

@ -0,0 +1,173 @@
import React, { useState, useRef } from 'react';
const InequalityRegionWidget: React.FC = () => {
// State for Inequalities: y > or < mx + b
// isGreater: true for >=, false for <=
const [ineq1, setIneq1] = useState({ m: 1, b: 1, isGreater: true });
const [ineq2, setIneq2] = useState({ m: -0.5, b: -2, isGreater: false });
const [testPoint, setTestPoint] = useState({ x: 0, y: 0 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Viewport
const range = 10;
const size = 300;
const scale = size / (range * 2);
const center = size / 2;
// Helpers
const toPx = (val: number, isY = false) => {
return isY ? center - val * scale : center + val * scale;
};
const fromPx = (px: number, isY = false) => {
return isY ? (center - px) / scale : (px - center) / scale;
};
// Generate polygon points for shading
const getRegionPoints = (m: number, b: number, isGreater: boolean) => {
const xMin = -range;
const xMax = range;
const yAtMin = m * xMin + b;
const yAtMax = m * xMax + b;
// y limit is the top (range) or bottom (-range) of the graph
const limitY = isGreater ? range : -range;
const p1 = { x: xMin, y: yAtMin };
const p2 = { x: xMax, y: yAtMax };
const p3 = { x: xMax, y: limitY };
const p4 = { x: xMin, y: limitY };
return `${toPx(p1.x)},${toPx(p1.y, true)} ${toPx(p2.x)},${toPx(p2.y, true)} ${toPx(p3.x)},${toPx(p3.y, true)} ${toPx(p4.x)},${toPx(p4.y, true)}`;
};
const getLinePath = (m: number, b: number) => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
// Interaction
const handleInteraction = (e: React.MouseEvent) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = fromPx(e.clientX - rect.left);
const y = fromPx(e.clientY - rect.top, true);
// Clamp
const cx = Math.max(-range, Math.min(range, x));
const cy = Math.max(-range, Math.min(range, y));
setTestPoint({ x: cx, y: cy });
};
// Logic Check
const check1 = ineq1.isGreater ? testPoint.y >= ineq1.m * testPoint.x + ineq1.b : testPoint.y <= ineq1.m * testPoint.x + ineq1.b;
const check2 = ineq2.isGreater ? testPoint.y >= ineq2.m * testPoint.x + ineq2.b : testPoint.y <= ineq2.m * testPoint.x + ineq2.b;
const isSolution = check1 && check2;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-6">
{/* Inequality 1 */}
<div className={`p-4 rounded-lg border ${check1 ? 'bg-indigo-50 border-indigo-200' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-indigo-800 text-sm">Region 1 (Blue)</span>
<button
onClick={() => setIneq1(p => ({...p, isGreater: !p.isGreater}))}
className="text-xs bg-white border border-indigo-200 px-2 py-1 rounded font-bold text-indigo-600"
>
{ineq1.isGreater ? 'y ≥ ...' : 'y ≤ ...'}
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Slope</span><span>{ineq1.m}</span></div>
<input type="range" min="-4" max="4" step="0.5" value={ineq1.m} onChange={e => setIneq1({...ineq1, m: parseFloat(e.target.value)})} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Y-Int</span><span>{ineq1.b}</span></div>
<input type="range" min="-8" max="8" step="1" value={ineq1.b} onChange={e => setIneq1({...ineq1, b: parseFloat(e.target.value)})} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
</div>
</div>
{/* Inequality 2 */}
<div className={`p-4 rounded-lg border ${check2 ? 'bg-rose-50 border-rose-200' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-rose-800 text-sm">Region 2 (Red)</span>
<button
onClick={() => setIneq2(p => ({...p, isGreater: !p.isGreater}))}
className="text-xs bg-white border border-rose-200 px-2 py-1 rounded font-bold text-rose-600"
>
{ineq2.isGreater ? 'y ≥ ...' : 'y ≤ ...'}
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Slope</span><span>{ineq2.m}</span></div>
<input type="range" min="-4" max="4" step="0.5" value={ineq2.m} onChange={e => setIneq2({...ineq2, m: parseFloat(e.target.value)})} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500"><span>Y-Int</span><span>{ineq2.b}</span></div>
<input type="range" min="-8" max="8" step="1" value={ineq2.b} onChange={e => setIneq2({...ineq2, b: parseFloat(e.target.value)})} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
</div>
</div>
<div className={`p-3 rounded-lg text-center font-bold text-sm border-2 transition-colors ${isSolution ? 'bg-emerald-100 border-emerald-400 text-emerald-800' : 'bg-slate-100 border-slate-300 text-slate-500'}`}>
Test Point: ({testPoint.x.toFixed(1)}, {testPoint.y.toFixed(1)}) <br/>
{isSolution ? "SOLUTION FOUND" : "NOT A SOLUTION"}
</div>
</div>
{/* Graph */}
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden cursor-crosshair">
<svg
ref={svgRef}
width="300" height="300" viewBox="0 0 300 300"
onMouseDown={(e) => { isDragging.current = true; handleInteraction(e); }}
onMouseMove={(e) => { if(isDragging.current) handleInteraction(e); }}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
<defs>
<pattern id="grid-ineq" width="15" height="15" patternUnits="userSpaceOnUse">
<path d="M 15 0 L 0 0 0 15" fill="none" stroke="#f8fafc" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-ineq)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Region 1 */}
<polygon points={getRegionPoints(ineq1.m, ineq1.b, ineq1.isGreater)} fill="rgba(99, 102, 241, 0.2)" />
<path d={getLinePath(ineq1.m, ineq1.b)} stroke="#4f46e5" strokeWidth="2" />
{/* Region 2 */}
<polygon points={getRegionPoints(ineq2.m, ineq2.b, ineq2.isGreater)} fill="rgba(225, 29, 72, 0.2)" />
<path d={getLinePath(ineq2.m, ineq2.b)} stroke="#e11d48" strokeWidth="2" />
{/* Test Point */}
<circle
cx={toPx(testPoint.x)} cy={toPx(testPoint.y, true)} r="6"
fill={isSolution ? "#10b981" : "#64748b"} stroke="white" strokeWidth="2" className="shadow-sm"
/>
</svg>
</div>
</div>
</div>
</div>
);
};
export default InequalityRegionWidget;

View File

@ -0,0 +1,232 @@
import React, { useState, useRef } from "react";
const InteractiveSectorWidget: React.FC = () => {
const [angle, setAngle] = useState(60); // degrees
const [radius, setRadius] = useState(120); // pixels
const isDragging = useRef<"angle" | "radius" | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const cx = 200;
const cy = 200;
const maxRadius = 160;
// Calculate Handle Position
const rad = (angle * Math.PI) / 180;
const hx = cx + radius * Math.cos(-rad); // SVG Y is down, so -rad for standard math "up" rotation behavior if we want counter-clockwise from East
const hy = cy + radius * Math.sin(-rad);
// For the arc path
// Start point is (cx + r, cy) [0 degrees]
// End point is (hx, hy)
const largeArc = angle > 180 ? 1 : 0;
// Sweep flag 0 because we are using -rad (counter clockwise visual in SVG)
const pathData = `
M ${cx} ${cy}
L ${cx + radius} ${cy}
A ${radius} ${radius} 0 ${largeArc} 0 ${hx} ${hy}
Z
`;
// Interaction
const handleInteraction = (e: React.MouseEvent) => {
if (!svgRef.current || !isDragging.current) return;
const rect = svgRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const dx = mx - cx;
const dy = my - cy;
if (isDragging.current === "angle") {
let deg = Math.atan2(-dy, dx) * (180 / Math.PI); // -dy to correct for SVG coords
if (deg < 0) deg += 360;
setAngle(Math.round(deg));
} else if (isDragging.current === "radius") {
const dist = Math.sqrt(dx * dx + dy * dy);
setRadius(Math.max(50, Math.min(maxRadius, dist)));
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row items-center gap-8">
<div className="relative select-none">
<svg
ref={svgRef}
width="400"
height="400"
onMouseMove={handleInteraction}
onMouseUp={() => (isDragging.current = null)}
onMouseLeave={() => (isDragging.current = null)}
className="cursor-crosshair"
>
{/* Full Circle Ghost */}
<circle
cx={cx}
cy={cy}
r={radius}
stroke="#e2e8f0"
strokeWidth="1"
fill="none"
strokeDasharray="4,4"
/>
{/* Sector */}
<path
d={pathData}
fill="rgba(249, 115, 22, 0.2)"
stroke="#f97316"
strokeWidth="2"
/>
{/* Radius Handle Line (Baseline) */}
<line
x1={cx}
y1={cy}
x2={cx + radius}
y2={cy}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Radius Drag Handle (on baseline) */}
<circle
cx={cx + radius}
cy={cy}
r={8}
fill="#94a3b8"
stroke="white"
strokeWidth="2"
className="cursor-ew-resize hover:fill-slate-600 shadow-sm"
onMouseDown={() => (isDragging.current = "radius")}
/>
{/* Angle Drag Handle (on arc) */}
<circle
cx={hx}
cy={hy}
r={10}
fill="#f97316"
stroke="white"
strokeWidth="2"
className="cursor-pointer hover:scale-110 transition-transform shadow-md"
onMouseDown={() => (isDragging.current = "angle")}
/>
{/* Angle Text */}
<text
x={cx + 20}
y={cy - 10}
className="text-xs font-bold fill-orange-600"
>
{angle}°
</text>
{/* Radius Text */}
<text
x={cx + radius / 2}
y={cy + 15}
textAnchor="middle"
className="text-xs font-bold fill-slate-400"
>
r = {Math.round(radius / 10)}
</text>
{/* Center */}
<circle cx={cx} cy={cy} r={4} fill="#64748b" />
</svg>
</div>
<div className="flex-1 w-full space-y-6">
<div className="bg-orange-50 border border-orange-100 p-4 rounded-xl">
<h3 className="text-orange-900 font-bold mb-2 flex items-center gap-2">
<span className="p-1 bg-orange-200 rounded text-xs">INPUT</span>
Parameters
</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs text-orange-700 uppercase font-bold mb-1">
Angle (θ): {angle}°
</div>
<input
type="range"
min="1"
max="360"
value={angle}
onChange={(e) => setAngle(parseInt(e.target.value))}
className="w-full h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer accent-orange-600"
/>
</div>
<div>
<div className="flex justify-between text-xs text-orange-700 uppercase font-bold mb-1">
Radius (r): {Math.round(radius / 10)}
</div>
<input
type="range"
min="50"
max={maxRadius}
value={radius}
onChange={(e) => setRadius(parseInt(e.target.value))}
className="w-full h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer accent-orange-600"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-bold text-slate-600">
Fraction of Circle
</span>
<span className="font-mono text-orange-600 font-bold">
{angle}/360 {(angle / 360).toFixed(2)}
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all"
style={{ width: `${(angle / 360) * 100}%` }}
></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<span className="text-xs font-bold text-slate-400 uppercase">
Arc Length
</span>
<div className="font-mono text-lg text-slate-800 mt-1">
2π({Math.round(radius / 10)}) ×{" "}
<span className="text-orange-600">
{(angle / 360).toFixed(2)}
</span>
</div>
<div className="font-bold text-xl text-slate-900 mt-1">
= {(2 * Math.PI * (radius / 10) * (angle / 360)).toFixed(1)}
</div>
</div>
<div className="p-4 bg-white border border-slate-200 rounded-xl shadow-sm">
<span className="text-xs font-bold text-slate-400 uppercase">
Sector Area
</span>
<div className="font-mono text-lg text-slate-800 mt-1">
π({Math.round(radius / 10)})² ×{" "}
<span className="text-orange-600">
{(angle / 360).toFixed(2)}
</span>
</div>
<div className="font-bold text-xl text-slate-900 mt-1">
={" "}
{(Math.PI * Math.pow(radius / 10, 2) * (angle / 360)).toFixed(
1,
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default InteractiveSectorWidget;

View File

@ -0,0 +1,186 @@
import React, { useState } from 'react';
type Relationship = 'none' | 'vertical' | 'linear' | 'corresponding' | 'alt-interior' | 'same-side';
const InteractiveTransversal: React.FC = () => {
const [activeRel, setActiveRel] = useState<Relationship>('same-side');
// SVG Config
const width = 500;
const height = 300;
const line1Y = 100;
const line2Y = 200;
// Transversal passes through (200, 100) and (300, 200)
// Slope is 1 (45 degrees down-right)
const intersection1 = { x: 200, y: 100 };
const intersection2 = { x: 300, y: 200 };
// Angle Definitions (SVG y-down coordinates)
// 0 deg = Right, 90 deg = Down, 180 deg = Left, 270 deg = Up
// Transversal vector is (1, 1), angle is 45 deg.
// Opposite ray is 225 deg.
const angles = [
// Intersection 1 (Top)
{ id: 1, cx: intersection1.x, cy: intersection1.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' }, // Top-Left (Acute)
{ id: 2, cx: intersection1.x, cy: intersection1.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' }, // Top-Right (Obtuse)
{ id: 3, cx: intersection1.x, cy: intersection1.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' }, // Bottom-Right (Acute)
{ id: 4, cx: intersection1.x, cy: intersection1.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' }, // Bottom-Left (Obtuse)
// Intersection 2 (Bottom)
{ id: 5, cx: intersection2.x, cy: intersection2.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' },
{ id: 6, cx: intersection2.x, cy: intersection2.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' },
{ id: 7, cx: intersection2.x, cy: intersection2.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' },
{ id: 8, cx: intersection2.x, cy: intersection2.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' },
];
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
// Convert to radians
const startRad = (startDeg * Math.PI) / 180;
const endRad = (endDeg * Math.PI) / 180;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
};
const getLabelPos = (cx: number, cy: number, r: number, angleDeg: number) => {
const rad = (angleDeg * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad)
};
};
const getStyles = (id: number) => {
const base = { fill: 'transparent', stroke: 'transparent', label: 'text-slate-400' };
const highlightBlue = { fill: 'rgba(99, 102, 241, 0.3)', stroke: '#4f46e5', label: 'text-indigo-600 font-bold' };
const highlightPink = { fill: 'rgba(244, 63, 94, 0.3)', stroke: '#e11d48', label: 'text-rose-600 font-bold' };
switch (activeRel) {
case 'vertical':
// 1 & 3 are equal
if ([1, 3].includes(id)) return highlightBlue;
return base;
case 'linear':
// 1 & 2 are supplementary
if (id === 1) return highlightBlue;
if (id === 2) return highlightPink;
return base;
case 'corresponding':
// 2 & 6 are equal
if ([2, 6].includes(id)) return highlightBlue;
return base;
case 'alt-interior':
// 4 & 6 are equal
if ([4, 6].includes(id)) return highlightBlue;
return base;
case 'same-side':
// 3 & 6 are supplementary
if (id === 3) return highlightBlue;
if (id === 6) return highlightPink;
return base;
default:
return base;
}
};
const getDescription = () => {
switch (activeRel) {
case 'vertical': return "Vertical Angles are equal (e.g. ∠1 = ∠3)";
case 'linear': return "Linear Pairs sum to 180° (e.g. ∠1 + ∠2 = 180°)";
case 'corresponding': return "Corresponding Angles are equal (e.g. ∠2 = ∠6)";
case 'alt-interior': return "Alternate Interior Angles are equal (e.g. ∠4 = ∠6)";
case 'same-side': return "Same-Side Interior sum to 180° (e.g. ∠3 + ∠6 = 180°)";
default: return "Select a relationship to highlight";
}
};
const buttons: { id: Relationship, label: string }[] = [
{ id: 'vertical', label: 'Vertical Angles' },
{ id: 'linear', label: 'Linear Pair' },
{ id: 'corresponding', label: 'Corresponding' },
{ id: 'alt-interior', label: 'Alt. Interior' },
{ id: 'same-side', label: 'Same-Side Interior' },
];
return (
<div className="flex flex-col items-center bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<div className="flex flex-wrap gap-2 justify-center mb-8">
{buttons.map(btn => (
<button
key={btn.id}
onClick={() => setActiveRel(activeRel === btn.id ? 'none' : btn.id)}
className={`px-4 py-2 rounded-full text-sm font-bold transition-all border ${
activeRel === btn.id
? 'bg-slate-900 text-white border-slate-900 shadow-md'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-400 hover:bg-slate-50'
}`}
>
{btn.label}
</button>
))}
</div>
<div className="relative w-full flex justify-center">
<div className="absolute top-0 left-0 w-full text-center">
<p className="text-slate-500 font-medium">{getDescription()}</p>
</div>
<svg width={width} height={height} className="mt-8 select-none">
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="4" orient="auto">
<path d="M0,0 L0,8 L12,4 z" fill="#64748b" />
</marker>
</defs>
{/* Parallel Lines */}
<line x1="50" y1={line1Y} x2="450" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="450" y1={line1Y} x2="50" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="50" y1={line2Y} x2="450" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
<line x1="450" y1={line2Y} x2="50" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
{/* Transversal (infinite line simulation) */}
<line x1="100" y1="0" x2="400" y2="300" stroke="#0f172a" strokeWidth="3" strokeLinecap="round" />
{/* Angles */}
{angles.map((angle) => {
const styles = getStyles(angle.id);
const r = 35;
const labelPos = getLabelPos(angle.cx, angle.cy, r + 15, angle.labelPos);
return (
<g key={angle.id}>
<path
d={getArcPath(angle.cx, angle.cy, r, angle.start, angle.end)}
fill={styles.fill}
stroke={styles.stroke}
strokeWidth={styles.stroke === 'transparent' ? 0 : 2}
/>
<text
x={labelPos.x}
y={labelPos.y}
textAnchor="middle"
dominantBaseline="middle"
className={`text-sm select-none ${styles.label}`}
>
{angle.id}
</text>
</g>
);
})}
</svg>
</div>
</div>
);
};
export default InteractiveTransversal;

View File

@ -0,0 +1,236 @@
import React, { useState, useRef, useEffect } from 'react';
// Helper to convert radians to degrees
const toDeg = (rad: number) => (rad * 180) / Math.PI;
const InteractiveTriangle: React.FC = () => {
// Vertex B state (the draggable top vertex)
// Default position forming a nice scalene triangle
const [bPos, setBPos] = useState({ x: 120, y: 50 });
const [isDragging, setIsDragging] = useState(false);
const [showProof, setShowProof] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
// Fixed vertices
const A = { x: 50, y: 250 };
const C = { x: 300, y: 250 };
const D = { x: 450, y: 250 }; // Extension of base AC
// Colors
const colors = {
A: { text: "text-indigo-600", stroke: "#4f46e5", fill: "rgba(79, 70, 229, 0.2)" },
B: { text: "text-emerald-600", stroke: "#059669", fill: "rgba(5, 150, 105, 0.2)" },
Ext: { text: "text-rose-600", stroke: "#e11d48", fill: "rgba(225, 29, 72, 0.2)" }
};
// Drag logic
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
let x = e.clientX - rect.left;
let y = e.clientY - rect.top;
// Constraints
x = Math.max(20, Math.min(x, 380));
y = Math.max(20, Math.min(y, 230)); // Keep B above the base (y < 250)
setBPos({ x, y });
};
const handleMouseUp = () => setIsDragging(false);
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
// Calculations
// SVG Coordinate system: Y is Down.
// We use atan2(dy, dx) to get angles.
// Angle of vector (dx, dy).
// Angle of AB
const angleAB_rad = Math.atan2(bPos.y - A.y, bPos.x - A.x);
const angleAB_deg = toDeg(angleAB_rad); // usually negative (e.g. -60)
// Angle of AC is 0.
// Angle A (magnitude)
const valA = Math.abs(angleAB_deg);
// Angle of CB
const angleCB_rad = Math.atan2(bPos.y - C.y, bPos.x - C.x);
const angleCB_deg = toDeg(angleCB_rad); // usually negative (e.g. -120)
// Angle of CA is 180.
// Angle C Interior (magnitude) = 180 - abs(angleCB_deg) if y < C.y (which it is).
const valC = 180 - Math.abs(angleCB_deg);
// Angle B (Interior)
const valB = 180 - valA - valC;
// Exterior Angle (magnitude)
// Between CD (0) and CB (angleCB_deg).
// Ext = abs(angleCB_deg).
const valExt = Math.abs(angleCB_deg);
// Arc Generation Helper
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
// SVG standard: degrees clockwise from X-axis.
// Our atan2 returns degrees relative to X-axis (clockwise positive if Y down).
// so we can use them directly.
const startRad = (startDeg * Math.PI) / 180;
const endRad = (endDeg * Math.PI) / 180;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
// Sweep flag: 0 if counter-clockwise, 1 if clockwise.
// We want to draw from start to end.
// If we go from negative angle (AB) to 0 (AC), difference is positive.
const largeArc = Math.abs(endDeg - startDeg) > 180 ? 1 : 0;
const sweep = endDeg > startDeg ? 1 : 0;
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} ${sweep} ${x2} ${y2} Z`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center select-none">
<div className="w-full flex justify-between items-center mb-4 px-2">
<h3 className="font-bold text-slate-700">Interactive Triangle</h3>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-slate-50 p-2 rounded transition-colors">
<input
type="checkbox"
checked={showProof}
onChange={(e) => setShowProof(e.target.checked)}
className="rounded text-indigo-600 focus:ring-indigo-500"
/>
<span className="font-medium text-slate-600">Show Proof (Parallel Line)</span>
</label>
</div>
<svg ref={svgRef} width="500" height="300" className="cursor-default">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#94a3b8" />
</marker>
</defs>
{/* Base Line Extension */}
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="6,6" />
<text x={D.x} y={D.y + 20} fontSize="12" fill="#94a3b8">D</text>
{/* Angle Arcs */}
{/* Angle A: from angleAB to 0 */}
<path
d={getArcPath(A.x, A.y, 40, angleAB_deg, 0)}
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
/>
<text x={A.x + 50} y={A.y - 10} className={`text-xs font-bold ${colors.A.text}`} style={{opacity: 0.8}}>{Math.round(valA)}°</text>
{/* Angle B: from angle of BA to angle of BC */}
{/* Angle of BA is angleAB + 180. Angle of BC is angleCB + 180. */}
{/* Wait, B is center. */}
{/* Vector BA: A - B. Angle = atan2(Ay - By, Ax - Bx). */}
{/* Vector BC: C - B. Angle = atan2(Cy - By, Cx - Bx). */}
<path
d={getArcPath(bPos.x, bPos.y, 40, Math.atan2(A.y - bPos.y, A.x - bPos.x) * 180/Math.PI, Math.atan2(C.y - bPos.y, C.x - bPos.x) * 180/Math.PI)}
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
/>
{/* Label B slightly above vertex */}
<text x={bPos.x} y={bPos.y - 15} textAnchor="middle" className={`text-xs font-bold ${colors.B.text}`} style={{opacity: 0.8}}>{Math.round(valB)}°</text>
{/* Exterior Angle: at C, from angleCB to 0 */}
{/* If showing proof, split it */}
{!showProof && (
<path
d={getArcPath(C.x, C.y, 50, angleCB_deg, 0)}
fill={colors.Ext.fill} stroke={colors.Ext.stroke} strokeWidth="1"
/>
)}
{/* Proof Visuals */}
{showProof && (
<>
{/* Parallel Line CE. Angle same as AB: angleAB_deg */}
<line
x1={C.x} y1={C.y}
x2={C.x + 100 * Math.cos(angleAB_rad)} y2={C.y + 100 * Math.sin(angleAB_rad)}
stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4"
/>
<text x={C.x + 110 * Math.cos(angleAB_rad)} y={C.y + 110 * Math.sin(angleAB_rad)} fontSize="12" fill="#94a3b8">E</text>
{/* Lower part of Ext (Corresponding to A) - From angleAB_deg to 0 */}
<path
d={getArcPath(C.x, C.y, 50, angleAB_deg, 0)}
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
/>
<text x={C.x + 60} y={C.y - 10} className={`text-xs font-bold ${colors.A.text}`}>{Math.round(valA)}°</text>
{/* Upper part of Ext (Alt Interior to B) - From angleCB_deg to angleAB_deg */}
<path
d={getArcPath(C.x, C.y, 50, angleCB_deg, angleAB_deg)}
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
/>
<text x={C.x + 35} y={C.y - 50} className={`text-xs font-bold ${colors.B.text}`}>{Math.round(valB)}°</text>
</>
)}
{/* Label Ext if not split or just general label */}
{!showProof && (
<text x={C.x + 60} y={C.y - 30} className={`text-sm font-bold ${colors.Ext.text}`}>Ext {Math.round(valExt)}°</text>
)}
{/* Triangle Lines */}
<path d={`M ${A.x} ${A.y} L ${bPos.x} ${bPos.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#1e293b" strokeWidth="2" strokeLinejoin="round" />
{/* Vertices */}
<circle cx={A.x} cy={A.y} r="4" fill="#1e293b" />
<text x={A.x - 15} y={A.y + 5} fontSize="14" fontWeight="bold" fill="#334155">A</text>
<circle cx={C.x} cy={C.y} r="4" fill="#1e293b" />
<text x={C.x + 5} y={C.y + 20} fontSize="14" fontWeight="bold" fill="#334155">C</text>
{/* Draggable B */}
<g
onMouseDown={() => setIsDragging(true)}
className="cursor-grab active:cursor-grabbing"
>
<circle cx={bPos.x} cy={bPos.y} r="12" fill="transparent" /> {/* Hit area */}
<circle cx={bPos.x} cy={bPos.y} r="6" fill="#4f46e5" stroke="white" strokeWidth="2" className="shadow-sm" />
<text x={bPos.x} y={bPos.y - 20} textAnchor="middle" fontSize="14" fontWeight="bold" fill="#334155">B</text>
</g>
</svg>
<div className="w-full mt-4 p-4 bg-slate-50 rounded-lg border border-slate-100 flex flex-col items-center">
<div className="flex items-center gap-4 text-lg font-mono">
<span className={colors.Ext.text}>Ext ({Math.round(valExt)}°)</span>
<span className="text-slate-400">=</span>
<span className={colors.A.text}>A ({Math.round(valA)}°)</span>
<span className="text-slate-400">+</span>
<span className={colors.B.text}>B ({Math.round(valB)}°)</span>
</div>
<p className="text-xs text-slate-400 mt-2">
{showProof
? "Notice how the parallel line 'transports' angle A and B to the exterior?"
: "Drag vertex B to see the values update."}
</p>
</div>
</div>
);
};
export default InteractiveTriangle;

View File

@ -0,0 +1,23 @@
// LessonRenderer.tsx
import { Suspense } from "react";
import { LESSON_COMPONENT_MAP } from "../FetchLessonPage";
import type { LessonId } from "../FetchLessonPage";
interface Props {
lessonId: LessonId;
}
export const LessonRenderer = ({ lessonId }: Props) => {
const LessonComponent = LESSON_COMPONENT_MAP[lessonId];
if (!LessonComponent) {
return <p>Lesson not found.</p>;
}
return (
<Suspense fallback={<p>Loading lesson...</p>}>
<LessonComponent />
</Suspense>
);
};

View File

@ -0,0 +1,499 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, ChevronDown, ChevronUp, ChevronRight } from "lucide-react";
import type { PracticeQuestion } from "../../types/lesson";
import {
transformMathHtml,
isQuestionBroken,
} from "../../utils/mathHtmlTransform";
export interface SectionDef {
title: string;
icon: React.ComponentType<{ className?: string }>;
}
interface LessonShellProps {
title: string;
sections: SectionDef[];
color: "blue" | "violet" | "amber" | "emerald";
onFinish?: () => void;
children: React.ReactNode;
}
/* ─── colour palette for each category ─── */
const PALETTES = {
blue: {
activeBg: "bg-blue-600",
activeText: "text-blue-900",
pastBg: "bg-blue-400",
sidebarActive: "bg-white/80 shadow-md border border-blue-100",
dotBg: "bg-blue-100",
dotText: "text-blue-500",
glassClass: "glass-blue",
},
violet: {
activeBg: "bg-violet-600",
activeText: "text-violet-900",
pastBg: "bg-violet-400",
sidebarActive: "bg-white/80 shadow-md border border-violet-100",
dotBg: "bg-violet-100",
dotText: "text-violet-500",
glassClass: "glass-violet",
},
amber: {
activeBg: "bg-amber-600",
activeText: "text-amber-900",
pastBg: "bg-amber-400",
sidebarActive: "bg-white/80 shadow-md border border-amber-100",
dotBg: "bg-amber-100",
dotText: "text-amber-500",
glassClass: "glass-amber",
},
emerald: {
activeBg: "bg-emerald-600",
activeText: "text-emerald-900",
pastBg: "bg-emerald-400",
sidebarActive: "bg-white/80 shadow-md border border-emerald-100",
dotBg: "bg-emerald-100",
dotText: "text-emerald-500",
glassClass: "glass-emerald",
},
};
export default function LessonShell({
title,
sections,
color,
onFinish,
children,
}: LessonShellProps) {
const palette = PALETTES[color];
const [activeSection, setActiveSection] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
setSidebarOpen(false);
};
/* scroll-spy */
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const idx = sectionsRef.current.indexOf(
entry.target as HTMLElement,
);
if (idx !== -1) setActiveSection(idx);
}
});
},
{ rootMargin: "-20% 0px -60% 0px" },
);
sectionsRef.current.forEach((s) => {
if (s) observer.observe(s);
});
return () => observer.disconnect();
}, []);
/* Inject ref callbacks onto section-wrapper children */
const childArray = React.Children.toArray(children);
return (
<div className="flex flex-col lg:flex-row min-h-screen lesson-bg">
{/* ── Mobile toggle ── */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="lg:hidden fixed bottom-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-full shadow-lg text-sm font-bold text-slate-700 bg-white"
>
{sidebarOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronUp className="w-4 h-4" />
)}
{sections[activeSection]?.title ?? "Sections"}
</button>
{/* ── Sidebar ── */}
<aside
className={`
${sidebarOpen ? "translate-y-0" : "translate-y-full lg:translate-y-0"}
fixed bottom-0 left-0 right-0 lg:top-20 lg:bottom-0 lg:left-0 lg:right-auto
w-full lg:w-64 z-40 lg:z-0
glass-sidebar p-4 lg:overflow-y-auto
transition-transform duration-300 ease-out
rounded-t-2xl lg:rounded-none
border-t lg:border-t-0 border-slate-200/50
`}
>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 mb-3 px-1 hidden lg:block">
Sections
</p>
<nav className="space-y-1.5 bg-white">
{sections.map((sec, i) => {
const isActive = activeSection === i;
const isPast = activeSection > i;
const Icon = sec.icon;
return (
<button
key={i}
onClick={() => scrollToSection(i)}
className={`flex items-center gap-3 p-2.5 w-full rounded-xl transition-all text-left ${
isActive ? palette.sidebarActive : "hover:bg-white/50"
}`}
>
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isActive
? `${palette.activeBg} text-white`
: isPast
? `${palette.pastBg} text-white`
: `${palette.dotBg} ${palette.dotText}`
}`}
>
{isPast ? (
<Check className="w-3.5 h-3.5" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
</div>
<span
className={`text-xs font-semibold leading-tight ${isActive ? palette.activeText : "text-slate-600"}`}
>
{sec.title}
</span>
</button>
);
})}
</nav>
</aside>
{/* ── Main content ── */}
<div className="flex-1 max-w-4xl mx-auto w-full">
{childArray.map((child, i) => (
<section
key={i}
ref={(el) => {
sectionsRef.current[i] = el;
}}
className="min-h-[70vh] mb-20 pt-16 lg:pt-4"
>
{child}
{/* next-section / finish button */}
{i < sections.length - 1 ? (
<button
onClick={() => scrollToSection(i + 1)}
className={`mt-10 group flex items-center gap-2 font-bold transition-colors ${
color === "blue"
? "text-blue-600 hover:text-blue-800"
: color === "violet"
? "text-violet-600 hover:text-violet-800"
: color === "amber"
? "text-amber-600 hover:text-amber-800"
: "text-emerald-600 hover:text-emerald-800"
}`}
>
Next: {sections[i + 1]?.title}
<ChevronRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
) : onFinish ? (
<button
onClick={onFinish}
className="mt-10 px-8 py-3 rounded-xl bg-linear-to-r from-slate-800 to-slate-900 text-white font-bold shadow-lg hover:from-slate-700 hover:to-slate-800 transition-all hover:scale-[1.02]"
>
Complete Lesson
</button>
) : null}
</section>
))}
</div>
</div>
);
}
/* ─── Reusable concept-card wrapper ─── */
export function ConceptCard({
color = "blue",
children,
className = "",
}: {
color?: "blue" | "violet" | "amber" | "emerald";
children: React.ReactNode;
className?: string;
}) {
const glassClass =
color === "blue"
? "glass-blue"
: color === "violet"
? "glass-violet"
: color === "amber"
? "glass-amber"
: "glass-emerald";
return (
<div
className={`${glassClass} glass-card rounded-2xl p-6 mb-8 space-y-5 ${className}`}
>
{children}
</div>
);
}
/* ─── Formula display box ─── */
export function FormulaBox({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`glass-formula text-center py-4 px-6 font-mono text-lg font-bold text-slate-800 ${className}`}
>
{children}
</div>
);
}
/* ─── Worked-example card ─── */
export function ExampleCard({
title,
color = "blue",
children,
}: {
title: string;
color?: "blue" | "violet" | "amber" | "emerald";
children: React.ReactNode;
}) {
const border =
color === "blue"
? "border-blue-200 bg-gradient-to-br from-blue-50/60 to-indigo-50/40"
: color === "violet"
? "border-violet-200 bg-gradient-to-br from-violet-50/60 to-purple-50/40"
: color === "amber"
? "border-amber-200 bg-gradient-to-br from-amber-50/60 to-orange-50/40"
: "border-emerald-200 bg-gradient-to-br from-emerald-50/60 to-green-50/40";
const titleColor =
color === "blue"
? "text-blue-800"
: color === "violet"
? "text-violet-800"
: color === "amber"
? "text-amber-800"
: "text-emerald-800";
return (
<div className={`rounded-xl border ${border} p-5`}>
<p className={`font-bold ${titleColor} mb-3`}>{title}</p>
<div className="font-mono text-sm space-y-1 text-slate-700">
{children}
</div>
</div>
);
}
/* ─── Tip / Warning card ─── */
export function TipCard({
type = "tip",
children,
}: {
type?: "tip" | "warning" | "remember";
children: React.ReactNode;
}) {
const style =
type === "warning"
? "bg-red-50/70 border-red-200 text-red-900"
: type === "remember"
? "bg-amber-50/70 border-amber-200 text-amber-900"
: "bg-blue-50/70 border-blue-200 text-blue-900";
const label =
type === "warning"
? "Common Mistake"
: type === "remember"
? "Remember"
: "SAT Tip";
return (
<div className={`rounded-xl border p-4 text-sm ${style}`}>
<p className="font-bold mb-1">{label}</p>
{children}
</div>
);
}
/* ─── Practice question from dataset ─── */
type PracticeColor =
| "blue"
| "violet"
| "amber"
| "emerald"
| "teal"
| "fuchsia"
| "rose"
| "purple";
export function PracticeFromDataset({
question,
color = "blue",
}: {
key?: React.Key;
question: PracticeQuestion;
color?: PracticeColor;
}) {
const [selected, setSelected] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [sprInput, setSprInput] = useState("");
const isCorrect =
question.type === "mcq"
? selected === question.correctAnswer
: (() => {
const u = sprInput.trim().toLowerCase();
const answers = question.correctAnswer
.split(",")
.map((a) => a.trim().toLowerCase());
if (answers.includes(u)) return true;
const toNum = (s: string): number | null => {
if (s.includes("/")) {
const p = s.split("/");
return p.length === 2
? parseFloat(p[0]) / parseFloat(p[1])
: null;
}
const n = parseFloat(s);
return isNaN(n) ? null : n;
};
const uN = toNum(u);
return (
uN !== null &&
answers.some((a) => {
const aN = toNum(a);
return aN !== null && Math.abs(uN - aN) < 0.0015;
})
);
})();
const handleSubmit = () => {
if (question.type === "mcq" && !selected) return;
if (question.type === "spr" && !sprInput.trim()) return;
setSubmitted(true);
};
const accentMap: Record<PracticeColor, string> = {
blue: "border-blue-500 bg-blue-50",
violet: "border-violet-500 bg-violet-50",
amber: "border-amber-500 bg-amber-50",
emerald: "border-emerald-500 bg-emerald-50",
teal: "border-teal-500 bg-teal-50",
fuchsia: "border-fuchsia-500 bg-fuchsia-50",
rose: "border-rose-500 bg-rose-50",
purple: "border-purple-500 bg-purple-50",
};
const accent = accentMap[color] ?? accentMap.blue;
// Skip broken questions
if (isQuestionBroken(question)) return null;
return (
<div className="glass-card rounded-2xl p-5 mb-6 space-y-4">
{/* Passage (EBRW questions) */}
{question.passage && (
<div className="bg-linear-to-b from-slate-50 to-white rounded-xl border border-slate-200 p-4 text-sm text-slate-700 leading-relaxed max-h-60 overflow-y-auto">
<p className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest mb-2">
Passage
</p>
<div dangerouslySetInnerHTML={{ __html: question.passage }} />
</div>
)}
{question.hasFigure && question.figureUrl && (
<img
src={question.figureUrl}
alt="Figure"
className="max-w-full max-h-80 mx-auto rounded-xl border border-slate-200"
/>
)}
<div
className="text-sm text-slate-700 leading-relaxed"
dangerouslySetInnerHTML={{
__html: transformMathHtml(question.questionHtml),
}}
/>
{question.type === "mcq" ? (
<div className="space-y-2">
{question.choices.map((c) => {
const isThis = selected === c.label;
let ring = "border-slate-200 hover:border-slate-300";
if (submitted && isThis)
ring = isCorrect
? "border-emerald-500 bg-emerald-50"
: "border-red-400 bg-red-50";
else if (submitted && c.label === question.correctAnswer)
ring = "border-emerald-500 bg-emerald-50";
else if (isThis) ring = accent;
const isTable = c.text.includes("<br><br>");
return (
<button
key={c.label}
onClick={() => !submitted && setSelected(c.label)}
disabled={submitted}
className={`w-full text-left flex items-center gap-3 p-3 rounded-xl border transition-all text-sm ${ring}`}
>
<span className="w-7 h-7 rounded-full border border-current flex items-center justify-center text-xs font-bold shrink-0">
{c.label}
</span>
<div
className={`flex-1 ${isTable ? "columns-2 gap-8" : ""}`}
dangerouslySetInnerHTML={{
__html: transformMathHtml(c.text),
}}
/>
</button>
);
})}
</div>
) : (
<input
type="text"
value={sprInput}
onChange={(e) => !submitted && setSprInput(e.target.value)}
disabled={submitted}
placeholder="Type your answer…"
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
)}
{!submitted ? (
<button
onClick={handleSubmit}
className="px-5 py-2 rounded-xl bg-slate-800 text-white text-sm font-bold hover:bg-slate-700 transition-colors"
>
Check Answer
</button>
) : (
<div
className={`rounded-xl border p-4 text-sm ${isCorrect ? "bg-emerald-50/70 border-emerald-200" : "bg-slate-50 border-slate-200"}`}
>
<p
className={`font-bold mb-1 ${isCorrect ? "text-emerald-700" : "text-red-600"}`}
>
{isCorrect
? "Correct!"
: `Incorrect — the answer is ${question.correctAnswer}`}
</p>
<div
className="text-slate-600 leading-relaxed"
dangerouslySetInnerHTML={{
__html: transformMathHtml(question.explanation),
}}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,111 @@
import React, { useState } from 'react';
const LinearQuadraticSystemWidget: React.FC = () => {
// Parabola: y = x^2
// Line: y = mx + b
const [m, setM] = useState(1);
const [b, setB] = useState(-2);
// System: x^2 = mx + b => x^2 - mx - b = 0
// Discriminant: D = (-m)^2 - 4(1)(-b) = m^2 + 4b
const disc = m*m + 4*b;
const numSolutions = disc > 0 ? 2 : disc === 0 ? 1 : 0;
// Visualization
const width = 300;
const height = 300;
const range = 5;
const scale = width / (range * 2);
const center = width / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const generateParabola = () => {
let d = "";
for (let x = -range; x <= range; x += 0.1) {
const y = x * x;
if (y > range) continue;
const px = toPx(x);
const py = toPx(y, true);
d += d ? ` L ${px} ${py}` : `M ${px} ${py}`;
}
return d;
};
const generateLine = () => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">System</div>
<div className="font-mono text-lg font-bold text-slate-800">
y = x² <br/>
y = {m}x {b >= 0 ? '+' : ''}{b}
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Slope (m) = {m}</label>
<input type="range" min="-4" max="4" step="0.5" value={m} onChange={e => setM(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Intercept (b) = {b}</label>
<input type="range" min="-5" max="5" step="0.5" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg accent-rose-600"/>
</div>
</div>
<div className={`p-4 rounded-xl border-l-4 ${numSolutions > 0 ? 'bg-emerald-50 border-emerald-500' : 'bg-rose-50 border-rose-500'}`}>
<div className="text-xs font-bold uppercase text-slate-500">Discriminant (m² + 4b)</div>
<div className="text-xl font-bold text-slate-800 my-1">{disc.toFixed(2)}</div>
<div className="text-sm font-bold">
{numSolutions === 0 && <span className="text-rose-600">No Solutions</span>}
{numSolutions === 1 && <span className="text-amber-600">1 Solution (Tangent)</span>}
{numSolutions === 2 && <span className="text-emerald-600">2 Solutions</span>}
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Axes */}
<line x1="0" y1={center} x2={width} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={height} stroke="#cbd5e1" strokeWidth="2" />
{/* Parabola */}
<path d={generateParabola()} fill="none" stroke="#64748b" strokeWidth="3" />
{/* Line */}
<path d={generateLine()} fill="none" stroke="#4f46e5" strokeWidth="3" />
{/* Intersections */}
{numSolutions > 0 && (
<>
{disc === 0 ? (
<circle cx={toPx(m/2)} cy={toPx((m/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
) : (
<>
<circle cx={toPx((m + Math.sqrt(disc))/2)} cy={toPx(((m + Math.sqrt(disc))/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
<circle cx={toPx((m - Math.sqrt(disc))/2)} cy={toPx(((m - Math.sqrt(disc))/2)**2, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
</>
)}
</>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default LinearQuadraticSystemWidget;

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
const LinearSolutionsWidget: React.FC = () => {
// Model: ax + b = cx + d
const [a, setA] = useState(2);
const [b, setB] = useState(4);
const [c, setC] = useState(2);
const [d, setD] = useState(8);
const isParallel = a === c;
const isCoincident = isParallel && b === d;
// Calculate solution if not parallel
// ax + b = cx + d => (a-c)x = d-b => x = (d-b)/(a-c)
const intersectionX = isParallel ? 0 : (d - b) / (a - c);
const intersectionY = a * intersectionX + b;
// Visualization range
const range = 10;
const scale = 20; // 1 unit = 20px
const center = 150; // px
const toPx = (val: number, isY = false) => {
if (isY) return center - val * scale;
return center + val * scale;
};
const getLinePath = (slope: number, intercept: number) => {
const x1 = -range;
const y1 = slope * x1 + intercept;
const x2 = range;
const y2 = slope * x2 + intercept;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="font-mono text-xl text-blue-700 font-bold">
<span className="text-indigo-600">{a}x + {b}</span> = <span className="text-emerald-600">{c}x + {d}</span>
</div>
<div className={`px-3 py-1 rounded text-sm font-bold uppercase ${
isCoincident ? 'bg-green-100 text-green-800' :
isParallel ? 'bg-rose-100 text-rose-800' :
'bg-blue-100 text-blue-800'
}`}>
{isCoincident ? "Infinite Solutions" : isParallel ? "No Solution" : "One Solution"}
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
{/* Controls */}
<div className="w-full md:w-1/3 space-y-4">
<div className="space-y-2 p-3 bg-indigo-50 rounded-lg border border-indigo-100">
<p className="text-xs font-bold text-indigo-800 uppercase">Left Side (Line 1)</p>
<div>
<label className="text-xs text-slate-500">Slope (a): {a}</label>
<input type="range" min="-5" max="5" step="1" value={a} onChange={e => setA(Number(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600" />
</div>
<div>
<label className="text-xs text-slate-500">Intercept (b): {b}</label>
<input type="range" min="-10" max="10" step="1" value={b} onChange={e => setB(Number(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600" />
</div>
</div>
<div className="space-y-2 p-3 bg-emerald-50 rounded-lg border border-emerald-100">
<p className="text-xs font-bold text-emerald-800 uppercase">Right Side (Line 2)</p>
<div>
<label className="text-xs text-slate-500">Slope (c): {c}</label>
<input type="range" min="-5" max="5" step="1" value={c} onChange={e => setC(Number(e.target.value))} className="w-full h-1 bg-emerald-200 rounded accent-emerald-600" />
</div>
<div>
<label className="text-xs text-slate-500">Intercept (d): {d}</label>
<input type="range" min="-10" max="10" step="1" value={d} onChange={e => setD(Number(e.target.value))} className="w-full h-1 bg-emerald-200 rounded accent-emerald-600" />
</div>
</div>
</div>
{/* Graph */}
<div className="w-full md:flex-1 border border-slate-200 rounded-lg overflow-hidden relative h-[300px] bg-white">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute top-0 left-0">
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Axes */}
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" />
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* Lines */}
<path d={getLinePath(a, b)} stroke="#4f46e5" strokeWidth="3" fill="none" />
<path d={getLinePath(c, d)} stroke={isCoincident ? "#4f46e5" : "#10b981"} strokeWidth="3" strokeDasharray={isCoincident ? "5,5" : ""} fill="none" />
{/* Intersection Point */}
{!isParallel && (
<circle cx={toPx(intersectionX)} cy={toPx(intersectionY, true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" />
)}
</svg>
{/* Labels */}
{!isParallel && (
<div className="absolute bottom-2 right-2 bg-white/90 p-2 rounded text-xs border border-slate-200 shadow-sm">
Intersection: ({intersectionX.toFixed(2)}, {intersectionY.toFixed(2)})
</div>
)}
</div>
</div>
{/* Logic Explanation */}
<div className="bg-slate-50 p-4 rounded-lg text-sm text-slate-700">
<p className="font-bold mb-1">Algebraic Check:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Subtract {c}x from both sides: <span className="font-mono font-bold">{(a-c).toFixed(0)}x + {b} = {d}</span></li>
{a === c ? (
<>
<li><span className="text-rose-600 font-bold">0x</span> (Variables cancel!)</li>
<li>Remaining statement: <span className="font-mono font-bold">{b} = {d}</span></li>
<li className={`font-bold ${b === d ? 'text-green-600' : 'text-rose-600'}`}>
{b === d ? "TRUE (Identity) → Infinite Solutions" : "FALSE (Contradiction) → No Solution"}
</li>
</>
) : (
<>
<li>Variables do NOT cancel.</li>
<li><span className="font-mono">{(a-c).toFixed(0)}x = {d - b}</span></li>
<li>One unique solution exists.</li>
</>
)}
</ul>
</div>
</div>
</div>
);
};
export default LinearSolutionsWidget;

View File

@ -0,0 +1,120 @@
import React, { useState } from 'react';
const LinearTransformationWidget: React.FC = () => {
const [h, setH] = useState(0); // Horizontal shift (x - h)
const [k, setK] = useState(0); // Vertical shift + k
const [reflectX, setReflectX] = useState(false); // -f(x)
const [stretch, setStretch] = useState(1); // a * f(x)
// Base function f(x) = 0.5x
// Transformed g(x) = a * f(x - h) + k
// g(x) = a * (0.5 * (x - h)) + k
// Actually, let's use f(x) = x for simplicity, or 0.5x to show slope changes easier?
// PDF examples use general f(x). Let's use f(x) = x as base.
// g(x) = stretch * (x - h) + k. If reflectX is true, stretch becomes -stretch.
const effectiveStretch = reflectX ? -stretch : stretch;
const range = 10;
const scale = 20; // 20px per unit
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
// Base: y = 0.5x (to make it distinct from diagonals)
const getBasePath = () => {
const m = 0.5;
const x1 = -range, x2 = range;
const y1 = m * x1;
const y2 = m * x2;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
const getTransformedPath = () => {
// f(x) = 0.5x
// g(x) = effectiveStretch * (0.5 * (x - h)) + k
const x1 = -range, x2 = range;
const y1 = effectiveStretch * (0.5 * (x1 - h)) + k;
const y2 = effectiveStretch * (0.5 * (x2 - h)) + k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm">
<p className="text-slate-400 mb-2">Base: <span className="text-slate-600 font-bold">f(x) = 0.5x</span></p>
<p className="text-indigo-900 font-bold text-lg">
g(x) = {reflectX ? '-' : ''}{stretch !== 1 ? stretch : ''}f(x {h > 0 ? '-' : '+'} {Math.abs(h)}) {k >= 0 ? '+' : '-'} {Math.abs(k)}
</p>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Horizontal Shift (h) <span>{h}</span>
</label>
<input
type="range" min="-5" max="5" step="1"
value={h} onChange={e => setH(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1"
/>
<div className="flex justify-between text-[10px] text-slate-400">
<span>Left (x+h)</span>
<span>Right (x-h)</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span>
</label>
<input
type="range" min="-5" max="5" step="1"
value={k} onChange={e => setK(parseInt(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer">
<input type="checkbox" checked={reflectX} onChange={e => setReflectX(e.target.checked)} className="accent-rose-600 w-4 h-4"/>
Reflect (-f(x))
</label>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-t" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-t)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Base Function (Ghost) */}
<path d={getBasePath()} stroke="#94a3b8" strokeWidth="2" strokeDasharray="4,4" />
<text x="260" y={toPx(0.5*8, true) - 5} className="text-xs fill-slate-400 font-bold">f(x)</text>
{/* Transformed Function */}
<path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" />
<text x="20" y="20" className="text-xs fill-indigo-600 font-bold">g(x)</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default LinearTransformationWidget;

View File

@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { Check, RotateCcw, ArrowRight } from 'lucide-react';
const LiteralEquationWidget: React.FC = () => {
const [problemIdx, setProblemIdx] = useState(0);
const [step, setStep] = useState(0);
const problems = [
{
id: 'perimeter',
title: 'Perimeter Formula',
goal: 'Isolate W',
steps: [
{
startEq: <>P = 2L + 2<span className="text-indigo-600">W</span></>,
options: [
{ text: 'Subtract 2L', correct: true },
{ text: 'Divide by 2', correct: false }
],
feedback: 'Moved 2L to the other side.',
nextEq: <>P - 2L = 2<span className="text-indigo-600">W</span></>
},
{
startEq: <>P - 2L = 2<span className="text-indigo-600">W</span></>,
options: [
{ text: 'Divide by 2', correct: true },
{ text: 'Subtract 2', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">W</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">P - 2L</span>2</span></>
}
]
},
{
id: 'linear',
title: 'Slope-Intercept',
goal: 'Isolate x',
steps: [
{
startEq: <>y = m<span className="text-indigo-600">x</span> + b</>,
options: [
{ text: 'Subtract b', correct: true },
{ text: 'Divide by m', correct: false }
],
feedback: 'Isolated the x term.',
nextEq: <>y - b = m<span className="text-indigo-600">x</span></>
},
{
startEq: <>y - b = m<span className="text-indigo-600">x</span></>,
options: [
{ text: 'Divide by m', correct: true },
{ text: 'Subtract m', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">x</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">y - b</span>m</span></>
}
]
},
{
id: 'standard',
title: 'Standard Form',
goal: 'Isolate y',
steps: [
{
startEq: <>Ax + B<span className="text-indigo-600">y</span> = C</>,
options: [
{ text: 'Subtract Ax', correct: true },
{ text: 'Divide by B', correct: false }
],
feedback: 'Moved the x term away.',
nextEq: <>B<span className="text-indigo-600">y</span> = C - Ax</>
},
{
startEq: <>B<span className="text-indigo-600">y</span> = C - Ax</>,
options: [
{ text: 'Divide by B', correct: true },
{ text: 'Subtract B', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">y</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">C - Ax</span>B</span></>
}
]
},
{
id: 'physics',
title: 'Velocity Formula',
goal: 'Isolate a',
steps: [
{
startEq: <>v = u + <span className="text-indigo-600">a</span>t</>,
options: [
{ text: 'Subtract u', correct: true },
{ text: 'Divide by t', correct: false }
],
feedback: 'Isolated the term with a.',
nextEq: <>v - u = <span className="text-indigo-600">a</span>t</>
},
{
startEq: <>v - u = <span className="text-indigo-600">a</span>t</>,
options: [
{ text: 'Divide by t', correct: true },
{ text: 'Subtract t', correct: false }
],
feedback: 'Solved!',
nextEq: <><span className="text-indigo-600">a</span> = <span className="inline-block align-middle text-center border-t border-slate-800 pt-1 leading-none"><span className="block pb-1">v - u</span>t</span></>
}
]
}
];
const currentProb = problems[problemIdx];
const currentStepData = currentProb.steps[step];
const handleNextProblem = () => {
let next = Math.floor(Math.random() * problems.length);
while (next === problemIdx) {
next = Math.floor(Math.random() * problems.length);
}
setProblemIdx(next);
setStep(0);
};
const reset = () => {
setStep(0);
};
const handleOption = (isCorrect: boolean) => {
if (isCorrect) {
setStep(step + 1);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex justify-between items-center mb-6">
<h4 className="font-bold text-slate-700 flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-xs font-bold">Ex</span>
{currentProb.goal}
</h4>
<button onClick={reset} className="text-slate-400 hover:text-indigo-600 transition-colors" title="Reset this problem">
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="text-center mb-8 h-32 flex flex-col items-center justify-center transition-all">
{step < 2 ? (
<div className="animate-fade-in">
<div className="text-3xl font-mono font-bold text-slate-800 mb-2">
{currentStepData.startEq}
</div>
{step === 1 && <p className="text-sm text-green-600 font-bold mb-2 animate-pulse">{problems[problemIdx].steps[0].feedback}</p>}
<p className="text-sm text-slate-500">
{step === 0 ? "What is the first step?" : "What is the next step?"}
</p>
</div>
) : (
<div className="animate-fade-in bg-green-50 p-6 rounded-xl border border-green-200 w-full">
<div className="text-3xl font-mono font-bold text-green-800 mb-2">
{currentProb.steps[1].nextEq}
</div>
<div className="flex items-center justify-center gap-2 text-green-700 font-bold">
<Check className="w-5 h-5" /> {currentProb.steps[1].feedback}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{step < 2 ? (
<>
{currentStepData.options.map((opt, i) => (
<button
key={i}
onClick={() => handleOption(opt.correct)}
className={`p-4 rounded-xl border-2 transition-all text-left group ${
opt.correct
? 'bg-slate-50 border-slate-200 hover:border-indigo-400 hover:bg-indigo-50'
: 'bg-slate-50 border-slate-200 hover:border-slate-300 opacity-80'
}`}
>
<span className={`text-xs font-bold uppercase mb-1 block ${opt.correct ? 'text-indigo-400 group-hover:text-indigo-600' : 'text-slate-400'}`}>
Option {i+1}
</span>
<span className="font-bold text-slate-700 group-hover:text-indigo-900">{opt.text}</span>
</button>
))}
</>
) : (
<button
onClick={handleNextProblem}
className="col-span-2 p-4 bg-indigo-600 text-white font-bold rounded-xl hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Try Another Problem <ArrowRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
export default LiteralEquationWidget;

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { ArrowRight } from 'lucide-react';
const MultiStepPercentWidget: React.FC = () => {
const [start, setStart] = useState(100);
const [change1, setChange1] = useState(40); // +40%
const [change2, setChange2] = useState(-25); // -25%
const step1Val = start * (1 + change1/100);
const finalVal = step1Val * (1 + change2/100);
const overallChange = ((finalVal - start) / start) * 100;
const naiveChange = change1 + change2;
// Scale for visualization
const maxVal = Math.max(start, step1Val, finalVal, 150);
const getWidth = (val: number) => (val / maxVal) * 100;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 1 (Markup)</label>
<div className="flex items-center gap-3">
<input
type="range" min="-50" max="100" step="5"
value={change1} onChange={e => setChange1(parseInt(e.target.value))}
className="flex-1 accent-indigo-600"
/>
<span className="font-bold text-indigo-600 w-12 text-right">{change1 > 0 ? '+' : ''}{change1}%</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 2 (Discount)</label>
<div className="flex items-center gap-3">
<input
type="range" min="-50" max="50" step="5"
value={change2} onChange={e => setChange2(parseInt(e.target.value))}
className="flex-1 accent-rose-600"
/>
<span className="font-bold text-rose-600 w-12 text-right">{change2 > 0 ? '+' : ''}{change2}%</span>
</div>
</div>
</div>
<div className="flex-1 space-y-4">
{/* Step 0 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-slate-400 mb-1">
<span>Start</span>
<span>${start}</span>
</div>
<div className="h-8 bg-slate-200 rounded-md" style={{ width: `${getWidth(start)}%` }}></div>
</div>
{/* Step 1 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-indigo-500 mb-1">
<span>After {change1 > 0 ? '+' : ''}{change1}%</span>
<span>${step1Val.toFixed(2)}</span>
</div>
<div className="h-8 bg-indigo-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(step1Val)}%` }}>
<div className="h-full bg-indigo-500 rounded-l-md" style={{ width: `${(start/step1Val)*100}%` }}></div>
</div>
</div>
{/* Step 2 */}
<div className="relative">
<div className="flex justify-between text-xs font-bold text-rose-500 mb-1">
<span>After {change2 > 0 ? '+' : ''}{change2}%</span>
<span>${finalVal.toFixed(2)}</span>
</div>
<div className="h-8 bg-rose-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(finalVal)}%` }}>
<div className="h-full bg-rose-500 rounded-l-md" style={{ width: `${(step1Val/finalVal)*100}%` }}></div>
</div>
</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-xs font-bold text-slate-400 uppercase mb-1">The Trap (Additive)</div>
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2">
{naiveChange > 0 ? '+' : ''}{naiveChange}%
</div>
<div className="text-[10px] text-slate-400">({change1} + {change2})</div>
</div>
<div>
<div className="text-xs font-bold text-emerald-600 uppercase mb-1">Actual Change</div>
<div className="text-2xl font-bold text-emerald-600">
{overallChange > 0 ? '+' : ''}{overallChange.toFixed(2)}%
</div>
<div className="text-[10px] text-emerald-600 font-mono">
1.{change1} × {1 + change2/100} = {(1 + change1/100) * (1 + change2/100)}
</div>
</div>
</div>
</div>
);
};
export default MultiStepPercentWidget;

View File

@ -0,0 +1,124 @@
import React, { useState } from 'react';
const MultiplicityWidget: React.FC = () => {
const [m1, setM1] = useState(1); // Multiplicity for (x+2)
const [m2, setM2] = useState(2); // Multiplicity for (x-1)
// f(x) = 0.1 * (x+2)^m1 * (x-1)^m2
// Scale factor to keep y-values reasonable for visualization
const width = 300;
const height = 200;
const rangeX = 4;
const scaleX = width / (rangeX * 2);
const centerX = width / 2;
const centerY = height / 2;
const scaleY = 15; // Vertical compression
const toPx = (x: number, y: number) => ({
x: centerX + x * scaleX,
y: centerY - y * scaleY
});
const generatePath = () => {
let d = "";
// f(x) scaling factor depends on degree to keep graph in view
const k = 0.5;
for (let x = -rangeX; x <= rangeX; x += 0.05) {
const y = k * Math.pow(x + 2, m1) * Math.pow(x - 1, m2);
if (Math.abs(y) > 100) continue; // Clip
const pos = toPx(x, y);
d += d ? ` L ${pos.x} ${pos.y}` : `M ${pos.x} ${pos.y}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 text-center">
<div className="inline-block bg-slate-50 p-4 rounded-xl border border-slate-200">
<span className="font-mono text-xl font-bold text-slate-800">
P(x) = (x + 2)<sup className="text-rose-600">{m1}</sup> (x - 1)<sup className="text-indigo-600">{m2}</sup>
</span>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8 items-center">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-rose-600 uppercase mb-2 block">Root x = -2</label>
<div className="flex gap-2">
<button
onClick={() => setM1(1)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m1 === 1 ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Odd (1)
</button>
<button
onClick={() => setM1(2)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m1 === 2 ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Even (2)
</button>
</div>
<p className="text-xs text-rose-600 mt-2 font-bold text-center">
{m1 % 2 !== 0 ? "CROSSES Axis" : "TOUCHES Axis"}
</p>
</div>
<div>
<label className="text-xs font-bold text-indigo-600 uppercase mb-2 block">Root x = 1</label>
<div className="flex gap-2">
<button
onClick={() => setM2(1)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m2 === 1 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Odd (1)
</button>
<button
onClick={() => setM2(2)}
className={`flex-1 py-2 rounded-lg font-bold text-sm border transition-all ${m2 === 2 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-500 border-slate-200'}`}
>
Even (2)
</button>
</div>
<p className="text-xs text-indigo-600 mt-2 font-bold text-center">
{m2 % 2 !== 0 ? "CROSSES Axis" : "TOUCHES Axis"}
</p>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[200px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 200">
{/* Grid Lines */}
<defs>
<pattern id="grid-mult" width="37.5" height="20" patternUnits="userSpaceOnUse">
<path d="M 37.5 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-mult)" />
{/* Axes */}
<line x1="0" y1={centerY} x2={width} y2={centerY} stroke="#94a3b8" strokeWidth="2" />
<line x1={centerX} y1="0" x2={centerX} y2={height} stroke="#94a3b8" strokeWidth="2" />
{/* Graph */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Roots */}
<circle cx={toPx(-2, 0).x} cy={toPx(-2, 0).y} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(-2, 0).x} y={toPx(-2, 0).y + 20} textAnchor="middle" className="text-xs font-bold fill-rose-600">-2</text>
<circle cx={toPx(1, 0).x} cy={toPx(1, 0).y} r="5" fill="#4f46e5" stroke="white" strokeWidth="2" />
<text x={toPx(1, 0).x} y={toPx(1, 0).y + 20} textAnchor="middle" className="text-xs font-bold fill-indigo-600">1</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default MultiplicityWidget;

View File

@ -0,0 +1,96 @@
import React, { useState } from 'react';
const ParabolaWidget: React.FC = () => {
const [a, setA] = useState(1);
const [h, setH] = useState(2);
const [k, setK] = useState(1);
// Viewport
const range = 10;
const size = 300;
const scale = 300 / (range * 2);
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
// Generate Path
const generatePath = () => {
const step = 0.5;
let d = "";
for (let x = -range; x <= range; x += step) {
const y = a * Math.pow(x - h, 2) + k;
// Clip if way out of bounds to avoid SVG issues
if (Math.abs(y) > range * 2) continue;
const px = toPx(x);
const py = toPx(y, true);
d += x === -range ? `M ${px} ${py}` : ` L ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl text-center border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-1">Vertex Form</div>
<div className="text-xl font-mono font-bold text-slate-800">
y = <span className="text-indigo-600">{a}</span>(x - <span className="text-emerald-600">{h}</span>)² + <span className="text-rose-600">{k}</span>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Stretch (a) <span>{a}</span>
</label>
<input type="range" min="-3" max="3" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg accent-indigo-600"/>
</div>
<div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
H-Shift (h) <span>{h}</span>
</label>
<input type="range" min="-5" max="5" step="0.5" value={h} onChange={e => setH(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg accent-emerald-600"/>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
V-Shift (k) <span>{k}</span>
</label>
<input type="range" min="-5" max="5" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg accent-rose-600"/>
</div>
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
<defs>
<pattern id="para-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#para-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Parabola */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" />
{/* Vertex */}
<circle cx={toPx(h)} cy={toPx(k, true)} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(h)} y={toPx(k, true) - 10} textAnchor="middle" className="text-xs font-bold fill-rose-600 bg-white">V({h}, {k})</text>
{/* Axis of Symmetry */}
<line x1={toPx(h)} y1="0" x2={toPx(h)} y2={size} stroke="#10b981" strokeWidth="1" strokeDasharray="4,4" opacity="0.5" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default ParabolaWidget;

View File

@ -0,0 +1,113 @@
import React, { useState } from 'react';
const ParallelPerpendicularWidget: React.FC = () => {
const [slope, setSlope] = useState(2);
const [showParallel, setShowParallel] = useState(true);
const [showPerpendicular, setShowPerpendicular] = useState(true);
const range = 10;
const scale = 20; // 20px per unit
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale;
const getLinePath = (m: number, b: number) => {
// Find two points on edges of view box (-range, +range)
// y = mx + b
// Need to clip lines to viewBox to be nice
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
const perpSlope = slope === 0 ? 1000 : -1 / slope; // Hack for vertical
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl">
<label className="text-xs font-bold text-slate-500 uppercase mb-2 block">Reference Slope (m)</label>
<div className="flex items-center gap-4">
<input
type="range" min="-4" max="4" step="0.5"
value={slope} onChange={e => setSlope(parseFloat(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<span className="font-mono font-bold text-indigo-700 w-12 text-right">{slope}</span>
</div>
</div>
<div className="space-y-3">
<button
onClick={() => setShowParallel(!showParallel)}
className={`w-full flex items-center justify-between p-3 rounded-lg border-2 transition-all ${
showParallel ? 'border-sky-500 bg-sky-50 text-sky-900' : 'border-slate-200 text-slate-400'
}`}
>
<span className="font-bold">Parallel</span>
<span className="font-mono text-sm">{showParallel ? `m = ${slope}` : 'Hidden'}</span>
</button>
<button
onClick={() => setShowPerpendicular(!showPerpendicular)}
className={`w-full flex items-center justify-between p-3 rounded-lg border-2 transition-all ${
showPerpendicular ? 'border-rose-500 bg-rose-50 text-rose-900' : 'border-slate-200 text-slate-400'
}`}
>
<span className="font-bold">Perpendicular</span>
<span className="font-mono text-sm">{showPerpendicular ? `m = ${slope === 0 ? 'Undef' : (-1/slope).toFixed(2)}` : 'Hidden'}</span>
</button>
</div>
<div className="text-xs text-slate-500 bg-slate-50 p-3 rounded">
<strong>Key Rule:</strong> Perpendicular slopes are negative reciprocals ($m$ vs $-1/m$). Their product is always -1.
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-p" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-p)" />
{/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
{/* Reference Line (Indigo) */}
<path d={getLinePath(slope, 0)} stroke="#4f46e5" strokeWidth="3" />
{/* Parallel Line (Sky) - Shifted up by 3 units */}
{showParallel && (
<path d={getLinePath(slope, 3)} stroke="#0ea5e9" strokeWidth="3" strokeDasharray="5,5" />
)}
{/* Perpendicular Line (Rose) - Through Origin */}
{showPerpendicular && (
<>
<path d={getLinePath(perpSlope, 0)} stroke="#e11d48" strokeWidth="3" />
{/* Right Angle Marker approx */}
<rect
x={center} y={center} width="15" height="15"
fill="rgba(225, 29, 72, 0.2)"
transform={`rotate(${-Math.atan(slope) * 180 / Math.PI} ${center} ${center})`}
/>
</>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default ParallelPerpendicularWidget;

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
const PercentChangeWidget: React.FC = () => {
const [original, setOriginal] = useState(100);
const [percent, setPercent] = useState(25); // -100 to 100
const multiplier = 1 + (percent / 100);
const newValue = original * multiplier;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8 space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-bold text-slate-500 uppercase">Original Value</label>
<input
type="number" value={original} onChange={e => setOriginal(Number(e.target.value))}
className="w-24 p-1 border border-slate-300 rounded font-mono font-bold text-slate-700 text-right"
/>
</div>
<div>
<div className="flex justify-between mb-2">
<label className="text-sm font-bold text-slate-500 uppercase">Percent Change</label>
<span className={`font-bold font-mono ${percent >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{percent > 0 ? '+' : ''}{percent}%
</span>
</div>
<input
type="range" min="-50" max="100" step="5" value={percent}
onChange={e => setPercent(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div>
</div>
<div className="flex items-end justify-center gap-8 h-48 border-b border-slate-200 pb-0 mb-6">
{/* Original Bar */}
<div className="flex flex-col items-center gap-2 w-24">
<span className="font-bold text-slate-500">{original}</span>
<div className="w-full bg-slate-400 rounded-t-lg transition-all duration-500" style={{ height: '120px' }}></div>
<span className="text-xs font-bold text-slate-400 uppercase mt-2">Original</span>
</div>
{/* New Bar */}
<div className="flex flex-col items-center gap-2 w-24">
<span className={`font-bold ${percent >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{newValue.toFixed(1)}
</span>
<div
className={`w-full rounded-t-lg transition-all duration-500 ${percent >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`}
style={{ height: `${120 * multiplier}px` }}
></div>
<span className="text-xs font-bold text-slate-400 uppercase mt-2">New</span>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2">Formula</h4>
<div className="font-mono text-lg text-center text-slate-800">
New = Original × (1 {percent >= 0 ? '+' : '-'} <span className="text-indigo-600">{Math.abs(percent/100)}</span>)
</div>
<div className="font-mono text-xl font-bold text-center text-indigo-700 mt-2">
New = {original} × {multiplier.toFixed(2)}
</div>
</div>
</div>
);
};
export default PercentChangeWidget;

View File

@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react';
const PolygonWidget: React.FC = () => {
const [n, setN] = useState(5);
// Math
const interiorSum = (n - 2) * 180;
const eachInterior = Math.round((interiorSum / n) * 100) / 100;
const eachExterior = Math.round((360 / n) * 100) / 100;
// SVG Config
const width = 300;
const height = 300;
const cx = width / 2;
const cy = height / 2;
const r = 80;
// Generate points
const points = [];
for (let i = 0; i < n; i++) {
const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top
points.push({
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
});
}
// Generate path string
const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z';
// Generate exterior lines (extensions)
const exteriorLines = points.map((p, i) => {
const nextP = points[(i + 1) % n];
// Vector from p to nextP
const dx = nextP.x - p.x;
const dy = nextP.y - p.y;
// Normalize and extend
const len = Math.sqrt(dx*dx + dy*dy);
const exLen = 40;
const exX = nextP.x + (dx/len) * exLen;
const exY = nextP.y + (dy/len) * exLen;
return { x1: nextP.x, y1: nextP.y, x2: exX, y2: exY };
});
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 w-full max-w-xs">
<label className="block text-sm font-bold text-slate-500 uppercase mb-2">Number of Sides (n): <span className="text-slate-900 text-lg">{n}</span></label>
<input
type="range" min="3" max="10" step="1"
value={n} onChange={(e) => setN(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6"
/>
<div className="space-y-3 font-mono text-sm">
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Interior Sum</div>
<div className="text-slate-800">(n - 2) × 180° = <strong className="text-emerald-600">{interiorSum}°</strong></div>
</div>
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Interior Angle</div>
<div className="text-slate-800">{interiorSum} / {n} = <strong className="text-emerald-600">{eachInterior}°</strong></div>
</div>
<div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Exterior Angle</div>
<div className="text-slate-800">360 / {n} = <strong className="text-rose-600">{eachExterior}°</strong></div>
</div>
</div>
</div>
<div className="flex-shrink-0 relative">
<svg width={width} height={height}>
{/* Extensions for exterior angles */}
{exteriorLines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
))}
{/* Polygon */}
<path d={pathD} fill="rgba(16, 185, 129, 0.1)" stroke="#059669" strokeWidth="3" />
{/* Vertices */}
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="4" fill="#059669" />
))}
{/* Center text */}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" fill="#059669" fontSize="24" fontWeight="bold" opacity="0.2">
{n}-gon
</text>
</svg>
</div>
</div>
);
};
export default PolygonWidget;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
const PolynomialBehaviorWidget: React.FC = () => {
const [degreeType, setDegreeType] = useState<'even' | 'odd'>('odd');
const [lcSign, setLcSign] = useState<'pos' | 'neg'>('pos');
// Visualization
const width = 300;
const height = 200;
const getPath = () => {
// Create schematic shapes
// Odd +: Low Left -> High Right
// Odd -: High Left -> Low Right
// Even +: High Left -> High Right
// Even -: Low Left -> Low Right
const startY = (degreeType === 'odd' && lcSign === 'pos') || (degreeType === 'even' && lcSign === 'neg') ? 180 : 20;
const endY = (lcSign === 'pos') ? 20 : 180;
// Control points for curvy polynomial look
const cp1Y = startY === 20 ? 150 : 50;
const cp2Y = endY === 20 ? 150 : 50;
return `M 20 ${startY} C 100 ${cp1Y}, 200 ${cp2Y}, 280 ${endY}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Degree (Highest Power)</p>
<div className="flex gap-2">
<button onClick={() => setDegreeType('even')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'even' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Even (x², x)</button>
<button onClick={() => setDegreeType('odd')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'odd' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Odd (x, x³)</button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Leading Coefficient</p>
<div className="flex gap-2">
<button onClick={() => setLcSign('pos')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'pos' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-slate-600 border-slate-200'}`}>Positive (+)</button>
<button onClick={() => setLcSign('neg')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'neg' ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-600 border-slate-200'}`}>Negative (-)</button>
</div>
</div>
</div>
<div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center">
<svg width="300" height="200">
<line x1="150" y1="20" x2="150" y2="180" stroke="#cbd5e1" strokeWidth="2" />
<line x1="20" y1="100" x2="280" y2="100" stroke="#cbd5e1" strokeWidth="2" />
<path d={getPath()} stroke="#8b5cf6" strokeWidth="4" fill="none" markerEnd="url(#arrow)" markerStart="url(#arrow-start)" />
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker>
<marker id="arrow-start" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto-start-reverse">
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker>
</defs>
</svg>
<div className="absolute top-2 left-2 text-xs font-bold text-slate-400">End Behavior</div>
</div>
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center">
{degreeType === 'even' && lcSign === 'pos' && "Ends go in the SAME direction (UP)."}
{degreeType === 'even' && lcSign === 'neg' && "Ends go in the SAME direction (DOWN)."}
{degreeType === 'odd' && lcSign === 'pos' && "Ends go in OPPOSITE directions (Down Left, Up Right)."}
{degreeType === 'odd' && lcSign === 'neg' && "Ends go in OPPOSITE directions (Up Left, Down Right)."}
</div>
</div>
);
};
export default PolynomialBehaviorWidget;

View File

@ -0,0 +1,364 @@
import React, { useState, useRef } from 'react';
type Mode = 'chords' | 'secants';
interface Point {
x: number;
y: number;
}
const PowerOfPointWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('chords');
// -- Common State --
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef<string | null>(null);
const center = { x: 200, y: 180 };
const radius = 100;
// -- Chords Mode State --
// Store angles for points A, B, C, D on the circle
const [chordAngles, setChordAngles] = useState({
a: 220, b: 40, // Chord 1
c: 140, d: 320 // Chord 2
});
// -- Secants Mode State --
// P is external point.
// Secant 1 defined by angle theta1 (offset from center-P line)
// Secant 2 defined by angle theta2
const [secantState, setSecantState] = useState({
px: 380, py: 180, // Point P
theta1: 15, // Angle offset for secant 1
});
// --- Helper Math ---
const getPosOnCircle = (deg: number) => ({
x: center.x + radius * Math.cos(deg * Math.PI / 180),
y: center.y + radius * Math.sin(deg * Math.PI / 180)
});
const dist = (p1: Point, p2: Point) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
const getIntersection = (p1: Point, p2: Point, p3: Point, p4: Point) => {
// Line AB represented as a1x + b1y = c1
const a1 = p2.y - p1.y;
const b1 = p1.x - p2.x;
const c1 = a1 * p1.x + b1 * p1.y;
// Line CD represented as a2x + b2y = c2
const a2 = p4.y - p3.y;
const b2 = p3.x - p4.x;
const c2 = a2 * p3.x + b2 * p3.y;
const determinant = a1 * b2 - a2 * b1;
if (Math.abs(determinant) < 0.001) return null; // Parallel
const x = (b2 * c1 - b1 * c2) / determinant;
const y = (a1 * c2 - a2 * c1) / determinant;
// Check if inside circle
if (dist({x,y}, center) > radius + 1) return null;
return { x, y };
};
// --- Interaction Handlers ---
const handleChordDrag = (e: React.MouseEvent, key: string) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI;
if (deg < 0) deg += 360;
setChordAngles(prev => ({ ...prev, [key]: deg }));
};
const handleSecantDrag = (e: React.MouseEvent) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (isDragging.current === 'P') {
// Constrain P outside
const dx = x - center.x;
const dy = y - center.y;
const d = Math.sqrt(dx*dx + dy*dy);
if (d > radius + 20) {
setSecantState(prev => ({...prev, px: x, py: y}));
} else {
const ang = Math.atan2(dy, dx);
setSecantState(prev => ({
...prev,
px: center.x + (radius+20)*Math.cos(ang),
py: center.y + (radius+20)*Math.sin(ang)
}));
}
} else if (isDragging.current === 'SecantEnd') {
// Calculate angle relative to PO line
// Vector PO
const pdx = center.x - secantState.px;
const pdy = center.y - secantState.py;
const poAngle = Math.atan2(pdy, pdx);
// Vector PA (mouse to P)
const mdx = x - secantState.px;
const mdy = y - secantState.py;
const mAngle = Math.atan2(mdy, mdx);
let diff = (mAngle - poAngle) * 180 / Math.PI;
// Normalize to -180 to 180
while (diff > 180) diff -= 360;
while (diff < -180) diff += 360;
// Clamp to hit circle. Max angle is asin(R/dist)
const distPO = Math.sqrt(pdx*pdx + pdy*pdy);
const maxAngle = Math.asin(radius/distPO) * 180 / Math.PI;
// Clamp
const clamped = Math.max(-maxAngle + 1, Math.min(maxAngle - 1, diff));
setSecantState(prev => ({...prev, theta1: clamped}));
}
};
// --- Render Helpers ---
const renderChords = () => {
const A = getPosOnCircle(chordAngles.a);
const B = getPosOnCircle(chordAngles.b);
const C = getPosOnCircle(chordAngles.c);
const D = getPosOnCircle(chordAngles.d);
const E = getIntersection(A, B, C, D);
const valid = !!E;
const ae = valid ? dist(A, E) : 0;
const eb = valid ? dist(E, B) : 0;
const ce = valid ? dist(C, E) : 0;
const ed = valid ? dist(E, D) : 0;
const points = [
{ k: 'a', p: A, l: 'A', c: '#7c3aed' },
{ k: 'b', p: B, l: 'B', c: '#7c3aed' },
{ k: 'c', p: C, l: 'C', c: '#059669' },
{ k: 'd', p: D, l: 'D', c: '#059669' }
];
return (
<>
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#059669" strokeWidth="3" />
{/* Points */}
{points.map((pt) => (
<g key={pt.k} onMouseDown={() => isDragging.current = pt.k} className="cursor-pointer hover:scale-110 transition-transform">
<circle cx={pt.p.x} cy={pt.p.y} r="15" fill="transparent" />
<circle cx={pt.p.x} cy={pt.p.y} r="6" fill={pt.c} stroke="white" strokeWidth="2" />
<text x={pt.p.x} y={pt.p.y - 12} textAnchor="middle" className="text-sm font-bold fill-slate-700">{pt.l}</text>
</g>
))}
{valid && (
<>
<circle cx={E.x} cy={E.y} r="4" fill="#0f172a" />
<text x={E.x + 10} y={E.y} className="text-xs font-bold fill-slate-500">E</text>
</>
)}
{/* Info Panel */}
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
{!valid ? (
<p className="text-red-500 font-bold">Chords must intersect inside!</p>
) : (
<div className="space-y-3 font-mono text-sm">
<div className="flex gap-4">
<div>
<div className="text-xs font-bold text-violet-600">Purple Chord</div>
<div>{ae.toFixed(0)} × {eb.toFixed(0)} = <strong>{(ae*eb).toFixed(0)}</strong></div>
</div>
</div>
<div className="flex gap-4">
<div>
<div className="text-xs font-bold text-emerald-600">Green Chord</div>
<div>{ce.toFixed(0)} × {ed.toFixed(0)} = <strong>{(ce*ed).toFixed(0)}</strong></div>
</div>
</div>
<div className="h-px bg-slate-200"></div>
<p className="text-slate-500 text-xs text-center font-sans">
AE · EB = CE · ED
</p>
</div>
)}
</div>
</>
);
};
const renderSecant = () => {
const { px, py, theta1 } = secantState;
// Calculate Tangent Point T (Upper)
const dx = px - center.x;
const dy = py - center.y;
const distPO = Math.sqrt(dx*dx + dy*dy);
const anglePO = Math.atan2(dy, dx);
const angleOffset = Math.acos(radius/distPO);
const tAngle = anglePO - angleOffset;
const T = {
x: center.x + radius * Math.cos(tAngle),
y: center.y + radius * Math.sin(tAngle)
};
const tangentLen = Math.sqrt(distPO*distPO - radius*radius);
// Calculate Secant Intersection Points
// Secant Line angle
const secantAngle = anglePO + theta1 * Math.PI / 180;
const vx = px - center.x;
const vy = py - center.y;
const cos = Math.cos(secantAngle);
const sin = Math.sin(secantAngle);
// t^2 + 2(V.D)t + (V^2 - R^2) = 0
const b = 2 * (vx * cos + vy * sin);
const c = vx*vx + vy*vy - radius*radius;
const det = b*b - 4*c;
let A = {x:0, y:0}, B = {x:0, y:0};
let valid = false;
if (det > 0) {
const tFar = (-b - Math.sqrt(det)) / 2;
const tNear = (-b + Math.sqrt(det)) / 2;
// A is Near (External part)
A = { x: px + tNear * cos, y: py + tNear * sin };
// B is Far (Whole secant endpoint)
B = { x: px + tFar * cos, y: py + tFar * sin };
valid = true;
}
const distPA = valid ? dist({x:px, y:py}, A) : 0;
const distPB = valid ? dist({x:px, y:py}, B) : 0;
return (
<>
{/* Tangent Line */}
<line x1={px} y1={py} x2={T.x} y2={T.y} stroke="#e11d48" strokeWidth="3" />
<circle cx={T.x} cy={T.y} r="5" fill="#e11d48" />
<text x={T.x} y={T.y - 10} className="text-xs font-bold fill-rose-600">T</text>
{/* Secant Line (Draw full segment P to B) */}
{valid && <line x1={px} y1={py} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />}
{valid && (
<>
{/* Point A (Near/External) */}
<circle cx={A.x} cy={A.y} r="5" fill="#7c3aed" />
<text x={A.x + 15} y={A.y} className="text-xs font-bold fill-violet-600">A</text>
{/* Point B (Far/Whole) */}
<circle cx={B.x} cy={B.y} r="5" fill="#7c3aed" />
<text x={B.x - 15} y={B.y} className="text-xs font-bold fill-violet-600">B</text>
</>
)}
{/* Point P */}
<g onMouseDown={() => isDragging.current = 'P'} className="cursor-grab active:cursor-grabbing">
<circle cx={px} cy={py} r="15" fill="transparent" />
<circle cx={px} cy={py} r="6" fill="#0f172a" stroke="white" strokeWidth="2" />
<text x={px + 10} y={py} className="text-sm font-bold fill-slate-800">P</text>
</g>
{/* Drag Handle for Secant Angle (at B, the far end) */}
{valid && (
<circle
cx={B.x} cy={B.y} r="12" fill="transparent" stroke="white" strokeWidth="2" strokeDasharray="2,2"
className="cursor-pointer hover:stroke-violet-400"
onMouseDown={() => isDragging.current = 'SecantEnd'}
/>
)}
{/* Info */}
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
<div className="space-y-3 font-mono text-sm">
<div className="mb-2">
<div className="text-xs font-bold text-rose-600 uppercase">Tangent² (PT²)</div>
<div>{tangentLen.toFixed(0)}² = <strong>{(tangentLen*tangentLen).toFixed(0)}</strong></div>
</div>
<div>
<div className="text-xs font-bold text-violet-600 uppercase">Secant (PA · PB)</div>
<div className="flex items-center gap-1">
<span title="External Part (PA)">{distPA.toFixed(0)}</span>
<span className="text-slate-400">×</span>
<span title="Whole Secant (PB)">{distPB.toFixed(0)}</span>
<span className="text-slate-400">=</span>
<strong>{(distPA*distPB).toFixed(0)}</strong>
</div>
</div>
<div className="h-px bg-slate-200 my-2"></div>
<p className="text-slate-500 text-xs text-center font-sans">
Tangent² = External × Whole
</p>
</div>
</div>
</>
);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return;
if (mode === 'chords') {
// Check if dragging specific points
if (['a','b','c','d'].includes(isDragging.current as string)) {
handleChordDrag(e, isDragging.current as string);
}
} else {
handleSecantDrag(e);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex gap-4 mb-6 justify-center">
<button
onClick={() => setMode('chords')}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'chords' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
Intersecting Chords
</button>
<button
onClick={() => setMode('secants')}
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'secants' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
Tangent-Secant
</button>
</div>
<div className="relative flex justify-center bg-slate-50 rounded-xl border border-slate-100 overflow-hidden">
<svg
ref={svgRef}
width="500" height="360"
className="select-none"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = null}
onMouseLeave={() => isDragging.current = null}
>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Circle */}
<circle cx={center.x} cy={center.y} r={radius} fill="white" stroke="#94a3b8" strokeWidth="2" />
<circle cx={center.x} cy={center.y} r="3" fill="#cbd5e1" />
{mode === 'chords' ? renderChords() : renderSecant()}
</svg>
</div>
<div className="mt-4 text-center text-sm text-slate-500">
{mode === 'chords'
? "Drag the colored points along the circle."
: "Drag point P or the secant endpoint B."
}
</div>
</div>
);
};
export default PowerOfPointWidget;

View File

@ -0,0 +1,110 @@
import React, { useState } from 'react';
type HighlightMode = 'none' | 'bus' | 'club' | 'cond_club_bus' | 'cond_bus_club' | 'or_bus_club';
const ProbabilityTableWidget: React.FC = () => {
const [highlight, setHighlight] = useState<HighlightMode>('none');
const data = {
bus_club: 36, bus_noClub: 24,
noBus_club: 30, noBus_noClub: 30
};
const totals = {
bus: data.bus_club + data.bus_noClub, // 60
noBus: data.noBus_club + data.noBus_noClub, // 60
club: data.bus_club + data.noBus_club, // 66
noClub: data.bus_noClub + data.noBus_noClub, // 54
total: 120
};
const getCellClass = (cell: string) => {
const base = "p-4 text-center border font-mono font-bold transition-colors duration-300 ";
// Logic for highlighting based on mode
let isNum = false;
let isDenom = false;
if (highlight === 'bus') {
if (cell === 'bus_total') isNum = true;
if (cell === 'grand_total') isDenom = true;
} else if (highlight === 'club') {
if (cell === 'club_total') isNum = true;
if (cell === 'grand_total') isDenom = true;
} else if (highlight === 'cond_club_bus') {
if (cell === 'bus_club') isNum = true;
if (cell === 'bus_total') isDenom = true;
} else if (highlight === 'cond_bus_club') {
if (cell === 'bus_club') isNum = true;
if (cell === 'club_total') isDenom = true;
} else if (highlight === 'or_bus_club') {
if (['bus_club', 'bus_noClub', 'noBus_club'].includes(cell)) isNum = true;
if (cell === 'grand_total') isDenom = true;
}
if (isNum) return base + "bg-emerald-100 text-emerald-800 border-emerald-300";
if (isDenom) return base + "bg-indigo-100 text-indigo-800 border-indigo-300";
return base + "bg-white border-slate-200 text-slate-600";
};
const explanation = () => {
switch(highlight) {
case 'bus': return { title: "P(Bus)", math: `${totals.bus} / ${totals.total} = 0.50` };
case 'club': return { title: "P(Club)", math: `${totals.club} / ${totals.total} = 0.55` };
case 'cond_club_bus': return { title: "P(Club | Bus)", math: `${data.bus_club} / ${totals.bus} = 0.60`, sub: "Given Bus, restrict to Bus row." };
case 'cond_bus_club': return { title: "P(Bus | Club)", math: `${data.bus_club} / ${totals.club} ≈ 0.55`, sub: "Given Club, restrict to Club column." };
case 'or_bus_club': return { title: "P(Bus OR Club)", math: `(${totals.bus} + ${totals.club} - ${data.bus_club}) / ${totals.total} = ${totals.bus+totals.club-data.bus_club}/${totals.total} = 0.75`, sub: "Add totals, subtract overlap." };
default: return { title: "Select a Probability", math: "---" };
}
};
const exp = explanation();
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-wrap gap-2 mb-6 justify-center">
<button onClick={() => setHighlight('bus')} className="px-3 py-1 bg-slate-100 hover:bg-slate-200 rounded text-sm font-bold text-slate-700">P(Bus)</button>
<button onClick={() => setHighlight('club')} className="px-3 py-1 bg-slate-100 hover:bg-slate-200 rounded text-sm font-bold text-slate-700">P(Club)</button>
<button onClick={() => setHighlight('cond_club_bus')} className="px-3 py-1 bg-indigo-100 hover:bg-indigo-200 rounded text-sm font-bold text-indigo-700">P(Club | Bus)</button>
<button onClick={() => setHighlight('cond_bus_club')} className="px-3 py-1 bg-indigo-100 hover:bg-indigo-200 rounded text-sm font-bold text-indigo-700">P(Bus | Club)</button>
<button onClick={() => setHighlight('or_bus_club')} className="px-3 py-1 bg-emerald-100 hover:bg-emerald-200 rounded text-sm font-bold text-emerald-700">P(Bus OR Club)</button>
</div>
<div className="overflow-hidden rounded-lg border border-slate-200 mb-6">
<div className="grid grid-cols-4 bg-slate-50 border-b border-slate-200">
<div className="p-3 text-center text-xs font-bold text-slate-400 uppercase"></div>
<div className="p-3 text-center text-xs font-bold text-slate-500 uppercase">Club</div>
<div className="p-3 text-center text-xs font-bold text-slate-500 uppercase">No Club</div>
<div className="p-3 text-center text-xs font-bold text-slate-800 uppercase">Total</div>
</div>
<div className="grid grid-cols-4">
<div className="p-4 flex items-center justify-center font-bold text-slate-600 bg-slate-50 border-r border-slate-200">Bus</div>
<div className={getCellClass('bus_club')}>{data.bus_club}</div>
<div className={getCellClass('bus_noClub')}>{data.bus_noClub}</div>
<div className={getCellClass('bus_total')}>{totals.bus}</div>
<div className="p-4 flex items-center justify-center font-bold text-slate-600 bg-slate-50 border-r border-slate-200 border-t border-slate-200">No Bus</div>
<div className={getCellClass('noBus_club')}>{data.noBus_club}</div>
<div className={getCellClass('noBus_noClub')}>{data.noBus_noClub}</div>
<div className={getCellClass('noBus_total')}>{totals.noBus}</div>
<div className="p-4 flex items-center justify-center font-bold text-slate-900 bg-slate-100 border-r border-slate-200 border-t border-slate-200">Total</div>
<div className={getCellClass('club_total')}>{totals.club}</div>
<div className={getCellClass('noClub_total')}>{totals.noClub}</div>
<div className={getCellClass('grand_total')}>{totals.total}</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl text-center">
<h4 className="text-sm font-bold text-slate-500 uppercase mb-2">{exp.title}</h4>
<div className="text-2xl font-mono font-bold text-slate-800 mb-1">
{exp.math}
</div>
{exp.sub && <p className="text-xs text-slate-400">{exp.sub}</p>}
</div>
</div>
);
};
export default ProbabilityTableWidget;

View File

@ -0,0 +1,253 @@
import React, { useState } from 'react';
const ProbabilityTreeWidget: React.FC = () => {
const [replacement, setReplacement] = useState(false);
const [initR, setInitR] = useState(3);
const [initB, setInitB] = useState(4);
const [hoverPath, setHoverPath] = useState<string | null>(null); // 'RR', 'RB', 'BR', 'BB'
const total = initR + initB;
// Level 1 Probs
const pR = initR / total;
const pB = initB / total;
// Level 2 Probs (Given R)
const r_R = replacement ? initR : Math.max(0, initR - 1);
const r_Total = replacement ? total : total - 1;
const pR_R = r_Total > 0 ? r_R / r_Total : 0;
const pB_R = r_Total > 0 ? 1 - pR_R : 0;
// Level 2 Probs (Given B)
const b_B = replacement ? initB : Math.max(0, initB - 1);
const b_Total = replacement ? total : total - 1;
const pB_B = b_Total > 0 ? b_B / b_Total : 0;
const pR_B = b_Total > 0 ? 1 - pB_B : 0;
// Final Probs
const pRR = pR * pR_R;
const pRB = pR * pB_R;
const pBR = pB * pR_B;
const pBB = pB * pB_B;
const fraction = (num: number, den: number) => {
if (den === 0) return "0";
return (
<span className="font-mono bg-white px-1 rounded shadow-sm border border-slate-200 text-xs inline-block mx-1">
{num}/{den}
</span>
);
};
const getPathColor = (path: string, segment: 'top' | 'bottom' | 'top-top' | 'top-bottom' | 'bottom-top' | 'bottom-bottom') => {
const defaultColor = "#cbd5e1"; // Slate 300
if (!hoverPath) {
// Default coloring based on branch type
if (segment.includes('top')) return "#f43f5e"; // Red branches
if (segment.includes('bottom')) return "#3b82f6"; // Blue branches
return defaultColor;
}
// Highlighting logic based on hoverPath
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? "#f43f5e" : "#f1f5f9";
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? "#3b82f6" : "#f1f5f9";
if (segment === 'top-top') return hoverPath === 'RR' ? "#f43f5e" : "#f1f5f9";
if (segment === 'top-bottom') return hoverPath === 'RB' ? "#3b82f6" : "#f1f5f9";
if (segment === 'bottom-top') return hoverPath === 'BR' ? "#f43f5e" : "#f1f5f9";
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? "#3b82f6" : "#f1f5f9";
return defaultColor;
};
const getStrokeWidth = (segment: string) => {
if (!hoverPath) return 2;
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? 4 : 1;
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? 4 : 1;
if (segment === 'top-top') return hoverPath === 'RR' ? 4 : 1;
if (segment === 'top-bottom') return hoverPath === 'RB' ? 4 : 1;
if (segment === 'bottom-top') return hoverPath === 'BR' ? 4 : 1;
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? 4 : 1;
return 2;
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
{/* Controls */}
<div className="flex flex-wrap justify-between items-center mb-6 gap-4">
<div className="flex gap-4">
<div className="flex flex-col">
<label className="text-xs font-bold text-rose-600 uppercase mb-1">Red Items</label>
<div className="flex items-center gap-2">
<button onClick={() => setInitR(Math.max(1, initR-1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">-</button>
<span className="font-bold w-4 text-center">{initR}</span>
<button onClick={() => setInitR(Math.min(10, initR+1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">+</button>
</div>
</div>
<div className="flex flex-col">
<label className="text-xs font-bold text-blue-600 uppercase mb-1">Blue Items</label>
<div className="flex items-center gap-2">
<button onClick={() => setInitB(Math.max(1, initB-1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">-</button>
<span className="font-bold w-4 text-center">{initB}</span>
<button onClick={() => setInitB(Math.min(10, initB+1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">+</button>
</div>
</div>
</div>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setReplacement(true)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`}
>
With Replacement
</button>
<button
onClick={() => setReplacement(false)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`}
>
Without Replacement
</button>
</div>
</div>
<div className="relative h-64 w-full max-w-lg mx-auto select-none">
<svg width="100%" height="100%" className="overflow-visible">
{/* Root */}
<circle cx="20" cy="128" r="6" fill="#64748b" />
{/* Level 1 Branches */}
<path d="M 20 128 C 50 128, 50 64, 150 64" fill="none" stroke={getPathColor('R', 'top')} strokeWidth={getStrokeWidth('top')} className="transition-all duration-300" />
<path d="M 20 128 C 50 128, 50 192, 150 192" fill="none" stroke={getPathColor('B', 'bottom')} strokeWidth={getStrokeWidth('bottom')} className="transition-all duration-300" />
{/* Level 1 Labels */}
<foreignObject x="60" y="70" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='R') ? 'text-slate-300' : 'text-rose-600'}`}>{initR}/{total}</div>
</foreignObject>
<foreignObject x="60" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='B') ? 'text-slate-300' : 'text-blue-600'}`}>{initB}/{total}</div>
</foreignObject>
{/* Level 1 Nodes */}
<circle cx="150" cy="64" r="18" fill="#f43f5e" className={`transition-all ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : 'opacity-100 shadow-md'}`} />
<text x="150" y="68" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : ''}`}>R</text>
<circle cx="150" cy="192" r="18" fill="#3b82f6" className={`transition-all ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : 'opacity-100 shadow-md'}`} />
<text x="150" y="196" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : ''}`}>B</text>
{/* Level 2 Branches (Top) */}
<path d="M 168 64 L 280 32" fill="none" stroke={getPathColor('RR', 'top-top')} strokeWidth={getStrokeWidth('top-top')} strokeDasharray="4,2" className="transition-all duration-300" />
<path d="M 168 64 L 280 96" fill="none" stroke={getPathColor('RB', 'top-bottom')} strokeWidth={getStrokeWidth('top-bottom')} strokeDasharray="4,2" className="transition-all duration-300" />
{/* Level 2 Top Labels */}
<foreignObject x="190" y="25" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{r_R}/{r_Total}</div>
</foreignObject>
<foreignObject x="190" y="80" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{initB}/{r_Total}</div>
</foreignObject>
{/* Level 2 Branches (Bottom) */}
<path d="M 168 192 L 280 160" fill="none" stroke={getPathColor('BR', 'bottom-top')} strokeWidth={getStrokeWidth('bottom-top')} strokeDasharray="4,2" className="transition-all duration-300" />
<path d="M 168 192 L 280 224" fill="none" stroke={getPathColor('BB', 'bottom-bottom')} strokeWidth={getStrokeWidth('bottom-bottom')} strokeDasharray="4,2" className="transition-all duration-300" />
{/* Level 2 Bottom Labels */}
<foreignObject x="190" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{initR}/{b_Total}</div>
</foreignObject>
<foreignObject x="190" y="210" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{b_B}/{b_Total}</div>
</foreignObject>
{/* Outcomes (Interactive Targets) */}
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('RR')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="36" className={`text-xs font-bold transition-all ${hoverPath === 'RR' ? 'fill-rose-600 text-base' : 'fill-slate-500'}`}>RR: {(pRR * 100).toFixed(1)}%</text>
<rect x="290" y="20" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('RB')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="100" className={`text-xs font-bold transition-all ${hoverPath === 'RB' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>RB: {(pRB * 100).toFixed(1)}%</text>
<rect x="290" y="85" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('BR')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="164" className={`text-xs font-bold transition-all ${hoverPath === 'BR' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>BR: {(pBR * 100).toFixed(1)}%</text>
<rect x="290" y="150" width="80" height="20" fill="transparent" />
</g>
<g
className="cursor-pointer"
onMouseEnter={() => setHoverPath('BB')}
onMouseLeave={() => setHoverPath(null)}
>
<text x="300" y="228" className={`text-xs font-bold transition-all ${hoverPath === 'BB' ? 'fill-blue-600 text-base' : 'fill-slate-500'}`}>BB: {(pBB * 100).toFixed(1)}%</text>
<rect x="290" y="215" width="80" height="20" fill="transparent" />
</g>
</svg>
</div>
{/* Calculation Panel */}
<div className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? 'bg-amber-50 border-amber-200 text-amber-900' : 'bg-slate-50 border-slate-100 text-slate-400'}`}>
{!hoverPath ? (
<p className="text-center italic">Hover over an outcome (e.g., RR) to see the calculation.</p>
) : (
<>
<p className="font-bold mb-1">
Calculation for <span className="font-mono bg-white px-1 rounded border border-amber-200">{hoverPath}</span>
({hoverPath[0] === 'R' ? 'Red' : 'Blue'} then {hoverPath[1] === 'R' ? 'Red' : 'Blue'}):
</p>
<div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start">
{/* First Draw */}
<span>P({hoverPath[0]})</span>
<span>×</span>
<span>P({hoverPath[1]} | {hoverPath[0]})</span>
<span>=</span>
{/* Numbers */}
{fraction(hoverPath[0] === 'R' ? initR : initB, total)}
<span>×</span>
{fraction(
hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B,
hoverPath[0] === 'R' ? r_Total : b_Total
)}
<span>=</span>
{/* Result */}
<strong className="text-amber-700">
{fraction(
(hoverPath[0] === 'R' ? initR : initB) * (hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B),
total * (hoverPath[0] === 'R' ? r_Total : b_Total)
)}
</strong>
</div>
{!replacement && hoverPath[0] === hoverPath[1] && (
<p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100">
Notice: The numerator decreased because we kept the first {hoverPath[0] === 'R' ? 'Red' : 'Blue'} item!
</p>
)}
</>
)}
</div>
</div>
);
};
export default ProbabilityTreeWidget;

View File

@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { QuizData } from '../types';
import { CheckCircle2, XCircle, ChevronRight } from 'lucide-react';
interface QuizProps {
data: QuizData;
onComplete?: () => void;
}
const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleOptionClick = (id: string) => {
if (isSubmitted && selectedId === id) return; // Allow changing selection if not correct? No, lock after submit usually. Let's strictly lock.
if (!isSubmitted) {
setSelectedId(id);
}
};
const handleSubmit = () => {
if (!selectedId) return;
setIsSubmitted(true);
const selectedOption = data.options.find(opt => opt.id === selectedId);
if (selectedOption?.isCorrect && onComplete) {
onComplete();
}
};
const selectedOption = data.options.find(opt => opt.id === selectedId);
const isCorrect = selectedOption?.isCorrect;
return (
<div className="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mt-6">
<div className="p-6">
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-2">Concept Check</h4>
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">{data.question}</p>
<div className="space-y-3">
{data.options.map((option) => {
let borderClass = "border-slate-200 hover:border-indigo-300";
let bgClass = "bg-white hover:bg-slate-50";
let icon = null;
if (isSubmitted) {
if (option.id === selectedId) {
if (option.isCorrect) {
borderClass = "border-green-500 bg-green-50";
icon = <CheckCircle2 className="w-5 h-5 text-green-600" />;
} else {
borderClass = "border-red-500 bg-red-50";
icon = <XCircle className="w-5 h-5 text-red-600" />;
}
} else if (option.isCorrect) {
// Highlight correct answer if wrong one was picked
borderClass = "border-green-200 bg-green-50/50";
}
} else if (selectedId === option.id) {
borderClass = "border-indigo-600 bg-indigo-50";
}
return (
<button
key={option.id}
onClick={() => handleOptionClick(option.id)}
disabled={isSubmitted}
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 flex items-center justify-between group ${borderClass} ${bgClass}`}
>
<div className="flex items-center">
<span className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
isSubmitted && option.isCorrect ? 'bg-green-200 text-green-800' :
isSubmitted && option.id === selectedId ? 'bg-red-200 text-red-800' :
selectedId === option.id ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-500'
}`}>
{option.id}
</span>
<span className="text-slate-700 group-hover:text-slate-900">{option.text}</span>
</div>
{icon}
</button>
);
})}
</div>
</div>
{/* Feedback Section */}
{isSubmitted && (
<div className={`p-6 border-t ${isCorrect ? 'bg-green-50 border-green-100' : 'bg-slate-50 border-slate-100'}`}>
<div className="flex items-start gap-3">
<div className={`mt-1 p-1 rounded-full ${isCorrect ? 'bg-green-200' : 'bg-slate-200'}`}>
{isCorrect ? <CheckCircle2 className="w-4 h-4 text-green-700" /> : <div className="w-4 h-4 text-slate-500 font-bold text-center leading-4">i</div>}
</div>
<div>
<p className={`font-bold ${isCorrect ? 'text-green-800' : 'text-slate-800'} mb-1`}>
{isCorrect ? "That's right!" : "Not quite."}
</p>
<p className="text-slate-600 mb-2">{selectedOption?.feedback}</p>
<div className="text-sm text-slate-500 bg-white p-3 rounded border border-slate-200">
<span className="font-semibold block mb-1">Explanation:</span>
{data.explanation}
</div>
</div>
</div>
</div>
)}
{!isSubmitted && (
<div className="p-4 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={handleSubmit}
disabled={!selectedId}
className={`px-6 py-2 rounded-full font-semibold transition-all flex items-center ${
selectedId
? 'bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}`}
>
Check Answer <ChevronRight className="w-4 h-4 ml-1" />
</button>
</div>
)}
</div>
);
};
export default Quiz;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
const RadicalSolutionWidget: React.FC = () => {
// Equation: sqrt(x) = x - k
const [k, setK] = useState(2);
// Intersection logic
// x = (x-k)^2 => x = x^2 - 2kx + k^2 => x^2 - (2k+1)x + k^2 = 0
// Roots via quadratic formula
const a = 1;
const b = -(2*k + 1);
const c = k*k;
const disc = b*b - 4*a*c;
let solutions: number[] = [];
if (disc >= 0) {
const x1 = (-b + Math.sqrt(disc)) / (2*a);
const x2 = (-b - Math.sqrt(disc)) / (2*a);
solutions = [x1, x2].filter(val => val >= 0); // Domain x>=0
}
// Check validity against original equation sqrt(x) = x - k
const validSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) < 0.01);
const extraneousSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01);
// Vis
const width = 300;
const height = 300;
const range = 10;
const scale = 25;
const toPx = (v: number, isY = false) => isY ? height - v * scale - 20 : v * scale + 20;
const pathSqrt = () => {
let d = "";
for(let x=0; x<=range; x+=0.1) {
d += d ? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`;
}
return d;
};
const pathLine = () => {
// y = x - k
const x1 = 0; const y1 = -k;
const x2 = range; const y2 = range - k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
// Phantom parabola path (x = y^2) - representing the squared equation
// This includes y = -sqrt(x)
const pathPhantom = () => {
let d = "";
for(let x=0; x<=range; x+=0.1) {
d += d ? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">Equation</div>
<div className="font-mono text-lg font-bold text-slate-800">
x = x - {k}
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Line (k) = {k}</label>
<input type="range" min="0" max="6" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"/>
</div>
<div className="space-y-3">
<div className="p-3 bg-emerald-50 rounded border border-emerald-100">
<div className="text-xs font-bold text-emerald-700 uppercase mb-1">Valid Solutions</div>
<div className="font-mono text-sm font-bold text-emerald-900">
{validSolutions.length > 0 ? validSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"}
</div>
</div>
<div className="p-3 bg-rose-50 rounded border border-rose-100">
<div className="text-xs font-bold text-rose-700 uppercase mb-1">Extraneous Solutions</div>
<div className="font-mono text-sm font-bold text-rose-900">
{extraneousSolutions.length > 0 ? extraneousSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"}
</div>
</div>
</div>
<p className="text-xs text-slate-400 leading-relaxed">
The <span className="text-rose-400 font-bold">extraneous</span> solution is a real intersection for the <em>squared</em> equation (the phantom curve), but not for the original radical.
</p>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Grid */}
<defs>
<pattern id="grid-rad" width="25" height="25" patternUnits="userSpaceOnUse">
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="#f8fafc" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-rad)" />
{/* Axes */}
<line x1="20" y1="0" x2="20" y2="300" stroke="#cbd5e1" strokeWidth="2" />
<line x1="0" y1={toPx(0, true)} x2="300" y2={toPx(0, true)} stroke="#cbd5e1" strokeWidth="2" />
{/* Phantom -sqrt(x) */}
<path d={pathPhantom()} fill="none" stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
{/* Real sqrt(x) */}
<path d={pathSqrt()} fill="none" stroke="#4f46e5" strokeWidth="3" />
{/* Line x-k */}
<path d={pathLine()} fill="none" stroke="#64748b" strokeWidth="2" />
{/* Points */}
{validSolutions.map(x => (
<circle key={`v-${x}`} cx={toPx(x)} cy={toPx(Math.sqrt(x), true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
))}
{extraneousSolutions.map(x => (
<circle key={`e-${x}`} cx={toPx(x)} cy={toPx(-(Math.sqrt(x)), true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" />
))}
</svg>
<div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">y = x</div>
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">y = x - {k}</div>
</div>
</div>
</div>
</div>
);
};
export default RadicalSolutionWidget;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
const RadicalWidget: React.FC = () => {
const [power, setPower] = useState(3);
const [root, setRoot] = useState(2);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center mb-8">
<div className="space-y-6">
<div>
<label className="text-xs font-bold text-violet-600 uppercase mb-1 block">Power (Numerator) m</label>
<div className="flex items-center gap-4">
<input
type="range" min="1" max="9" value={power} onChange={e => setPower(parseInt(e.target.value))}
className="flex-1 h-2 bg-violet-100 rounded-lg accent-violet-600"
/>
<span className="font-mono font-bold text-violet-800 text-xl">{power}</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-fuchsia-600 uppercase mb-1 block">Root (Denominator) n</label>
<div className="flex items-center gap-4">
<input
type="range" min="2" max="9" value={root} onChange={e => setRoot(parseInt(e.target.value))}
className="flex-1 h-2 bg-fuchsia-100 rounded-lg accent-fuchsia-600"
/>
<span className="font-mono font-bold text-fuchsia-800 text-xl">{root}</span>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center p-6 bg-slate-50 rounded-xl border border-slate-200 min-h-[160px]">
<div className="flex items-center gap-8 text-4xl font-serif">
{/* Rational Exponent Form */}
<div className="text-center group">
<span className="font-bold text-slate-700 italic">x</span>
<sup className="text-2xl font-sans font-bold">
<span className="text-violet-600 group-hover:scale-110 inline-block transition-transform">{power}</span>
<span className="text-slate-400 mx-1">/</span>
<span className="text-fuchsia-600 group-hover:scale-110 inline-block transition-transform">{root}</span>
</sup>
</div>
<span className="text-slate-300">=</span>
{/* Radical Form */}
<div className="text-center relative group">
<span className="absolute -top-3 -left-3 text-lg font-bold text-fuchsia-600 font-sans group-hover:scale-110 transition-transform">{root}</span>
<span className="text-slate-400"></span>
<span className="border-t-2 border-slate-400 px-1 font-bold text-slate-700 italic">
x
<sup className="text-violet-600 text-2xl font-sans ml-0.5 group-hover:scale-110 inline-block transition-transform">{power}</sup>
</span>
</div>
</div>
</div>
</div>
<div className="text-sm text-slate-600 bg-indigo-50 p-4 rounded-lg border border-indigo-100">
<p className="mb-2"><strong>The Golden Rule:</strong> The top number stays with x (power), the bottom number becomes the root.</p>
<p className="font-mono">
Exponent <span className="text-violet-600 font-bold">{power}</span> goes inside.
Root <span className="text-fuchsia-600 font-bold">{root}</span> goes outside.
</p>
</div>
</div>
);
};
export default RadicalWidget;

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
const RatioVisualizerWidget: React.FC = () => {
const [partA, setPartA] = useState(3);
const [partB, setPartB] = useState(2);
const [scale, setScale] = useState(1);
const totalParts = partA + partB;
const scaledA = partA * scale;
const scaledB = partB * scale;
const scaledTotal = totalParts * scale;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6">
<div>
<label className="text-xs font-bold text-indigo-600 uppercase">Part A (Indigo)</label>
<input
type="range" min="1" max="10" value={partA}
onChange={e => setPartA(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-2"
/>
<div className="text-right font-mono font-bold text-indigo-700">{partA} parts</div>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Part B (Rose)</label>
<input
type="range" min="1" max="10" value={partB}
onChange={e => setPartB(parseInt(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<div className="text-right font-mono font-bold text-rose-700">{partB} parts</div>
</div>
<div className="pt-4 border-t border-slate-200">
<label className="text-xs font-bold text-slate-500 uppercase">Multiplier (k)</label>
<input
type="range" min="1" max="5" value={scale}
onChange={e => setScale(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-600 mt-2"
/>
<div className="text-right font-mono font-bold text-slate-700">k = {scale}</div>
</div>
</div>
<div className="flex-1 flex flex-col justify-center">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 mb-4">
<div className="flex flex-wrap gap-2 justify-center content-start min-h-[100px]">
{Array.from({ length: scaledA }).map((_, i) => (
<div key={`a-${i}`} className="w-6 h-6 rounded-full bg-indigo-500 shadow-sm animate-fade-in"></div>
))}
{Array.from({ length: scaledB }).map((_, i) => (
<div key={`b-${i}`} className="w-6 h-6 rounded-full bg-rose-500 shadow-sm animate-fade-in"></div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-3 bg-white border border-slate-200 rounded-lg shadow-sm">
<p className="text-xs font-bold text-slate-400 uppercase">Part-to-Part Ratio</p>
<p className="text-lg font-bold text-slate-800">
<span className="text-indigo-600">{scaledA}</span> : <span className="text-rose-600">{scaledB}</span>
</p>
<p className="text-xs text-slate-400 mt-1">({partA}k : {partB}k)</p>
</div>
<div className="p-3 bg-white border border-slate-200 rounded-lg shadow-sm">
<p className="text-xs font-bold text-slate-400 uppercase">Part-to-Whole (Indigo)</p>
<p className="text-lg font-bold text-slate-800">
<span className="text-indigo-600">{scaledA}</span> / {scaledTotal}
</p>
<p className="text-xs text-slate-400 mt-1">({partA}k / {totalParts}k)</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default RatioVisualizerWidget;

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react';
const RationalExplorer: React.FC = () => {
const [cancelFactor, setCancelFactor] = useState(false); // If true, (x-2) is in numerator
// Base function f(x) = (x+1) / [(x-2)(x+1)] ? No simple case.
// Let's do: f(x) = (x+1) * [ (x-2) if cancel ] / [ (x-2) * (x-3) ]
// If cancel: f(x) = (x+1)/(x-3) with Hole at 2.
// If not cancel: f(x) = (x+1) / [(x-2)(x-3)] ... complex.
// Better example: f(x) = [numerator] / (x-2)
// Numerator options: (x-2) -> Hole. 1 -> VA.
const width = 300;
const height = 200;
const range = 6;
const scale = width / (range * 2);
const center = width / 2;
const toPx = (v: number, isY = false) => isY ? height/2 - v * scale : center + v * scale;
const generatePath = () => {
let d = "";
for (let x = -range; x <= range; x += 0.05) {
if (Math.abs(x - 2) < 0.1) continue; // Skip near discontinuity
let y = 0;
if (cancelFactor) {
// f(x) = (x-2) / (x-2) = 1
y = 1;
} else {
// f(x) = 1 / (x-2)
y = 1 / (x - 2);
}
if (Math.abs(y) > range) {
d += ` M `; // Break path
continue;
}
const px = toPx(x);
const py = toPx(y, true);
d += d.endsWith('M ') ? `${px} ${py}` : ` L ${px} ${py}`;
}
return d;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 flex flex-col items-center">
<div className="text-xl font-mono font-bold bg-slate-50 px-6 py-3 rounded-lg border border-slate-200 mb-4">
f(x) = <div className="inline-block align-middle text-center mx-2">
<div className="border-b border-slate-800 pb-1 mb-1">{cancelFactor ? <span className="text-rose-600">(x-2)</span> : "1"}</div>
<div className="text-indigo-600">(x-2)</div>
</div>
</div>
<div className="flex bg-slate-100 p-1 rounded-lg">
<button
onClick={() => setCancelFactor(false)}
className={`px-4 py-2 text-sm font-bold rounded-md transition-all ${!cancelFactor ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Different Factor (1)
</button>
<button
onClick={() => setCancelFactor(true)}
className={`px-4 py-2 text-sm font-bold rounded-md transition-all ${cancelFactor ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Common Factor (x-2)
</button>
</div>
</div>
<div className="relative h-[200px] w-full bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`}>
{/* Axes */}
<line x1="0" y1={height/2} x2={width} y2={height/2} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={height} stroke="#cbd5e1" strokeWidth="2" />
{/* Discontinuity at x=2 */}
<line x1={toPx(2)} y1={0} x2={toPx(2)} y2={height} stroke="#cbd5e1" strokeDasharray="4,4" />
{/* Graph */}
<path d={generatePath()} stroke="#8b5cf6" strokeWidth="3" fill="none" />
{/* Hole Visualization */}
{cancelFactor && (
<circle cx={toPx(2)} cy={toPx(1, true)} r="4" fill="white" stroke="#8b5cf6" strokeWidth="2" />
)}
</svg>
{/* Labels */}
<div className="absolute top-2 right-2 text-xs font-bold text-slate-400">x=2</div>
</div>
<div className="mt-4 p-4 rounded-lg bg-violet-50 border border-violet-100 text-sm text-violet-900 text-center">
{cancelFactor ? (
<span>
<strong>Hole:</strong> The factor (x-2) cancels out. The graph looks like y=1, but x=2 is undefined.
</span>
) : (
<span>
<strong>Vertical Asymptote:</strong> The factor (x-2) stays in the denominator. y approaches infinity near x=2.
</span>
)}
</div>
</div>
);
};
export default RationalExplorer;

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
const RemainderTheoremWidget: React.FC = () => {
const [a, setA] = useState(2); // Dividing by (x - a)
// Polynomial P(x) = x^3 - 3x^2 - x + 3
const calculateP = (x: number) => Math.pow(x, 3) - 3 * Math.pow(x, 2) - x + 3;
const remainder = calculateP(a);
// Visualization
const width = 300;
const height = 200;
const rangeX = 4;
const rangeY = 10;
const scaleX = width / (rangeX * 2);
const scaleY = height / (rangeY * 2);
const centerX = width / 2;
const centerY = height / 2;
const toPx = (x: number, y: number) => ({
x: centerX + x * scaleX,
y: centerY - y * scaleY
});
const path = [];
for(let x = -rangeX; x <= rangeX; x+=0.1) {
const y = calculateP(x);
if(Math.abs(y) <= rangeY) {
path.push(toPx(x, y));
}
}
const pathD = `M ${path.map(p => `${p.x} ${p.y}`).join(' L ')}`;
const point = toPx(a, remainder);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 space-y-4">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100">
<p className="text-xs font-bold text-violet-400 uppercase mb-1">Polynomial</p>
<p className="font-mono font-bold text-lg text-slate-700">P(x) = x³ - 3x² - x + 3</p>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Divisor (x - a)</label>
<div className="flex items-center gap-4 mt-1">
<span className="font-mono font-bold text-lg text-slate-700">x - </span>
<input
type="number" value={a} onChange={e => setA(parseFloat(e.target.value))}
className="w-16 p-2 border rounded text-center font-bold text-indigo-600"
/>
</div>
<input
type="range" min="-3" max="4" step="0.1" value={a}
onChange={e => setA(parseFloat(e.target.value))}
className="w-full mt-2 h-2 bg-slate-200 rounded-lg accent-indigo-600"
/>
</div>
<div className="p-4 bg-emerald-50 rounded-xl border border-emerald-100">
<p className="text-xs font-bold text-emerald-600 uppercase mb-1">Remainder Theorem Result</p>
<p className="text-sm text-slate-600 mb-2">Remainder of P(x) ÷ (x - {a}) is <strong>P({a})</strong></p>
<p className="font-mono font-bold text-2xl text-emerald-700">
R = {remainder.toFixed(2)}
</p>
</div>
</div>
<div className="flex-none">
<div className="relative w-[300px] h-[200px] border border-slate-200 rounded-xl bg-white overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 200">
<line x1="0" y1={centerY} x2={width} y2={centerY} stroke="#e2e8f0" strokeWidth="2" />
<line x1={centerX} y1="0" x2={centerX} y2={height} stroke="#e2e8f0" strokeWidth="2" />
<path d={pathD} fill="none" stroke="#8b5cf6" strokeWidth="3" />
<circle cx={point.x} cy={point.y} r="6" fill="#10b981" stroke="white" strokeWidth="2" />
<text x={point.x + 10} y={point.y} className="text-xs font-bold fill-slate-500">({a}, {remainder.toFixed(1)})</text>
</svg>
</div>
</div>
</div>
</div>
);
};
export default RemainderTheoremWidget;

View File

@ -0,0 +1,166 @@
import React, { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface Dot {
id: number;
type: 'red' | 'blue';
x: number;
y: number;
}
const SamplingVisualizerWidget: React.FC = () => {
const [population, setPopulation] = useState<Dot[]>([]);
const [sample, setSample] = useState<number[]>([]); // IDs of selected dots
const [mode, setMode] = useState<'none' | 'random' | 'biased'>('none');
// Generate population on mount
useEffect(() => {
const dots: Dot[] = [];
for (let i = 0; i < 100; i++) {
// Biased distribution: Reds cluster in top-right
const isClustered = i < 40; // 40% Red
let x, y;
if (isClustered) {
// Cluster Reds (Type A) in top right (50-100, 0-50)
x = 50 + Math.random() * 50;
y = Math.random() * 50;
} else {
// Blues scattered everywhere else, but mostly bottom/left
// To make it simple, just uniform random, but if we hit the "Red Zone" we retry or accept overlap
// Let's force Blues to be mostly Bottom or Left
if (Math.random() > 0.5) {
x = Math.random() * 50; // Left half
y = Math.random() * 100;
} else {
x = 50 + Math.random() * 50; // Right half
y = 50 + Math.random() * 50; // Bottom right
}
}
dots.push({
id: i,
type: isClustered ? 'red' : 'blue',
x,
y
});
}
setPopulation(dots);
}, []);
const takeRandomSample = () => {
const indices: number[] = [];
const pool = [...population];
// Pick 10 random
for (let i = 0; i < 10; i++) {
const idx = Math.floor(Math.random() * pool.length);
indices.push(pool[idx].id);
pool.splice(idx, 1);
}
setSample(indices);
setMode('random');
};
const takeBiasedSample = () => {
// Simulate "Convenience": Pick from top-right (the Red cluster)
// Find dots with x > 50 and y < 50
const candidates = population.filter(d => d.x > 50 && d.y < 50);
// Take 10 from there
const selected = candidates.slice(0, 10).map(d => d.id);
setSample(selected);
setMode('biased');
};
// Stats
const sampleDots = population.filter(d => sample.includes(d.id));
const sampleRedCount = sampleDots.filter(d => d.type === 'red').length;
const samplePercent = sampleDots.length > 0 ? (sampleRedCount / sampleDots.length) * 100 : 0;
const truePercent = 40; // Hardcoded based on generation logic
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<div className="relative h-64 bg-slate-50 rounded-lg border border-slate-200 overflow-hidden mb-4">
{population.map(dot => {
const isSelected = sample.includes(dot.id);
const isRed = dot.type === 'red';
return (
<div
key={dot.id}
className={`absolute w-3 h-3 rounded-full transition-all duration-500 ${
isSelected
? 'ring-4 ring-offset-1 z-10 scale-125'
: 'opacity-40 scale-75'
} ${
isRed ? 'bg-rose-500' : 'bg-indigo-500'
} ${
isSelected && isRed ? 'ring-rose-200' : ''
} ${
isSelected && !isRed ? 'ring-indigo-200' : ''
}`}
style={{ left: `${dot.x}%`, top: `${dot.y}%` }}
/>
);
})}
{/* Labels for Bias Zone */}
<div className="absolute top-2 right-2 text-xs font-bold text-rose-300 uppercase pointer-events-none">
Cluster Zone
</div>
</div>
<p className="text-xs text-center text-slate-400">Population: 100 individuals (40% Red)</p>
</div>
<div className="w-full md:w-1/3 flex flex-col justify-center space-y-4">
<div className="space-y-2">
<button
onClick={takeRandomSample}
className="w-full py-3 px-4 bg-emerald-100 hover:bg-emerald-200 text-emerald-800 rounded-lg font-bold transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" /> Random Sample
</button>
<button
onClick={takeBiasedSample}
className="w-full py-3 px-4 bg-amber-100 hover:bg-amber-200 text-amber-800 rounded-lg font-bold transition-colors"
>
Convenience Sample
</button>
</div>
<div className={`p-4 rounded-xl border ${mode === 'none' ? 'border-slate-100 bg-slate-50' : 'bg-white border-slate-200'}`}>
<h4 className="text-xs font-bold text-slate-400 uppercase mb-2">Sample Result (n=10)</h4>
{mode === 'none' ? (
<p className="text-sm text-slate-500 italic">Select a method...</p>
) : (
<div>
<div className="flex justify-between items-end mb-1">
<span className="text-slate-600 font-medium">Estimated Red %</span>
<span className={`text-2xl font-bold ${Math.abs(samplePercent - truePercent) > 15 ? 'text-rose-600' : 'text-emerald-600'}`}>
{samplePercent}%
</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-500 ${Math.abs(samplePercent - truePercent) > 15 ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${samplePercent}%` }}
></div>
</div>
<p className="text-xs text-slate-400 text-right">True Population: 40%</p>
{mode === 'biased' && (
<p className="mt-2 text-xs font-bold text-amber-600 bg-amber-50 p-2 rounded">
Bias Alert: Selecting only from the "easy to reach" cluster overestimates the Red group.
</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default SamplingVisualizerWidget;

View File

@ -0,0 +1,133 @@
import React, { useState } from "react";
const ScaleFactorWidget: React.FC = () => {
const [k, setK] = useState(2);
const unit = 24; // Base size in px
const size = k * unit;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 w-full max-w-3xl">
<div className="mb-8">
<label className="flex justify-between font-bold text-slate-700 mb-2">
Scale Factor (k):{" "}
<span className="text-indigo-600 text-xl">{k}x</span>
</label>
<input
type="range"
min="1"
max="4"
step="1"
value={k}
onChange={(e) => setK(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
<div className="flex justify-between text-xs text-slate-400 mt-1 font-mono">
<span>1x</span>
<span>2x</span>
<span>3x</span>
<span>4x</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
{/* 1D: Length */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
1D: Length
</h4>
<div className="flex-1 flex items-center justify-center min-h-[160px]">
<div
className="h-3 bg-indigo-500 rounded-full transition-all duration-500 shadow-sm"
style={{ width: `${k * 20}%` }}
></div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-indigo-700">k = {k}</p>
</div>
</div>
{/* 2D: Area */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
2D: Area
</h4>
<div className="flex-1 flex items-center justify-center relative min-h-[160px]">
{/* Base */}
<div className="w-8 h-8 border-2 border-emerald-500/30 absolute"></div>
{/* Scaled */}
<div
className="bg-emerald-500 shadow-lg transition-all duration-500 ease-out"
style={{ width: `${size}px`, height: `${size}px` }}
></div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-emerald-700">k² = {k * k}</p>
</div>
</div>
{/* 3D: Volume */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex flex-col overflow-hidden">
<h4 className="text-sm font-bold uppercase text-slate-500 mb-4">
3D: Volume
</h4>
<div className="flex-1 flex items-center justify-center perspective-1000 min-h-[160px]">
<div
className="relative transform-style-3d transition-all duration-500 ease-out"
style={{
width: `${size}px`,
height: `${size}px`,
transform: "rotateX(-20deg) rotateY(-30deg)",
}}
>
{/* Faces */}
{[
// Front
{ trans: `translateZ(${size / 2}px)`, color: "bg-rose-500" },
// Back
{
trans: `rotateY(180deg) translateZ(${size / 2}px)`,
color: "bg-rose-600",
},
// Right
{
trans: `rotateY(90deg) translateZ(${size / 2}px)`,
color: "bg-rose-600",
},
// Left
{
trans: `rotateY(-90deg) translateZ(${size / 2}px)`,
color: "bg-rose-500",
},
// Top
{
trans: `rotateX(90deg) translateZ(${size / 2}px)`,
color: "bg-rose-400",
},
// Bottom
{
trans: `rotateX(-90deg) translateZ(${size / 2}px)`,
color: "bg-rose-700",
},
].map((face, i) => (
<div
key={i}
className={`absolute inset-0 border border-white/20 ${face.color} shadow-sm`}
style={{ transform: face.trans }}
/>
))}
</div>
</div>
<div className="mt-auto border-t border-slate-200 pt-2">
<p className="text-slate-500 text-xs">Multiplier</p>
<p className="text-2xl font-bold text-rose-700">k³ = {k * k * k}</p>
</div>
</div>
</div>
</div>
);
};
export default ScaleFactorWidget;

View File

@ -0,0 +1,125 @@
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;

View File

@ -0,0 +1,300 @@
import React, { useState, useRef, useEffect } from 'react';
type Mode = 'AA' | 'SAS' | 'SSS';
const SimilarityTestsWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('AA');
const [scale, setScale] = useState(1.5);
// Store Vertex B's position relative to A (x offset, y height)
// A is at (40, 220). SVG Y is down.
const [vertexB, setVertexB] = useState({ x: 40, y: 100 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Triangle 1 (ABC) - Fixed base AC
const A = { x: 40, y: 220 };
const C = { x: 120, y: 220 }; // Base length = 80
// Calculate B in SVG coordinates based on state
// vertexB.y is the height (upwards), so we subtract from A.y
const B = { x: A.x + vertexB.x, y: A.y - vertexB.y };
// Calculate lengths and angles for T1
const dist = (p1: {x:number, y:number}, p2: {x:number, y:number}) => Math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2);
const c1 = dist(A, B); // side c (opp C) - Side AB
const a1 = dist(B, C); // side a (opp A) - Side BC
const b1 = dist(A, C); // side b (opp B) - Side AC (Base)
const getAngle = (a: number, b: number, c: number) => {
return Math.acos((b**2 + c**2 - a**2) / (2 * b * c)) * (180 / Math.PI);
};
const angleA = getAngle(a1, b1, c1);
const angleB = getAngle(b1, a1, c1);
// const angleC = getAngle(c1, a1, b1);
// Triangle 2 (DEF) - Scaled version of ABC
// Start D with enough margin. Max width of T1 is ~100-140.
// Let's place D at x=240.
const D = { x: 240, y: 220 };
// F is horizontal from D by scaled base length
const F = { x: D.x + b1 * scale, y: D.y };
// E is scaled vector AB from D
const vecAB = { x: B.x - A.x, y: B.y - A.y };
const E = {
x: D.x + vecAB.x * scale,
y: D.y + vecAB.y * scale
};
// Interaction
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Constraints for B relative to A
// Keep B within reasonable bounds to prevent breaking the layout
// Base is 40 to 120. B.x can range from 0 to 140?
const newX = x - A.x;
const height = A.y - y;
// Clamp
const clampedX = Math.max(-20, Math.min(100, newX));
const clampedH = Math.max(40, Math.min(180, height));
setVertexB({ x: clampedX, y: clampedH });
};
const angleColor = "#6366f1"; // Indigo
const sideColor = "#059669"; // Emerald
// Helper: draw filled angle wedge + labelled badge at a vertex
const angleC = 180 - angleA - angleB;
const renderAngle = (
vx: number, vy: number,
p1x: number, p1y: number,
p2x: number, p2y: number,
deg: number,
r = 28
) => {
const d1 = Math.atan2(p1y - vy, p1x - vx);
const d2 = Math.atan2(p2y - vy, p2x - vx);
const sx = vx + r * Math.cos(d1), sy = vy + r * Math.sin(d1);
const ex = vx + r * Math.cos(d2), ey = vy + r * Math.sin(d2);
const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx);
const sweep = cross > 0 ? 1 : 0;
let diff = d2 - d1;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
const mid = d1 + diff / 2;
const lr = r + 18;
const lx = vx + lr * Math.cos(mid), ly = vy + lr * Math.sin(mid);
const txt = `${Math.round(deg)}°`;
return (
<g>
<path d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`} fill={angleColor} fillOpacity={0.12} stroke={angleColor} strokeWidth={2} />
<rect x={lx - 18} y={ly - 10} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={angleColor} strokeWidth={0.8} />
<text x={lx} y={ly + 5} textAnchor="middle" fill={angleColor} fontSize="13" fontWeight="bold" fontFamily="system-ui">{txt}</text>
</g>
);
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6">
<div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full">
{(['AA', 'SAS', 'SSS'] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${
mode === m
? 'bg-white text-rose-600 shadow-sm'
: 'text-slate-500 hover:text-rose-600'
}`}
>
{m}
</button>
))}
</div>
<div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200">
<span className="text-xs font-bold text-slate-400 uppercase">Scale (k)</span>
<input
type="range" min="0.5" max="2.5" step="0.1"
value={scale}
onChange={e => setScale(parseFloat(e.target.value))}
className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
<span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">{scale.toFixed(1)}x</span>
</div>
</div>
<div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center">
<svg
ref={svgRef}
width="550" height="280"
className="cursor-default select-none"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Triangle 1 (ABC) */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" />
{/* Vertices T1 */}
<circle cx={A.x} cy={A.y} r="4" fill="#334155" />
<text x={A.x - 16} y={A.y + 14} fontWeight="bold" fill="#334155" fontSize="14">A</text>
<circle cx={C.x} cy={C.y} r="4" fill="#334155" />
<text x={C.x + 8} y={C.y + 14} fontWeight="bold" fill="#334155" fontSize="14">C</text>
{/* Draggable B */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing">
<circle cx={B.x} cy={B.y} r="20" fill="transparent" /> {/* Hit area */}
<circle cx={B.x} cy={B.y} r="7" fill="#f43f5e" stroke="white" strokeWidth="2" />
<text x={B.x} y={B.y - 16} textAnchor="middle" fontWeight="bold" fill="#f43f5e" fontSize="14">B</text>
</g>
{/* Triangle 2 (DEF) */}
<path d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" />
<circle cx={D.x} cy={D.y} r="4" fill="#334155" />
<text x={D.x - 16} y={D.y + 14} fontWeight="bold" fill="#334155" fontSize="14">D</text>
<circle cx={F.x} cy={F.y} r="4" fill="#334155" />
<text x={F.x + 8} y={F.y + 14} fontWeight="bold" fill="#334155" fontSize="14">F</text>
<circle cx={E.x} cy={E.y} r="4" fill="#334155" />
<text x={E.x} y={E.y - 16} textAnchor="middle" fontWeight="bold" fill="#334155" fontSize="14">E</text>
{/* Visual Overlays based on Mode */}
{mode === 'AA' && (
<>
{/* Angle A and D (base-left) */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
{renderAngle(D.x, D.y, F.x, F.y, E.x, E.y, angleA)}
{/* Angle B and E (apex) */}
{renderAngle(B.x, B.y, A.x, A.y, C.x, C.y, angleB)}
{renderAngle(E.x, E.y, D.x, D.y, F.x, F.y, angleB)}
</>
)}
{mode === 'SAS' && (
<>
{/* Included Angle A and D */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
{renderAngle(D.x, D.y, F.x, F.y, E.x, E.y, angleA)}
{/* Side labels with background badges */}
{/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text>
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text>
{/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text>
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text>
</>
)}
{mode === 'SSS' && (
<>
{/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text>
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text>
{/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text>
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text>
{/* Side BC / EF */}
<rect x={(B.x + C.x)/2 + 2} y={(B.y + C.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(B.x + C.x)/2 + 20} y={(B.y + C.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1)}</text>
<rect x={(E.x + F.x)/2 + 2} y={(E.y + F.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} />
<text x={(E.x + F.x)/2 + 20} y={(E.y + F.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1 * scale)}</text>
</>
)}
</svg>
</div>
<div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900">
<h4 className="font-bold mb-2 flex items-center gap-2 text-lg">
<span className="w-3 h-3 rounded-full bg-rose-500"></span>
{mode === 'AA' && "Angle-Angle (AA) Similarity"}
{mode === 'SAS' && "Side-Angle-Side (SAS) Similarity"}
{mode === 'SSS' && "Side-Side-Side (SSS) Similarity"}
</h4>
<div className="text-sm font-mono space-y-2">
{mode === 'AA' && (
<>
<p className="leading-relaxed">If two angles of one triangle are equal to two angles of another triangle, then the triangles are similar.</p>
<div className="flex gap-8 mt-2">
<div>
<span className="text-xs font-bold text-rose-400 uppercase">First Angle</span>
<p className="font-bold text-lg">A = D = {Math.round(angleA)}°</p>
</div>
<div>
<span className="text-xs font-bold text-rose-400 uppercase">Second Angle</span>
<p className="font-bold text-lg">B = E = {Math.round(angleB)}°</p>
</div>
</div>
</>
)}
{mode === 'SAS' && (
<>
<p className="leading-relaxed">If two sides are proportional and the included angles are equal, the triangles are similar.</p>
<div className="grid grid-cols-2 gap-4 mt-2">
<div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (c)</p>
<p>DE / AB = {(c1*scale).toFixed(0)} / {c1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p>
</div>
<div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (b)</p>
<p>DF / AC = {(b1*scale).toFixed(0)} / {b1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p>
</div>
</div>
<p className="mt-2 font-bold text-rose-800">Included Angle: A = D = {Math.round(angleA)}°</p>
</>
)}
{mode === 'SSS' && (
<>
<p className="leading-relaxed">If the corresponding sides of two triangles are proportional, then the triangles are similar.</p>
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">Scale Factor k = {scale.toFixed(1)}</p>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="bg-white p-1 rounded">
DE/AB = {scale.toFixed(1)}
</div>
<div className="bg-white p-1 rounded">
EF/BC = {scale.toFixed(1)}
</div>
<div className="bg-white p-1 rounded">
DF/AC = {scale.toFixed(1)}
</div>
</div>
</>
)}
</div>
<p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2">
Drag vertex <strong>B</strong> on the first triangle to explore different shapes!
</p>
</div>
</div>
);
};
export default SimilarityTestsWidget;

View File

@ -0,0 +1,119 @@
import React, { useState, useRef, useEffect } from 'react';
const SimilarityWidget: React.FC = () => {
const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1)
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Triangle Vertices
const A = { x: 200, y: 50 };
const B = { x: 50, y: 300 };
const C = { x: 350, y: 300 };
// Calculate D and E based on ratio
const D = {
x: A.x + (B.x - A.x) * ratio,
y: A.y + (B.y - A.y) * ratio
};
const E = {
x: A.x + (C.x - A.x) * ratio,
y: A.y + (C.y - A.y) * ratio
};
const handleInteraction = (clientY: number) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const y = clientY - rect.top;
// Clamp y between A.y and B.y
const clampedY = Math.max(A.y, Math.min(B.y, y));
// Calculate new ratio
const newRatio = (clampedY - A.y) / (B.y - A.y);
setRatio(Math.max(0.1, Math.min(0.9, newRatio))); // clamp to avoid degenerate
};
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
handleInteraction(e.clientY);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging.current) {
handleInteraction(e.clientY);
}
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row items-center gap-8">
<svg
ref={svgRef}
width="400"
height="350"
className="select-none cursor-ns-resize"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
{/* Main Triangle */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#e2e8f0" strokeWidth="2" />
{/* Filled Top Triangle (Similar) */}
<path d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`} fill="rgba(244, 63, 94, 0.1)" stroke="none" />
{/* Parallel Line DE */}
<line x1={D.x} y1={D.y} x2={E.x} y2={E.y} stroke="#e11d48" strokeWidth="3" />
{/* Labels */}
<text x={A.x} y={A.y - 10} textAnchor="middle" fontWeight="bold" fill="#64748b">A</text>
<text x={B.x - 10} y={B.y} textAnchor="end" fontWeight="bold" fill="#64748b">B</text>
<text x={C.x + 10} y={C.y} textAnchor="start" fontWeight="bold" fill="#64748b">C</text>
<text x={D.x - 10} y={D.y} textAnchor="end" fontWeight="bold" fill="#e11d48">D</text>
<text x={E.x + 10} y={E.y} textAnchor="start" fontWeight="bold" fill="#e11d48">E</text>
{/* Drag Handle */}
<circle cx={D.x} cy={D.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" />
<circle cx={E.x} cy={E.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" />
</svg>
<div className="flex-1 w-full">
<h3 className="text-lg font-bold text-slate-800 mb-4">Triangle Proportionality</h3>
<p className="text-sm text-slate-500 mb-6">Drag the red line. Because DE || BC, the small triangle is similar to the large triangle.</p>
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500">
<p className="text-xs font-bold text-slate-400 uppercase mb-1">Scale Factor</p>
<p className="font-mono text-xl text-rose-700">{ratio.toFixed(2)}</p>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Corresponding Sides Ratio:</p>
<div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">AD / AB</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">AE / AC</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">{ratio.toFixed(2)}</div>
</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Area Ratio (k²):</p>
<div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">Area(ADE)</div>
<div className="text-slate-400">/</div>
<div className="text-slate-600">Area(ABC)</div>
<div className="text-slate-400">=</div>
<div className="text-rose-600">{(ratio * ratio).toFixed(2)}</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SimilarityWidget;

View File

@ -0,0 +1,93 @@
import React, { useState } from 'react';
const SlopeInterceptWidget: React.FC = () => {
const [m, setM] = useState(2);
const [b, setB] = useState(1);
// Visualization config
const range = 10;
const scale = 25; // px per unit
const center = 150;
const toPx = (val: number, isY = false) => isY ? center - val * scale : center + val * scale;
// Points for triangle
const p1 = { x: 0, y: b };
const p2 = { x: 1, y: m * 1 + b };
// Triangle vertex (1, b)
const p3 = { x: 1, y: b };
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 text-center">
<div className="text-sm text-slate-500 font-bold uppercase mb-1">Equation</div>
<div className="text-2xl font-mono font-bold text-slate-800">
y = <span className="text-blue-600">{m}</span>x + <span className="text-rose-600">{b}</span>
</div>
</div>
<div>
<label className="text-xs font-bold text-blue-600 uppercase">Slope (m) = {m}</label>
<input
type="range" min="-5" max="5" step="0.5"
value={m} onChange={e => setM(parseFloat(e.target.value))}
className="w-full h-2 bg-blue-100 rounded-lg appearance-none cursor-pointer accent-blue-600 mt-2"
/>
<p className="text-xs text-slate-400 mt-1">Rate of Change (Rise / Run)</p>
</div>
<div>
<label className="text-xs font-bold text-rose-600 uppercase">Y-Intercept (b) = {b}</label>
<input
type="range" min="-5" max="5" step="1"
value={b} onChange={e => setB(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600 mt-2"
/>
<p className="text-xs text-slate-400 mt-1">Starting Value (when x=0)</p>
</div>
</div>
<div className="w-full md:flex-1 h-[300px] bg-white border border-slate-200 rounded-xl relative overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute top-0 left-0">
<defs>
<pattern id="si-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#si-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2="300" y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2="300" stroke="#cbd5e1" strokeWidth="2" />
{/* The Line */}
<line
x1={toPx(-range)} y1={toPx(m * -range + b, true)}
x2={toPx(range)} y2={toPx(m * range + b, true)}
stroke="#1e293b" strokeWidth="3"
/>
{/* Slope Triangle (between x=0 and x=1) */}
<path
d={`M ${toPx(p1.x)} ${toPx(p1.y, true)} L ${toPx(p3.x)} ${toPx(p3.y, true)} L ${toPx(p2.x)} ${toPx(p2.y, true)} Z`}
fill="rgba(37, 99, 235, 0.1)" stroke="#2563eb" strokeWidth="1" strokeDasharray="4,2"
/>
{/* Intercept Point */}
<circle cx={toPx(0)} cy={toPx(b, true)} r="5" fill="#e11d48" stroke="white" strokeWidth="2" />
<text x={toPx(0) + 10} y={toPx(b, true)} className="text-xs font-bold fill-rose-600">b={b}</text>
{/* Rise/Run Labels */}
<text x={toPx(0.5)} y={toPx(b, true) + (m>0 ? 15 : -10)} textAnchor="middle" className="text-[10px] font-bold fill-blue-400">Run: 1</text>
<text x={toPx(1) + 5} y={toPx(b + m/2, true)} className="text-[10px] font-bold fill-blue-600">Rise: {m}</text>
</svg>
</div>
</div>
</div>
);
};
export default SlopeInterceptWidget;

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
const StandardFormWidget: React.FC = () => {
const [A, setA] = useState(2);
const [B, setB] = useState(3);
const [C, setC] = useState(12);
// Intercepts
const xInt = A !== 0 ? C / A : null;
const yInt = B !== 0 ? C / B : null;
// Vis
const range = 15;
const scale = 15;
const center = 150;
const toPx = (val: number, isY = false) => isY ? center - val * scale : center + val * scale;
// Line points
// If B!=0, y = (C - Ax)/B. If A!=0, x = (C - By)/A.
let p1, p2;
if (B !== 0) {
p1 = { x: -range, y: (C - A * -range) / B };
p2 = { x: range, y: (C - A * range) / B };
} else {
// Vertical line x = C/A
p1 = { x: C/A, y: -range };
p2 = { x: C/A, y: range };
}
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col gap-6">
<div className="flex justify-center mb-4">
<div className="px-6 py-3 bg-slate-800 text-white rounded-xl shadow-md text-2xl font-mono font-bold tracking-wider">
{A}x + {B}y = {C}
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="bg-indigo-50 p-3 rounded-lg border border-indigo-100">
<label className="text-xs font-bold text-indigo-800 uppercase block mb-1">A (x-coeff)</label>
<input type="number" value={A} onChange={e => setA(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
<div className="bg-emerald-50 p-3 rounded-lg border border-emerald-100">
<label className="text-xs font-bold text-emerald-800 uppercase block mb-1">B (y-coeff)</label>
<input type="number" value={B} onChange={e => setB(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
<div className="bg-amber-50 p-3 rounded-lg border border-amber-100">
<label className="text-xs font-bold text-amber-800 uppercase block mb-1">C (constant)</label>
<input type="number" value={C} onChange={e => setC(Number(e.target.value))} className="w-full p-1 border rounded text-center font-bold"/>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<h4 className="font-bold text-slate-700 mb-2 border-b pb-1">Cover-Up Method</h4>
<div className="mb-4">
<p className="text-xs text-slate-500 uppercase font-bold mb-1">Find X-Intercept (Set y=0)</p>
<div className="font-mono text-sm bg-white p-2 rounded border border-slate-200 text-slate-600">
{A}x = {C} <br/>
x = {C} / {A} <br/>
<span className="text-indigo-600 font-bold">x = {xInt !== null ? xInt.toFixed(2) : 'Undefined'}</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500 uppercase font-bold mb-1">Find Y-Intercept (Set x=0)</p>
<div className="font-mono text-sm bg-white p-2 rounded border border-slate-200 text-slate-600">
{B}y = {C} <br/>
y = {C} / {B} <br/>
<span className="text-emerald-600 font-bold">y = {yInt !== null ? yInt.toFixed(2) : 'Undefined'}</span>
</div>
</div>
</div>
</div>
<div className="w-full md:flex-1 h-[300px] border border-slate-200 rounded-lg relative bg-white overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300" className="absolute">
{/* Grid */}
<defs>
<pattern id="sf-grid" width={scale} height={scale} patternUnits="userSpaceOnUse">
<path d={`M ${scale} 0 L 0 0 0 ${scale}`} fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#sf-grid)" />
{/* Axes */}
<line x1="0" y1={center} x2="300" y2={center} stroke="#94a3b8" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2="300" stroke="#94a3b8" strokeWidth="2" />
{/* Line */}
<line
x1={toPx(p1.x)} y1={toPx(p1.y, true)}
x2={toPx(p2.x)} y2={toPx(p2.y, true)}
stroke="#0f172a" strokeWidth="3"
/>
{/* Intercepts */}
{xInt !== null && Math.abs(xInt) <= range && (
<g>
<circle cx={toPx(xInt)} cy={center} r="5" fill="#4f46e5" stroke="white" strokeWidth="2"/>
<text x={toPx(xInt)} y={center + 20} textAnchor="middle" className="text-xs font-bold fill-indigo-700">{xInt.toFixed(1)}</text>
</g>
)}
{yInt !== null && Math.abs(yInt) <= range && (
<g>
<circle cx={center} cy={toPx(yInt, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2"/>
<text x={center + 10} y={toPx(yInt, true)} dominantBaseline="middle" className="text-xs font-bold fill-emerald-700">{yInt.toFixed(1)}</text>
</g>
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default StandardFormWidget;

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { ArrowRight, Users, FlaskConical } from 'lucide-react';
const StudyDesignWidget: React.FC = () => {
const [isRandomSample, setIsRandomSample] = useState(false);
const [isRandomAssign, setIsRandomAssign] = useState(false);
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* Sampling */}
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${isRandomSample ? 'border-amber-500 bg-amber-50' : 'border-slate-200 hover:border-amber-200'}`}
onClick={() => setIsRandomSample(!isRandomSample)}>
<div className="flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isRandomSample ? 'bg-amber-500 text-white' : 'bg-slate-200'}`}>
<Users className="w-4 h-4" />
</div>
<h4 className="font-bold text-slate-800">Selection Method</h4>
</div>
<p className="text-sm text-slate-600 mb-3">How were participants chosen?</p>
<div className="flex justify-between items-center bg-white p-2 rounded border border-slate-100">
<span className="text-xs font-bold uppercase text-slate-400">Current:</span>
<span className={`font-bold ${isRandomSample ? 'text-amber-600' : 'text-slate-500'}`}>
{isRandomSample ? "Random Sample" : "Convenience / Voluntary"}
</span>
</div>
</div>
{/* Assignment */}
<div className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${isRandomAssign ? 'border-amber-500 bg-amber-50' : 'border-slate-200 hover:border-amber-200'}`}
onClick={() => setIsRandomAssign(!isRandomAssign)}>
<div className="flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${isRandomAssign ? 'bg-amber-500 text-white' : 'bg-slate-200'}`}>
<FlaskConical className="w-4 h-4" />
</div>
<h4 className="font-bold text-slate-800">Assignment Method</h4>
</div>
<p className="text-sm text-slate-600 mb-3">How were treatments assigned?</p>
<div className="flex justify-between items-center bg-white p-2 rounded border border-slate-100">
<span className="text-xs font-bold uppercase text-slate-400">Current:</span>
<span className={`font-bold ${isRandomAssign ? 'text-amber-600' : 'text-slate-500'}`}>
{isRandomAssign ? "Random Assignment" : "Observational / None"}
</span>
</div>
</div>
</div>
{/* Conclusions */}
<div className="bg-slate-900 text-white p-6 rounded-xl relative overflow-hidden">
<div className="relative z-10 grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h4 className="text-slate-400 text-xs font-bold uppercase mb-1">Generalization</h4>
<p className="text-lg font-bold mb-2">
Can apply to Population?
</p>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full font-bold text-sm ${isRandomSample ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`}>
{isRandomSample ? <ArrowRight className="w-4 h-4" /> : null}
{isRandomSample ? "YES" : "NO (Sample Only)"}
</div>
<p className="text-xs text-slate-400 mt-2">
{isRandomSample
? "Random sampling reduces bias, allowing results to represent the whole population."
: "Without random sampling, results may be biased and only apply to the specific people studied."}
</p>
</div>
<div>
<h4 className="text-slate-400 text-xs font-bold uppercase mb-1">Causation</h4>
<p className="text-lg font-bold mb-2">
Can prove Cause & Effect?
</p>
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full font-bold text-sm ${isRandomAssign ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`}>
{isRandomAssign ? <ArrowRight className="w-4 h-4" /> : null}
{isRandomAssign ? "YES" : "NO (Association Only)"}
</div>
<p className="text-xs text-slate-400 mt-2">
{isRandomAssign
? "Random assignment creates comparable groups, so differences can be attributed to the treatment."
: "Without random assignment (experiment), confounding variables might explain the difference."}
</p>
</div>
</div>
</div>
</div>
);
};
export default StudyDesignWidget;

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
const SystemVisualizerWidget: React.FC = () => {
// Line 1: y = m1x + b1
const [m1, setM1] = useState(1);
const [b1, setB1] = useState(2);
// Line 2: y = m2x + b2
const [m2, setM2] = useState(-1);
const [b2, setB2] = useState(6);
// Visualization params
const range = 10;
const scale = 20;
const size = 300;
const center = size / 2;
const toPx = (v: number, isY = false) => {
return isY ? center - v * scale : center + v * scale;
};
// Logic
let intersectX = 0;
let intersectY = 0;
let solutionType = 'one'; // 'one', 'none', 'inf'
if (m1 === m2) {
if (b1 === b2) solutionType = 'inf';
else solutionType = 'none';
} else {
intersectX = (b2 - b1) / (m1 - m2);
intersectY = m1 * intersectX + b1;
}
const getLinePath = (m: number, b: number) => {
const x1 = -range;
const y1 = m * x1 + b;
const x2 = range;
const y2 = m * x2 + b;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6">
{/* Line 1 */}
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-indigo-800 text-sm">Line 1: y = {m1}x + {b1}</span>
</div>
<div className="space-y-2">
<input type="range" min="-4" max="4" step="0.5" value={m1} onChange={e => setM1(parseFloat(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
<input type="range" min="-8" max="8" step="1" value={b1} onChange={e => setB1(parseFloat(e.target.value))} className="w-full h-1 bg-indigo-200 rounded accent-indigo-600"/>
</div>
</div>
{/* Line 2 */}
<div className="p-4 bg-rose-50 border border-rose-100 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-rose-800 text-sm">Line 2: y = {m2}x + {b2}</span>
</div>
<div className="space-y-2">
<input type="range" min="-4" max="4" step="0.5" value={m2} onChange={e => setM2(parseFloat(e.target.value))} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
<input type="range" min="-8" max="8" step="1" value={b2} onChange={e => setB2(parseFloat(e.target.value))} className="w-full h-1 bg-rose-200 rounded accent-rose-600"/>
</div>
</div>
{/* Result */}
<div className={`p-4 rounded-lg text-center font-bold border-2 ${
solutionType === 'one' ? 'bg-emerald-50 border-emerald-200 text-emerald-800' :
solutionType === 'none' ? 'bg-slate-50 border-slate-200 text-slate-500' :
'bg-amber-50 border-amber-200 text-amber-800'
}`}>
{solutionType === 'one' && `Intersection: (${intersectX.toFixed(1)}, ${intersectY.toFixed(1)})`}
{solutionType === 'none' && "No Solution (Parallel Lines)"}
{solutionType === 'inf' && "Infinite Solutions (Same Line)"}
</div>
</div>
<div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="300" height="300" viewBox="0 0 300 300">
<defs>
<pattern id="grid-sys" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid-sys)" />
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" />
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" />
<path d={getLinePath(m1, b1)} stroke="#4f46e5" strokeWidth="3" />
<path d={getLinePath(m2, b2)} stroke="#e11d48" strokeWidth="3" strokeDasharray={solutionType === 'inf' ? "5,5" : ""} />
{solutionType === 'one' && (
<circle cx={toPx(intersectX)} cy={toPx(intersectY, true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" />
)}
</svg>
</div>
</div>
</div>
</div>
);
};
export default SystemVisualizerWidget;

View File

@ -0,0 +1,179 @@
import React, { useState, useRef } from 'react';
const TangentPropertiesWidget: React.FC = () => {
const [pointP, setPointP] = useState({ x: 350, y: 150 });
const isDragging = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
const center = { x: 150, y: 150 };
const radius = 60;
// Interaction
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Constrain P to be outside the circle (distance > radius)
const dx = x - center.x;
const dy = y - center.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Min distance to keep things looking nice (radius + padding)
if (dist < radius + 20) {
const angle = Math.atan2(dy, dx);
setPointP({
x: center.x + (radius + 20) * Math.cos(angle),
y: center.y + (radius + 20) * Math.sin(angle)
});
} else {
setPointP({ x, y });
}
};
// Calculations
const dx = pointP.x - center.x;
const dy = pointP.y - center.y;
const distPO = Math.sqrt(dx * dx + dy * dy);
const anglePO = Math.atan2(dy, dx);
// Angle offset to tangent points
// cos(theta) = Adjacent / Hypotenuse = radius / distPO
const theta = Math.acos(radius / distPO);
const t1Angle = anglePO - theta;
const t2Angle = anglePO + theta;
const T1 = {
x: center.x + radius * Math.cos(t1Angle),
y: center.y + radius * Math.sin(t1Angle)
};
const T2 = {
x: center.x + radius * Math.cos(t2Angle),
y: center.y + radius * Math.sin(t2Angle)
};
const tangentLength = Math.sqrt(distPO * distPO - radius * radius);
// Right Angle Markers
const markerSize = 10;
const getRightAnglePath = (p: {x:number, y:number}, angle: number) => {
// angle is the angle of the radius. We need to go inwards and perpendicular
// Actually simpler: Vector from Center to T, and Vector T to P are perp.
// Let's just draw a small square aligned with radius
const rAngle = angle;
// Point on radius
const p1 = { x: p.x - markerSize * Math.cos(rAngle), y: p.y - markerSize * Math.sin(rAngle) };
// Point on tangent (towards P)
// Tangent is perpendicular to radius.
// We need to know if we go clockwise or counter clockwise.
// Vector T->P
const tpAngle = Math.atan2(pointP.y - p.y, pointP.x - p.x);
const p2 = { x: p.x + markerSize * Math.cos(tpAngle), y: p.y + markerSize * Math.sin(tpAngle) };
// Corner
const p3 = { x: p1.x + markerSize * Math.cos(tpAngle), y: p1.y + markerSize * Math.sin(tpAngle) };
return `M ${p1.x} ${p1.y} L ${p3.x} ${p3.y} L ${p2.x} ${p2.y}`;
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="relative">
<svg
ref={svgRef}
width="400" height="300"
className="select-none cursor-default bg-slate-50 rounded-lg border border-slate-100"
onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false}
onMouseLeave={() => isDragging.current = false}
>
{/* Circle */}
<circle cx={center.x} cy={center.y} r={radius} fill="white" stroke="#94a3b8" strokeWidth="2" />
<circle cx={center.x} cy={center.y} r="3" fill="#64748b" />
<text x={center.x - 15} y={center.y + 5} className="text-xs font-bold fill-slate-400">O</text>
{/* Radii */}
<line x1={center.x} y1={center.y} x2={T1.x} y2={T1.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
<line x1={center.x} y1={center.y} x2={T2.x} y2={T2.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" />
{/* Tangents */}
<line x1={pointP.x} y1={pointP.y} x2={T1.x} y2={T1.y} stroke="#7c3aed" strokeWidth="3" />
<line x1={pointP.x} y1={pointP.y} x2={T2.x} y2={T2.y} stroke="#7c3aed" strokeWidth="3" />
{/* Right Angle Markers */}
<path d={getRightAnglePath(T1, t1Angle)} stroke="#64748b" fill="transparent" strokeWidth="1" />
<path d={getRightAnglePath(T2, t2Angle)} stroke="#64748b" fill="transparent" strokeWidth="1" />
{/* Points */}
<circle cx={T1.x} cy={T1.y} r="5" fill="#7c3aed" />
<text x={T1.x + (T1.x - center.x)*0.2} y={T1.y + (T1.y - center.y)*0.2} className="text-xs font-bold fill-violet-700">A</text>
<circle cx={T2.x} cy={T2.y} r="5" fill="#7c3aed" />
<text x={T2.x + (T2.x - center.x)*0.2} y={T2.y + (T2.y - center.y)*0.2} className="text-xs font-bold fill-violet-700">B</text>
{/* External Point P */}
<g
onMouseDown={() => isDragging.current = true}
className="cursor-grab active:cursor-grabbing"
>
<circle cx={pointP.x} cy={pointP.y} r="15" fill="transparent" />
<circle cx={pointP.x} cy={pointP.y} r="6" fill="#f43f5e" stroke="white" strokeWidth="2" />
<text x={pointP.x + 10} y={pointP.y} className="text-sm font-bold fill-rose-600">P</text>
</g>
{/* Length Labels (Midpoints) */}
<rect
x={(pointP.x + T1.x)/2 - 15} y={(pointP.y + T1.y)/2 - 10}
width="30" height="20" rx="4" fill="white" stroke="#e2e8f0"
/>
<text x={(pointP.x + T1.x)/2} y={(pointP.y + T1.y)/2 + 4} textAnchor="middle" className="text-xs font-bold fill-violet-600">
{Math.round(tangentLength)}
</text>
<rect
x={(pointP.x + T2.x)/2 - 15} y={(pointP.y + T2.y)/2 - 10}
width="30" height="20" rx="4" fill="white" stroke="#e2e8f0"
/>
<text x={(pointP.x + T2.x)/2} y={(pointP.y + T2.y)/2 + 4} textAnchor="middle" className="text-xs font-bold fill-violet-600">
{Math.round(tangentLength)}
</text>
</svg>
</div>
<div className="flex-1 space-y-6">
<div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
<h4 className="font-bold text-violet-900 mb-2 flex items-center gap-2">
<span className="bg-violet-200 text-xs px-2 py-0.5 rounded-full text-violet-800">Rule 1</span>
Equal Tangents
</h4>
<p className="text-sm text-violet-800 mb-2">
Tangents from the same external point are always congruent.
</p>
<p className="font-mono text-lg font-bold text-violet-600 bg-white p-2 rounded border border-violet-100 text-center">
PA = PB = {Math.round(tangentLength)}
</p>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<h4 className="font-bold text-slate-700 mb-2 flex items-center gap-2">
<span className="bg-slate-200 text-xs px-2 py-0.5 rounded-full text-slate-600">Rule 2</span>
Perpendicular Radius
</h4>
<p className="text-sm text-slate-600">
The radius to the point of tangency is always perpendicular to the tangent line.
</p>
<div className="flex gap-4 mt-2 justify-center">
<span className="text-xs font-bold bg-white px-2 py-1 rounded border border-slate-200">OAP = 90°</span>
<span className="text-xs font-bold bg-white px-2 py-1 rounded border border-slate-200">OBP = 90°</span>
</div>
</div>
<p className="text-xs text-center text-slate-400">Drag point <strong>P</strong> to verify!</p>
</div>
</div>
);
};
export default TangentPropertiesWidget;

View File

@ -0,0 +1,233 @@
import React, { useState, useRef, useEffect } from 'react';
const SPECIAL_ANGLES = [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330, 360];
const UnitCircleWidget: React.FC = () => {
const [angle, setAngle] = useState(45); // Degrees
const [snap, setSnap] = useState(true); // Snap to special angles
const svgRef = useRef<SVGSVGElement>(null);
const isDragging = useRef(false);
const radius = 140;
const center = { x: 200, y: 200 };
const handleInteraction = (clientX: number, clientY: number) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const dx = clientX - rect.left - center.x;
const dy = clientY - rect.top - center.y;
// Calculate angle from 0 to 360
let rad = Math.atan2(-dy, dx);
if (rad < 0) rad += 2 * Math.PI;
let deg = (rad * 180) / Math.PI;
if (snap) {
const nearest = SPECIAL_ANGLES.reduce((prev, curr) =>
Math.abs(curr - deg) < Math.abs(prev - deg) ? curr : prev
);
if (Math.abs(nearest - deg) < 15) {
deg = nearest;
}
}
if (deg > 360) deg = 360;
setAngle(Math.round(deg));
};
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
handleInteraction(e.clientX, e.clientY);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging.current) {
handleInteraction(e.clientX, e.clientY);
}
};
const handleMouseUp = () => {
isDragging.current = false;
};
useEffect(() => {
const upHandler = () => isDragging.current = false;
window.addEventListener('mouseup', upHandler);
return () => window.removeEventListener('mouseup', upHandler);
}, []);
const rad = (angle * Math.PI) / 180;
const x = Math.cos(rad);
const y = Math.sin(rad);
const px = center.x + radius * x;
const py = center.y - radius * y;
const getExactValue = (val: number) => {
if (Math.abs(val) < 0.01) return "0";
if (Math.abs(val - 0.5) < 0.01) return "1/2";
if (Math.abs(val + 0.5) < 0.01) return "-1/2";
if (Math.abs(val - Math.sqrt(2)/2) < 0.01) return "√2/2";
if (Math.abs(val + Math.sqrt(2)/2) < 0.01) return "-√2/2";
if (Math.abs(val - Math.sqrt(3)/2) < 0.01) return "√3/2";
if (Math.abs(val + Math.sqrt(3)/2) < 0.01) return "-√3/2";
if (Math.abs(val - 1) < 0.01) return "1";
if (Math.abs(val + 1) < 0.01) return "-1";
return val.toFixed(3);
};
const getRadianLabel = (deg: number) => {
// Removed Record type annotation to prevent parsing error
const map: any = {
0: "0", 30: "π/6", 45: "π/4", 60: "π/3", 90: "π/2",
120: "2π/3", 135: "3π/4", 150: "5π/6", 180: "π",
210: "7π/6", 225: "5π/4", 240: "4π/3", 270: "3π/2",
300: "5π/3", 315: "7π/4", 330: "11π/6", 360: "2π"
};
if (map[deg]) return map[deg];
return ((deg * Math.PI) / 180).toFixed(2);
};
const cosStr = getExactValue(x);
const sinStr = getExactValue(y);
const getAngleColor = () => {
if (angle < 90) return "text-emerald-600";
if (angle < 180) return "text-indigo-600";
if (angle < 270) return "text-amber-600";
return "text-rose-600";
};
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row gap-8 select-none">
<div className="flex-shrink-0 flex flex-col items-center">
<svg
ref={svgRef}
width="400"
height="400"
className="cursor-pointer touch-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<line x1="200" y1="20" x2="200" y2="380" stroke="#f1f5f9" strokeWidth="1" />
<line x1="20" y1="200" x2="380" y2="200" stroke="#f1f5f9" strokeWidth="1" />
<line x1="200" y1="40" x2="200" y2="360" stroke="#cbd5e1" strokeWidth="1" />
<line x1="40" y1="200" x2="360" y2="200" stroke="#cbd5e1" strokeWidth="1" />
<circle cx="200" cy="200" r={radius} fill="transparent" stroke="#e2e8f0" strokeWidth="2" />
{SPECIAL_ANGLES.map(a => {
const rTick = (a * Math.PI) / 180;
const x1 = 200 + (radius - 5) * Math.cos(rTick);
const y1 = 200 - (radius - 5) * Math.sin(rTick);
const x2 = 200 + radius * Math.cos(rTick);
const y2 = 200 - radius * Math.sin(rTick);
return <line key={a} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#94a3b8" strokeWidth="1" />;
})}
<path d={`M 200 200 L ${px} 200 L ${px} ${py} Z`} fill="rgba(224, 231, 255, 0.4)" stroke="none" />
<line x1="200" y1="200" x2={px} y2={py} stroke="#1e293b" strokeWidth="2" />
<line x1="200" y1="200" x2={px} y2="200" stroke="#4f46e5" strokeWidth="3" />
<line x1={px} y1="200" x2={px} y2={py} stroke="#e11d48" strokeWidth="3" />
{angle > 0 && (
<path
d={`M 230 200 A 30 30 0 ${angle > 180 ? 1 : 0} 0 ${200 + 30*Math.cos(rad)} ${200 - 30*Math.sin(rad)}`}
fill="none" stroke="#0f172a" strokeWidth="1.5"
/>
)}
<circle cx={px} cy={py} r="8" fill="#0f172a" stroke="white" strokeWidth="2" className="shadow-sm" />
<circle cx={px} cy={py} r="20" fill="transparent" cursor="grab" />
<text x={200 + (px - 200)/2} y={200 + (y >= 0 ? 15 : -10)} textAnchor="middle" className="text-xs font-bold fill-indigo-600">cos</text>
<text x={px + (x >= 0 ? 10 : -10)} y={200 - (200 - py)/2} textAnchor={x >= 0 ? "start" : "end"} className="text-xs font-bold fill-rose-600">sin</text>
<g transform={`translate(${x >= 0 ? 280 : 40}, ${y >= 0 ? 40 : 360})`}>
<rect x="-10" y="-20" width="130" height="40" rx="8" fill="white" stroke="#e2e8f0" className="shadow-sm" />
<text x="55" y="5" textAnchor="middle" className="font-mono text-sm font-bold fill-slate-700">
({cosStr}, {sinStr})
</text>
</g>
</svg>
<div className="flex gap-4 mt-2">
<button
onClick={() => setSnap(!snap)}
className={`text-xs px-3 py-1 rounded-full font-bold border transition-colors ${snap ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200'}`}
>
{snap ? "Snapping ON" : "Snapping OFF"}
</button>
</div>
</div>
<div className="flex-1 w-full space-y-6">
<div className="bg-slate-50 p-5 rounded-xl border border-slate-200">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-sm font-bold uppercase text-slate-500">Current Angle</h3>
<div className="flex items-baseline gap-3 mt-1">
<span className={`text-4xl font-mono font-bold ${getAngleColor()}`}>{Math.round(angle)}°</span>
<span className="text-2xl font-mono text-slate-400">=</span>
<span className="text-3xl font-mono font-bold text-slate-700">{getRadianLabel(angle)}</span>
<span className="text-sm text-slate-400 ml-1">rad</span>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Common Angles</p>
<div className="flex flex-wrap gap-2">
{[0, 30, 45, 60, 90, 180, 270].map(a => (
<button
key={a}
onClick={() => setAngle(a)}
className={`w-10 h-10 rounded-lg text-sm font-bold transition-all ${
angle === a
? 'bg-indigo-600 text-white shadow-md scale-110'
: 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300 hover:text-indigo-600'
}`}
>
{a}°
</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-xl">
<div className="text-xs font-bold uppercase text-indigo-800 mb-1">Cosine (x)</div>
<div className="text-3xl font-mono font-bold text-indigo-900">{cosStr}</div>
<div className="text-xs text-indigo-400 mt-1 font-mono">adj / hyp</div>
</div>
<div className="p-4 bg-rose-50 border border-rose-100 rounded-xl">
<div className="text-xs font-bold uppercase text-rose-800 mb-1">Sine (y)</div>
<div className="text-3xl font-mono font-bold text-rose-900">{sinStr}</div>
<div className="text-xs text-rose-400 mt-1 font-mono">opp / hyp</div>
</div>
</div>
<div className="p-4 bg-amber-50 border border-amber-100 rounded-xl text-center">
<div className="text-xs font-bold uppercase text-amber-800 mb-1">Tangent (sin/cos)</div>
<div className="text-2xl font-mono font-bold text-amber-900">
{Math.abs(x) < 0.001 ? "Undefined" : getExactValue(y/x)}
</div>
</div>
<p className="text-xs text-slate-400 text-center">
Pro tip: On the SAT, memorize the values for 30°, 45°, and 60°!
</p>
</div>
</div>
);
};
export default UnitCircleWidget;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { ArrowRight } from 'lucide-react';
const UnitConversionWidget: React.FC = () => {
const [speed, setSpeed] = useState(60); // miles per hour
// Steps
const ftPerMile = 5280;
const secPerHour = 3600;
const ftPerHour = speed * ftPerMile;
const ftPerSec = ftPerHour / secPerHour;
return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-8">
<label className="text-sm font-bold text-slate-500 uppercase">Speed (mph)</label>
<div className="flex items-center gap-4 mt-2">
<input
type="range" min="10" max="100" step="5" value={speed}
onChange={e => setSpeed(Number(e.target.value))}
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-amber-600"
/>
<span className="font-mono font-bold text-2xl text-slate-800 w-20 text-right">{speed}</span>
</div>
</div>
<div className="space-y-6">
{/* Step 1: Write initial */}
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-lg border border-slate-200 overflow-x-auto">
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-slate-800">{speed} miles</span>
<div className="w-full h-0.5 bg-slate-800 my-1"></div>
<span className="font-bold text-lg text-slate-800">1 hour</span>
</div>
<span className="text-slate-400 font-bold">×</span>
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-emerald-600">5280 feet</span>
<div className="w-full h-0.5 bg-emerald-600 my-1"></div>
<span className="font-bold text-lg text-rose-600 line-through decoration-2">1 mile</span>
</div>
<span className="text-slate-400 font-bold">×</span>
<div className="flex flex-col items-center">
<span className="font-bold text-lg text-slate-800 line-through decoration-2 decoration-rose-600">1 hour</span>
<div className="w-full h-0.5 bg-slate-800 my-1"></div>
<span className="font-bold text-lg text-emerald-600">3600 sec</span>
</div>
<ArrowRight className="w-6 h-6 text-slate-400 shrink-0" />
<div className="flex flex-col items-center bg-white px-4 py-2 rounded shadow-sm border border-emerald-200">
<span className="font-bold text-xl text-emerald-700">{ftPerSec.toFixed(1)} ft</span>
<div className="w-full h-0.5 bg-emerald-700 my-1"></div>
<span className="font-bold text-xl text-emerald-700">1 sec</span>
</div>
</div>
<div className="text-sm text-slate-500">
<p><strong className="text-rose-600">Red units</strong> cancel out (top and bottom).</p>
<p><strong className="text-emerald-600">Green units</strong> remain.</p>
</div>
</div>
</div>
);
};
export default UnitConversionWidget;

View File

@ -0,0 +1,378 @@
import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, User, Shield, Clock, BookOpen, Calculator, Award,
TrendingUp, CheckCircle2, Circle, Lock, Eye, EyeOff, AlertCircle,
Check, Sparkles,
} from 'lucide-react';
import { useAuth, UserRecord } from './auth/AuthContext';
import { useProgress } from './progress/ProgressContext';
import { useGoldCoins } from './practice/GoldCoinContext';
import { LESSONS, EBRW_LESSONS } from '../constants';
import Mascot from './Mascot';
// Animated count-up
function useCountUp(target: number, duration = 900) {
const [count, setCount] = useState(0);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const startTime = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 2.5);
setCount(Math.round(eased * target));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [target, duration]);
return count;
}
interface UserDashboardProps {
onExit: () => void;
}
export default function UserDashboard({ onExit }: UserDashboardProps) {
const { username, role, getUserRecord, changePassword, updateDisplayName } = useAuth();
const { getSubjectStats, getLessonStatus } = useProgress();
const { totalCoins, state: coinState } = useGoldCoins();
const user = getUserRecord(username || '');
const mathStats = getSubjectStats('math');
const ebrwStats = getSubjectStats('ebrw');
// Account settings
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwLoading, setPwLoading] = useState(false);
const [editName, setEditName] = useState(false);
const [nameInput, setNameInput] = useState(user?.displayName || '');
const [nameSaved, setNameSaved] = useState(false);
const animCoins = useCountUp(totalCoins, 1200);
// Count completed topics across all practice
const topicsAttempted = Object.keys(coinState.topicProgress).length;
// Calculate total accuracy
let totalAttempted = 0;
let totalCorrect = 0;
Object.values(coinState.topicProgress).forEach((tp: any) => {
(['easy', 'medium', 'hard'] as const).forEach(d => {
totalAttempted += tp[d]?.attempted || 0;
totalCorrect += tp[d]?.correct || 0;
});
});
const accuracy = totalAttempted > 0 ? Math.round((totalCorrect / totalAttempted) * 100) : 0;
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmPassword) {
setPwMsg({ type: 'error', text: 'New passwords do not match.' });
return;
}
setPwLoading(true);
const result = await changePassword(username || '', currentPassword, newPassword);
setPwLoading(false);
if (result.success) {
setPwMsg({ type: 'success', text: 'Password changed successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
setPwMsg({ type: 'error', text: result.error || 'Failed to change password.' });
}
};
const handleSaveName = () => {
if (username && nameInput.trim()) {
updateDisplayName(username, nameInput.trim());
setEditName(false);
setNameSaved(true);
setTimeout(() => setNameSaved(false), 2000);
}
};
// Progress ring
function ProgressRing({ percent, size = 72, stroke = 6, color }: { percent: number; size?: number; stroke?: number; color: string }) {
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const offset = circ - (percent / 100) * circ;
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-slate-100" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="text-sm font-bold fill-slate-800 transform rotate-90" style={{ transformOrigin: 'center' }}>
{percent}%
</text>
</svg>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
if (status === 'in_progress') return <Circle className="w-4 h-4 text-blue-400" />;
return <Lock className="w-3.5 h-3.5 text-slate-300" />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-white via-slate-50/50 to-white">
{/* Header */}
<header className="sticky top-0 z-40 glass-nav border-b border-slate-100">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<button onClick={onExit} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to Home
</button>
<h1 className="text-sm font-bold text-slate-800">My Dashboard</h1>
<div className="w-20" />
</div>
</header>
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
{/* ── Welcome Hero ── */}
<div className="relative bg-gradient-to-br from-cyan-50 via-white to-blue-50 rounded-2xl p-8 border border-cyan-100 overflow-hidden anim-fade-in-up">
<div className="absolute -top-2 -right-2 pointer-events-none select-none opacity-80">
<Mascot pose="waving" height={120} />
</div>
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-cyan-100 flex items-center justify-center">
<User className="w-6 h-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-2">
{editName ? (
<div className="flex items-center gap-2">
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
className="text-xl font-bold text-slate-900 bg-white border border-slate-200 rounded-lg px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-cyan-400 w-48"
autoFocus onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button onClick={handleSaveName} className="text-xs font-bold text-cyan-600 hover:text-cyan-800">Save</button>
<button onClick={() => setEditName(false)} className="text-xs text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<>
<h2 className="text-xl font-bold text-slate-900">{user?.displayName || username}</h2>
<button onClick={() => { setNameInput(user?.displayName || ''); setEditName(true); }}
className="text-xs text-cyan-500 hover:text-cyan-700 font-medium">edit</button>
{nameSaved && <span className="text-xs text-emerald-500 font-medium flex items-center gap-1"><Check className="w-3 h-3" /> Saved</span>}
</>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
role === 'admin' ? 'bg-amber-100 text-amber-700' : 'bg-cyan-100 text-cyan-700'
}`}>
{role === 'admin' && <Shield className="w-3 h-3" />}
{role}
</span>
<span className="text-xs text-slate-400">@{username}</span>
</div>
</div>
</div>
{user?.lastLoginAt && (
<p className="text-xs text-slate-400 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Last login: {new Date(user.lastLoginAt).toLocaleString()}
{user.lastLoginIp && user.lastLoginIp !== 'unknown' && <span className="ml-1">from {user.lastLoginIp}</span>}
</p>
)}
</div>
</div>
{/* ── Stats Overview ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 anim-fade-in-up stagger-1">
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-slate-900 tabular-nums">{mathStats.completed + ebrwStats.completed}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Lessons Done</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-amber-500 tabular-nums flex items-center justify-center gap-1">
<Award className="w-5 h-5" />{animCoins}
</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Gold Coins</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-emerald-500 tabular-nums">{accuracy}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Accuracy</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-blue-500 tabular-nums">{topicsAttempted}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Topics Practiced</p>
</div>
</div>
{/* ── Lesson Progress ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 anim-fade-in-up stagger-2">
{/* Math */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Calculator className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Mathematics</h3>
<p className="text-xs text-slate-400">{mathStats.completed}/{mathStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={mathStats.percentComplete} color="#3b82f6" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-blue-500 rounded-full transition-all duration-1000" style={{ width: `${mathStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'math')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
{/* EBRW */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Reading & Writing</h3>
<p className="text-xs text-slate-400">{ebrwStats.completed}/{ebrwStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={ebrwStats.percentComplete} color="#a855f7" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-purple-500 rounded-full transition-all duration-1000" style={{ width: `${ebrwStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{EBRW_LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'ebrw')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
</div>
{/* ── Practice Performance ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-3">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Practice Performance</h3>
<p className="text-xs text-slate-400">{totalAttempted} questions attempted across {topicsAttempted} topics</p>
</div>
</div>
{topicsAttempted === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
<Sparkles className="w-6 h-6 mx-auto mb-2 text-amber-300" />
No practice sessions yet. Start practicing to see your performance!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(coinState.topicProgress).map(([topicId, tp]: [string, any]) => {
const easy = tp.easy || { attempted: 0, correct: 0 };
const medium = tp.medium || { attempted: 0, correct: 0 };
const hard = tp.hard || { attempted: 0, correct: 0 };
const total = easy.attempted + medium.attempted + hard.attempted;
const correct = easy.correct + medium.correct + hard.correct;
const acc = total > 0 ? Math.round((correct / total) * 100) : 0;
return (
<div key={topicId} className="border border-slate-100 rounded-xl p-3 hover:border-slate-200 transition-colors">
<p className="text-xs font-semibold text-slate-700 truncate mb-2">{topicId}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-1">
<span>{correct}/{total} correct</span>
<span className={`font-bold ${acc >= 70 ? 'text-emerald-500' : acc >= 40 ? 'text-amber-500' : 'text-rose-500'}`}>{acc}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${acc >= 70 ? 'bg-emerald-400' : acc >= 40 ? 'bg-amber-400' : 'bg-rose-400'}`} style={{ width: `${acc}%` }} />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-slate-400">
<span>E: {easy.correct}/{easy.attempted}</span>
<span>M: {medium.correct}/{medium.attempted}</span>
<span>H: {hard.correct}/{hard.attempted}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Account Settings ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-4">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Lock className="w-5 h-5 text-slate-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Change Password</h3>
<p className="text-xs text-slate-400">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
{pwMsg && (
<div className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
pwMsg.type === 'success' ? 'bg-emerald-50 border border-emerald-200 text-emerald-700' : 'bg-rose-50 border border-rose-200 text-rose-700'
}`}>
{pwMsg.type === 'success' ? <Check className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{pwMsg.text}
</div>
)}
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">Current Password</label>
<input type={showCurrentPw ? 'text' : 'password'} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required />
<button type="button" onClick={() => setShowCurrentPw(!showCurrentPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showCurrentPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">New Password</label>
<input type={showNewPw ? 'text' : 'password'} value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
<button type="button" onClick={() => setShowNewPw(!showNewPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showNewPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
</div>
<button type="submit" disabled={pwLoading}
className="px-5 py-2 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-700 transition-all btn-primary disabled:opacity-50">
{pwLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
</div>
</div>
);
}