796 lines
28 KiB
TypeScript
796 lines
28 KiB
TypeScript
"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 3–6 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 < or >
|
||
</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>
|
||
);
|
||
}
|