Initial Commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user