Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

View File

@@ -0,0 +1,338 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
interface FractionVal {
num: number;
den: number;
}
interface Expression {
fractions: FractionVal[];
operators: string[];
}
interface Step {
label: string;
math: string;
highlightIndex: number | null; // which operator is being evaluated
}
const OP_PRIORITY: Record<string, number> = {
"×": 2,
"÷": 2,
"+": 1,
"": 1,
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
let result: [number, number];
switch (op) {
case "+":
result = add(a.num, a.den, b.num, b.den);
break;
case "":
result = subtract(a.num, a.den, b.num, b.den);
break;
case "×":
result = multiply(a.num, a.den, b.num, b.den);
break;
case "÷":
result = divide(a.num, a.den, b.num, b.den);
break;
default:
result = [0, 1];
}
return { num: result[0], den: result[1] };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
const parts: string[] = [];
fracs.forEach((f, i) => {
parts.push(toKatex(f.num, f.den));
if (i < ops.length) {
if (i === highlightIdx) {
parts.push(`\\;\\boxed{${ops[i]}}\\;`);
} else {
parts.push(`\\;${ops[i]}\\;`);
}
}
});
return parts.join("");
}
function buildBodmasSteps(expr: Expression): Step[] {
const steps: Step[] = [];
let fracs = [...expr.fractions];
let ops = [...expr.operators];
// Step 0: Show the expression
steps.push({
label: "Start with the expression",
math: expressionToKatex(fracs, ops, null),
highlightIndex: null,
});
while (ops.length > 0) {
// Find highest priority operator (leftmost if tie)
let maxPri = 0;
let opIdx = 0;
ops.forEach((op, i) => {
if (OP_PRIORITY[op] > maxPri) {
maxPri = OP_PRIORITY[op];
opIdx = i;
}
});
// Highlight the operation to perform
steps.push({
label: `BODMAS: do ${ops[opIdx]} first (${OP_PRIORITY[ops[opIdx]] === 2 ? "multiplication/division" : "addition/subtraction"})`,
math: expressionToKatex(fracs, ops, opIdx),
highlightIndex: opIdx,
});
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
const [sn, sd] = simplify(result.num, result.den);
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });
const newOps = [...ops];
newOps.splice(opIdx, 1);
steps.push({
label: `${fracs[opIdx].num}/${fracs[opIdx].den} ${ops[opIdx]} ${fracs[opIdx + 1].num}/${fracs[opIdx + 1].den} = ${sn}/${sd}`,
math: expressionToKatex(newFracs, newOps, null),
highlightIndex: null,
});
fracs = newFracs;
ops = newOps;
}
return steps;
}
const PRESETS: { label: string; fracs: FractionVal[]; ops: string[] }[] = [
{
label: "½ + ¼ × ⅗",
fracs: [
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
],
ops: ["+", "×"],
},
{
label: "⅔ × ¾ ½",
fracs: [
{ num: 2, den: 3 },
{ num: 3, den: 4 },
{ num: 1, den: 2 },
],
ops: ["×", ""],
},
{
label: "⅗ ÷ ¼ + ⅔",
fracs: [
{ num: 3, den: 5 },
{ num: 1, den: 4 },
{ num: 2, den: 3 },
],
ops: ["÷", "+"],
},
];
export function BODMASExplorer() {
const [fracs, setFracs] = useState<FractionVal[]>([
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
]);
const [ops, setOps] = useState<string[]>(["+", "×"]);
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;
function updateFrac(idx: number, field: "num" | "den", value: string) {
setFracs((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: parseInt(value) || 0 };
return next;
});
}
function handleGo() {
setError("");
if (fracs.some((f) => f.den === 0)) {
setError("Denominators cannot be zero.");
return;
}
if (fracs.some((f) => Math.abs(f.num) > 99 || Math.abs(f.den) > 99)) {
setError("Keep numbers under 100.");
return;
}
try {
const s = buildBodmasSteps({ fractions: fracs, operators: ops });
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
function loadPreset(idx: number) {
const p = PRESETS[idx];
setFracs([...p.fracs]);
setOps([...p.ops]);
reset();
}
function stepForward() {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}
function stepBack() {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}
function togglePlay() {
setIsPlaying((p) => !p);
}
function reset() {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}
const step = steps ? steps[currentStep] : null;
const opChoices = ["+", "", "×", "÷"];
return (
<div className="space-y-4">
{/* Presets */}
<div className="flex flex-wrap gap-2">
{PRESETS.map((p, i) => (
<button
key={i}
onClick={() => loadPreset(i)}
className="rounded-full border-2 border-unit-1/40 px-4 py-2 text-sm font-semibold text-unit-1 transition-colors hover:bg-unit-1-light"
>
{p.label}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
Enter a BODMAS expression with fractions
</p>
<div className="flex flex-wrap items-center gap-2">
{fracs.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col items-center">
<input
type="number"
value={f.num || ""}
onChange={(e) => updateFrac(i, "num", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Numerator ${i + 1}`}
/>
<div className="my-0.5 h-0.5 w-10 bg-foreground" />
<input
type="number"
value={f.den || ""}
onChange={(e) => updateFrac(i, "den", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Denominator ${i + 1}`}
/>
</div>
{i < ops.length && (
<select
value={ops[i]}
onChange={(e) => {
const next = [...ops];
next[i] = e.target.value;
setOps(next);
}}
className="rounded-lg border-2 border-border bg-surface px-2 py-2 text-center text-lg font-bold text-foreground outline-none focus:border-unit-1"
>
{opChoices.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
</div>
))}
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter an expression above and click <strong>Go</strong>
</p>
) : (
<>
<p className="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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Final Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,345 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { simplify, toKatex, gcd } from "@/lib/math/fractions";
type ConvertMode = "decToFrac" | "fracToDec";
interface Step {
label: string;
math: string;
gridFilled?: number; // out of 100 for decimal grid
barFilled?: number;
barTotal?: number;
}
function buildDecToFracSteps(decimal: string): Step[] {
const steps: Step[] = [];
const val = parseFloat(decimal);
steps.push({
label: `Convert ${decimal} to a fraction`,
math: `${decimal} = \\;?`,
gridFilled: Math.round(val * 100),
});
// Count decimal places
const parts = decimal.split(".");
const decPlaces = parts[1]?.length || 0;
const denominator = Math.pow(10, decPlaces);
const numerator = Math.round(val * denominator);
steps.push({
label: `${decPlaces} decimal place${decPlaces !== 1 ? "s" : ""} → denominator is ${denominator}`,
math: `${decimal} = ${toKatex(numerator, denominator)}`,
gridFilled: Math.round(val * 100),
barFilled: numerator,
barTotal: denominator,
});
// Simplify
const [sn, sd] = simplify(numerator, denominator);
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(numerator, denominator)} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
}
steps.push({
label: `Result: ${decimal} = ${sn}/${sd}`,
math: `${decimal} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
return steps;
}
function buildFracToDecSteps(num: number, den: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Convert ${num}/${den} to a decimal`,
math: `${toKatex(num, den)} = \\;?`,
barFilled: num,
barTotal: den,
});
// Step: Perform the division
const result = num / den;
const resultStr = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/0+$/, "");
steps.push({
label: `Divide numerator by denominator`,
math: `${num} \\div ${den} = ${resultStr}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
// Check for terminating vs repeating
let tempDen = den;
const [, simpDen] = simplify(num, den);
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;
while (tempDen % 5 === 0) tempDen /= 5;
const isTerminating = tempDen === 1;
steps.push({
label: isTerminating
? `This is a terminating decimal`
: `This is a recurring decimal`,
math: `${toKatex(num, den)} = ${resultStr}${!isTerminating ? "..." : ""}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
return steps;
}
function DecimalGrid({ filled }: { filled: number }) {
return (
<div className="grid grid-cols-10 gap-0.5 rounded-lg border-2 border-border p-1" style={{ width: "fit-content" }}>
{Array.from({ length: 100 }, (_, i) => (
<div
key={i}
className="h-4 w-4 rounded-sm transition-colors duration-300"
style={{
backgroundColor: i < filled ? "var(--unit-3)" : "var(--background)",
}}
/>
))}
</div>
);
}
function FractionBarSmall({ filled, total }: { filled: number; total: number }) {
const segments = Math.min(total, 20);
const fillCount = Math.min(filled, segments);
return (
<div className="flex h-8 w-full max-w-[240px] overflow-hidden rounded-lg border-2 border-border">
{Array.from({ length: segments }, (_, i) => (
<div
key={i}
className="border-r border-border/30 transition-colors duration-300 last:border-r-0"
style={{
flex: 1,
backgroundColor: i < fillCount ? "var(--unit-3)" : "transparent",
}}
/>
))}
</div>
);
}
export function ConversionExplorer() {
const [mode, setMode] = useState<ConvertMode>("decToFrac");
const [decInput, setDecInput] = useState("0.75");
const [numInput, setNumInput] = useState("3");
const [denInput, setDenInput] = useState("8");
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;
function handleGo() {
setError("");
try {
let s: Step[];
if (mode === "decToFrac") {
const val = parseFloat(decInput);
if (isNaN(val) || val < 0 || val >= 10) {
setError("Enter a valid decimal between 0 and 10.");
return;
}
s = buildDecToFracSteps(decInput.trim());
} else {
const n = parseInt(numInput);
const d = parseInt(denInput);
if (isNaN(n) || isNaN(d) || d === 0) {
setError("Enter a valid fraction (denominator ≠ 0).");
return;
}
s = buildFracToDecSteps(n, d);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("decToFrac");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "decToFrac"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Decimal Fraction
</button>
<button
onClick={() => {
setMode("fracToDec");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "fracToDec"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Fraction Decimal
</button>
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-3">
{mode === "decToFrac" ? "Enter a decimal" : "Enter a fraction"}
</p>
<div className="flex flex-wrap items-center gap-2.5">
{mode === "decToFrac" ? (
<input
type="text"
value={decInput}
onChange={(e) => setDecInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 0.75"
autoComplete="off"
className="max-w-[180px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
/>
) : (
<div className="flex flex-col items-center">
<input
type="number"
value={numInput}
onChange={(e) => setNumInput(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3"
aria-label="Numerator"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={denInput}
onChange={(e) => setDenInput(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3"
aria-label="Denominator"
/>
</div>
)}
<button
onClick={handleGo}
className="rounded-lg bg-unit-3 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-3-dark"
>
Convert
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a value above and click <strong>Convert</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="flex flex-wrap items-center justify-center gap-6">
{step.gridFilled !== undefined && (
<div className="flex flex-col items-center gap-1">
<DecimalGrid filled={step.gridFilled} />
<span className="text-xs text-muted">{step.gridFilled}/100</span>
</div>
)}
{step.barFilled !== undefined && step.barTotal !== undefined && (
<div className="flex flex-col items-center gap-1">
<FractionBarSmall filled={step.barFilled} total={step.barTotal} />
<span className="text-xs text-muted">
{step.barFilled}/{step.barTotal}
</span>
</div>
)}
</div>
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,403 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type DecOp = "add" | "subtract" | "multiply" | "divide";
interface Step {
label: string;
columns: ColumnDisplay[];
carry?: string;
resultRow?: string;
}
interface ColumnDisplay {
rows: string[];
highlight?: number; // which row is highlighted
separator?: boolean; // line above this row
}
function padAndAlign(a: string, b: string): { aStr: string; bStr: string; dotPos: number } {
const aParts = a.split(".");
const bParts = b.split(".");
const aInt = aParts[0] || "0";
const bInt = bParts[0] || "0";
const aDec = aParts[1] || "";
const bDec = bParts[1] || "";
const maxInt = Math.max(aInt.length, bInt.length);
const maxDec = Math.max(aDec.length, bDec.length);
const aAligned = aInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + aDec.padEnd(maxDec, "0") : "");
const bAligned = bInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + bDec.padEnd(maxDec, "0") : "");
return { aStr: aAligned, bStr: bAligned, dotPos: maxInt };
}
function buildAddSubSteps(a: number, b: number, op: DecOp): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const { aStr: aAligned, bStr: bAligned } = padAndAlign(aStr, bStr);
const opSymbol = op === "add" ? "+" : "";
const result = op === "add" ? a + b : a - b;
const resultStr = parseFloat(result.toFixed(10)).toString();
// Step 0: Show the problem
steps.push({
label: `${aStr} ${opSymbol} ${bStr}`,
columns: [],
});
// Step 1: Align decimal points
steps.push({
label: "Align the decimal points",
columns: [
{ rows: [aAligned, `${opSymbol} ${bAligned}`] },
],
});
// Step 2: Add trailing zeros
steps.push({
label: "Fill in zeros as placeholders",
columns: [
{ rows: [aAligned, `${opSymbol} ${bAligned}`], separator: true },
],
});
// Step 3: Compute
const { aStr: aFinal, bStr: bFinal } = padAndAlign(aStr, bStr);
const resultAligned = padAndAlign(resultStr, aStr).aStr;
steps.push({
label: `Compute column by column`,
columns: [
{ rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
],
});
// Step 4: Result
steps.push({
label: `${aStr} ${opSymbol} ${bStr} = ${resultStr}`,
columns: [
{ rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 },
],
resultRow: resultStr,
});
return steps;
}
function buildMultiplySteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const aDec = (aStr.split(".")[1] || "").length;
const bDec = (bStr.split(".")[1] || "").length;
const totalDec = aDec + bDec;
// Remove decimals for integer multiplication
const aInt = Math.round(a * Math.pow(10, aDec));
const bInt = Math.round(b * Math.pow(10, bDec));
const result = a * b;
const resultStr = parseFloat(result.toFixed(10)).toString();
steps.push({
label: `${aStr} × ${bStr}`,
columns: [],
});
if (totalDec > 0) {
steps.push({
label: `Count decimal places: ${aDec} + ${bDec} = ${totalDec}`,
columns: [
{ rows: [`${aStr}${aDec} d.p.`, `${bStr}${bDec} d.p.`, `Total: ${totalDec} d.p.`] },
],
});
steps.push({
label: `Multiply as whole numbers: ${aInt} × ${bInt}`,
columns: [
{ rows: [`${aInt}`, `× ${bInt}`, `${aInt * bInt}`], separator: true, highlight: 2 },
],
});
steps.push({
label: `Place decimal point ${totalDec} place${totalDec !== 1 ? "s" : ""} from the right`,
columns: [
{ rows: [`${aInt * bInt}`, `${resultStr}`], highlight: 1 },
],
resultRow: resultStr,
});
} else {
const intResult = a * b;
steps.push({
label: `Multiply: ${aStr} × ${bStr} = ${intResult}`,
columns: [
{ rows: [`${aStr}`, `× ${bStr}`, `${intResult}`], separator: true, highlight: 2 },
],
resultRow: intResult.toString(),
});
}
return steps;
}
function buildDivideSteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const aStr = a.toString();
const bStr = b.toString();
const result = a / b;
const resultStr = parseFloat(result.toFixed(10)).toString();
steps.push({
label: `${aStr} ÷ ${bStr}`,
columns: [],
});
// Make divisor a whole number
const bDec = (bStr.split(".")[1] || "").length;
if (bDec > 0) {
const factor = Math.pow(10, bDec);
const newA = parseFloat((a * factor).toFixed(10));
const newB = parseFloat((b * factor).toFixed(10));
steps.push({
label: `Make divisor whole: multiply both by ${factor}`,
columns: [
{ rows: [`${aStr} × ${factor} = ${newA}`, `${bStr} × ${factor} = ${newB}`] },
],
});
steps.push({
label: `Now divide: ${newA} ÷ ${newB}`,
columns: [
{ rows: [`${newA} ÷ ${newB}`, `= ${resultStr}`], highlight: 1 },
],
});
} else {
steps.push({
label: `Divide: ${aStr} ÷ ${bStr}`,
columns: [
{ rows: [`${aStr} ÷ ${bStr}`, `= ${resultStr}`], highlight: 1 },
],
});
}
steps.push({
label: `${aStr} ÷ ${bStr} = ${resultStr}`,
columns: [
{ rows: [`${aStr} ÷ ${bStr} = ${resultStr}`] },
],
resultRow: resultStr,
});
return steps;
}
function ColumnArithmetic({
columns,
}: {
columns: ColumnDisplay[];
}) {
if (columns.length === 0) return null;
return (
<div className="flex flex-col items-center gap-0.5 font-mono text-xl">
{columns.map((col, ci) =>
col.rows.map((row, ri) => (
<div key={`${ci}-${ri}`}>
{col.separator && ri === col.rows.length - 1 && (
<div className="mb-1 h-0.5 bg-foreground" />
)}
<div
className={`rounded-lg px-6 py-1.5 text-right tracking-wider transition-all duration-300 ${
ri === col.highlight
? "bg-unit-3-light font-bold text-foreground"
: "text-foreground"
}`}
>
{row}
</div>
</div>
)),
)}
</div>
);
}
export function DecimalArithmeticExplorer() {
const [op, setOp] = useState<DecOp>("add");
const [aInput, setAInput] = useState("12.45");
const [bInput, setBInput] = useState("3.7");
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;
function handleGo() {
setError("");
const a = parseFloat(aInput);
const b = parseFloat(bInput);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid decimal numbers.");
return;
}
if (op === "divide" && b === 0) {
setError("Cannot divide by zero.");
return;
}
try {
let s: Step[];
if (op === "add" || op === "subtract") {
s = buildAddSubSteps(a, b, op);
} else if (op === "multiply") {
s = buildMultiplySteps(a, b);
} else {
s = buildDivideSteps(a, b);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const opLabels: Record<DecOp, string> = {
add: "Add (+)",
subtract: "Subtract ()",
multiply: "Multiply (×)",
divide: "Divide (÷)",
};
return (
<div className="space-y-4">
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract", "multiply", "divide"] as DecOp[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
{opLabels[o]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-3">
Enter two decimal numbers
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={aInput}
onChange={(e) => setAInput(e.target.value)}
placeholder="e.g. 12.45"
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
aria-label="First number"
/>
<span className="text-xl font-bold text-muted">
{{ add: "+", subtract: "", multiply: "×", divide: "÷" }[op]}
</span>
<input
type="text"
value={bInput}
onChange={(e) => setBInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.7"
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
aria-label="Second number"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-3 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-3-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<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-sm font-medium text-muted">{step.label}</p>
<ColumnArithmetic columns={step.columns} />
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !step?.resultRow ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{step.resultRow}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type SortDirection = "ascending" | "descending";
interface Step {
label: string;
values: { value: number; str: string; sorted: boolean; comparing: boolean }[];
comparingPair?: [number, number];
}
function padDecimal(s: string, maxInt: number, maxDec: number): string {
const parts = s.split(".");
const intPart = (parts[0] || "0").padStart(maxInt, "\u00A0"); // nbsp padding
const decPart = (parts[1] || "").padEnd(maxDec, "0");
return maxDec > 0 ? `${intPart}.${decPart}` : intPart;
}
function buildSortSteps(values: number[], direction: SortDirection): Step[] {
const steps: Step[] = [];
const strs = values.map((v) => v.toString());
// Find max integer and decimal lengths for alignment
const maxInt = Math.max(...strs.map((s) => s.split(".")[0].length));
const maxDec = Math.max(...strs.map((s) => (s.split(".")[1] || "").length));
// Step 0: Show unsorted
steps.push({
label: `Sort ${values.length} decimals in ${direction} order`,
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Step 1: Show aligned place values
steps.push({
label: "Align decimal points to compare place values",
values: values.map((v, i) => ({
value: v,
str: padDecimal(strs[i], maxInt, maxDec),
sorted: false,
comparing: false,
})),
});
// Bubble sort with steps
const arr = [...values];
const arrStrs = [...strs];
const cmp = direction === "ascending" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - 1 - i; j++) {
// Show comparison
steps.push({
label: `Compare ${arrStrs[j]} and ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
comparingPair: [j, j + 1],
});
if (cmp(arr[j], arr[j + 1])) {
// Swap
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
[arrStrs[j], arrStrs[j + 1]] = [arrStrs[j + 1], arrStrs[j]];
steps.push({
label: `Swap: ${arrStrs[j]} ${direction === "ascending" ? "<" : ">"} ${arrStrs[j + 1]}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: k >= arr.length - i,
comparing: k === j || k === j + 1,
})),
});
}
}
}
// Final step: Show sorted
steps.push({
label: `Sorted (${direction}): ${arrStrs.join(direction === "ascending" ? " < " : " > ")}`,
values: arr.map((v, k) => ({
value: v,
str: padDecimal(arrStrs[k], maxInt, maxDec),
sorted: true,
comparing: false,
})),
});
return steps;
}
function NumberLine({ values, min, max }: { values: { value: number; sorted: boolean; comparing: boolean }[]; min: number; max: number }) {
const range = max - min || 1;
return (
<div className="w-full max-w-lg">
<div className="relative h-2 rounded-full bg-border">
{values.map((v, i) => {
const pct = ((v.value - min) / range) * 100;
return (
<div
key={i}
className={`absolute -top-2.5 h-7 w-7 -translate-x-1/2 rounded-full border-2 text-center text-[0.6rem] font-bold leading-[1.5rem] transition-all duration-500 ${
v.comparing
? "border-hint bg-hint-light text-foreground z-10"
: v.sorted
? "border-correct bg-correct-light text-foreground"
: "border-unit-2 bg-unit-2-light text-foreground"
}`}
style={{ left: `${Math.max(5, Math.min(95, pct))}%` }}
>
{i + 1}
</div>
);
})}
</div>
<div className="mt-4 flex justify-between text-xs text-muted">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}
export function DecimalOrderExplorer() {
const [input, setInput] = useState("3.14, 3.1, 3.141, 3.04, 3.4");
const [direction, setDirection] = useState<SortDirection>("ascending");
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;
function handleGo() {
setError("");
const parts = input
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length < 2) {
setError("Enter at least 2 decimals separated by commas.");
return;
}
if (parts.length > 6) {
setError("Maximum 6 decimals.");
return;
}
const values = parts.map(Number);
if (values.some(isNaN)) {
setError("All values must be valid numbers.");
return;
}
try {
const s = buildSortSteps(values, direction);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Direction tabs */}
<div className="flex gap-2">
{(["ascending", "descending"] as SortDirection[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
direction === d
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{d}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter 2-6 decimal numbers (comma separated)
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.14, 3.1, 3.141"
autoComplete="off"
className="max-w-[320px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Sort
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-5 p-6">
{!step ? (
<p className="text-muted/50">
Enter decimals above and click <strong>Sort</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
{/* Place value columns */}
<div className="flex flex-col gap-1.5 font-mono text-xl">
{step.values.map((v, i) => (
<div
key={i}
className={`rounded-lg border-2 px-4 py-2 text-center transition-all duration-300 ${
v.comparing
? "border-hint bg-hint-light"
: v.sorted
? "border-correct bg-correct-light"
: "border-border bg-surface"
}`}
>
{v.str}
</div>
))}
</div>
{/* Number line */}
<NumberLine
values={step.values}
min={Math.min(...step.values.map((v) => v.value)) - 0.1}
max={Math.max(...step.values.map((v) => v.value)) + 0.1}
/>
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">
Sorted ({direction})
</p>
<p className="text-2xl font-extrabold text-foreground max-sm:text-xl">
{step!.values.map((v) => v.value).join(direction === "ascending" ? " < " : " > ")}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,389 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
import { MathDisplay } from "@/components/math/math-display";
type Operation = "add" | "subtract" | "multiply" | "divide";
interface Step {
label: string;
math: string;
barA: { filled: number; total: number };
barB: { filled: number; total: number };
barResult?: { filled: number; total: number };
}
function buildSteps(
n1: number, d1: number,
n2: number, d2: number,
op: Operation,
): Step[] {
const steps: Step[] = [];
// Step 0: Show the original fractions
const opSymbol = { add: "+", subtract: "-", multiply: "\\times", divide: "\\div" }[op];
steps.push({
label: "Start with the two fractions",
math: `${toKatex(n1, d1)} ${opSymbol} ${toKatex(n2, d2)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
});
if (op === "add" || op === "subtract") {
// Step 1: Find LCD
const lcd = lcm(d1, d2);
const mult1 = lcd / d1;
const mult2 = lcd / d2;
const cn1 = n1 * mult1;
const cn2 = n2 * mult2;
if (d1 !== d2) {
steps.push({
label: `Find the LCD of ${d1} and ${d2}`,
math: `\\text{LCD}(${d1}, ${d2}) = ${lcd}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
});
// Step 2: Convert both fractions
steps.push({
label: "Convert to equivalent fractions with the LCD",
math: `${toKatex(n1, d1)} = ${toKatex(cn1, lcd)} \\quad ${toKatex(n2, d2)} = ${toKatex(cn2, lcd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
});
}
// Step 3: Perform the operation
const resultNum = op === "add" ? cn1 + cn2 : cn1 - cn2;
steps.push({
label: op === "add" ? "Add the numerators" : "Subtract the numerators",
math: `${toKatex(cn1, lcd)} ${opSymbol} ${toKatex(cn2, lcd)} = ${toKatex(resultNum, lcd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
barResult: { filled: Math.abs(resultNum), total: lcd },
});
// Step 4: Simplify if needed
const [sn, sd] = simplify(resultNum, lcd);
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
const g = gcd(Math.abs(resultNum), lcd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(resultNum, lcd)} = ${toKatex(sn, sd)}`,
barA: { filled: cn1, total: lcd },
barB: { filled: cn2, total: lcd },
barResult: { filled: Math.abs(sn), total: sd },
});
}
} else if (op === "multiply") {
// Step 1: Multiply numerators and denominators
const rn = n1 * n2;
const rd = d1 * d2;
steps.push({
label: "Multiply numerators and denominators",
math: `\\frac{${n1} \\times ${n2}}{${d1} \\times ${d2}} = ${toKatex(rn, rd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
barResult: { filled: rn, total: rd },
});
// Step 2: Simplify
const [sn, sd] = simplify(rn, rd);
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: n2, total: d2 },
barResult: { filled: Math.abs(sn), total: sd },
});
}
} else {
// divide: invert and multiply
steps.push({
label: "Invert the second fraction (reciprocal)",
math: `${toKatex(n1, d1)} \\times ${toKatex(d2, n2)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
});
const rn = n1 * d2;
const rd = d1 * n2;
steps.push({
label: "Multiply numerators and denominators",
math: `\\frac{${n1} \\times ${d2}}{${d1} \\times ${n2}} = ${toKatex(rn, rd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
barResult: { filled: rn, total: rd },
});
const [sn, sd] = simplify(rn, rd);
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`,
barA: { filled: n1, total: d1 },
barB: { filled: d2, total: n2 },
barResult: { filled: Math.abs(sn), total: sd },
});
}
}
return steps;
}
function FractionBar({
filled,
total,
color,
label,
}: {
filled: number;
total: number;
color: string;
label?: string;
}) {
const maxSegments = Math.min(total, 24);
const fillCount = Math.min(filled, maxSegments);
return (
<div className="flex flex-col items-center gap-1">
{label && <span className="text-xs font-medium text-muted">{label}</span>}
<div className="flex h-10 w-full max-w-[280px] overflow-hidden rounded-lg border-2 border-border">
{Array.from({ length: maxSegments }, (_, i) => (
<div
key={i}
className="border-r border-border/30 transition-colors duration-500 last:border-r-0"
style={{
flex: 1,
backgroundColor: i < fillCount ? color : "transparent",
}}
/>
))}
</div>
<span className="text-xs text-muted">
{filled}/{total}
</span>
</div>
);
}
export function FractionOperationExplorer() {
const [op, setOp] = useState<Operation>("add");
const [n1, setN1] = useState("1");
const [d1, setD1] = useState("3");
const [n2, setN2] = useState("1");
const [d2, setD2] = useState("4");
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;
function handleGo() {
setError("");
const nums = [parseInt(n1), parseInt(d1), parseInt(n2), parseInt(d2)];
if (nums.some(isNaN) || nums.some((n) => n === 0)) {
setError("Enter valid non-zero numbers.");
return;
}
if (nums[1] < 0 || nums[3] < 0) {
setError("Denominators must be positive.");
return;
}
if (nums.some((n) => Math.abs(n) > 99)) {
setError("Keep numbers under 100.");
return;
}
try {
const s = buildSteps(nums[0], nums[1], nums[2], nums[3], op);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const opLabels: Record<Operation, string> = {
add: "Add (+)",
subtract: "Subtract ()",
multiply: "Multiply (×)",
divide: "Divide (÷)",
};
return (
<div className="space-y-4">
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract", "multiply", "divide"] as Operation[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
{opLabels[o]}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
Enter two fractions
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1">
<div className="flex flex-col items-center">
<input
type="number"
value={n1}
onChange={(e) => setN1(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator 1"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={d1}
onChange={(e) => setD1(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator 1"
/>
</div>
</div>
<span className="text-xl font-bold text-muted">
{{ add: "+", subtract: "", multiply: "×", divide: "÷" }[op]}
</span>
<div className="flex items-center gap-1">
<div className="flex flex-col items-center">
<input
type="number"
value={n2}
onChange={(e) => setN2(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator 2"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={d2}
onChange={(e) => setD2(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator 2"
/>
</div>
</div>
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display Card */}
<Card className="flex min-h-[240px] flex-col items-center justify-center gap-5 p-6">
{!step ? (
<p className="text-muted/50">
Enter fractions above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="flex w-full max-w-md flex-col gap-3">
<FractionBar
filled={step.barA.filled}
total={step.barA.total}
color="var(--unit-1)"
label="First"
/>
<FractionBar
filled={step.barB.filled}
total={step.barB.total}
color="var(--unit-1-dark)"
label="Second"
/>
{step.barResult && (
<FractionBar
filled={step.barResult.filled}
total={step.barResult.total}
color="var(--correct)"
label="Result"
/>
)}
</div>
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex } from "@/lib/math/fractions";
type FQMode = "fractionOf" | "findWhole";
interface Step {
label: string;
math: string;
barFilled: number; // 01 proportion
barLabel?: string;
}
function buildFractionOfSteps(num: number, den: number, quantity: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Find ${toKatexStr(num, den)} of ${quantity}`,
math: `${toKatex(num, den)} \\text{ of } ${quantity}`,
barFilled: 0,
});
// Step 1: Divide quantity by denominator
const onePart = quantity / den;
steps.push({
label: `Divide ${quantity} by ${den} to find one part`,
math: `${quantity} \\div ${den} = ${fmt(onePart)}`,
barFilled: 1 / den,
barLabel: fmt(onePart),
});
// Step 2: Multiply by numerator
const result = onePart * num;
steps.push({
label: `Multiply one part by ${num}`,
math: `${fmt(onePart)} \\times ${num} = ${fmt(result)}`,
barFilled: num / den,
barLabel: fmt(result),
});
steps.push({
label: `Answer: ${toKatexStr(num, den)} of ${quantity} = ${fmt(result)}`,
math: `${toKatex(num, den)} \\times ${quantity} = ${fmt(result)}`,
barFilled: num / den,
barLabel: fmt(result),
});
return steps;
}
function buildFindWholeSteps(num: number, den: number, part: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `If ${toKatexStr(num, den)} of the whole = ${part}, find the whole`,
math: `${toKatex(num, den)} \\text{ of ? } = ${part}`,
barFilled: num / den,
barLabel: `${part}`,
});
// Step 1: Find one part
const onePart = part / num;
steps.push({
label: `Divide ${part} by ${num} to find one ${den}th`,
math: `${part} \\div ${num} = ${fmt(onePart)}`,
barFilled: 1 / den,
barLabel: fmt(onePart),
});
// Step 2: Multiply by denominator
const whole = onePart * den;
steps.push({
label: `Multiply by ${den} to find the whole`,
math: `${fmt(onePart)} \\times ${den} = ${fmt(whole)}`,
barFilled: 1,
barLabel: fmt(whole),
});
steps.push({
label: `The whole is ${fmt(whole)}`,
math: `\\text{Whole} = ${fmt(whole)}`,
barFilled: 1,
barLabel: fmt(whole),
});
return steps;
}
function toKatexStr(n: number, d: number): string {
return `${n}/${d}`;
}
function fmt(n: number): string {
return Number.isInteger(n) ? n.toString() : n.toFixed(2);
}
function QuantityBar({ filled, label }: { filled: number; label?: string }) {
const pct = Math.max(0, Math.min(1, filled)) * 100;
return (
<div className="w-full max-w-md">
<div className="relative h-12 overflow-hidden rounded-xl border-2 border-border bg-background">
<div
className="flex h-full items-center justify-center bg-unit-1 text-sm font-bold text-white transition-all duration-700"
style={{ width: `${pct}%` }}
>
{label && pct > 15 ? label : ""}
</div>
</div>
{label && pct <= 15 && (
<p className="mt-1 text-center text-sm font-semibold text-unit-1">{label}</p>
)}
<div className="mt-1 flex justify-between text-xs text-muted">
<span>0</span>
<span>{Math.round(pct)}%</span>
</div>
</div>
);
}
export function FractionQuantityExplorer() {
const [mode, setMode] = useState<FQMode>("fractionOf");
const [num, setNum] = useState("3");
const [den, setDen] = useState("4");
const [quantity, setQuantity] = useState("80");
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;
function handleGo() {
setError("");
const n = parseInt(num);
const d = parseInt(den);
const q = parseFloat(quantity);
if (isNaN(n) || isNaN(d) || d === 0) {
setError("Enter a valid fraction (denominator ≠ 0).");
return;
}
if (isNaN(q) || q <= 0) {
setError("Enter a valid positive quantity.");
return;
}
try {
const s =
mode === "fractionOf"
? buildFractionOfSteps(n, d, q)
: buildFindWholeSteps(n, d, q);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setMode("fractionOf");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "fractionOf"
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
Fraction OF Quantity
</button>
<button
onClick={() => {
setMode("findWhole");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "findWhole"
? "border-unit-1 bg-unit-1 text-white"
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
}`}
>
Find the WHOLE
</button>
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
{mode === "fractionOf" ? "Enter a fraction and quantity" : "Enter a fraction and the part value"}
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-center">
<input
type="number"
value={num}
onChange={(e) => setNum(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Numerator"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={den}
onChange={(e) => setDen(e.target.value)}
className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label="Denominator"
/>
</div>
<span className="text-lg font-semibold text-muted">
{mode === "fractionOf" ? "of" : "= "}
</span>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-1"
aria-label={mode === "fractionOf" ? "Quantity" : "Part value"}
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<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-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<QuantityBar filled={step.barFilled} label={step.barLabel} />
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,401 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { simplifyRatio } from "@/lib/math/ratios";
import { gcd } from "@/lib/math/fractions";
type RatioMode = "divide" | "simplify" | "equivalent";
interface Step {
label: string;
math: string;
barSegments: { value: number; filled: boolean; label?: string }[];
}
const COLORS = ["var(--unit-4)", "var(--unit-1)", "var(--unit-3)", "var(--hint)"];
function buildDivideSteps(total: number, parts: number[]): Step[] {
const steps: Step[] = [];
const sum = parts.reduce((a, b) => a + b, 0);
// Step 0: Show the ratio and total
steps.push({
label: `Divide ${total} in the ratio ${parts.join(" : ")}`,
math: `${total} \\div (${parts.join(" : ")})`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Step 1: Sum the parts
steps.push({
label: `Add the ratio parts: ${parts.join(" + ")} = ${sum}`,
math: `${parts.join(" + ")} = ${sum} \\text{ total parts}`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Step 2: Find one part
const onePart = total / sum;
steps.push({
label: `Find one part: ${total} ÷ ${sum} = ${onePart}`,
math: `\\text{One part} = ${total} \\div ${sum} = ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)}`,
barSegments: parts.map((p) => ({ value: p, filled: false })),
});
// Steps 3+: Calculate each share
const shares: number[] = [];
parts.forEach((p, i) => {
const share = p * onePart;
shares.push(share);
steps.push({
label: `Part ${i + 1}: ${p} × ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
math: `${p} \\times ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`,
barSegments: parts.map((pp, j) => ({
value: pp,
filled: j <= i,
label: j <= i ? `${Number.isInteger(shares[j]) ? shares[j] : shares[j].toFixed(2)}` : undefined,
})),
});
});
// Final step: Show all shares
const shareStrs = shares.map((s) => (Number.isInteger(s) ? s.toString() : s.toFixed(2)));
steps.push({
label: `Result: ${shareStrs.join(", ")}`,
math: `${total} = ${shareStrs.join(" + ")}`,
barSegments: parts.map((pp, j) => ({
value: pp,
filled: true,
label: shareStrs[j],
})),
});
return steps;
}
function buildSimplifySteps(parts: number[]): Step[] {
const steps: Step[] = [];
steps.push({
label: `Simplify the ratio ${parts.join(" : ")}`,
math: parts.join(" : "),
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
// Find GCD
let g = parts[0];
for (let i = 1; i < parts.length; i++) g = gcd(g, parts[i]);
if (g === 1) {
steps.push({
label: "The ratio is already in its simplest form (GCD = 1)",
math: `\\text{GCD} = 1`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
} else {
steps.push({
label: `Find the GCD of all parts: ${g}`,
math: `\\text{GCD}(${parts.join(", ")}) = ${g}`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
const simplified = simplifyRatio(parts);
steps.push({
label: `Divide each part by ${g}`,
math: parts.map((p) => `${p} \\div ${g} = ${p / g}`).join(", \\quad "),
barSegments: simplified.map((p) => ({ value: p, filled: true })),
});
steps.push({
label: `Simplified ratio: ${simplified.join(" : ")}`,
math: `${parts.join(" : ")} = ${simplified.join(" : ")}`,
barSegments: simplified.map((p) => ({ value: p, filled: true })),
});
}
return steps;
}
function buildEquivalentSteps(parts: number[], multiplier: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Find an equivalent ratio: ${parts.join(" : ")} × ${multiplier}`,
math: `(${parts.join(" : ")}) \\times ${multiplier}`,
barSegments: parts.map((p) => ({ value: p, filled: true })),
});
const result = parts.map((p) => p * multiplier);
steps.push({
label: `Multiply each part by ${multiplier}`,
math: parts.map((p) => `${p} \\times ${multiplier} = ${p * multiplier}`).join(", \\quad "),
barSegments: result.map((p) => ({ value: p, filled: true })),
});
steps.push({
label: `Equivalent ratio: ${result.join(" : ")}`,
math: `${parts.join(" : ")} = ${result.join(" : ")}`,
barSegments: result.map((p) => ({ value: p, filled: true })),
});
return steps;
}
function RatioBar({
segments,
}: {
segments: { value: number; filled: boolean; label?: string }[];
}) {
const total = segments.reduce((s, seg) => s + seg.value, 0);
if (total === 0) return null;
return (
<div className="w-full max-w-lg">
<div className="flex h-14 overflow-hidden rounded-xl border-2 border-border">
{segments.map((seg, i) => (
<div
key={i}
className="relative flex items-center justify-center border-r border-white/40 text-sm font-bold text-white transition-all duration-500 last:border-r-0"
style={{
flex: seg.value,
backgroundColor: seg.filled ? COLORS[i % COLORS.length] : "transparent",
color: seg.filled ? "white" : "var(--foreground)",
}}
>
{seg.label || seg.value}
</div>
))}
</div>
<div className="mt-1 flex">
{segments.map((seg, i) => (
<div
key={i}
className="text-center text-xs text-muted"
style={{ flex: seg.value }}
>
{seg.label && `(${seg.value} parts)`}
</div>
))}
</div>
</div>
);
}
export function RatioExplorer() {
const [mode, setMode] = useState<RatioMode>("divide");
const [partA, setPartA] = useState("2");
const [partB, setPartB] = useState("3");
const [partC, setPartC] = useState("");
const [total, setTotal] = useState("60");
const [multiplier, setMultiplier] = useState("4");
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;
function getParts(): number[] {
const parts = [parseInt(partA), parseInt(partB)];
if (partC.trim()) parts.push(parseInt(partC));
return parts;
}
function handleGo() {
setError("");
const parts = getParts();
if (parts.some(isNaN) || parts.some((p) => p <= 0)) {
setError("Enter valid positive ratio parts.");
return;
}
try {
let s: Step[];
if (mode === "divide") {
const t = parseFloat(total);
if (isNaN(t) || t <= 0) {
setError("Enter a valid positive total.");
return;
}
s = buildDivideSteps(t, parts);
} else if (mode === "simplify") {
s = buildSimplifySteps(parts);
} else {
const m = parseInt(multiplier);
if (isNaN(m) || m <= 0) {
setError("Enter a valid positive multiplier.");
return;
}
s = buildEquivalentSteps(parts, m);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const modeLabels: Record<RatioMode, string> = {
divide: "Divide in Ratio",
simplify: "Simplify Ratio",
equivalent: "Equivalent Ratio",
};
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex flex-wrap gap-2">
{(["divide", "simplify", "equivalent"] as RatioMode[]).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-4 bg-unit-4 text-white"
: "border-unit-4/40 text-unit-4 hover:bg-unit-4-light"
}`}
>
{modeLabels[m]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-4">
{mode === "divide" ? "Enter ratio parts and total" : mode === "simplify" ? "Enter ratio parts" : "Enter ratio parts and multiplier"}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
value={partA}
onChange={(e) => setPartA(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part A"
/>
<span className="text-xl font-bold text-muted">:</span>
<input
type="number"
value={partB}
onChange={(e) => setPartB(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part B"
/>
<span className="text-xl font-bold text-muted">:</span>
<input
type="number"
value={partC}
onChange={(e) => setPartC(e.target.value)}
placeholder="?"
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Part C (optional)"
/>
{mode === "divide" && (
<>
<span className="text-sm font-medium text-muted">Total:</span>
<input
type="number"
value={total}
onChange={(e) => setTotal(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Total"
/>
</>
)}
{mode === "equivalent" && (
<>
<span className="text-sm font-medium text-muted">× </span>
<input
type="number"
value={multiplier}
onChange={(e) => setMultiplier(e.target.value)}
className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4"
aria-label="Multiplier"
/>
</>
)}
<button
onClick={handleGo}
className="rounded-lg bg-unit-4 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-4-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<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-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-xl" />
<RatioBar segments={step.barSegments} />
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<p className="text-2xl font-extrabold text-foreground max-sm:text-xl">
{steps[steps.length - 1].label.replace("Result: ", "").replace("Simplified ratio: ", "").replace("Equivalent ratio: ", "")}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,559 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type RoundTarget = "whole" | "1dp" | "2dp" | "1sf" | "2sf" | "3sf";
interface Step {
label: string;
digits: DigitInfo[];
resultText?: string;
}
interface DigitInfo {
char: string;
state: "normal" | "target" | "decider" | "dim" | "significant";
}
function getDigits(value: string): string[] {
return value.split("");
}
// ── Decimal place rounding steps ─────────────────────────────────────────────
function buildDPSteps(rawValue: string, target: "whole" | "1dp" | "2dp"): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const dotIndex = digits.indexOf(".");
const steps: Step[] = [];
function makeDigits(
targetIdx: number | null,
deciderIdx: number | null,
): DigitInfo[] {
return digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === targetIdx
? "target"
: i === deciderIdx
? "decider"
: "normal",
}));
}
let targetDigitIdx: number;
let roundedValue: number;
let targetLabel: string;
if (target === "whole") {
targetDigitIdx = dotIndex === -1 ? digits.length - 1 : dotIndex - 1;
roundedValue = Math.round(value);
targetLabel = "ones (whole number)";
} else if (target === "1dp") {
targetDigitIdx = dotIndex + 1;
roundedValue = Math.round(value * 10) / 10;
targetLabel = "1 decimal place (tenths)";
} else {
targetDigitIdx = dotIndex + 2;
roundedValue = Math.round(value * 100) / 100;
targetLabel = "2 decimal places (hundredths)";
}
// Compute decider index (skip over decimal point if needed)
const nextIdx = targetDigitIdx + 1;
const deciderIdx =
nextIdx < digits.length && digits[nextIdx] === "."
? nextIdx + 1
: nextIdx;
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${targetLabel}`,
digits: makeDigits(null, null),
});
// Step 1: Identify target
steps.push({
label: `Identify the target digit (${targetLabel})`,
digits: makeDigits(targetDigitIdx, null),
});
// Step 2: Identify decider
if (deciderIdx < digits.length) {
steps.push({
label: "Look at the digit to the right (the decider)",
digits: makeDigits(targetDigitIdx, deciderIdx),
});
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP`
: `Decider is ${deciderDigit} (< 5) → round DOWN`,
digits: makeDigits(targetDigitIdx, deciderIdx),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: makeDigits(targetDigitIdx, null),
});
}
// Step 3: Result
const resultStr =
target === "whole"
? roundedValue.toString()
: target === "1dp"
? roundedValue.toFixed(1)
: roundedValue.toFixed(2);
const resultDigits = getDigits(resultStr);
steps.push({
label: `Result: ${resultStr}`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
// ── Significant figures rounding steps ───────────────────────────────────────
function buildSFSteps(rawValue: string, sfCount: number): Step[] {
const value = parseFloat(rawValue);
const digits = getDigits(rawValue);
const steps: Step[] = [];
// Step 0: Show the number
steps.push({
label: `Rounding ${rawValue} to ${sfCount} significant figure${sfCount > 1 ? "s" : ""}`,
digits: digits.map((ch) => ({ char: ch, state: "normal" as const })),
});
// Step 1: Identify which digits are significant
// Rule: significant figures start at the first non-zero digit
const sigDigitIndices: number[] = [];
let foundFirstNonZero = false;
for (let i = 0; i < digits.length; i++) {
if (digits[i] === ".") continue;
if (!foundFirstNonZero && digits[i] === "0") continue;
foundFirstNonZero = true;
sigDigitIndices.push(i);
}
// Build display: highlight all significant digits found
const sigSet = new Set(sigDigitIndices);
steps.push({
label: `Identify significant figures — start from the first non-zero digit`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
});
// Count and label the sig figs we need
const sigLabel = sigDigitIndices.slice(0, sfCount).map((idx, n) => {
return `${ordinal(n + 1)} s.f. = ${digits[idx]}`;
});
const targetIdx = sigDigitIndices[sfCount - 1]; // the last sig fig we keep
if (targetIdx === undefined) {
// Not enough significant figures in the number
steps.push({
label: `The number only has ${sigDigitIndices.length} significant figure${sigDigitIndices.length !== 1 ? "s" : ""} — already rounded`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: sigSet.has(i)
? "significant"
: "dim",
})),
resultText: rawValue,
});
return steps;
}
// Step 2: Show which sig figs we're keeping
const keepSet = new Set(sigDigitIndices.slice(0, sfCount));
steps.push({
label: `Keep ${sfCount} sig fig${sfCount > 1 ? "s" : ""}: ${sigLabel.join(", ")}`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: sigSet.has(i)
? "normal"
: "dim",
})),
});
// Step 3: Identify the decider
const deciderCandidateIdx = sigDigitIndices[sfCount]; // next sig fig after the ones we keep
// But the decider could also be after a decimal point
let deciderIdx: number | null = null;
if (deciderCandidateIdx !== undefined) {
deciderIdx = deciderCandidateIdx;
} else {
// Look for any digit after the target
for (let i = targetIdx + 1; i < digits.length; i++) {
if (digits[i] !== ".") {
deciderIdx = i;
break;
}
}
}
if (deciderIdx !== null && deciderIdx < digits.length) {
steps.push({
label: `Look at the next digit: ${digits[deciderIdx]} (the decider)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
// Step 4: Round decision
const deciderDigit = parseInt(digits[deciderIdx]);
const roundsUp = deciderDigit >= 5;
steps.push({
label: roundsUp
? `Decider is ${deciderDigit} (≥ 5) → round UP the last kept digit`
: `Decider is ${deciderDigit} (< 5) → round DOWN (keep it the same)`,
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: i === deciderIdx
? "decider"
: keepSet.has(i)
? "target"
: "dim",
})),
});
} else {
steps.push({
label: "No digit to the right — no rounding needed",
digits: digits.map((ch, i) => ({
char: ch,
state:
ch === "."
? "normal"
: keepSet.has(i)
? "target"
: "dim",
})),
});
}
// Step 5: Compute and show result
let roundedValue: number;
if (value === 0) {
roundedValue = 0;
} else {
const magnitude = Math.floor(Math.log10(Math.abs(value)));
const factor = Math.pow(10, sfCount - 1 - magnitude);
roundedValue = Math.round(value * factor) / factor;
}
// Format result with correct sig figs (preserve trailing zeros)
const resultStr = formatSigFigs(roundedValue, sfCount);
const resultDigits = getDigits(resultStr);
// Step: explain what happens to remaining digits
const dotIdx = rawValue.indexOf(".");
const hasIntegerPart = dotIdx === -1 || dotIdx > 0;
if (hasIntegerPart && sigDigitIndices.length > sfCount) {
// Some trailing digits in the integer part get replaced with zeros
steps.push({
label: "Replace remaining digits with zeros (to keep the place value)",
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
});
}
steps.push({
label: `Result: ${resultStr} (${sfCount} significant figure${sfCount > 1 ? "s" : ""})`,
digits: resultDigits.map((ch) => ({
char: ch,
state: "normal" as const,
})),
resultText: resultStr,
});
return steps;
}
function formatSigFigs(value: number, sfCount: number): string {
if (value === 0) return "0";
const magnitude = Math.floor(Math.log10(Math.abs(value)));
if (magnitude >= sfCount - 1) {
// Integer result — no decimal point needed
return Math.round(value / Math.pow(10, magnitude - sfCount + 1))
* Math.pow(10, magnitude - sfCount + 1) + "";
}
// Decimal result — need to show enough places
const decimalPlaces = sfCount - 1 - magnitude;
return value.toFixed(decimalPlaces);
}
function ordinal(n: number): string {
if (n === 1) return "1st";
if (n === 2) return "2nd";
if (n === 3) return "3rd";
return `${n}th`;
}
// ── Main step builder ────────────────────────────────────────────────────────
function buildSteps(rawValue: string, target: RoundTarget): Step[] {
if (target === "whole" || target === "1dp" || target === "2dp") {
return buildDPSteps(rawValue, target);
}
const sfCount = target === "1sf" ? 1 : target === "2sf" ? 2 : 3;
return buildSFSteps(rawValue, sfCount);
}
// ── Digit box component ─────────────────────────────────────────────────────
function DigitBox({ char, state }: DigitInfo) {
if (char === ".") {
return (
<div className="flex h-[88px] w-6 items-end justify-center pb-2 max-sm:h-[70px]">
<div className="h-3 w-3 rounded-full bg-foreground" />
</div>
);
}
const styles: Record<DigitInfo["state"], string> = {
normal: "border-border bg-surface text-foreground",
target: "border-unit-2 bg-unit-2-light text-foreground ring-2 ring-unit-2/30",
decider: "border-hint bg-hint-light text-foreground ring-2 ring-hint/30",
significant: "border-unit-4 bg-unit-4-light text-foreground ring-2 ring-unit-4/30",
dim: "border-border/40 bg-background text-muted/30",
};
return (
<div
className={`flex h-[88px] w-[68px] shrink-0 select-none items-center justify-center rounded-xl border-2 text-[3.2rem] font-bold transition-all duration-300 max-sm:h-[70px] max-sm:w-[52px] max-sm:text-[2.4rem] ${styles[state]}`}
>
{char}
</div>
);
}
// ── Explorer component ──────────────────────────────────────────────────────
export function RoundingExplorer() {
const [input, setInput] = useState("3.4567");
const [target, setTarget] = useState<RoundTarget>("1dp");
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;
function handleGo() {
setError("");
const trimmed = input.trim();
const val = parseFloat(trimmed);
if (isNaN(val)) {
setError("Enter a valid number.");
return;
}
if (val < 0) {
setError("Enter a positive number.");
return;
}
if (trimmed.length > 16) {
setError("Number too long.");
return;
}
if (!trimmed.includes(".") && (target === "1dp" || target === "2dp")) {
setError("Enter a decimal number for decimal place rounding.");
return;
}
try {
const s = buildSteps(trimmed, target);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not process. 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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const targetLabels: Record<RoundTarget, string> = {
whole: "Whole Number",
"1dp": "1 d.p.",
"2dp": "2 d.p.",
"1sf": "1 s.f.",
"2sf": "2 s.f.",
"3sf": "3 s.f.",
};
return (
<div className="space-y-4">
{/* Target tabs */}
<div className="flex flex-wrap gap-2">
{(["whole", "1dp", "2dp", "1sf", "2sf", "3sf"] as RoundTarget[]).map((t) => (
<button
key={t}
onClick={() => {
setTarget(t);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
target === t
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
{targetLabels[t]}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
Enter a number
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 3.4567 or 0.00456"
maxLength={18}
autoComplete="off"
className="max-w-[240px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Round
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a number above and click <strong>Round</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<div className="flex flex-wrap items-end justify-center gap-2">
{step.digits.map((d, i) => (
<DigitBox key={i} {...d} />
))}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted">
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-2 bg-unit-2-light" />
Target digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-hint bg-hint-light" />
Decider digit
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-unit-4 bg-unit-4-light" />
Significant
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded border-2 border-border/40 bg-background" />
Not significant
</span>
</div>
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !step?.resultText ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Rounded Value</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{step.resultText}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,445 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
type Mode = "toSF" | "toOrd";
interface DisplayState {
digits: string[];
decimalPos: number;
initPos: number;
targetPos: number;
origPower: number;
}
function parseOrdinary(raw: string): { digits: string[]; decimalPos: number } {
const s = raw.trim().replace(/[\s,]/g, "");
if (s.startsWith("-")) throw new Error("Please enter a positive number.");
if (!/^\d*\.?\d+$/.test(s) || s === "")
throw new Error("Invalid number. Try something like 7438 or 0.0055");
if (s.length > 16) throw new Error("Number too long (max 16 digits).");
const dot = s.indexOf(".");
if (dot === -1) {
return { digits: s.split(""), decimalPos: s.length };
}
return {
digits: (s.slice(0, dot) + s.slice(dot + 1)).split(""),
decimalPos: dot,
};
}
function firstNonZero(digits: string[]): number {
return digits.findIndex((d) => d !== "0");
}
function sfTargetPos(digits: string[]): number {
const i = firstNonZero(digits);
return i === -1 ? 1 : i + 1;
}
function buildCoeff(sigDigs: string[]): string {
if (sigDigs.length === 0) return "0";
if (sigDigs.length === 1) return sigDigs[0] + ".0";
const rest = sigDigs.slice(1).join("").replace(/0+$/, "") || "0";
return sigDigs[0] + "." + rest;
}
function buildOrdinary(digits: string[], pos: number): string {
let s: string;
if (pos <= 0) {
s = "0." + "0".repeat(-pos) + digits.join("");
} else if (pos >= digits.length) {
s = digits.join("") + "0".repeat(pos - digits.length);
} else {
s = digits.slice(0, pos).join("") + "." + digits.slice(pos).join("");
}
s = s.replace(/^0+(?=[1-9])/, "");
if (s.startsWith(".")) s = "0" + s;
if (s.includes(".")) s = s.replace(/\.?0+$/, "");
return s || "0";
}
export function StandardFormExplorer() {
const [mode, setMode] = useState<Mode>("toSF");
const [ordinaryInput, setOrdinaryInput] = useState("");
const [coeffInput, setCoeffInput] = useState("");
const [powerInput, setPowerInput] = useState("");
const [error, setError] = useState("");
const [display, setDisplay] = useState<DisplayState | null>(null);
const [currentPos, setCurrentPos] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [done, setDone] = useState(false);
const numberRowRef = useRef<HTMLDivElement>(null);
const dotRef = useRef<HTMLDivElement>(null);
const [dotLeft, setDotLeft] = useState(0);
const [dotAnimate, setDotAnimate] = useState(false);
const [powerPop, setPowerPop] = useState(false);
const totalSteps = display ? Math.abs(display.targetPos - display.initPos) : 0;
const currentStep = display ? Math.abs(currentPos - display.initPos) : 0;
const currentPower = display
? mode === "toSF"
? display.initPos - currentPos
: display.origPower - (currentPos - display.initPos)
: 0;
// Position decimal dot
const placeDot = useCallback(
(animate: boolean) => {
if (!numberRowRef.current || !display) return;
const boxes = numberRowRef.current.querySelectorAll<HTMLDivElement>("[data-digit-box]");
if (!boxes.length) return;
let leftPx: number;
const dp = currentPos;
if (dp <= 0) {
leftPx = boxes[0].offsetLeft - 6;
} else if (dp >= boxes.length) {
const last = boxes[boxes.length - 1];
leftPx = last.offsetLeft + last.offsetWidth - 4;
} else {
const prev = boxes[dp - 1];
const next = boxes[dp];
leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2);
}
setDotAnimate(animate);
setDotLeft(leftPx);
},
[currentPos, display],
);
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(true));
});
}
}, [currentPos, display, placeDot]);
// Initial placement without animation
useEffect(() => {
if (display) {
requestAnimationFrame(() => {
requestAnimationFrame(() => placeDot(false));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [display?.digits.join("")]);
function initDisplay(digits: string[], start: number, target: number, origPower: number) {
setIsPlaying(false);
setDone(start === target);
setDisplay({ digits, decimalPos: start, initPos: start, targetPos: target, origPower });
setCurrentPos(start);
setError("");
}
function handleConvertToSF() {
setError("");
try {
const { digits, decimalPos } = parseOrdinary(ordinaryInput);
const target = sfTargetPos(digits);
initDisplay(digits, decimalPos, target, 0);
} catch (e) {
setError((e as Error).message);
}
}
function handleConvertToOrd() {
setError("");
try {
if (!coeffInput.trim()) throw new Error("Enter the coefficient A.");
const n = parseInt(powerInput, 10);
if (isNaN(n)) throw new Error("Enter the power n as a whole number.");
if (Math.abs(n) > 12) throw new Error("Power too large for display (max ±12).");
if (n === 0) throw new Error("Power is 0 — the number is already in ordinary form.");
const { digits } = parseOrdinary(coeffInput);
const A = parseFloat(coeffInput);
if (isNaN(A) || A < 1 || A >= 10)
throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffInput}).`);
const decimalPos = 1;
const expandedDigits = [...digits];
let startPos: number;
let targetPos: number;
if (n > 0) {
targetPos = decimalPos + n;
startPos = decimalPos;
while (expandedDigits.length < targetPos) expandedDigits.push("0");
} else {
const absN = Math.abs(n);
const zeros = Array(absN).fill("0");
expandedDigits.unshift(...zeros);
startPos = decimalPos + absN;
targetPos = startPos + n;
}
initDisplay(expandedDigits, startPos, targetPos, n);
} catch (e) {
setError((e as Error).message);
}
}
const stepForward = useCallback(() => {
if (!display || currentPos === display.targetPos) return;
const dir = display.targetPos > display.initPos ? 1 : -1;
const nextPos = currentPos + dir;
setCurrentPos(nextPos);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
if (nextPos === display.targetPos) {
setDone(true);
setIsPlaying(false);
}
}, [display, currentPos]);
const stepBack = useCallback(() => {
if (!display || currentPos === display.initPos) return;
if (done) setDone(false);
const dir = display.targetPos > display.initPos ? -1 : 1;
setCurrentPos((p) => p + dir);
setPowerPop(true);
setTimeout(() => setPowerPop(false), 300);
}, [display, currentPos, done]);
const togglePlay = useCallback(() => {
setIsPlaying((p) => !p);
}, []);
const reset = useCallback(() => {
setIsPlaying(false);
setDisplay(null);
setDone(false);
setError("");
}, []);
// Direction label
const remaining = display ? display.targetPos - currentPos : 0;
const dirText =
!display || done || remaining === 0
? null
: remaining < 0
? `← Moving left · ${Math.abs(remaining)} step${Math.abs(remaining) !== 1 ? "s" : ""} remaining`
: `→ Moving right · ${remaining} step${remaining !== 1 ? "s" : ""} remaining`;
// Result
const fz = display ? firstNonZero(display.digits) : 0;
return (
<div className="space-y-4">
{/* Mode Tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("toSF");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toSF"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Ordinary Standard Form
</button>
<button
onClick={() => {
setMode("toOrd");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "toOrd"
? "border-unit-2 bg-unit-2 text-white"
: "border-unit-2/40 text-unit-2 hover:bg-unit-2-light"
}`}
>
Standard Form Ordinary
</button>
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-2">
{mode === "toSF" ? "Enter an ordinary number" : "Enter a number in standard form A × 10ⁿ"}
</p>
{mode === "toSF" ? (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={ordinaryInput}
onChange={(e) => setOrdinaryInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToSF()}
placeholder="e.g. 7438 or 0.0055"
maxLength={18}
autoComplete="off"
className="max-w-[220px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleConvertToSF}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2.5">
<input
type="text"
value={coeffInput}
onChange={(e) => setCoeffInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="A e.g. 3.6"
maxLength={12}
autoComplete="off"
className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<span className="text-lg font-semibold">
× 10<sup>n</sup> where n =
</span>
<input
type="number"
value={powerInput}
onChange={(e) => setPowerInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()}
placeholder="e.g. 4 or 3"
autoComplete="off"
className="max-w-[110px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2"
/>
<button
onClick={handleConvertToOrd}
className="rounded-lg bg-unit-2 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-2-dark"
>
Convert
</button>
</div>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display Card */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6">
{!display ? (
<p className="text-muted/50">
Enter a number above and click <strong>Convert</strong>
</p>
) : (
<>
{/* Step info */}
<p className="text-sm text-muted">
{totalSteps === 0
? "Number is already in standard form position — power = 0"
: `Step ${currentStep} of ${totalSteps}`}
</p>
{/* Digit row */}
<div ref={numberRowRef} className="relative inline-flex items-end gap-2.5 px-4 pb-7 pt-2">
{display.digits.map((ch, i) => {
const isLeadingZero = i < fz && fz !== -1;
const isHighlight = i === display.targetPos - 1;
return (
<div
key={i}
data-digit-box
className={`flex h-[88px] w-[68px] shrink-0 select-none items-center justify-center rounded-xl border-2 text-[3.2rem] font-bold transition-all duration-300 max-sm:h-[70px] max-sm:w-[52px] max-sm:text-[2.4rem] ${
isLeadingZero
? "border-border/60 bg-background text-muted/30"
: isHighlight
? "border-unit-2 bg-unit-2-light text-foreground"
: "border-border bg-surface text-foreground"
}`}
>
{ch}
</div>
);
})}
{/* Decimal dot */}
<div
ref={dotRef}
className="absolute bottom-1.5 z-10 h-[18px] w-[18px] -translate-x-1/2 rounded-full bg-incorrect shadow-[0_2px_8px_rgba(239,68,68,0.45)]"
style={{
left: dotLeft,
transition: dotAnimate ? "left 0.52s cubic-bezier(0.34,1.56,0.64,1), opacity 0.3s" : "none",
opacity: done && currentPos >= display.digits.length ? 0 : 1,
}}
/>
</div>
{/* Direction label */}
{dirText && (
<p
className={`text-sm font-semibold ${
remaining < 0 ? "text-incorrect" : "text-hint"
}`}
>
{dirText}
</p>
)}
{/* Power counter */}
<div className="flex items-baseline gap-1.5 text-2xl text-muted">
<span>×</span>
<span className="font-bold text-foreground">10</span>
<sup
className={`inline-block min-w-[38px] text-2xl font-extrabold text-hint ${
powerPop ? "animate-bounce" : ""
}`}
>
{currentPower}
</sup>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!display && currentPos !== display.targetPos}
canStepBack={!!display && currentPos !== display.initPos}
/>
</Card>
{/* Result Card */}
<Card className="flex min-h-[90px] flex-col items-center justify-center gap-1.5 text-center">
{!display || !done ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : mode === "toSF" ? (
<>
<p className="text-xs uppercase tracking-wider text-muted">Standard Form</p>
<p className="text-3xl font-extrabold max-sm:text-2xl">
<span className="text-foreground">
{buildCoeff(display.digits.slice(fz === -1 ? 0 : fz))}
</span>
<span className="mx-2 text-muted">×</span>
<span className="text-foreground">10</span>
<sup className="text-xl text-incorrect">{display.initPos - display.targetPos}</sup>
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Ordinary Form</p>
<p className="text-3xl font-extrabold text-foreground max-sm:text-2xl">
{buildOrdinary(display.digits, display.targetPos)}
</p>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
interface StepControlsProps {
currentStep: number;
totalSteps: number;
isPlaying: boolean;
onStepForward: () => void;
onStepBack: () => void;
onTogglePlay: () => void;
onReset: () => void;
canStepForward: boolean;
canStepBack: boolean;
playIntervalMs?: number;
}
export function StepControls({
currentStep,
totalSteps,
isPlaying,
onStepForward,
onStepBack,
onTogglePlay,
onReset,
canStepForward,
canStepBack,
playIntervalMs = 950,
}: StepControlsProps) {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Auto-play timer
useEffect(() => {
if (isPlaying && canStepForward) {
timerRef.current = setInterval(() => {
onStepForward();
}, playIntervalMs);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isPlaying, canStepForward, onStepForward, playIntervalMs]);
// Keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return;
if (e.key === "ArrowRight" || e.key === " ") {
e.preventDefault();
if (canStepForward) onStepForward();
}
if (e.key === "ArrowLeft") {
e.preventDefault();
if (canStepBack) onStepBack();
}
if (e.key.toLowerCase() === "p") onTogglePlay();
if (e.key.toLowerCase() === "r") onReset();
},
[canStepForward, canStepBack, onStepForward, onStepBack, onTogglePlay, onReset],
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const progress = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 0;
return (
<div className="space-y-3">
{/* Progress bar */}
{totalSteps > 0 && (
<div className="mx-auto max-w-md">
<div className="mb-1.5 flex items-center justify-between text-xs text-muted">
<span>Step {currentStep} of {totalSteps}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-border/60">
<div
className="h-full rounded-full bg-gradient-to-r from-unit-1 to-unit-4 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Controls */}
<div className="flex flex-wrap items-center justify-center gap-2">
<button
onClick={onStepBack}
disabled={!canStepBack || isPlaying}
className="rounded-xl border border-border/60 bg-surface px-4 py-2.5 text-sm font-semibold text-foreground shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
&#9664; Back
</button>
<button
onClick={onTogglePlay}
disabled={!canStepForward && !isPlaying}
className="min-w-[110px] rounded-xl bg-correct px-5 py-2.5 text-sm font-semibold text-white shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
{isPlaying ? "⏸ Pause" : "▶ Play"}
</button>
<button
onClick={onStepForward}
disabled={!canStepForward || isPlaying}
className="rounded-xl bg-foreground px-5 py-2.5 text-sm font-semibold text-background shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
>
Next Step &#9654;
</button>
<button
onClick={onReset}
className="rounded-xl border border-incorrect/30 bg-surface px-4 py-2.5 text-sm font-semibold text-incorrect shadow-[var(--shadow-sm)] transition-all duration-200 hover:bg-incorrect hover:text-white hover:shadow-[var(--shadow-md)] hover:-translate-y-px"
>
&#8634; Reset
</button>
</div>
{/* Keyboard hint */}
<p className="text-center text-[0.7rem] text-muted/50">
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">Space</kbd>{" / "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">&#8594;</kbd>{" "}
next{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">&#8592;</kbd>{" "}
back{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">P</kbd>{" "}
play{" · "}
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">R</kbd>{" "}
reset
</p>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb" className="mb-6">
<ol className="flex flex-wrap items-center gap-1.5 text-sm">
<li>
<Link href="/" className="text-muted transition-colors hover:text-foreground">
Home
</Link>
</li>
{items.map((item, i) => (
<li key={i} className="flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-border" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{item.href ? (
<Link href={item.href} className="text-muted transition-colors hover:text-foreground">
{item.label}
</Link>
) : (
<span className="font-medium text-foreground">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,32 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="border-t border-border/60 bg-surface/95">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-unit-1 via-unit-2 to-unit-3 shadow-[var(--shadow-sm)]">
<span className="text-xs font-extrabold text-white">C</span>
</div>
<div>
<p className="text-sm font-extrabold text-[#17367d]">Cabrits Mathematics</p>
<p className="text-xs text-[#4c6290]">Learn it. Try it. Master it.</p>
</div>
</div>
<nav className="flex items-center gap-5 text-sm font-semibold text-muted">
<Link href="/lessons" className="transition-colors hover:text-foreground">
Lessons
</Link>
<Link href="/practice" className="transition-colors hover:text-foreground">
Practice
</Link>
</nav>
<p className="text-xs font-medium text-muted">
Portsmouth Secondary School
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link";
const navItems = [
{ href: "/", label: "Home", className: "bg-[#e0483d] hover:bg-[#cb3a30]" },
{ href: "/lessons", label: "Lessons", className: "bg-[#f08b24] hover:bg-[#d97706]" },
{ href: "/practice", label: "Practice", className: "bg-[#1faa59] hover:bg-[#168845]" },
];
export function Header() {
return (
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
<div className="mx-auto max-w-6xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<Link href="/" className="group flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">
<span className="text-lg font-extrabold">C</span>
</div>
<div className="rounded-md bg-white px-2.5 py-1 leading-none shadow-sm">
<p className="text-2xl font-extrabold tracking-tight text-[#17367d]">Cabrits</p>
<p className="text-xs font-bold uppercase tracking-[0.14em] text-[#3a5ea8]">Math Playground</p>
</div>
</Link>
<div className="flex items-center gap-2 text-xs font-bold sm:text-sm">
<span className="rounded-full bg-[#1a49b6] px-3 py-1">Ages 12-16</span>
<span className="rounded-full bg-[#1a49b6] px-3 py-1">Form 1 Term 2</span>
</div>
</div>
<nav className="flex flex-wrap gap-1 bg-[#1b49b5] px-2 pb-2 pt-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`rounded-sm px-4 py-2 text-sm font-extrabold text-white transition-colors ${item.className}`}
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
const unitColorMap = {
"unit-1": "text-unit-1-dark",
"unit-2": "text-unit-2-dark",
"unit-3": "text-unit-3-dark",
"unit-4": "text-unit-4-dark",
};
const unitDotColor = {
"unit-1": "bg-unit-1",
"unit-2": "bg-unit-2",
"unit-3": "bg-unit-3",
"unit-4": "bg-unit-4",
};
export function MobileNav() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="lg:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3.5 py-2 text-sm font-medium shadow-[var(--shadow-sm)] transition-all hover:shadow-[var(--shadow-md)]"
aria-label="Toggle navigation"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{isOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
Topics
</button>
{isOpen && (
<div className="absolute left-0 right-0 top-full z-40 max-h-[70vh] overflow-y-auto border-b border-border/60 bg-surface p-4 shadow-[var(--shadow-lg)]">
<Link
href="/lessons"
onClick={() => setIsOpen(false)}
className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => (
<div key={unit.slug} className="mb-4">
<p className={cn("mb-1.5 flex items-center gap-2 px-3 text-xs font-bold uppercase tracking-wider", unitColorMap[unit.color])}>
<span className={cn("h-2 w-2 rounded-full", unitDotColor[unit.color])} />
Unit {unit.number}: {unit.title}
</p>
<div className="space-y-0.5">
{unit.topics.map((topic) => {
const href = `/lessons/${unit.slug}/${topic.slug}`;
return (
<Link
key={topic.slug}
href={href}
onClick={() => setIsOpen(false)}
className={cn(
"block rounded-lg px-3 py-1.5 text-sm transition-colors",
pathname === href
? "bg-foreground text-background font-medium"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
{topic.shortTitle}
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
const unitColorMap = {
"unit-1": {
active: "bg-unit-1-light text-unit-1-dark border-unit-1/20",
dot: "bg-unit-1",
heading: "text-unit-1-dark",
hoverBg: "hover:bg-unit-1-light/50",
},
"unit-2": {
active: "bg-unit-2-light text-unit-2-dark border-unit-2/20",
dot: "bg-unit-2",
heading: "text-unit-2-dark",
hoverBg: "hover:bg-unit-2-light/50",
},
"unit-3": {
active: "bg-unit-3-light text-unit-3-dark border-unit-3/20",
dot: "bg-unit-3",
heading: "text-unit-3-dark",
hoverBg: "hover:bg-unit-3-light/50",
},
"unit-4": {
active: "bg-unit-4-light text-unit-4-dark border-unit-4/20",
dot: "bg-unit-4",
heading: "text-unit-4-dark",
hoverBg: "hover:bg-unit-4-light/50",
},
};
export function Sidebar() {
const pathname = usePathname();
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
const open = new Set<number>();
for (const unit of curriculum) {
if (pathname.includes(unit.slug)) {
open.add(unit.number);
}
}
if (open.size === 0) open.add(1);
return open;
});
function toggleUnit(num: number) {
setOpenUnits((prev) => {
const next = new Set(prev);
if (next.has(num)) next.delete(num);
else next.add(num);
return next;
});
}
return (
<aside className="hidden w-64 shrink-0 border-r border-border/60 bg-surface lg:block">
<nav className="sticky top-[4.125rem] h-[calc(100vh-4.125rem)] overflow-y-auto p-4">
<Link
href="/lessons"
className={cn(
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
pathname === "/lessons"
? "bg-foreground text-background shadow-[var(--shadow-sm)]"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => {
const colors = unitColorMap[unit.color];
const isOpen = openUnits.has(unit.number);
return (
<div key={unit.slug} className="mb-1">
<button
onClick={() => toggleUnit(unit.number)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold transition-all duration-200",
colors.heading,
colors.hoverBg,
)}
>
<span className="truncate">Unit {unit.number}: {unit.title}</span>
<svg
className={cn(
"h-4 w-4 shrink-0 transition-transform duration-200",
isOpen && "rotate-90",
)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{isOpen && (
<div className="ml-2 mt-0.5 space-y-0.5 border-l-2 border-border/40 pl-2">
{unit.topics.map((topic) => {
const href = `/lessons/${unit.slug}/${topic.slug}`;
const isActive = pathname === href;
return (
<Link
key={topic.slug}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
isActive
? cn(colors.active, "font-medium shadow-[var(--shadow-sm)]")
: "text-muted hover:text-foreground",
)}
>
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full transition-colors",
isActive ? colors.dot : "bg-border",
)}
/>
<span className="truncate">{topic.shortTitle}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import katex from "katex";
interface MathDisplayProps {
math: string;
className?: string;
}
export function MathDisplay({ math, className }: MathDisplayProps) {
const html = katex.renderToString(math, {
displayMode: true,
throwOnError: false,
});
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
export function MathInline({ math, className }: MathDisplayProps) {
const html = katex.renderToString(math, {
displayMode: false,
throwOnError: false,
});
return (
<span
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
interface FractionInputProps {
numerator: string;
denominator: string;
onNumeratorChange: (value: string) => void;
onDenominatorChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FractionInput({
numerator,
denominator,
onNumeratorChange,
onDenominatorChange,
disabled = false,
className,
}: FractionInputProps) {
return (
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={numerator}
onChange={(e) => onNumeratorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Numerator"
/>
<div className="h-0.5 w-16 bg-foreground" />
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={denominator}
onChange={(e) => onDenominatorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Denominator"
/>
</div>
);
}
interface DecimalInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
return (
<input
type="text"
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
className,
)}
placeholder="?"
aria-label="Answer"
/>
);
}
interface RatioInputProps {
parts: string[];
onChange: (index: number, value: string) => void;
disabled?: boolean;
className?: string;
}
export function RatioInput({ parts, onChange, disabled, className }: RatioInputProps) {
return (
<div className={cn("inline-flex items-center gap-1", className)}>
{parts.map((p, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-lg font-bold text-muted">:</span>}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={p}
onChange={(e) => onChange(i, e.target.value)}
disabled={disabled}
className="w-14 rounded-lg border border-border bg-surface px-2 py-2 text-center text-lg font-bold focus:border-unit-4 focus:outline-none"
placeholder="?"
aria-label={`Part ${i + 1}`}
/>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { MathProblem, Difficulty } from "@/lib/problems/types";
import { checkFractionAnswer, checkDecimalAnswer, checkRatioAnswer, checkIntegerAnswer, type AnswerResult } from "@/lib/math/validation";
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import katex from "katex";
interface PracticeSectionProps {
title: string;
generator: (difficulty: Difficulty) => MathProblem;
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
}
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
const activeDifficultyStyle: Record<UnitColor, string> = {
"unit-1": "bg-unit-1 text-white",
"unit-2": "bg-unit-2 text-white",
"unit-3": "bg-unit-3 text-white",
"unit-4": "bg-unit-4 text-white",
};
export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) {
const [difficulty, setDifficulty] = useState<Difficulty>(1);
const [problem, setProblem] = useState<MathProblem>(() => generator(1));
const [userAnswer, setUserAnswer] = useState<Record<string, string>>({});
const [result, setResult] = useState<AnswerResult | null>(null);
const [showHint, setShowHint] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
const [showSolution, setShowSolution] = useState(false);
const [score, setScore] = useState({ correct: 0, total: 0 });
const generateNew = useCallback((diff: Difficulty) => {
setProblem(generator(diff));
setUserAnswer({});
setResult(null);
setShowHint(false);
setHintIndex(0);
setShowSolution(false);
}, [generator]);
function checkAnswer() {
const answer = problem.answer;
let res: AnswerResult;
switch (answer.kind) {
case "fraction": {
const num = parseInt(userAnswer.numerator || "0");
const den = parseInt(userAnswer.denominator || "0");
if (isNaN(num) || isNaN(den)) {
res = { correct: false, message: "Enter valid numbers" };
} else {
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
}
break;
}
case "decimal": {
const val = parseFloat(userAnswer.value || "");
if (isNaN(val)) {
res = { correct: false, message: "Enter a valid number" };
} else {
res = checkDecimalAnswer(val, answer.value);
}
break;
}
case "integer": {
const val = parseInt(userAnswer.value || "");
if (isNaN(val)) {
res = { correct: false, message: "Enter a valid number" };
} else {
res = checkIntegerAnswer(val, answer.value);
}
break;
}
case "ratio": {
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
if (parts.some(isNaN)) {
res = { correct: false, message: "Enter valid ratio parts" };
} else {
res = checkRatioAnswer(parts, answer.parts);
}
break;
}
case "standardForm": {
const coeff = parseFloat(userAnswer.coefficient || "");
const exp = parseInt(userAnswer.exponent || "");
if (isNaN(coeff) || isNaN(exp)) {
res = { correct: false, message: "Enter valid numbers" };
} else if (
Math.abs(coeff - answer.coefficient) < 0.01 &&
exp === answer.exponent
) {
res = { correct: true, simplified: true };
} else {
res = { correct: false, message: "That's not quite right. Try again!" };
}
break;
}
}
setResult(res);
if (res.correct) {
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
} else {
setScore((s) => ({ ...s, total: s.total + 1 }));
}
}
function nextHint() {
if (hintIndex < problem.hints.length - 1) {
setHintIndex((i) => i + 1);
}
setShowHint(true);
}
const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true });
return (
<Card className="mt-8 border-2">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-extrabold">{title}</h3>
<div className="flex items-center gap-2 text-sm">
<span className="rounded-full bg-background px-2.5 py-1 font-bold text-muted">
{score.correct}/{score.total}
</span>
<div className="flex gap-1">
{([1, 2, 3] as Difficulty[]).map((d) => (
<button
key={d}
onClick={() => {
setDifficulty(d);
generateNew(d);
}}
className={`rounded-lg px-2.5 py-1 text-xs font-extrabold transition-colors ${
difficulty === d
? activeDifficultyStyle[unitColor]
: "bg-border/70 text-muted hover:bg-border hover:text-foreground"
}`}
>
L{d}
</button>
))}
</div>
</div>
</div>
{/* Problem */}
<div
className="mb-6 text-center text-xl"
dangerouslySetInnerHTML={{ __html: promptHtml }}
/>
{/* Input */}
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
{problem.answer.kind === "fraction" && (
<FractionInput
numerator={userAnswer.numerator || ""}
denominator={userAnswer.denominator || ""}
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
disabled={result?.correct === true}
/>
)}
{(problem.answer.kind === "decimal" || problem.answer.kind === "integer") && (
<DecimalInput
value={userAnswer.value || ""}
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
disabled={result?.correct === true}
/>
)}
{problem.answer.kind === "ratio" && (() => {
const ratioParts = (problem.answer as { kind: "ratio"; parts: number[] }).parts;
return (
<RatioInput
parts={
userAnswer.ratioParts
? JSON.parse(userAnswer.ratioParts)
: ratioParts.map(() => "")
}
onChange={(i, v) => {
const current = userAnswer.ratioParts
? JSON.parse(userAnswer.ratioParts)
: ratioParts.map(() => "");
current[i] = v;
setUserAnswer((a) => ({
...a,
ratioParts: JSON.stringify(current),
ratio: current.join(":"),
}));
}}
disabled={result?.correct === true}
/>
);
})()}
{problem.answer.kind === "standardForm" && (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="decimal"
value={userAnswer.coefficient || ""}
onChange={(e) => setUserAnswer((a) => ({ ...a, coefficient: e.target.value }))}
disabled={result?.correct === true}
className="w-16 rounded-lg border border-border bg-surface px-2 py-2 text-center font-bold focus:border-unit-2 focus:outline-none"
placeholder="?"
aria-label="Coefficient"
/>
<span className="text-lg font-bold">x 10</span>
<input
type="text"
inputMode="numeric"
value={userAnswer.exponent || ""}
onChange={(e) => setUserAnswer((a) => ({ ...a, exponent: e.target.value }))}
disabled={result?.correct === true}
className="w-12 -translate-y-2 rounded-lg border border-border bg-surface px-2 py-1 text-center text-sm font-bold focus:border-unit-2 focus:outline-none"
placeholder="?"
aria-label="Exponent"
/>
</div>
)}
</div>
{/* Actions */}
<div className="mb-4 flex flex-wrap justify-center gap-3">
{!result?.correct && (
<>
<Button variant={unitColor} size="sm" onClick={checkAnswer}>
Check Answer
</Button>
<Button variant="secondary" size="sm" onClick={nextHint}>
Hint
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
Show Solution
</Button>
</>
)}
{result?.correct && (
<Button variant={unitColor} size="sm" onClick={() => generateNew(difficulty)}>
Next Problem
</Button>
)}
</div>
{/* Feedback */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
result.correct
? result.simplified
? "bg-correct-light text-correct"
: "bg-hint-light text-hint"
: "bg-incorrect-light text-incorrect"
}`}
>
{result.correct
? result.simplified
? "Correct!"
: "Correct, but can you simplify further?"
: result.message}
</motion.div>
)}
</AnimatePresence>
{/* Hint */}
<AnimatePresence>
{showHint && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 rounded-xl bg-hint-light px-4 py-3 text-sm text-hint"
>
{problem.hints[hintIndex]}
</motion.div>
)}
</AnimatePresence>
{/* Solution */}
<AnimatePresence>
{showSolution && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="mt-3 space-y-2 rounded-xl bg-background p-4"
>
<p className="text-xs font-semibold uppercase text-muted">Solution</p>
{problem.steps.map((step, i) => (
<div key={i} className="flex gap-2 text-sm">
<span className="shrink-0 font-bold text-muted">{i + 1}.</span>
<span>{step.explanation}</span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</Card>
);
}

31
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
const variantStyles: Record<BadgeVariant, string> = {
default: "border border-border/70 bg-background text-muted",
"unit-1": "border border-unit-1/20 bg-unit-1-light text-unit-1-dark",
"unit-2": "border border-unit-2/20 bg-unit-2-light text-unit-2-dark",
"unit-3": "border border-unit-3/20 bg-unit-3-light text-unit-3-dark",
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
};
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-3 py-1 text-xs font-extrabold tracking-wide",
variantStyles[variant],
className,
)}
{...props}
>
{children}
</span>
);
}

51
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { cn } from "@/lib/utils";
import { type ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary: "bg-foreground text-background shadow-[var(--shadow-sm)] hover:bg-foreground/90 hover:shadow-[var(--shadow-lg)]",
secondary: "border-2 border-border/70 bg-surface text-foreground shadow-[var(--shadow-sm)] hover:bg-background hover:shadow-[var(--shadow-md)]",
ghost: "text-foreground hover:bg-border/30",
"unit-1": "bg-unit-1 text-white shadow-[var(--shadow-sm)] hover:bg-unit-1-dark hover:shadow-[var(--shadow-lg)]",
"unit-2": "bg-unit-2 text-white shadow-[var(--shadow-sm)] hover:bg-unit-2-dark hover:shadow-[var(--shadow-lg)]",
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
};
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-sm",
md: "px-5.5 py-2.5 text-base",
lg: "px-8 py-3.5 text-lg",
};
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-2xl font-extrabold tracking-wide transition-all duration-200",
"active:translate-y-0.5",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-unit-1",
"disabled:pointer-events-none disabled:opacity-50",
variantStyles[variant],
sizeStyles[size],
className,
)}
{...props}
>
{children}
</button>
);
}

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
hover?: boolean;
}
const accentStyles = {
"unit-1": "border-l-4 border-l-unit-1",
"unit-2": "border-l-4 border-l-unit-2",
"unit-3": "border-l-4 border-l-unit-3",
"unit-4": "border-l-4 border-l-unit-4",
};
export function Card({
accent,
hover = false,
className,
children,
...props
}: CardProps) {
return (
<div
className={cn(
"rounded-3xl border-2 border-border/60 bg-surface p-6",
"shadow-[var(--shadow-sm)]",
accent && accentStyles[accent],
hover && "transition-all duration-200 hover:-translate-y-1 hover:shadow-[var(--shadow-lg)]",
className,
)}
{...props}
>
{children}
</div>
);
}
export function CardHeader({
className,
children,
...props
}: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("mb-4", className)} {...props}>
{children}
</div>
);
}
export function CardTitle({
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-xl font-bold tracking-tight", className)}
{...props}
>
{children}
</h3>
);
}
export function CardDescription({
className,
children,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted", className)} {...props}>
{children}
</p>
);
}