306 lines
9.5 KiB
TypeScript
306 lines
9.5 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 { toKatex } from "@/lib/math/fractions";
|
||
|
||
type FQMode = "fractionOf" | "findWhole";
|
||
|
||
interface Step {
|
||
label: string;
|
||
math: string;
|
||
barFilled: number; // 0–1 proportion
|
||
barLabel?: string;
|
||
}
|
||
|
||
function buildFractionOfSteps(num: number, den: number, quantity: number): Step[] {
|
||
const steps: Step[] = [];
|
||
|
||
steps.push({
|
||
label: `Find ${toKatexStr(num, den)} of ${quantity}`,
|
||
math: `${toKatex(num, den)} \\text{ of } ${quantity}`,
|
||
barFilled: 0,
|
||
});
|
||
|
||
// Step 1: Divide quantity by denominator
|
||
const onePart = quantity / den;
|
||
steps.push({
|
||
label: `Divide ${quantity} by ${den} to find one part`,
|
||
math: `${quantity} \\div ${den} = ${fmt(onePart)}`,
|
||
barFilled: 1 / den,
|
||
barLabel: fmt(onePart),
|
||
});
|
||
|
||
// Step 2: Multiply by numerator
|
||
const result = onePart * num;
|
||
steps.push({
|
||
label: `Multiply one part by ${num}`,
|
||
math: `${fmt(onePart)} \\times ${num} = ${fmt(result)}`,
|
||
barFilled: num / den,
|
||
barLabel: fmt(result),
|
||
});
|
||
|
||
steps.push({
|
||
label: `Answer: ${toKatexStr(num, den)} of ${quantity} = ${fmt(result)}`,
|
||
math: `${toKatex(num, den)} \\times ${quantity} = ${fmt(result)}`,
|
||
barFilled: num / den,
|
||
barLabel: fmt(result),
|
||
});
|
||
|
||
return steps;
|
||
}
|
||
|
||
function buildFindWholeSteps(num: number, den: number, part: number): Step[] {
|
||
const steps: Step[] = [];
|
||
|
||
steps.push({
|
||
label: `If ${toKatexStr(num, den)} of the whole = ${part}, find the whole`,
|
||
math: `${toKatex(num, den)} \\text{ of ? } = ${part}`,
|
||
barFilled: num / den,
|
||
barLabel: `${part}`,
|
||
});
|
||
|
||
// Step 1: Find one part
|
||
const onePart = part / num;
|
||
steps.push({
|
||
label: `Divide ${part} by ${num} to find one ${den}th`,
|
||
math: `${part} \\div ${num} = ${fmt(onePart)}`,
|
||
barFilled: 1 / den,
|
||
barLabel: fmt(onePart),
|
||
});
|
||
|
||
// Step 2: Multiply by denominator
|
||
const whole = onePart * den;
|
||
steps.push({
|
||
label: `Multiply by ${den} to find the whole`,
|
||
math: `${fmt(onePart)} \\times ${den} = ${fmt(whole)}`,
|
||
barFilled: 1,
|
||
barLabel: fmt(whole),
|
||
});
|
||
|
||
steps.push({
|
||
label: `The whole is ${fmt(whole)}`,
|
||
math: `\\text{Whole} = ${fmt(whole)}`,
|
||
barFilled: 1,
|
||
barLabel: fmt(whole),
|
||
});
|
||
|
||
return steps;
|
||
}
|
||
|
||
function toKatexStr(n: number, d: number): string {
|
||
return `${n}/${d}`;
|
||
}
|
||
|
||
function fmt(n: number): string {
|
||
return Number.isInteger(n) ? n.toString() : n.toFixed(2);
|
||
}
|
||
|
||
function QuantityBar({ filled, label }: { filled: number; label?: string }) {
|
||
const pct = Math.max(0, Math.min(1, filled)) * 100;
|
||
return (
|
||
<div className="w-full max-w-md">
|
||
<div className="relative h-12 overflow-hidden rounded-xl border-2 border-border bg-background">
|
||
<div
|
||
className="flex h-full items-center justify-center bg-unit-1 text-sm font-bold text-white transition-all duration-700"
|
||
style={{ width: `${pct}%` }}
|
||
>
|
||
{label && pct > 15 ? label : ""}
|
||
</div>
|
||
</div>
|
||
{label && pct <= 15 && (
|
||
<p className="mt-1 text-center text-sm font-semibold text-unit-1">{label}</p>
|
||
)}
|
||
<div className="mt-1 flex justify-between text-xs text-muted">
|
||
<span>0</span>
|
||
<span>{Math.round(pct)}%</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FractionQuantityExplorer() {
|
||
const [mode, setMode] = useState<FQMode>("fractionOf");
|
||
const [num, setNum] = useState("3");
|
||
const [den, setDen] = useState("4");
|
||
const [quantity, setQuantity] = useState("80");
|
||
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("");
|
||
const n = parseInt(num);
|
||
const d = parseInt(den);
|
||
const q = parseFloat(quantity);
|
||
if (isNaN(n) || isNaN(d) || d === 0) {
|
||
setError("Enter a valid fraction (denominator ≠ 0).");
|
||
return;
|
||
}
|
||
if (isNaN(q) || q <= 0) {
|
||
setError("Enter a valid positive quantity.");
|
||
return;
|
||
}
|
||
try {
|
||
const s =
|
||
mode === "fractionOf"
|
||
? buildFractionOfSteps(n, d, q)
|
||
: buildFindWholeSteps(n, d, q);
|
||
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;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Mode tabs */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setMode("fractionOf");
|
||
reset();
|
||
}}
|
||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||
mode === "fractionOf"
|
||
? "border-unit-1 bg-unit-1 text-white"
|
||
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
|
||
}`}
|
||
>
|
||
Fraction OF Quantity
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setMode("findWhole");
|
||
reset();
|
||
}}
|
||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||
mode === "findWhole"
|
||
? "border-unit-1 bg-unit-1 text-white"
|
||
: "border-unit-1/40 text-unit-1 hover:bg-unit-1-light"
|
||
}`}
|
||
>
|
||
Find the WHOLE
|
||
</button>
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<Card>
|
||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
|
||
{mode === "fractionOf" ? "Enter a fraction and quantity" : "Enter a fraction and the part value"}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="flex flex-col items-center">
|
||
<input
|
||
type="number"
|
||
value={num}
|
||
onChange={(e) => setNum(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-1"
|
||
aria-label="Numerator"
|
||
/>
|
||
<div className="my-0.5 h-0.5 w-12 bg-foreground" />
|
||
<input
|
||
type="number"
|
||
value={den}
|
||
onChange={(e) => setDen(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-1"
|
||
aria-label="Denominator"
|
||
/>
|
||
</div>
|
||
<span className="text-lg font-semibold text-muted">
|
||
{mode === "fractionOf" ? "of" : "= "}
|
||
</span>
|
||
<input
|
||
type="number"
|
||
value={quantity}
|
||
onChange={(e) => setQuantity(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && handleGo()}
|
||
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-1"
|
||
aria-label={mode === "fractionOf" ? "Quantity" : "Part value"}
|
||
/>
|
||
<button
|
||
onClick={handleGo}
|
||
className="rounded-lg bg-unit-1 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-1-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-2xl" />
|
||
<QuantityBar filled={step.barFilled} label={step.barLabel} />
|
||
</>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|