Files
cabrits-math/components/explorers/conversion-explorer.tsx
2026-03-01 19:48:29 -04:00

347 lines
11 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 { simplify, toKatex, gcd } from "@/lib/math/fractions";
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
type ConvertMode = "decToFrac" | "fracToDec";
interface Step {
label: string;
math: string;
gridFilled?: number; // out of 100 for decimal grid
barFilled?: number;
barTotal?: number;
}
function buildDecToFracSteps(decimal: string): Step[] {
const steps: Step[] = [];
const val = parseFloat(decimal);
steps.push({
label: `Convert ${decimal} to a fraction`,
math: `${decimal} = \\;?`,
gridFilled: Math.round(val * 100),
});
// Count decimal places
const parts = decimal.split(".");
const decPlaces = parts[1]?.length || 0;
const denominator = Math.pow(10, decPlaces);
const numerator = Math.round(val * denominator);
steps.push({
label: `${decPlaces} decimal place${decPlaces !== 1 ? "s" : ""} → denominator is ${denominator}`,
math: `${decimal} = ${toKatex(numerator, denominator)}`,
gridFilled: Math.round(val * 100),
barFilled: numerator,
barTotal: denominator,
});
// Simplify
const [sn, sd] = simplify(numerator, denominator);
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
label: `Simplify by dividing by GCD = ${g}`,
math: `${toKatex(numerator, denominator)} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
}
steps.push({
label: `Result: ${decimal} = ${sn}/${sd}`,
math: `${decimal} = ${toKatex(sn, sd)}`,
gridFilled: Math.round(val * 100),
barFilled: sn,
barTotal: sd,
});
return steps;
}
function buildFracToDecSteps(num: number, den: number): Step[] {
const steps: Step[] = [];
steps.push({
label: `Convert ${num}/${den} to a decimal`,
math: `${toKatex(num, den)} = \\;?`,
barFilled: num,
barTotal: den,
});
// Step: Perform the division
const result = num / den;
const resultStr = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/0+$/, "");
steps.push({
label: `Divide numerator by denominator`,
math: `${num} \\div ${den} = ${resultStr}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
// Check for terminating vs repeating
let tempDen = den;
const [, simpDen] = simplify(num, den);
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;
while (tempDen % 5 === 0) tempDen /= 5;
const isTerminating = tempDen === 1;
steps.push({
label: isTerminating
? `This is a terminating decimal`
: `This is a recurring decimal`,
math: `${toKatex(num, den)} = ${resultStr}${!isTerminating ? "..." : ""}`,
barFilled: num,
barTotal: den,
gridFilled: Math.min(100, Math.round(result * 100)),
});
return steps;
}
function DecimalGrid({ filled }: { filled: number }) {
return (
<div className="grid grid-cols-10 gap-0.5 rounded-lg border-2 border-border p-1" style={{ width: "fit-content" }}>
{Array.from({ length: 100 }, (_, i) => (
<div
key={i}
className="h-4 w-4 rounded-sm transition-colors duration-300"
style={{
backgroundColor: i < filled ? "var(--unit-3)" : "var(--background)",
}}
/>
))}
</div>
);
}
function FractionBarSmall({ filled, total }: { filled: number; total: number }) {
const segments = Math.min(total, 20);
const fillCount = Math.min(filled, segments);
return (
<div className="flex h-8 w-full max-w-[240px] overflow-hidden rounded-lg border-2 border-border">
{Array.from({ length: segments }, (_, i) => (
<div
key={i}
className="border-r border-border/30 transition-colors duration-300 last:border-r-0"
style={{
flex: 1,
backgroundColor: i < fillCount ? "var(--unit-3)" : "transparent",
}}
/>
))}
</div>
);
}
export function ConversionExplorer() {
const [mode, setMode] = useState<ConvertMode>("decToFrac");
const [decInput, setDecInput] = useState("0.75");
const [numInput, setNumInput] = useState("3");
const [denInput, setDenInput] = useState("8");
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("");
try {
let s: Step[];
if (mode === "decToFrac") {
const val = parseStrictDecimal(decInput);
if (val === null || val < 0 || val >= 10) {
setError("Enter a valid decimal between 0 and 10.");
return;
}
s = buildDecToFracSteps(decInput.trim());
} else {
const n = parseStrictInt(numInput);
const d = parseStrictInt(denInput);
if (n === null || d === null || d === 0) {
setError("Enter a valid fraction (denominator ≠ 0).");
return;
}
s = buildFracToDecSteps(n, d);
}
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your input.");
}
}
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;
return (
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-2">
<button
onClick={() => {
setMode("decToFrac");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "decToFrac"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Decimal Fraction
</button>
<button
onClick={() => {
setMode("fracToDec");
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === "fracToDec"
? "border-unit-3 bg-unit-3 text-white"
: "border-unit-3/40 text-unit-3 hover:bg-unit-3-light"
}`}
>
Fraction Decimal
</button>
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-3">
{mode === "decToFrac" ? "Enter a decimal" : "Enter a fraction"}
</p>
<div className="flex flex-wrap items-center gap-2.5">
{mode === "decToFrac" ? (
<input
type="text"
value={decInput}
onChange={(e) => setDecInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleGo()}
placeholder="e.g. 0.75"
autoComplete="off"
className="max-w-[180px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3"
/>
) : (
<div className="flex flex-col items-center">
<input
type="number"
value={numInput}
onChange={(e) => setNumInput(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-3"
aria-label="Numerator"
/>
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
<input
type="number"
value={denInput}
onChange={(e) => setDenInput(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-3"
aria-label="Denominator"
/>
</div>
)}
<button
onClick={handleGo}
className="rounded-lg bg-unit-3 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-3-dark"
>
Convert
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display */}
<Card className="flex min-h-[260px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a value above and click <strong>Convert</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="flex flex-wrap items-center justify-center gap-6">
{step.gridFilled !== undefined && (
<div className="flex flex-col items-center gap-1">
<DecimalGrid filled={step.gridFilled} />
<span className="text-xs text-muted">{step.gridFilled}/100</span>
</div>
)}
{step.barFilled !== undefined && step.barTotal !== undefined && (
<div className="flex flex-col items-center gap-1">
<FractionBarSmall filled={step.barFilled} total={step.barTotal} />
<span className="text-xs text-muted">
{step.barFilled}/{step.barTotal}
</span>
</div>
)}
</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>
);
}