373 lines
14 KiB
TypeScript
373 lines
14 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";
|
||
|
||
type Mode = "expand" | "index";
|
||
type IndexOp = "multiply" | "divide";
|
||
|
||
interface Step {
|
||
label: string;
|
||
math: string;
|
||
}
|
||
|
||
function repeat(base: number, count: number): string {
|
||
return Array.from({ length: count }, () => `${base}`).join(" \\times ");
|
||
}
|
||
|
||
function buildExpandSteps(base: number, exp: number): Step[] {
|
||
if (exp === 0) {
|
||
return [
|
||
{ label: "The power (index) is 0.", math: `${base}^{0}` },
|
||
{ label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
|
||
];
|
||
}
|
||
if (exp === 1) {
|
||
return [
|
||
{ label: "The power (index) is 1.", math: `${base}^{1}` },
|
||
{ label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
|
||
];
|
||
}
|
||
|
||
const steps: Step[] = [
|
||
{
|
||
label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
|
||
math: `${base}^{${exp}}`,
|
||
},
|
||
];
|
||
let running = base;
|
||
for (let k = 2; k <= exp; k++) {
|
||
running *= base;
|
||
steps.push({
|
||
label: `Multiply on another ${base}.`,
|
||
math: `${repeat(base, k)} = ${running}`,
|
||
});
|
||
}
|
||
steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
|
||
return steps;
|
||
}
|
||
|
||
function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
|
||
if (op === "multiply") {
|
||
const total = m + n;
|
||
const value = Math.pow(base, total);
|
||
const steps: Step[] = [
|
||
{ label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
|
||
{ label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
|
||
{ label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
|
||
];
|
||
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
|
||
return steps;
|
||
}
|
||
// divide, m >= n
|
||
const diff = m - n;
|
||
const value = Math.pow(base, diff);
|
||
const steps: Step[] = [
|
||
{ label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
|
||
{ label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
|
||
{ label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
|
||
];
|
||
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
|
||
return steps;
|
||
}
|
||
|
||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||
|
||
interface PracticeQ {
|
||
prompt: string;
|
||
answer: number;
|
||
}
|
||
|
||
function randInt(min: number, max: number) {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
|
||
const SUPERSCRIPT: Record<string, string> = {
|
||
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
|
||
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
|
||
};
|
||
function sup(n: number): string {
|
||
return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
|
||
}
|
||
|
||
function makeQuestion(): PracticeQ {
|
||
const roll = Math.floor(Math.random() * 5);
|
||
if (roll === 0) {
|
||
const base = randInt(2, 7);
|
||
const exp = randInt(2, 3);
|
||
return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
|
||
}
|
||
if (roll === 1) {
|
||
const base = randInt(2, 9);
|
||
return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
|
||
}
|
||
if (roll === 2) {
|
||
const base = randInt(2, 4);
|
||
const a = randInt(2, 3);
|
||
const b = 1;
|
||
return { prompt: `What is ${base}${sup(a)} − ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
|
||
}
|
||
if (roll === 3) {
|
||
const base = randInt(2, 5);
|
||
const m = randInt(3, 6);
|
||
const n = randInt(1, m - 1);
|
||
return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
|
||
}
|
||
const base = randInt(2, 6);
|
||
const m = randInt(1, 4);
|
||
const n = randInt(1, 4);
|
||
return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
|
||
}
|
||
|
||
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 ExponentsExplorer() {
|
||
const [mode, setMode] = useState<Mode>("expand");
|
||
const [indexOp, setIndexOp] = useState<IndexOp>("multiply");
|
||
const [base, setBase] = useState("2");
|
||
const [power, setPower] = useState("4");
|
||
const [m, setM] = useState("4");
|
||
const [n, setN] = useState("3");
|
||
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(md: Mode) {
|
||
setMode(md);
|
||
setError("");
|
||
reset();
|
||
}
|
||
|
||
function handleGo() {
|
||
setError("");
|
||
const b = parseInt(base, 10);
|
||
if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
|
||
|
||
if (mode === "expand") {
|
||
const e = parseInt(power, 10);
|
||
if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
|
||
if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
|
||
setSteps(buildExpandSteps(b, e));
|
||
} else {
|
||
const mm = parseInt(m, 10);
|
||
const nn = parseInt(n, 10);
|
||
if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
|
||
if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
|
||
setSteps(buildIndexSteps(b, mm, nn, indexOp));
|
||
}
|
||
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">
|
||
{/* Mode toggle */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{(["expand", "index"] as Mode[]).map((md) => (
|
||
<button
|
||
key={md}
|
||
onClick={() => switchMode(md)}
|
||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||
mode === md
|
||
? "border-unit-8 bg-unit-8 text-white"
|
||
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||
}`}
|
||
>
|
||
{md === "expand" ? "Expand a Power" : "Index Laws"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Index op tabs */}
|
||
{mode === "index" && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{(["multiply", "divide"] as IndexOp[]).map((o) => (
|
||
<button
|
||
key={o}
|
||
onClick={() => {
|
||
setIndexOp(o);
|
||
reset();
|
||
}}
|
||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||
indexOp === o
|
||
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||
}`}
|
||
>
|
||
{o === "multiply" ? "Multiply (add powers)" : "Divide (subtract powers)"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Input card */}
|
||
<Card>
|
||
{mode === "expand" ? (
|
||
<>
|
||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">Enter a base and a power</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<input type="number" value={base} onChange={(e) => setBase(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="Base" />
|
||
<span className="text-sm font-bold text-muted">to the power</span>
|
||
<input type="number" value={power} onChange={(e) => setPower(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="Power" />
|
||
<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>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||
Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<span className="text-sm font-bold text-muted">base</span>
|
||
<input type="number" value={base} onChange={(e) => setBase(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="Base" />
|
||
<span className="text-sm font-bold text-muted">powers</span>
|
||
<input type="number" value={m} onChange={(e) => setM(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="First power" />
|
||
<span className="text-lg font-bold text-muted">{indexOp === "multiply" ? "×" : "÷"}</span>
|
||
<input type="number" value={n} onChange={(e) => setN(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="Second power" />
|
||
<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 values 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" />
|
||
</>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|