form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
import { toKatex, Fraction } from "@/lib/math/fractions";
interface FractionVal {
num: number;
@@ -30,24 +30,26 @@ const OP_PRIORITY: Record<string, number> = {
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
let result: [number, number];
const fa = new Fraction(a.num, a.den);
const fb = new Fraction(b.num, b.den);
let result: Fraction;
switch (op) {
case "+":
result = add(a.num, a.den, b.num, b.den);
result = fa.plus(fb);
break;
case "":
result = subtract(a.num, a.den, b.num, b.den);
result = fa.minus(fb);
break;
case "×":
result = multiply(a.num, a.den, b.num, b.den);
result = fa.times(fb);
break;
case "÷":
result = divide(a.num, a.den, b.num, b.den);
result = fa.dividedBy(fb);
break;
default:
result = [0, 1];
result = new Fraction(0, 1);
}
return { num: result[0], den: result[1] };
return { num: result.n, den: result.d };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
@@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] {
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
const [sn, sd] = simplify(result.num, result.den);
const { n: sn, d: sd } = new Fraction(result.num, result.den).simplified();
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });

View File

@@ -0,0 +1,181 @@
"use client";
import { useId } from "react";
export interface GridPoint {
x: number;
y: number;
label?: string;
tone?: "solid" | "target" | "wrong" | "muted";
}
export interface GridSegment {
from: { x: number; y: number };
to: { x: number; y: number };
}
export interface GridArrow {
from: { x: number; y: number };
to: { x: number; y: number };
dashed?: boolean;
}
interface CartesianGridProps {
points?: GridPoint[];
segments?: GridSegment[];
arrows?: GridArrow[];
/** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
onGridClick?: (x: number, y: number) => void;
}
const MIN = -10;
const MAX = 10;
const UNIT = 20;
const MARGIN = 20;
const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
const toneColor: Record<NonNullable<GridPoint["tone"]>, string> = {
solid: "var(--unit-7)",
target: "var(--correct)",
wrong: "var(--incorrect)",
muted: "var(--muted)",
};
export function CartesianGrid({
points = [],
segments = [],
arrows = [],
highlight,
onGridClick,
}: CartesianGridProps) {
const arrowHeadId = useId();
const axisArrowId = useId();
const ticks: number[] = [];
for (let v = MIN; v <= MAX; v++) ticks.push(v);
const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGridClick) return;
const rect = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * SIZE;
const py = ((e.clientY - rect.top) / rect.height) * SIZE;
const x = Math.round((px - MARGIN) / UNIT + MIN);
const y = Math.round(MAX - (py - MARGIN) / UNIT);
if (x < MIN || x > MAX || y < MIN || y > MAX) return;
onGridClick(x, y);
}
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={`h-auto w-full max-w-md ${onGridClick ? "cursor-crosshair" : ""}`}
onClick={handleClick}
role="img"
aria-label="Cartesian coordinate grid"
>
<defs>
<marker id={arrowHeadId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="var(--unit-7-dark)" />
</marker>
<marker id={axisArrowId} markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
<path d="M0 0 L9 4.5 L0 9 z" fill="var(--foreground)" />
</marker>
</defs>
{/* Light gridlines */}
{ticks.map((v) => (
<g key={`grid-${v}`}>
<line x1={sx(v)} y1={sy(MIN)} x2={sx(v)} y2={sy(MAX)} stroke="var(--border)" strokeWidth={1} />
<line x1={sx(MIN)} y1={sy(v)} x2={sx(MAX)} y2={sy(v)} stroke="var(--border)" strokeWidth={1} />
</g>
))}
{/* Axes */}
<line x1={12} y1={sy(0)} x2={SIZE - 8} y2={sy(0)} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<line x1={sx(0)} y1={SIZE - 8} x2={sx(0)} y2={12} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<text x={SIZE - 12} y={sy(0) - 8} fontSize={14} fontWeight={700} fill="var(--foreground)" textAnchor="end">x</text>
<text x={sx(0) + 10} y={18} fontSize={14} fontWeight={700} fill="var(--foreground)">y</text>
{/* Axis labels */}
{labelTicks.map((v) => (
<text key={`xl-${v}`} x={sx(v)} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="middle">
{v}
</text>
))}
{labelTicks.map((v) => (
<text key={`yl-${v}`} x={sx(0) - 7} y={sy(v) + 3.5} fontSize={10} fill="var(--muted)" textAnchor="end">
{v}
</text>
))}
<text x={sx(0) - 7} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="end">0</text>
{/* Guide lines */}
{highlight && (
<g>
{(highlight.axis ?? "both") !== "y" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(highlight.x)} y2={sy(0)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
{(highlight.axis ?? "both") !== "x" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(0)} y2={sy(highlight.y)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
</g>
)}
{/* Segments */}
{segments.map((s, i) => (
<line
key={`seg-${i}`}
x1={sx(s.from.x)}
y1={sy(s.from.y)}
x2={sx(s.to.x)}
y2={sy(s.to.y)}
stroke="var(--unit-7)"
strokeWidth={2.5}
strokeLinecap="round"
/>
))}
{/* Movement arrows */}
{arrows.map((a, i) => (
<line
key={`arr-${i}`}
x1={sx(a.from.x)}
y1={sy(a.from.y)}
x2={sx(a.to.x)}
y2={sy(a.to.y)}
stroke="var(--unit-7-dark)"
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray={a.dashed ? "5 4" : undefined}
markerEnd={`url(#${arrowHeadId})`}
/>
))}
{/* Points */}
{points.map((p, i) => {
const color = toneColor[p.tone ?? "solid"];
return (
<g key={`pt-${i}`}>
<circle cx={sx(p.x)} cy={sy(p.y)} r={5} fill={color} stroke="white" strokeWidth={1.5} />
{p.label && (
<text
x={sx(p.x) + 8}
y={sy(p.y) - 8}
fontSize={12}
fontWeight={700}
fill={color}
>
{p.label}
</text>
)}
</g>
);
})}
</svg>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { simplify, toKatex, gcd } from "@/lib/math/fractions";
import { toKatex, gcd, Fraction } from "@/lib/math/fractions";
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
type ConvertMode = "decToFrac" | "fracToDec";
@@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] {
});
// Simplify
const [sn, sd] = simplify(numerator, denominator);
const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified();
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
@@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] {
// Check for terminating vs repeating
let tempDen = den;
const [, simpDen] = simplify(num, den);
const { d: simpDen } = new Fraction(num, den).simplified();
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;

View File

@@ -0,0 +1,372 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "expand" | "index";
type IndexOp = "multiply" | "divide";
interface Step {
label: string;
math: string;
}
function repeat(base: number, count: number): string {
return Array.from({ length: count }, () => `${base}`).join(" \\times ");
}
function buildExpandSteps(base: number, exp: number): Step[] {
if (exp === 0) {
return [
{ label: "The power (index) is 0.", math: `${base}^{0}` },
{ label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
];
}
if (exp === 1) {
return [
{ label: "The power (index) is 1.", math: `${base}^{1}` },
{ label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
];
}
const steps: Step[] = [
{
label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
math: `${base}^{${exp}}`,
},
];
let running = base;
for (let k = 2; k <= exp; k++) {
running *= base;
steps.push({
label: `Multiply on another ${base}.`,
math: `${repeat(base, k)} = ${running}`,
});
}
steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
return steps;
}
function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
if (op === "multiply") {
const total = m + n;
const value = Math.pow(base, total);
const steps: Step[] = [
{ label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
{ label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
{ label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
];
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
return steps;
}
// divide, m >= n
const diff = m - n;
const value = Math.pow(base, diff);
const steps: Step[] = [
{ label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
{ label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
{ label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
];
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
return steps;
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const SUPERSCRIPT: Record<string, string> = {
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
};
function sup(n: number): string {
return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 5);
if (roll === 0) {
const base = randInt(2, 7);
const exp = randInt(2, 3);
return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
}
if (roll === 1) {
const base = randInt(2, 9);
return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
}
if (roll === 2) {
const base = randInt(2, 4);
const a = randInt(2, 3);
const b = 1;
return { prompt: `What is ${base}${sup(a)} ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
}
if (roll === 3) {
const base = randInt(2, 5);
const m = randInt(3, 6);
const n = randInt(1, m - 1);
return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
}
const base = randInt(2, 6);
const m = randInt(1, 4);
const n = randInt(1, 4);
return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function ExponentsExplorer() {
const [mode, setMode] = useState<Mode>("expand");
const [indexOp, setIndexOp] = useState<IndexOp>("multiply");
const [base, setBase] = useState("2");
const [power, setPower] = useState("4");
const [m, setM] = useState("4");
const [n, setN] = useState("3");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(md: Mode) {
setMode(md);
setError("");
reset();
}
function handleGo() {
setError("");
const b = parseInt(base, 10);
if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
if (mode === "expand") {
const e = parseInt(power, 10);
if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
setSteps(buildExpandSteps(b, e));
} else {
const mm = parseInt(m, 10);
const nn = parseInt(n, 10);
if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
setSteps(buildIndexSteps(b, mm, nn, indexOp));
}
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["expand", "index"] as Mode[]).map((md) => (
<button
key={md}
onClick={() => switchMode(md)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === md
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{md === "expand" ? "Expand a Power" : "Index Laws"}
</button>
))}
</div>
{/* Index op tabs */}
{mode === "index" && (
<div className="flex flex-wrap gap-2">
{(["multiply", "divide"] as IndexOp[]).map((o) => (
<button
key={o}
onClick={() => {
setIndexOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
indexOp === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{o === "multiply" ? "Multiply (add powers)" : "Divide (subtract powers)"}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
{mode === "expand" ? (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">Enter a base and a power</p>
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={base} onChange={(e) => setBase(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Base" />
<span className="text-sm font-bold text-muted">to the power</span>
<input type="number" value={power} onChange={(e) => setPower(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Power" />
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
</>
) : (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-bold text-muted">base</span>
<input type="number" value={base} onChange={(e) => setBase(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Base" />
<span className="text-sm font-bold text-muted">powers</span>
<input type="number" value={m} onChange={(e) => setM(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="First power" />
<span className="text-lg font-bold text-muted">{indexOp === "multiply" ? "×" : "÷"}</span>
<input type="number" value={n} onChange={(e) => setN(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Second power" />
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter values above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
import { gcd, lcm, toKatex, Fraction } from "@/lib/math/fractions";
import { MathDisplay } from "@/components/math/math-display";
type Operation = "add" | "subtract" | "multiply" | "divide";
@@ -68,7 +68,7 @@ function buildSteps(
});
// Step 4: Simplify if needed
const [sn, sd] = simplify(resultNum, lcd);
const { n: sn, d: sd } = new Fraction(resultNum, lcd).simplified();
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
const g = gcd(Math.abs(resultNum), lcd);
steps.push({
@@ -92,7 +92,7 @@ function buildSteps(
});
// Step 2: Simplify
const [sn, sd] = simplify(rn, rd);
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
@@ -122,7 +122,7 @@ function buildSteps(
barResult: { filled: rn, total: rd },
});
const [sn, sd] = simplify(rn, rd);
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "identity" | "inverse" | "zero";
type Op = "add" | "multiply";
interface Step {
label: string;
math: string;
danger?: boolean;
}
function buildIdentitySteps(n: number, op: Op): Step[] {
if (op === "add") {
return [
{ label: "The identity for addition is 0 — it leaves a number unchanged.", math: `${n} + 0` },
{ label: "Adding 0 changes nothing.", math: `${n} + 0 = ${n}` },
{ label: "So 0 is the identity element for addition.", math: `n + 0 = n` },
];
}
return [
{ label: "The identity for multiplication is 1 — it leaves a number unchanged.", math: `${n} \\times 1` },
{ label: "Multiplying by 1 changes nothing.", math: `${n} \\times 1 = ${n}` },
{ label: "So 1 is the identity element for multiplication.", math: `n \\times 1 = n` },
];
}
function buildInverseSteps(n: number, op: Op): Step[] {
if (op === "add") {
const inv = -n;
const invText = inv < 0 ? `(${inv})` : `${inv}`;
return [
{ label: "The additive inverse is the number that brings you back to the identity, 0.", math: `${n} + \\square = 0` },
{ label: `Flip the sign of ${n} to get its additive inverse.`, math: `\\text{inverse of } ${n} = ${inv}` },
{ label: "Check: adding a number and its inverse gives 0.", math: `${n} + ${invText} = 0` },
];
}
// multiplicative inverse (reciprocal)
const absN = Math.abs(n);
const recip = n < 0 ? `-\\frac{1}{${absN}}` : `\\frac{1}{${absN}}`;
const recipParen = n < 0 ? `\\left(-\\frac{1}{${absN}}\\right)` : `\\frac{1}{${absN}}`;
return [
{ label: "The multiplicative inverse (reciprocal) brings you back to the identity, 1.", math: `${n} \\times \\square = 1` },
{ label: `Write ${n} as a fraction and flip it. The sign stays in front.`, math: `\\text{inverse of } ${n} = ${recip}` },
{ label: "Check: a number times its reciprocal gives 1.", math: `${n} \\times ${recipParen} = 1` },
];
}
function buildZeroSteps(n: number): Step[] {
return [
{ label: "Multiplying any number by zero always gives zero.", math: `${n} \\times 0 = 0` },
{ label: "Zero shared into any number of groups is still zero.", math: `0 \\div ${n === 0 ? 5 : n} = 0` },
{
label: "But you can never divide BY zero — there is no answer. It is undefined.",
math: `${n} \\div 0 = \\text{undefined}`,
danger: true,
},
];
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 5);
if (roll === 0) {
const n = randInt(-12, 12) || 3;
return { prompt: `What is the additive inverse of ${n}?`, answer: -n };
}
if (roll === 1) {
const n = randInt(1, 20);
return { prompt: `What is ${n} × 0?`, answer: 0 };
}
if (roll === 2) {
return { prompt: `What number is the identity for addition?`, answer: 0 };
}
if (roll === 3) {
return { prompt: `What number is the identity for multiplication?`, answer: 1 };
}
const n = randInt(2, 12);
return { prompt: `The multiplicative inverse of ${n} is 1 over what number?`, answer: n };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function IdentityInverseExplorer() {
const [mode, setMode] = useState<Mode>("identity");
const [op, setOp] = useState<Op>("add");
const [input, setInput] = useState("3");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handleGo() {
setError("");
const n = parseInt(input, 10);
if (isNaN(n)) return setError("Enter a valid whole number.");
if (Math.abs(n) > 100) return setError("Keep the number between -100 and 100.");
if (mode === "inverse" && op === "multiply" && n === 0) {
return setError("Zero has no multiplicative inverse — try another number.");
}
if (mode === "identity") setSteps(buildIdentitySteps(n, op));
else if (mode === "inverse") setSteps(buildInverseSteps(n, op));
else setSteps(buildZeroSteps(n));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const showOp = mode !== "zero";
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["identity", "inverse", "zero"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{m === "identity" ? "Identity" : m === "inverse" ? "Inverse" : "Zero Facts"}
</button>
))}
</div>
{/* Operation tabs */}
{showOp && (
<div className="flex flex-wrap gap-2">
{(["add", "multiply"] as Op[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{o === "add" ? "Addition" : "Multiplication"}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
{mode === "identity"
? "See how the identity leaves a number unchanged"
: mode === "inverse"
? "Find the inverse of a number"
: "Explore what happens with zero"}
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8"
aria-label="Number"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a number above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay
math={step.math}
className={`text-3xl ${step.danger ? "text-incorrect" : ""}`}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
@@ -282,21 +283,21 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
);
}
function generateProblem() {
const ops = ["+", "-"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
const a = Math.floor(Math.random() * 21) - 10;
const b = Math.floor(Math.random() * 21) - 10;
const answer = op === "+" ? a + b : a - b;
return { a, b, op, answer };
}
function QuickPractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function generateProblem() {
const ops = ["+", "-"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
const a = Math.floor(Math.random() * 21) - 10;
const b = Math.floor(Math.random() * 21) - 10;
const answer = op === "+" ? a + b : a - b;
return { a, b, op, answer };
}
function checkAnswer() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() {
</Card>
{/* Quick Practice */}
<QuickPractice />
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
@@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) {
const startTemp = data?.startTemp ?? 0;
const endTemp = data?.endTemp ?? 0;
const startY = ((startTemp - minTemp) / range) * 100;
const endY = ((endTemp - minTemp) / range) * 100;
const fillY = ((endTemp - minTemp) / range) * 100;
const ticks = [];
@@ -372,30 +371,30 @@ function ThermometerPractice() {
);
}
function generateProblem() {
const ops = ["×", "÷"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
if (op === "×") {
const a = Math.floor(Math.random() * 25) - 12;
const b = Math.floor(Math.random() * 25) - 12;
return { a, b, op, answer: a * b };
} else {
// Generate division with clean integer results
const b = Math.floor(Math.random() * 11) - 5;
const nonZeroB = b === 0 ? 3 : b;
const answer = Math.floor(Math.random() * 21) - 10;
const a = answer * nonZeroB;
return { a, b: nonZeroB, op, answer };
}
}
function MultiplyDividePractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function generateProblem() {
const ops = ["×", "÷"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
if (op === "×") {
const a = Math.floor(Math.random() * 25) - 12;
const b = Math.floor(Math.random() * 25) - 12;
return { a, b, op, answer: a * b };
} else {
// Generate division with clean integer results
const b = Math.floor(Math.random() * 11) - 5;
const nonZeroB = b === 0 ? 3 : b;
const answer = Math.floor(Math.random() * 21) - 10;
const a = answer * nonZeroB;
return { a, b: nonZeroB, op, answer };
}
}
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() {
</Card>
{/* Practice sections */}
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,795 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { shuffle } from "@/lib/utils";
type Mode = "number-line" | "order" | "compare";
type Direction = "asc" | "desc";
type Tone = "start" | "highlight" | "muted";
const MIN_VAL = -20;
const MAX_VAL = 20;
const RANGE = MAX_VAL - MIN_VAL;
interface Marker {
value: number;
label?: string;
tone?: Tone;
}
interface OrderChips {
pool: number[];
ordered: number[];
total: number;
}
interface Step {
label: string;
math: string;
markers: Marker[];
order?: OrderChips;
}
// Real-world contexts for the "Number Line" mode
const CONTEXTS = {
number: {
emoji: "🔢",
label: "Number",
describe: (n: number) =>
n === 0
? "0 is neither positive nor negative — it separates the two sides."
: `${n} is ${Math.abs(n)} step${Math.abs(n) === 1 ? "" : "s"} to the ${n < 0 ? "LEFT" : "RIGHT"} of zero.`,
},
thermometer: {
emoji: "🌡️",
label: "Temperature",
describe: (n: number) =>
n === 0
? "0 °C is freezing point — the boundary between warm and cold."
: `${n} °C means ${Math.abs(n)} degree${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} freezing.`,
},
money: {
emoji: "💰",
label: "Money",
describe: (n: number) =>
n === 0
? "$0 means you have nothing and owe nothing."
: n < 0
? `$${Math.abs(n)} means you OWE $${Math.abs(n)} (an overdraft).`
: `+$${n} means you HAVE $${n} in the bank.`,
},
sea: {
emoji: "🌊",
label: "Sea level",
describe: (n: number) =>
n === 0
? "0 m is exactly at sea level."
: `${n} m means ${Math.abs(n)} metre${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} sea level.`,
},
} as const;
type ContextKey = keyof typeof CONTEXTS;
function buildNumberLineSteps(n: number, ctx: ContextKey): Step[] {
const context = CONTEXTS[ctx];
const typeWord = n === 0 ? "zero" : n < 0 ? "a NEGATIVE integer" : "a POSITIVE integer";
return [
{
label: "Zero sits in the middle. Negative numbers are to its LEFT, positive numbers to its RIGHT.",
math: `\\ldots -3,\\ -2,\\ -1,\\ 0,\\ 1,\\ 2,\\ 3 \\ldots`,
markers: [{ value: 0, label: "0", tone: "start" }],
},
{
label: `Find ${n} on the number line and mark it.`,
math: `\\text{Mark } ${n}`,
markers: [
{ value: 0, tone: "muted" },
{ value: n, label: `${n}`, tone: "highlight" },
],
},
{
label: `${context.emoji} ${context.describe(n)}`,
math: `${n}`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
{
label: `So ${n} is ${typeWord}.`,
math: n === 0 ? `0` : `${n} \\text{ is } ${n < 0 ? "<" : ">"} 0`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
];
}
function buildOrderSteps(values: number[], direction: Direction): Step[] {
const sorted = [...values].sort((a, b) => (direction === "asc" ? a - b : b - a));
const sizeWord = direction === "asc" ? "smallest" : "largest";
const posWord = direction === "asc" ? "furthest LEFT" : "furthest RIGHT";
const total = values.length;
const steps: Step[] = [
{
label: "Here are the numbers we need to put in order.",
math: `\\{\\, ${values.join(",\\ ")} \\,\\}`,
markers: values.map((v) => ({ value: v, tone: "muted" as Tone })),
order: { pool: [...values], ordered: [], total },
},
{
label: "Place every number on the number line. Smaller numbers sit further left.",
math: `\\text{left = smaller} \\qquad \\text{right = larger}`,
markers: values.map((v) => ({ value: v, tone: "highlight" as Tone })),
order: { pool: [...values], ordered: [], total },
},
];
const pool = [...values];
const ordered: number[] = [];
for (let i = 0; i < sorted.length; i++) {
const pick = sorted[i];
pool.splice(pool.indexOf(pick), 1);
ordered.push(pick);
steps.push({
label: `The ${sizeWord} number still to place is the one ${posWord}: ${pick}.`,
math: `${ordered.join(direction === "asc" ? " < " : " > ")}${pool.length ? "\\ \\ldots" : ""}`,
markers: [
...ordered.map((v) => ({ value: v, tone: "start" as Tone })),
...pool.map((v) => ({ value: v, tone: "muted" as Tone })),
],
order: { pool: [...pool], ordered: [...ordered], total },
});
}
steps.push({
label:
direction === "asc"
? "Done! In ascending order: least → greatest."
: "Done! In descending order: greatest → least.",
math: `${sorted.join(direction === "asc" ? " < " : " > ")}`,
markers: sorted.map((v) => ({ value: v, tone: "start" as Tone })),
order: { pool: [], ordered: [...sorted], total },
});
return steps;
}
function buildCompareSteps(a: number, b: number): Step[] {
const symbol = a < b ? "<" : a > b ? ">" : "=";
const steps: Step[] = [
{
label: `Put both numbers on the number line.`,
math: `${a} \\;\\square\\; ${b}`,
markers: [
{ value: a, label: `${a}`, tone: "highlight" },
{ value: b, label: `${b}`, tone: "highlight" },
],
},
];
if (symbol === "=") {
steps.push({
label: "Both numbers land on the same spot, so they are equal.",
math: `${a} = ${b}`,
markers: [{ value: a, label: `${a}`, tone: "start" }],
});
return steps;
}
const larger = a > b ? a : b;
const smaller = a > b ? b : a;
steps.push(
{
label: `The number further RIGHT is always larger. ${larger} is to the right of ${smaller}.`,
math: `${larger} \\text{ is larger}`,
markers: [
{ value: smaller, label: `${smaller}`, tone: "muted" },
{ value: larger, label: `${larger}`, tone: "start" },
],
},
{
label: `The open mouth of the sign always points to the LARGER number.`,
math: `${a} ${symbol} ${b}`,
markers: [
{ value: a, label: `${a}`, tone: a === larger ? "start" : "muted" },
{ value: b, label: `${b}`, tone: b === larger ? "start" : "muted" },
],
},
);
return steps;
}
function NumberLine({ markers }: { markers: Marker[] }) {
return (
<div className="mx-auto w-full max-w-2xl select-none">
<div className="relative h-28 px-4">
{/* Markers above the line */}
{markers.map((m, i) => {
const x = ((m.value - MIN_VAL) / RANGE) * 100;
const tone = m.tone ?? "highlight";
const bg =
tone === "start" ? "bg-unit-5" : tone === "muted" ? "bg-muted/70" : "bg-correct";
return (
<div
key={`${m.value}-${i}`}
className="absolute bottom-14 -translate-x-1/2 transition-all duration-500"
style={{ left: `calc(${x}% + 16px)` }}
>
<div className="flex flex-col items-center">
{m.label !== undefined && (
<span
className={`rounded-full ${bg} px-2 py-0.5 text-[10px] font-bold text-white shadow-md`}
>
{m.label}
</span>
)}
<div className={`h-3 w-0.5 ${bg}`} />
</div>
</div>
);
})}
{/* Number line */}
<div className="absolute bottom-8 left-4 right-4">
<div className="h-0.5 bg-foreground/40" />
{Array.from({ length: RANGE + 1 }, (_, i) => {
const val = MIN_VAL + i;
const x = (i / RANGE) * 100;
const isZero = val === 0;
const marked = markers.some((m) => m.value === val);
return (
<div key={val} className="absolute -translate-x-1/2" style={{ left: `${x}%` }}>
<div
className={`h-3 w-0.5 -translate-y-1.5 transition-colors duration-300 ${
marked
? "h-4 bg-unit-5"
: isZero
? "bg-foreground"
: "bg-foreground/30"
}`}
/>
{(val % 5 === 0 || marked) && (
<span
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
marked
? "font-extrabold text-unit-5"
: isZero
? "font-bold text-foreground"
: "text-muted/70"
}`}
>
{val}
</span>
)}
</div>
);
})}
</div>
{/* Edge labels */}
<div className="absolute bottom-0 left-2 right-2 flex justify-between text-xs text-muted/50">
<span> negative</span>
<span>positive </span>
</div>
</div>
</div>
);
}
function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) {
const emptySlots = order.total - order.ordered.length;
return (
<div className="w-full space-y-3">
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted">Still to place</p>
<div className="flex min-h-9 flex-wrap gap-2">
{order.pool.length === 0 ? (
<span className="text-sm text-muted/50"> all placed </span>
) : (
order.pool.map((v, i) => (
<span
key={`${v}-${i}`}
className="rounded-lg border-2 border-border bg-surface px-3 py-1.5 text-sm font-bold text-muted"
>
{v}
</span>
))
)}
</div>
</div>
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-unit-5">
Ordered ({direction === "asc" ? "small → large" : "large → small"})
</p>
<div className="flex flex-wrap items-center gap-2">
{order.ordered.map((v, i) => (
<span key={`${v}-${i}`} className="flex items-center gap-2">
{i > 0 && <span className="text-sm font-bold text-unit-5">{direction === "asc" ? "<" : ">"}</span>}
<span className="rounded-lg border-2 border-unit-5 bg-unit-5-light px-3 py-1.5 text-sm font-extrabold text-unit-5-dark">
{v}
</span>
</span>
))}
{Array.from({ length: emptySlots }, (_, i) => (
<span key={`empty-${i}`} className="flex items-center gap-2">
{(order.ordered.length > 0 || i > 0) && (
<span className="text-sm font-bold text-muted/40">{direction === "asc" ? "<" : ">"}</span>
)}
<span className="h-9 w-11 rounded-lg border-2 border-dashed border-border" />
</span>
))}
</div>
</div>
</div>
);
}
// ── Quick practice ───────────────────────────────────────────────────────────
const SEED_PAIRS: [number, number][] = [
[-5, 5],
[4, -16],
[-6, -11],
[0, -3],
[8, -1],
];
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makePair(): [number, number] {
if (Math.random() < 0.4) return SEED_PAIRS[Math.floor(Math.random() * SEED_PAIRS.length)];
const a = randInt(-15, 15);
let b = randInt(-15, 15);
if (a === b) b = a + 1;
return [a, b];
}
function makeOrderValues(): number[] {
const set = new Set<number>();
while (set.size < 4) set.add(randInt(-12, 12));
return shuffle([...set]);
}
function ComparePractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [pair, setPair] = useState<[number, number]>(() => makePair());
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [a, b] = pair;
const answer = a < b ? "<" : ">";
function pick(sign: "<" | ">") {
if (feedback === "correct") return;
const correct = sign === answer;
setFeedback(correct ? "correct" : "incorrect");
if (correct) onScore(true);
else onScore(false);
}
function next() {
setPair(makePair());
setFeedback(null);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Which sign makes it true?</p>
<div className="flex items-center justify-center gap-4 text-3xl font-extrabold">
<span>{a}</span>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-xl border-2 border-dashed border-unit-5/50 text-unit-5">
?
</span>
<span>{b}</span>
</div>
<div className="flex justify-center gap-3">
{(["<", ">"] as const).map((sign) => (
<button
key={sign}
onClick={() => pick(sign)}
disabled={feedback === "correct"}
className={`h-14 w-14 rounded-xl border-2 text-2xl font-extrabold transition-colors ${
feedback === "correct" && sign === answer
? "border-correct bg-correct-light text-correct"
: feedback === "incorrect" && sign !== answer
? "border-border text-muted"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light disabled:opacity-50"
}`}
>
{sign}
</button>
))}
</div>
{feedback === "correct" && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">Correct! {a} {answer} {b}</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
{feedback === "incorrect" && (
<p className="text-sm font-bold text-incorrect">Not quite remember, further right means larger. Try again.</p>
)}
</div>
);
}
function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [values, setValues] = useState<number[]>(() => makeOrderValues());
const [placed, setPlaced] = useState<number[]>([]);
const [wrong, setWrong] = useState<number | null>(null);
const [mistakes, setMistakes] = useState(0);
const [done, setDone] = useState(false);
const sorted = [...values].sort((a, b) => a - b);
const nextExpected = sorted[placed.length];
function clickChip(v: number) {
if (done || placed.includes(v)) return;
if (v === nextExpected) {
const nextPlaced = [...placed, v];
setPlaced(nextPlaced);
setWrong(null);
if (nextPlaced.length === sorted.length) {
setDone(true);
onScore(mistakes === 0);
}
} else {
setWrong(v);
setMistakes((m) => m + 1);
setTimeout(() => setWrong((w) => (w === v ? null : w)), 500);
}
}
function next() {
setValues(makeOrderValues());
setPlaced([]);
setWrong(null);
setMistakes(0);
setDone(false);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Click the numbers in ascending order (smallest first).</p>
<div className="flex flex-wrap justify-center gap-2">
{values.map((v) => {
const isPlaced = placed.includes(v);
const isWrong = wrong === v;
return (
<button
key={v}
onClick={() => clickChip(v)}
disabled={isPlaced || done}
className={`h-12 w-14 rounded-xl border-2 text-lg font-extrabold transition-all ${
isPlaced
? "border-correct bg-correct-light text-correct"
: isWrong
? "border-incorrect bg-incorrect-light text-incorrect"
: "border-unit-5/40 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{v}
</button>
);
})}
</div>
<div className="flex items-center justify-center gap-2 text-lg font-bold text-unit-5">
{placed.map((v, i) => (
<span key={v}>
{i > 0 && <span className="mr-2 text-muted">{"<"}</span>}
{v}
</span>
))}
{placed.length < sorted.length && <span className="text-muted/40"></span>}
</div>
{done && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">
{mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`}
</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</div>
);
}
function QuickPractice() {
const [kind, setKind] = useState<"compare" | "order">("compare");
const [score, setScore] = useState({ correct: 0, total: 0 });
const record = useCallback((correct: boolean) => {
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}, []);
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{(["compare", "order"] as const).map((k) => (
<button
key={k}
onClick={() => setKind(k)}
className={`rounded-full px-3 py-1 text-xs font-bold transition-colors ${
kind === k ? "bg-unit-5 text-white" : "text-unit-5 hover:bg-unit-5-light"
}`}
>
{k === "compare" ? "Compare" : "Order"}
</button>
))}
</div>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
{kind === "compare" ? (
<ComparePractice key={`c-${score.total}`} onScore={record} />
) : (
<OrderPractice key={`o-${score.total}`} onScore={record} />
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function IntegerOrderCompareExplorer() {
const [mode, setMode] = useState<Mode>("number-line");
// number-line mode
const [nlInput, setNlInput] = useState("-7");
const [context, setContext] = useState<ContextKey>("thermometer");
// order mode
const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5");
const [direction, setDirection] = useState<Direction>("asc");
// compare mode
const [cmpA, setCmpA] = useState("-5");
const [cmpB, setCmpB] = useState("5");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handleGo() {
setError("");
try {
if (mode === "number-line") {
const n = parseInt(nlInput, 10);
if (isNaN(n)) return setError("Enter a valid integer.");
if (n < MIN_VAL || n > MAX_VAL) return setError(`Keep the number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildNumberLineSteps(n, context));
} else if (mode === "order") {
const parts = orderInput
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const nums = parts.map((s) => parseInt(s, 10));
if (nums.some((n) => isNaN(n))) return setError("Use whole numbers separated by commas, e.g. -8, -10, 5.");
if (nums.length < 3 || nums.length > 6) return setError("Enter between 3 and 6 numbers.");
if (nums.some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep every number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildOrderSteps(nums, direction));
} else {
const a = parseInt(cmpA, 10);
const b = parseInt(cmpB, 10);
if (isNaN(a) || isNaN(b)) return setError("Enter two valid integers.");
if ([a, b].some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep both numbers between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildCompareSteps(a, b));
}
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not build the steps. Check your input.");
}
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["number-line", "order", "compare"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{m === "number-line" ? "Number Line" : m === "order" ? "Order" : "Compare"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "number-line" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Place an integer on the number line
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(Object.keys(CONTEXTS) as ContextKey[]).map((key) => (
<button
key={key}
onClick={() => {
setContext(key);
reset();
}}
className={`rounded-full border-2 px-3 py-1.5 text-xs font-semibold transition-colors ${
context === key
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{CONTEXTS[key].emoji} {CONTEXTS[key].label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={nlInput}
onChange={(e) => setNlInput(e.target.value)}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "order" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter 36 integers to order
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(["asc", "desc"] as Direction[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-1.5 text-xs font-semibold transition-colors ${
direction === d
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{d === "asc" ? "Ascending (small → large)" : "Descending (large → small)"}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
value={orderInput}
onChange={(e) => setOrderInput(e.target.value)}
className="min-w-[16rem] flex-1 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Comma-separated integers"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "compare" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Compare two integers with &lt; or &gt;
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={cmpA}
onChange={(e) => setCmpA(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="First integer"
/>
<span className="text-xl font-bold text-muted">?</span>
<input
type="number"
value={cmpB}
onChange={(e) => setCmpB(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Second integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Set up the numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<NumberLine markers={step.markers} />
{step.order && <OrderChipsView order={step.order} direction={direction} />}
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,393 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { FractionInput } from "@/components/practice/fraction-input";
import { Fraction, lcm } from "@/lib/math/fractions";
import { checkFractionAnswer } from "@/lib/math/validation";
type Op = "+" | "-" | "\\times" | "\\div";
interface Step {
label: string;
math: string;
}
const OP_LABEL: Record<Op, string> = { "+": "+", "-": "", "\\times": "×", "\\div": "÷" };
/** Wrap a negative operand in parentheses so "a + (-b)" reads clearly. */
function operand(f: Fraction): string {
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
}
function signWord(f: Fraction): string {
return f.n < 0 ? "negative" : "positive";
}
function buildAddSubtractSteps(a: Fraction, b: Fraction, op: "+" | "-"): Step[] {
const opSym = op;
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${opSym} ${operand(b)}`,
},
];
const lcd = lcm(a.d, b.d);
let an = a.n;
let bn = b.n;
if (a.d !== b.d) {
an = a.n * (lcd / a.d);
bn = b.n * (lcd / b.d);
steps.push({
label: `The denominators are different. Find the common denominator: the LCM of ${a.d} and ${b.d} is ${lcd}.`,
math: `\\text{LCD} = ${lcd}`,
});
steps.push({
label: "Rewrite both fractions over the common denominator (the sign travels with the numerator).",
math: `${a.toSignedKatex()} = ${new Fraction(an, lcd).toSignedKatex()} \\quad ${b.toSignedKatex()} = ${new Fraction(bn, lcd).toSignedKatex()}`,
});
}
const rawNum = op === "+" ? an + bn : an - bn;
const bnShown = bn < 0 ? `\\left(${bn}\\right)` : `${bn}`;
steps.push({
label:
op === "+"
? "Add the numerators using the integer rules, keeping the common denominator."
: "Subtract the numerators using the integer rules (subtracting a negative adds), keeping the common denominator.",
math: `\\frac{${an} ${opSym} ${bnShown}}{${lcd}} = \\frac{${rawNum}}{${lcd}}`,
});
const raw = new Fraction(rawNum, lcd);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: `The answer is ${result.n < 0 ? "negative" : "positive"}.`,
math: `${a.toSignedKatex()} ${opSym} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
function buildMultiplyDivideSteps(a: Fraction, b: Fraction, op: "\\times" | "\\div"): Step[] {
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${op} ${operand(b)}`,
},
];
let working = b;
if (op === "\\div") {
working = new Fraction(b.d, b.n); // reciprocal keeps the sign
steps.push({
label: "Dividing by a fraction is the same as multiplying by its reciprocal — keep, change, flip.",
math: `${a.toSignedKatex()} \\times ${operand(working)}`,
});
}
const negatives = (a.n < 0 ? 1 : 0) + (working.n < 0 ? 1 : 0);
const resultNegative = negatives % 2 === 1;
steps.push({
label: `Decide the sign: ${signWord(a)} and ${signWord(working)}. ${
negatives === 1 ? "Different signs give a NEGATIVE answer." : "The signs match, so the answer is POSITIVE."
}`,
math: `(${a.n < 0 ? "-" : "+"}) \\times (${working.n < 0 ? "-" : "+"}) = (${resultNegative ? "-" : "+"})`,
});
const absNum = Math.abs(a.n) * Math.abs(working.n);
const den = a.d * working.d;
steps.push({
label: "Multiply the numerators, multiply the denominators, then apply the sign.",
math: `\\frac{${Math.abs(a.n)} \\times ${Math.abs(working.n)}}{${a.d} \\times ${working.d}} = ${new Fraction(resultNegative ? -absNum : absNum, den).toSignedKatex()}`,
});
const raw = new Fraction(resultNegative ? -absNum : absNum, den);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: "Final answer.",
math: `${a.toSignedKatex()} ${op} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
// ── Quick practice ───────────────────────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/** A proper-fraction numerator with a random sign: magnitude 1..den-1. */
function properSigned(den: number): number {
const mag = randInt(1, Math.max(1, den - 1));
return Math.random() < 0.5 ? -mag : mag;
}
interface PracticeProblem {
a: Fraction;
b: Fraction;
op: Op;
answer: Fraction;
}
function makeProblem(): PracticeProblem {
const op = (["+", "-", "\\times", "\\div"] as Op[])[randInt(0, 3)];
const d1 = randInt(2, 6);
const d2 = randInt(2, 6);
let n1 = properSigned(d1);
let n2 = properSigned(d2);
// This is the negative-fractions topic — make sure at least one operand is negative.
if (n1 > 0 && n2 > 0) {
if (Math.random() < 0.5) n1 = -n1;
else n2 = -n2;
}
const a = new Fraction(n1, d1);
const b = new Fraction(n2, d2);
let answer: Fraction;
if (op === "+") answer = a.plus(b).simplified();
else if (op === "-") answer = a.minus(b).simplified();
else if (op === "\\times") answer = a.times(b).simplified();
else answer = a.dividedBy(b).simplified();
return { a, b, op, answer };
}
function QuickPractice() {
const [problem, setProblem] = useState<PracticeProblem>(() => makeProblem());
const [num, setNum] = useState("");
const [den, setDen] = useState("");
const [status, setStatus] = useState<"solved" | "unsimplified" | "wrong" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const [missed, setMissed] = useState(false);
const [scored, setScored] = useState(false);
function check() {
const n = parseInt(num, 10);
const d = parseInt(den, 10);
if (isNaN(n) || isNaN(d)) return;
const res = checkFractionAnswer(n, d, problem.answer.n, problem.answer.d);
setStatus(res.status);
if (res.status === "wrong") {
setMissed(true);
} else if (res.status === "solved" && !scored) {
setScored(true);
setScore((s) => ({ correct: s.correct + (missed ? 0 : 1), total: s.total + 1 }));
}
}
function next() {
setProblem(makeProblem());
setNum("");
setDen("");
setStatus(null);
setMissed(false);
setScored(false);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">Quick Practice</p>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-sm text-muted">Work it out and give your answer in its simplest form.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<MathDisplay
math={`${problem.a.toSignedKatex()} ${problem.op} ${problem.b.n < 0 ? `\\left(${problem.b.toSignedKatex()}\\right)` : problem.b.toSignedKatex()} =`}
className="text-2xl"
/>
<FractionInput
numerator={num}
denominator={den}
onNumeratorChange={setNum}
onDenominatorChange={setDen}
onSubmit={status === "solved" ? next : check}
disabled={status === "solved"}
/>
{status === "solved" ? (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
) : (
<button onClick={check} className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-5-dark">
Check
</button>
)}
</div>
{status === "solved" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{status === "unsimplified" && (
<p className="text-center text-sm font-bold text-hint">Right value now write it in its simplest form.</p>
)}
{status === "wrong" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite. Watch the signs, then try again.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function NegativeFractionsExplorer() {
const [op, setOp] = useState<Op>("+");
const [n1, setN1] = useState("-1");
const [d1, setD1] = useState("2");
const [n2, setN2] = useState("3");
const [d2, setD2] = useState("4");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function handleGo() {
setError("");
const pn1 = parseInt(n1, 10);
const pd1 = parseInt(d1, 10);
const pn2 = parseInt(n2, 10);
const pd2 = parseInt(d2, 10);
if ([pn1, pd1, pn2, pd2].some((v) => isNaN(v))) return setError("Fill in every box with a whole number.");
if (pd1 === 0 || pd2 === 0) return setError("A denominator cannot be zero.");
if ([pn1, pd1, pn2, pd2].some((v) => Math.abs(v) > 20)) return setError("Keep the numbers between -20 and 20.");
if (op === "\\div" && pn2 === 0) return setError("You cannot divide by zero.");
const a = new Fraction(pn1, pd1);
const b = new Fraction(pn2, pd2);
setSteps(
op === "+" || op === "-"
? buildAddSubtractSteps(a, b, op)
: buildMultiplyDivideSteps(a, b, op),
);
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const fieldClass =
"w-16 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-5";
return (
<div className="space-y-4">
{/* Operation selector */}
<div className="flex flex-wrap gap-2">
{(["+", "-", "\\times", "\\div"] as Op[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`h-11 w-14 rounded-full border-2 text-xl font-bold transition-colors ${
op === o
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
aria-label={OP_LABEL[o]}
>
{OP_LABEL[o]}
</button>
))}
</div>
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter two fractions (numerators can be negative)
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n1} onChange={(e) => setN1(e.target.value)} className={fieldClass} aria-label="First numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d1} onChange={(e) => setD1(e.target.value)} className={fieldClass} aria-label="First denominator" />
</div>
<span className="text-2xl font-bold text-muted">{OP_LABEL[op]}</span>
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n2} onChange={(e) => setN2(e.target.value)} className={fieldClass} aria-label="Second numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d2} onChange={(e) => setD2(e.target.value)} className={fieldClass} aria-label="Second denominator" />
</div>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter two fractions above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,356 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "commutative" | "associative" | "distributive";
type Op = "+" | "-" | "\\times" | "\\div";
interface Step {
label: string;
math: string;
match?: boolean; // colour the result green (holds) / red (fails)
}
const OP_META: Record<Op, { apply: (a: number, b: number) => number; commutative: boolean; associative: boolean; name: string }> = {
"+": { apply: (a, b) => a + b, commutative: true, associative: true, name: "Addition" },
"-": { apply: (a, b) => a - b, commutative: false, associative: false, name: "Subtraction" },
"\\times": { apply: (a, b) => a * b, commutative: true, associative: true, name: "Multiplication" },
"\\div": { apply: (a, b) => a / b, commutative: false, associative: false, name: "Division" },
};
function fmt(x: number): string {
if (Number.isInteger(x)) return `${x}`;
return `${parseFloat(x.toFixed(3))}`;
}
function buildCommutativeSteps(a: number, b: number, op: Op): Step[] {
const meta = OP_META[op];
const r1 = meta.apply(a, b);
const r2 = meta.apply(b, a);
const same = r1 === r2;
return [
{
label: "Commutative law: does the ORDER of the numbers change the answer?",
math: `${a} ${op} ${b} \\quad \\text{vs} \\quad ${b} ${op} ${a}`,
},
{ label: `Work out the first order.`, math: `${a} ${op} ${b} = ${fmt(r1)}` },
{ label: `Now swap the order.`, math: `${b} ${op} ${a} = ${fmt(r2)}` },
meta.commutative
? {
label: `Same answer! ${meta.name} IS commutative — order does not matter.`,
math: `a ${op} b = b ${op} a`,
match: true,
}
: {
label: same
? `They match here only because the numbers are special. In general, ${meta.name.toLowerCase()} is NOT commutative.`
: `Different answers! ${meta.name} is NOT commutative — order matters.`,
math: `a ${op} b \\neq b ${op} a`,
match: false,
},
];
}
function buildAssociativeSteps(a: number, b: number, c: number, op: Op): Step[] {
const meta = OP_META[op];
const left = meta.apply(meta.apply(a, b), c);
const right = meta.apply(a, meta.apply(b, c));
return [
{
label: "Associative law: does where we put the BRACKETS change the answer?",
math: `(${a} ${op} ${b}) ${op} ${c} \\quad \\text{vs} \\quad ${a} ${op} (${b} ${op} ${c})`,
},
{ label: "Do the brackets on the LEFT first.", math: `(${a} ${op} ${b}) ${op} ${c} = ${fmt(meta.apply(a, b))} ${op} ${c} = ${fmt(left)}` },
{ label: "Now do the brackets on the RIGHT first.", math: `${a} ${op} (${b} ${op} ${c}) = ${a} ${op} ${fmt(meta.apply(b, c))} = ${fmt(right)}` },
meta.associative
? { label: `Same answer! ${meta.name} IS associative — the grouping does not matter.`, math: `(a ${op} b) ${op} c = a ${op} (b ${op} c)`, match: true }
: { label: `Different answers! ${meta.name} is NOT associative — the grouping matters.`, math: `(a ${op} b) ${op} c \\neq a ${op} (b ${op} c)`, match: false },
];
}
function buildDistributiveSteps(a: number, b: number, c: number): Step[] {
const inside = b + c;
const answer = a * inside;
return [
{ label: "Distributive law: a number outside a bracket multiplies EVERYTHING inside.", math: `${a} \\times (${b} + ${c})` },
{
label: `Multiply the ${a} by each number inside the bracket separately.`,
math: `${a} \\times ${b} \\; + \\; ${a} \\times ${c}`,
},
{ label: "Working the direct way: add inside the bracket first, then multiply.", math: `${a} \\times (${b} + ${c}) = ${a} \\times ${inside} = ${answer}` },
{ label: "Working the expanded way gives the same answer.", math: `${a * b} + ${a * c} = ${answer}`, match: true },
];
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 4);
if (roll === 0) {
const a = randInt(2, 9), b = randInt(1, 9), c = randInt(1, 9);
return { prompt: `Fill the blank: ${a} × (${b} + ${c}) = ${a} × ${b} + ${a} ×`, answer: c };
}
if (roll === 1) {
const a = randInt(3, 12), b = randInt(1, a - 1);
return { prompt: `Subtraction is not commutative. What is ${a} ${b}?`, answer: a - b };
}
if (roll === 2) {
const a = randInt(2, 8), b = randInt(1, 6), c = randInt(1, 6);
return { prompt: `Work out ${a} × (${b} + ${c})`, answer: a * (b + c) };
}
const a = randInt(2, 9), b = randInt(2, 9);
return { prompt: `Multiplication is commutative, so ${a} × ${b} equals ${b} × ▢. What is ▢?`, answer: a };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function NumberLawsExplorer() {
const [mode, setMode] = useState<Mode>("commutative");
const [op, setOp] = useState<Op>("+");
const [a, setA] = useState("8");
const [b, setB] = useState("3");
const [c, setC] = useState("2");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
// distributive is fixed to + inside; keep whatever op for the others
reset();
}
const opChoices: Op[] = mode === "associative" ? ["+", "\\times", "-"] : ["+", "-", "\\times", "\\div"];
function handleGo() {
setError("");
const na = parseInt(a, 10);
const nb = parseInt(b, 10);
const nc = parseInt(c, 10);
if (isNaN(na) || isNaN(nb) || (mode !== "commutative" && isNaN(nc))) {
return setError("Enter whole numbers in every box.");
}
const vals = mode === "commutative" ? [na, nb] : [na, nb, nc];
if (vals.some((v) => Math.abs(v) > 50)) return setError("Keep the numbers between -50 and 50.");
if ((op === "\\div") && (nb === 0 || (mode === "associative" && nc === 0))) {
return setError("Cannot divide by zero — change the numbers.");
}
if (mode === "commutative") setSteps(buildCommutativeSteps(na, nb, op));
else if (mode === "associative") setSteps(buildAssociativeSteps(na, nb, nc, op));
else setSteps(buildDistributiveSteps(na, nb, nc));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const showThree = mode !== "commutative";
const showOp = mode !== "distributive";
const opLabel: Record<Op, string> = { "+": "+", "-": "", "\\times": "×", "\\div": "÷" };
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["commutative", "associative", "distributive"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
mode === m
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{m}
</button>
))}
</div>
{/* Operation tabs */}
{showOp && (
<div className="flex flex-wrap gap-2">
{opChoices.map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`h-10 w-12 rounded-full border-2 text-lg font-bold transition-colors ${
op === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{opLabel[o]}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
{mode === "distributive" ? "Enter a, b and c for a × (b + c)" : showThree ? "Enter three numbers" : "Enter two numbers"}
</p>
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={a} onChange={(e) => setA(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="a" />
<input type="number" value={b} onChange={(e) => setB(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="b" />
{showThree && (
<input type="number" value={c} onChange={(e) => setC(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="c" />
)}
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay
math={step.math}
className={`text-2xl ${step.match === true ? "text-correct" : step.match === false ? "text-incorrect" : ""}`}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,351 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
interface Column {
exponent: number;
placeValue: number;
digit: string;
contrib: number;
}
interface Step {
label: string;
math: string;
activeCol: number | null;
revealed: number; // how many columns show their contribution
}
const PLACE_NAMES = ["ones", "tens", "hundreds", "thousands", "ten thousands", "hundred thousands"];
function columnsFor(n: number): Column[] {
const str = `${n}`;
const len = str.length;
return str.split("").map((d, i) => {
const exponent = len - 1 - i;
return {
exponent,
placeValue: Math.pow(10, exponent),
digit: d,
contrib: parseInt(d, 10) * Math.pow(10, exponent),
};
});
}
function buildSteps(n: number): Step[] {
const cols = columnsFor(n);
const len = cols.length;
const steps: Step[] = [
{
label: "In the denary (base 10) system, each digit has a place value that is a power of 10.",
math: `${n}`,
activeCol: null,
revealed: 0,
},
];
cols.forEach((c, i) => {
steps.push({
label: `The ${PLACE_NAMES[c.exponent]} digit is ${c.digit}. Its place value is 10^${c.exponent} = ${c.placeValue.toLocaleString("en-US")}.`,
math: `${c.digit} \\times 10^{${c.exponent}} = ${c.contrib.toLocaleString("en-US")}`,
activeCol: i,
revealed: i + 1,
});
});
const expanded = cols.map((c) => `${c.digit} \\times 10^{${c.exponent}}`).join(" + ");
steps.push({
label: "Put it all together — this is the number in expanded form.",
math: `${n} = ${expanded}`,
activeCol: null,
revealed: len,
});
const sumParts = cols.map((c) => c.contrib.toLocaleString("en-US")).join(" + ");
steps.push({
label: "Add every part back up and you return to the original number.",
math: `${sumParts} = ${n.toLocaleString("en-US")}`,
activeCol: null,
revealed: len,
});
return steps;
}
function PlaceValueChart({ columns, step }: { columns: Column[]; step: Step }) {
return (
<div className="overflow-x-auto rounded-xl border-2 border-unit-6/30 bg-unit-6-light/40 p-3">
<table className="mx-auto border-collapse font-mono">
<tbody>
<tr>
{columns.map((c, i) => (
<td
key={`p-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6-dark ${
step.activeCol === i ? "bg-unit-6 text-white" : "bg-unit-6-light"
}`}
>
10<sup>{c.exponent}</sup>
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`v-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6 ${
step.activeCol === i ? "bg-unit-6-light" : "bg-surface"
}`}
>
{c.placeValue.toLocaleString("en-US")}
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`d-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-2xl font-extrabold ${
step.activeCol === i ? "bg-unit-6-light text-unit-6-dark" : "bg-surface text-foreground"
}`}
>
{c.digit}
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`c-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold ${
i < step.revealed ? "bg-correct-light text-correct" : "bg-[#fafbfc] text-muted/50"
}`}
>
{i < step.revealed ? c.contrib.toLocaleString("en-US") : "_"}
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
if (Math.random() < 0.5) {
// value of a digit
const n = randInt(1000, 99999);
const cols = columnsFor(n);
const nonZero = cols.filter((c) => c.digit !== "0");
const pick = nonZero[Math.floor(Math.random() * nonZero.length)];
return {
prompt: `In ${n.toLocaleString("en-US")}, what is the VALUE of the digit ${pick.digit}?`,
answer: pick.contrib,
};
}
// exponent of a named column
const exp = randInt(0, 5);
return {
prompt: `Which power of 10 is the ${PLACE_NAMES[exp]} column? Enter the exponent.`,
answer: exp,
};
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-6">Quick Practice</p>
<span className="rounded-full bg-unit-6-light px-3 py-1 text-xs font-bold text-unit-6-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-32 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-6"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-6 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-6-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer.toLocaleString("en-US")}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function PlaceValueExplorer() {
const [input, setInput] = useState("983275");
const [error, setError] = useState("");
const [columns, setColumns] = useState<Column[] | null>(null);
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setColumns(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function handleGo() {
setError("");
const cleaned = input.replace(/[,\s]/g, "");
const n = parseInt(cleaned, 10);
if (isNaN(n) || !/^\d+$/.test(cleaned)) return setError("Enter a whole number (digits only).");
if (n < 1 || n > 999999) return setError("Use a whole number between 1 and 999,999.");
setColumns(columnsFor(n));
setSteps(buildSteps(n));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-6">
Enter a whole number (up to 999,999)
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
inputMode="numeric"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGo();
}}
className="w-44 rounded-lg border-2 border-unit-6 bg-surface px-3 py-2 text-center font-mono text-2xl font-bold text-unit-6 outline-none focus:border-unit-6-dark"
aria-label="Number input"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-6 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-6-dark"
>
Break Down
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step || !columns ? (
<p className="text-muted/50">
Enter a number above and click <strong>Break Down</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-xl" />
<PlaceValueChart columns={columns} step={step} />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,418 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { CartesianGrid, type GridPoint, type GridSegment, type GridArrow } from "./cartesian-grid";
type Mode = "plot" | "shape";
interface Step {
label: string;
math: string;
points: GridPoint[];
arrows?: GridArrow[];
segments?: GridSegment[];
highlight?: { x: number; y: number };
}
// ── Plot a single point ──────────────────────────────────────────────────────
function buildPlotSteps(x: number, y: number): Step[] {
const steps: Step[] = [
{
label: "Start at the origin (0, 0) — where the two axes cross.",
math: `(0,\\ 0)`,
points: [{ x: 0, y: 0, label: "start", tone: "muted" }],
},
];
// Move along x
if (x === 0) {
steps.push({
label: "The x-coordinate is 0, so stay on the y-axis — don't move left or right.",
math: `x = 0`,
points: [{ x: 0, y: 0, tone: "muted" }],
});
} else {
steps.push({
label: `Move ALONG the x-axis: ${Math.abs(x)} to the ${x > 0 ? "RIGHT" : "LEFT"}.`,
math: `x = ${x}`,
points: [{ x: 0, y: 0, tone: "muted" }],
arrows: [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
}
// Move along y
if (y === 0) {
steps.push({
label: "The y-coordinate is 0, so stay on the x-axis — don't move up or down.",
math: `y = 0`,
points: [{ x, y: 0, tone: "muted" }],
arrows: x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
} else {
steps.push({
label: `Now move parallel to the y-axis: ${Math.abs(y)} ${y > 0 ? "UP" : "DOWN"}.`,
math: `y = ${y}`,
points: [{ x, y: 0, tone: "muted" }],
arrows: [
...(x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }]),
{ from: { x, y: 0 }, to: { x, y } },
],
highlight: { x, y },
});
}
steps.push({
label: `Mark the point (${x}, ${y}). Remember: the x-coordinate always comes first!`,
math: `(${x},\\ ${y})`,
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
highlight: { x, y },
});
return steps;
}
// ── Shape reveal presets ─────────────────────────────────────────────────────
interface Shape {
name: string;
points: { label: string; x: number; y: number }[];
}
const SHAPES: Record<string, Shape> = {
mystery: {
name: "Mystery Shape",
points: [
{ label: "A", x: -4, y: -8 },
{ label: "B", x: -3, y: -9 },
{ label: "C", x: -2, y: -8 },
{ label: "D", x: -2, y: 0 },
{ label: "E", x: -5, y: 0 },
{ label: "F", x: -6, y: 4 },
{ label: "G", x: -2, y: 5 },
{ label: "H", x: 2, y: 4 },
{ label: "I", x: 5, y: 0 },
],
},
square: {
name: "Square",
points: [
{ label: "A", x: 2, y: 2 },
{ label: "B", x: 7, y: 2 },
{ label: "C", x: 7, y: 7 },
{ label: "D", x: 2, y: 7 },
],
},
house: {
name: "House",
points: [
{ label: "A", x: -5, y: -4 },
{ label: "B", x: 5, y: -4 },
{ label: "C", x: 5, y: 3 },
{ label: "D", x: 0, y: 7 },
{ label: "E", x: -5, y: 3 },
],
},
};
function buildShapeSteps(shape: Shape): Step[] {
const pts = shape.points;
const steps: Step[] = [];
for (let i = 0; i < pts.length; i++) {
const plotted = pts.slice(0, i + 1);
const segments: GridSegment[] = [];
for (let j = 1; j <= i; j++) {
segments.push({ from: pts[j - 1], to: pts[j] });
}
const p = pts[i];
steps.push({
label:
i === 0
? `Plot the first point ${p.label} at (${p.x}, ${p.y}).`
: `Plot ${p.label} at (${p.x}, ${p.y}) and join it to ${pts[i - 1].label}.`,
math: `${p.label} = (${p.x},\\ ${p.y})`,
points: plotted.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments,
highlight: { x: p.x, y: p.y },
});
}
// Closing segment
const allSegments: GridSegment[] = [];
for (let j = 1; j < pts.length; j++) allSegments.push({ from: pts[j - 1], to: pts[j] });
allSegments.push({ from: pts[pts.length - 1], to: pts[0] });
steps.push({
label: `Finally, join ${pts[pts.length - 1].label} back to ${pts[0].label} to close the shape. What is it?`,
math: `${pts[pts.length - 1].label} \\to ${pts[0].label}`,
points: pts.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments: allSegments,
});
return steps;
}
// ── Quick practice: click the point ──────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function ClickPractice() {
const [target, setTarget] = useState(() => ({ x: randInt(-8, 8), y: randInt(-8, 8) }));
const [clicked, setClicked] = useState<{ x: number; y: number } | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const isCorrect = clicked && clicked.x === target.x && clicked.y === target.y;
function handleClick(x: number, y: number) {
if (clicked) return;
const correct = x === target.x && y === target.y;
setClicked({ x, y });
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setTarget({ x: randInt(-8, 8), y: randInt(-8, 8) });
setClicked(null);
}
const points: GridPoint[] = [];
if (clicked) {
if (isCorrect) {
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
} else {
points.push({ x: clicked.x, y: clicked.y, label: "you", tone: "wrong" });
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
}
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">
Click where <span className="text-unit-7-dark">({target.x}, {target.y})</span> belongs
</p>
<div className="mx-auto max-w-md">
<CartesianGrid points={points} highlight={isCorrect ? target : undefined} onGridClick={clicked ? undefined : handleClick} />
</div>
{clicked && (
<div className="space-y-2 text-center">
{isCorrect ? (
<p className="text-sm font-bold text-correct">Spot on! That is ({target.x}, {target.y}).</p>
) : (
<p className="text-sm font-bold text-incorrect">
You clicked ({clicked.x}, {clicked.y}). The target ({target.x}, {target.y}) is shown in green.
</p>
)}
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function PlotPointsExplorer() {
const [mode, setMode] = useState<Mode>("plot");
const [inputX, setInputX] = useState("4");
const [inputY, setInputY] = useState("3");
const [shapeKey, setShapeKey] = useState<keyof typeof SHAPES>("mystery");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handlePlot() {
setError("");
const x = parseInt(inputX, 10);
const y = parseInt(inputY, 10);
if (isNaN(x) || isNaN(y)) return setError("Enter whole numbers for x and y.");
if ([x, y].some((v) => v < -10 || v > 10)) return setError("Keep both coordinates between -10 and 10.");
setSteps(buildPlotSteps(x, y));
setCurrentStep(0);
setIsPlaying(false);
}
function handleShape() {
setError("");
setSteps(buildShapeSteps(SHAPES[shapeKey]));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["plot", "shape"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-7 bg-unit-7 text-white"
: "border-unit-7/40 text-unit-7 hover:bg-unit-7-light"
}`}
>
{m === "plot" ? "Plot a Point" : "Shape Reveal"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "plot" ? (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Enter the coordinates to plot
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-lg font-bold text-muted">(</span>
<input
type="number"
value={inputX}
onChange={(e) => setInputX(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="x coordinate"
/>
<span className="text-lg font-bold text-muted">,</span>
<input
type="number"
value={inputY}
onChange={(e) => setInputY(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="y coordinate"
/>
<span className="text-lg font-bold text-muted">)</span>
<button
onClick={handlePlot}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Plot
</button>
</div>
</>
) : (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Pick a shape, then step through the points to reveal it
</p>
<div className="flex flex-wrap items-center gap-3">
<select
value={shapeKey}
onChange={(e) => {
setShapeKey(e.target.value as keyof typeof SHAPES);
reset();
}}
className="rounded-lg border-2 border-border bg-surface px-3 py-2 text-sm font-bold outline-none focus:border-unit-7"
aria-label="Choose a shape"
>
{Object.entries(SHAPES).map(([key, s]) => (
<option key={key} value={key}>
{s.name}
</option>
))}
</select>
<button
onClick={handleShape}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Start
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
{mode === "plot" ? (
<>Enter coordinates above and click <strong>Plot</strong></>
) : (
<>Pick a shape and click <strong>Start</strong></>
)}
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="w-full max-w-md">
<CartesianGrid
points={step.points}
segments={step.segments}
arrows={step.arrows}
highlight={step.highlight}
/>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<ClickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { CartesianGrid, type GridPoint } from "./cartesian-grid";
interface Step {
label: string;
math: string;
points: GridPoint[];
highlight?: { x: number; y: number };
guide?: "x" | "y" | "both";
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function quadrantName(x: number, y: number): string {
if (x === 0 && y === 0) return "the origin";
if (x === 0) return "on the y-axis";
if (y === 0) return "on the x-axis";
if (x > 0 && y > 0) return "the top-right quadrant";
if (x < 0 && y > 0) return "the top-left quadrant";
if (x < 0 && y < 0) return "the bottom-left quadrant";
return "the bottom-right quadrant";
}
function buildReadSteps(x: number, y: number): Step[] {
const point: GridPoint = { x, y, tone: "solid" };
return [
{
label: "Here is a point. Let's read off its coordinates.",
math: `(?,\\ ?)`,
points: [point],
},
{
label: `Trace straight ${y >= 0 ? "DOWN" : "UP"} to the x-axis. It lands on ${x}.`,
math: `x = ${x}`,
points: [point],
highlight: { x, y },
guide: "x",
},
{
label: `Now trace straight across to the y-axis. It lands on ${y}.`,
math: `y = ${y}`,
points: [point],
highlight: { x, y },
guide: "both",
},
{
label: `The coordinates are (${x}, ${y}) — x first, then y. This point is in ${quadrantName(x, y)}.`,
math: `(${x},\\ ${y})`,
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
highlight: { x, y },
guide: "both",
},
];
}
// ── Quick practice: read the coordinates ─────────────────────────────────────
function ReadPractice() {
const [target, setTarget] = useState(() => ({ x: randInt(-9, 9), y: randInt(-9, 9) }));
const [xVal, setXVal] = useState("");
const [yVal, setYVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "swapped" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const gx = parseInt(xVal, 10);
const gy = parseInt(yVal, 10);
if (isNaN(gx) || isNaN(gy)) return;
if (gx === target.x && gy === target.y) {
setFeedback("correct");
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
} else if (gx === target.y && gy === target.x && target.x !== target.y) {
setFeedback("swapped");
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
} else {
setFeedback("incorrect");
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
}
}
function next() {
setTarget({ x: randInt(-9, 9), y: randInt(-9, 9) });
setXVal("");
setYVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">What are the coordinates of this point?</p>
<div className="mx-auto max-w-md">
<CartesianGrid points={[{ x: target.x, y: target.y, tone: "solid" }]} highlight={feedback ? target : undefined} />
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<span className="text-lg font-bold text-muted">(</span>
<input
type="number"
value={xVal}
onChange={(e) => {
setXVal(e.target.value);
setFeedback(null);
}}
disabled={feedback === "correct"}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
placeholder="x"
aria-label="x coordinate"
/>
<span className="text-lg font-bold text-muted">,</span>
<input
type="number"
value={yVal}
onChange={(e) => {
setYVal(e.target.value);
setFeedback(null);
}}
disabled={feedback === "correct"}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback === "correct") next();
else check();
}
}}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
placeholder="y"
aria-label="y coordinate"
/>
<span className="text-lg font-bold text-muted">)</span>
{feedback === "correct" ? (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
) : (
<button onClick={check} className="rounded-lg bg-unit-7 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-7-dark">
Check
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct! ({target.x}, {target.y})</p>}
{feedback === "swapped" && (
<p className="text-center text-sm font-bold text-incorrect">You swapped them the x-coordinate comes first! Try again.</p>
)}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite. Trace down to the x-axis, then across to the y-axis.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function ReadCoordinatesExplorer() {
const [point, setPoint] = useState(() => ({ x: 3, y: 2 }));
const [steps, setSteps] = useState<Step[] | null>(() => buildReadSteps(3, 2));
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setCurrentStep(0);
setIsPlaying(false);
}, []);
function newPoint() {
let x = randInt(-9, 9);
const y = randInt(-9, 9);
if (x === point.x && y === point.y) x = -x || 1;
setPoint({ x, y });
setSteps(buildReadSteps(x, y));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Read the coordinates of the point
</p>
<button
onClick={newPoint}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
New Point
</button>
</Card>
{/* Display card */}
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
{step && (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="w-full max-w-md">
<CartesianGrid
points={step.points}
highlight={
step.guide && step.highlight
? { ...step.highlight, axis: step.guide === "x" ? "x" : "both" }
: undefined
}
/>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<ReadPractice />
</ClientOnly>
</div>
);
}