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