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

352 lines
12 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";
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>
);
}