Initial Commit
This commit is contained in:
401
components/explorers/ratio-explorer.tsx
Normal file
401
components/explorers/ratio-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user