Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

View File

@@ -0,0 +1,401 @@
"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>
);
}