Files
cabrits-math/components/explorers/fraction-operation-explorer.tsx
2026-03-01 18:50:29 -04:00

390 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}