form 1 topics added
This commit is contained in:
795
components/explorers/integer-order-compare-explorer.tsx
Normal file
795
components/explorers/integer-order-compare-explorer.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user