Files
cabrits-math/components/explorers/number-laws-explorer.tsx
2026-07-05 11:12:45 -04:00

357 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}