Initial Commit
This commit is contained in:
345
components/explorers/conversion-explorer.tsx
Normal file
345
components/explorers/conversion-explorer.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
"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";
|
||||
|
||||
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 = parseFloat(decInput);
|
||||
if (isNaN(val) || val < 0 || val >= 10) {
|
||||
setError("Enter a valid decimal between 0 and 10.");
|
||||
return;
|
||||
}
|
||||
s = buildDecToFracSteps(decInput.trim());
|
||||
} else {
|
||||
const n = parseInt(numInput);
|
||||
const d = parseInt(denInput);
|
||||
if (isNaN(n) || isNaN(d) || 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user