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

402 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 { 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>
);
}