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