feat(lessons): add lessons from client db
This commit is contained in:
87
src/components/lessons/BoxPlotAnatomyWidget.tsx
Normal file
87
src/components/lessons/BoxPlotAnatomyWidget.tsx
Normal 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;
|
||||
111
src/components/lessons/BoxPlotComparisonWidget.tsx
Normal file
111
src/components/lessons/BoxPlotComparisonWidget.tsx
Normal 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;
|
||||
121
src/components/lessons/CircleTheoremsWidget.tsx
Normal file
121
src/components/lessons/CircleTheoremsWidget.tsx
Normal 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;
|
||||
163
src/components/lessons/ClauseBreakdownWidget.tsx
Normal file
163
src/components/lessons/ClauseBreakdownWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/lessons/CompletingSquareWidget.tsx
Normal file
166
src/components/lessons/CompletingSquareWidget.tsx
Normal 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;
|
||||
227
src/components/lessons/CompositeAreaWidget.tsx
Normal file
227
src/components/lessons/CompositeAreaWidget.tsx
Normal 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;
|
||||
255
src/components/lessons/CompositeSolidsWidget.tsx
Normal file
255
src/components/lessons/CompositeSolidsWidget.tsx
Normal 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;
|
||||
98
src/components/lessons/ConfidenceIntervalWidget.tsx
Normal file
98
src/components/lessons/ConfidenceIntervalWidget.tsx
Normal 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;
|
||||
175
src/components/lessons/ContextEliminationWidget.tsx
Normal file
175
src/components/lessons/ContextEliminationWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/lessons/CoordinatePlane.tsx
Normal file
186
src/components/lessons/CoordinatePlane.tsx
Normal 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;
|
||||
443
src/components/lessons/DataClaimWidget.tsx
Normal file
443
src/components/lessons/DataClaimWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/lessons/DataModifierWidget.tsx
Normal file
127
src/components/lessons/DataModifierWidget.tsx
Normal 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">x̄</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;
|
||||
235
src/components/lessons/DecisionTreeWidget.tsx
Normal file
235
src/components/lessons/DecisionTreeWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/lessons/DiscriminantWidget.tsx
Normal file
80
src/components/lessons/DiscriminantWidget.tsx
Normal 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 Δ > 0: Crosses axis twice</p>
|
||||
<p>If Δ = 0: Touches axis once (Vertex on axis)</p>
|
||||
<p>If Δ < 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;
|
||||
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal file
255
src/components/lessons/EvidenceHunterWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/lessons/ExponentialExplorer.tsx
Normal file
88
src/components/lessons/ExponentialExplorer.tsx
Normal 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;
|
||||
116
src/components/lessons/FactoringWidget.tsx
Normal file
116
src/components/lessons/FactoringWidget.tsx
Normal 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;
|
||||
114
src/components/lessons/FrequencyMeanWidget.tsx
Normal file
114
src/components/lessons/FrequencyMeanWidget.tsx
Normal 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;
|
||||
84
src/components/lessons/GrowthComparisonWidget.tsx
Normal file
84
src/components/lessons/GrowthComparisonWidget.tsx
Normal 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;
|
||||
86
src/components/lessons/HistogramBuilderWidget.tsx
Normal file
86
src/components/lessons/HistogramBuilderWidget.tsx
Normal 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;
|
||||
173
src/components/lessons/InequalityRegionWidget.tsx
Normal file
173
src/components/lessons/InequalityRegionWidget.tsx
Normal 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;
|
||||
232
src/components/lessons/InteractiveSectorWidget.tsx
Normal file
232
src/components/lessons/InteractiveSectorWidget.tsx
Normal 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;
|
||||
186
src/components/lessons/InteractiveTransversal.tsx
Normal file
186
src/components/lessons/InteractiveTransversal.tsx
Normal 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;
|
||||
236
src/components/lessons/InteractiveTriangle.tsx
Normal file
236
src/components/lessons/InteractiveTriangle.tsx
Normal 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;
|
||||
23
src/components/lessons/LessonRenderer.tsx
Normal file
23
src/components/lessons/LessonRenderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
499
src/components/lessons/LessonShell.tsx
Normal file
499
src/components/lessons/LessonShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/components/lessons/LinearQuadraticSystemWidget.tsx
Normal file
111
src/components/lessons/LinearQuadraticSystemWidget.tsx
Normal 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;
|
||||
140
src/components/lessons/LinearSolutionsWidget.tsx
Normal file
140
src/components/lessons/LinearSolutionsWidget.tsx
Normal 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;
|
||||
120
src/components/lessons/LinearTransformationWidget.tsx
Normal file
120
src/components/lessons/LinearTransformationWidget.tsx
Normal 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;
|
||||
201
src/components/lessons/LiteralEquationWidget.tsx
Normal file
201
src/components/lessons/LiteralEquationWidget.tsx
Normal 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;
|
||||
103
src/components/lessons/MultiStepPercentWidget.tsx
Normal file
103
src/components/lessons/MultiStepPercentWidget.tsx
Normal 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;
|
||||
124
src/components/lessons/MultiplicityWidget.tsx
Normal file
124
src/components/lessons/MultiplicityWidget.tsx
Normal 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;
|
||||
96
src/components/lessons/ParabolaWidget.tsx
Normal file
96
src/components/lessons/ParabolaWidget.tsx
Normal 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;
|
||||
113
src/components/lessons/ParallelPerpendicularWidget.tsx
Normal file
113
src/components/lessons/ParallelPerpendicularWidget.tsx
Normal 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;
|
||||
69
src/components/lessons/PercentChangeWidget.tsx
Normal file
69
src/components/lessons/PercentChangeWidget.tsx
Normal 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;
|
||||
98
src/components/lessons/PolygonWidget.tsx
Normal file
98
src/components/lessons/PolygonWidget.tsx
Normal 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;
|
||||
77
src/components/lessons/PolynomialBehaviorWidget.tsx
Normal file
77
src/components/lessons/PolynomialBehaviorWidget.tsx
Normal 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;
|
||||
364
src/components/lessons/PowerOfPointWidget.tsx
Normal file
364
src/components/lessons/PowerOfPointWidget.tsx
Normal 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;
|
||||
110
src/components/lessons/ProbabilityTableWidget.tsx
Normal file
110
src/components/lessons/ProbabilityTableWidget.tsx
Normal 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;
|
||||
253
src/components/lessons/ProbabilityTreeWidget.tsx
Normal file
253
src/components/lessons/ProbabilityTreeWidget.tsx
Normal 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;
|
||||
126
src/components/lessons/Quiz.tsx
Normal file
126
src/components/lessons/Quiz.tsx
Normal 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;
|
||||
135
src/components/lessons/RadicalSolutionWidget.tsx
Normal file
135
src/components/lessons/RadicalSolutionWidget.tsx
Normal 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;
|
||||
71
src/components/lessons/RadicalWidget.tsx
Normal file
71
src/components/lessons/RadicalWidget.tsx
Normal 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;
|
||||
80
src/components/lessons/RatioVisualizerWidget.tsx
Normal file
80
src/components/lessons/RatioVisualizerWidget.tsx
Normal 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;
|
||||
109
src/components/lessons/RationalExplorer.tsx
Normal file
109
src/components/lessons/RationalExplorer.tsx
Normal 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;
|
||||
86
src/components/lessons/RemainderTheoremWidget.tsx
Normal file
86
src/components/lessons/RemainderTheoremWidget.tsx
Normal 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;
|
||||
166
src/components/lessons/SamplingVisualizerWidget.tsx
Normal file
166
src/components/lessons/SamplingVisualizerWidget.tsx
Normal 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;
|
||||
133
src/components/lessons/ScaleFactorWidget.tsx
Normal file
133
src/components/lessons/ScaleFactorWidget.tsx
Normal 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;
|
||||
125
src/components/lessons/ScatterplotInteractiveWidget.tsx
Normal file
125
src/components/lessons/ScatterplotInteractiveWidget.tsx
Normal 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;
|
||||
300
src/components/lessons/SimilarityTestsWidget.tsx
Normal file
300
src/components/lessons/SimilarityTestsWidget.tsx
Normal 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;
|
||||
119
src/components/lessons/SimilarityWidget.tsx
Normal file
119
src/components/lessons/SimilarityWidget.tsx
Normal 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;
|
||||
93
src/components/lessons/SlopeInterceptWidget.tsx
Normal file
93
src/components/lessons/SlopeInterceptWidget.tsx
Normal 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;
|
||||
121
src/components/lessons/StandardFormWidget.tsx
Normal file
121
src/components/lessons/StandardFormWidget.tsx
Normal 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;
|
||||
88
src/components/lessons/StudyDesignWidget.tsx
Normal file
88
src/components/lessons/StudyDesignWidget.tsx
Normal 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;
|
||||
108
src/components/lessons/SystemVisualizerWidget.tsx
Normal file
108
src/components/lessons/SystemVisualizerWidget.tsx
Normal 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;
|
||||
179
src/components/lessons/TangentPropertiesWidget.tsx
Normal file
179
src/components/lessons/TangentPropertiesWidget.tsx
Normal 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;
|
||||
233
src/components/lessons/UnitCircleWidget.tsx
Normal file
233
src/components/lessons/UnitCircleWidget.tsx
Normal 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;
|
||||
71
src/components/lessons/UnitConversionWidget.tsx
Normal file
71
src/components/lessons/UnitConversionWidget.tsx
Normal 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;
|
||||
378
src/components/lessons/UserDashboard.tsx
Normal file
378
src/components/lessons/UserDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user