Initial Commit
This commit is contained in:
338
components/explorers/bodmas-explorer.tsx
Normal file
338
components/explorers/bodmas-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
components/explorers/conversion-explorer.tsx
Normal file
345
components/explorers/conversion-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
components/explorers/decimal-arithmetic-explorer.tsx
Normal file
403
components/explorers/decimal-arithmetic-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
311
components/explorers/decimal-order-explorer.tsx
Normal file
311
components/explorers/decimal-order-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
389
components/explorers/fraction-operation-explorer.tsx
Normal file
389
components/explorers/fraction-operation-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
components/explorers/fraction-quantity-explorer.tsx
Normal file
305
components/explorers/fraction-quantity-explorer.tsx
Normal 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; // 0–1 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>
|
||||
);
|
||||
}
|
||||
401
components/explorers/ratio-explorer.tsx
Normal file
401
components/explorers/ratio-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
559
components/explorers/rounding-explorer.tsx
Normal file
559
components/explorers/rounding-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
445
components/explorers/standard-form-explorer.tsx
Normal file
445
components/explorers/standard-form-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
components/explorers/step-controls.tsx
Normal file
134
components/explorers/step-controls.tsx
Normal 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)]"
|
||||
>
|
||||
◀ 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 ▶
|
||||
</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"
|
||||
>
|
||||
↺ 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]">→</kbd>{" "}
|
||||
next{" · "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">←</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>
|
||||
);
|
||||
}
|
||||
38
components/layout/breadcrumbs.tsx
Normal file
38
components/layout/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
components/layout/footer.tsx
Normal file
32
components/layout/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
components/layout/header.tsx
Normal file
42
components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
components/layout/mobile-nav.tsx
Normal file
89
components/layout/mobile-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
components/layout/sidebar.tsx
Normal file
142
components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/math/math-display.tsx
Normal file
36
components/math/math-display.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
components/practice/fraction-input.tsx
Normal file
104
components/practice/fraction-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
components/practice/practice-section.tsx
Normal file
308
components/practice/practice-section.tsx
Normal 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
31
components/ui/badge.tsx
Normal 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
51
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user