402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|