Files
cabrits-math/components/explorers/bodmas-explorer.tsx
2026-03-01 18:50:29 -04:00

339 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
interface FractionVal {
num: number;
den: number;
}
interface Expression {
fractions: FractionVal[];
operators: string[];
}
interface Step {
label: string;
math: string;
highlightIndex: number | null; // which operator is being evaluated
}
const OP_PRIORITY: Record<string, number> = {
"×": 2,
"÷": 2,
"+": 1,
"": 1,
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
let result: [number, number];
switch (op) {
case "+":
result = add(a.num, a.den, b.num, b.den);
break;
case "":
result = subtract(a.num, a.den, b.num, b.den);
break;
case "×":
result = multiply(a.num, a.den, b.num, b.den);
break;
case "÷":
result = divide(a.num, a.den, b.num, b.den);
break;
default:
result = [0, 1];
}
return { num: result[0], den: result[1] };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
const parts: string[] = [];
fracs.forEach((f, i) => {
parts.push(toKatex(f.num, f.den));
if (i < ops.length) {
if (i === highlightIdx) {
parts.push(`\\;\\boxed{${ops[i]}}\\;`);
} else {
parts.push(`\\;${ops[i]}\\;`);
}
}
});
return parts.join("");
}
function buildBodmasSteps(expr: Expression): Step[] {
const steps: Step[] = [];
let fracs = [...expr.fractions];
let ops = [...expr.operators];
// Step 0: Show the expression
steps.push({
label: "Start with the expression",
math: expressionToKatex(fracs, ops, null),
highlightIndex: null,
});
while (ops.length > 0) {
// Find highest priority operator (leftmost if tie)
let maxPri = 0;
let opIdx = 0;
ops.forEach((op, i) => {
if (OP_PRIORITY[op] > maxPri) {
maxPri = OP_PRIORITY[op];
opIdx = i;
}
});
// Highlight the operation to perform
steps.push({
label: `BODMAS: do ${ops[opIdx]} first (${OP_PRIORITY[ops[opIdx]] === 2 ? "multiplication/division" : "addition/subtraction"})`,
math: expressionToKatex(fracs, ops, opIdx),
highlightIndex: opIdx,
});
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
const [sn, sd] = simplify(result.num, result.den);
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });
const newOps = [...ops];
newOps.splice(opIdx, 1);
steps.push({
label: `${fracs[opIdx].num}/${fracs[opIdx].den} ${ops[opIdx]} ${fracs[opIdx + 1].num}/${fracs[opIdx + 1].den} = ${sn}/${sd}`,
math: expressionToKatex(newFracs, newOps, null),
highlightIndex: null,
});
fracs = newFracs;
ops = newOps;
}
return steps;
}
const PRESETS: { label: string; fracs: FractionVal[]; ops: string[] }[] = [
{
label: "½ + ¼ × ⅗",
fracs: [
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
],
ops: ["+", "×"],
},
{
label: "⅔ × ¾ ½",
fracs: [
{ num: 2, den: 3 },
{ num: 3, den: 4 },
{ num: 1, den: 2 },
],
ops: ["×", ""],
},
{
label: "⅗ ÷ ¼ + ⅔",
fracs: [
{ num: 3, den: 5 },
{ num: 1, den: 4 },
{ num: 2, den: 3 },
],
ops: ["÷", "+"],
},
];
export function BODMASExplorer() {
const [fracs, setFracs] = useState<FractionVal[]>([
{ num: 1, den: 2 },
{ num: 1, den: 4 },
{ num: 3, den: 5 },
]);
const [ops, setOps] = useState<string[]>(["+", "×"]);
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 updateFrac(idx: number, field: "num" | "den", value: string) {
setFracs((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: parseInt(value) || 0 };
return next;
});
}
function handleGo() {
setError("");
if (fracs.some((f) => f.den === 0)) {
setError("Denominators cannot be zero.");
return;
}
if (fracs.some((f) => Math.abs(f.num) > 99 || Math.abs(f.den) > 99)) {
setError("Keep numbers under 100.");
return;
}
try {
const s = buildBodmasSteps({ fractions: fracs, operators: ops });
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
function loadPreset(idx: number) {
const p = PRESETS[idx];
setFracs([...p.fracs]);
setOps([...p.ops]);
reset();
}
function stepForward() {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}
function stepBack() {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}
function togglePlay() {
setIsPlaying((p) => !p);
}
function reset() {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}
const step = steps ? steps[currentStep] : null;
const opChoices = ["+", "", "×", "÷"];
return (
<div className="space-y-4">
{/* Presets */}
<div className="flex flex-wrap gap-2">
{PRESETS.map((p, i) => (
<button
key={i}
onClick={() => loadPreset(i)}
className="rounded-full border-2 border-unit-1/40 px-4 py-2 text-sm font-semibold text-unit-1 transition-colors hover:bg-unit-1-light"
>
{p.label}
</button>
))}
</div>
{/* Input */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-1">
Enter a BODMAS expression with fractions
</p>
<div className="flex flex-wrap items-center gap-2">
{fracs.map((f, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col items-center">
<input
type="number"
value={f.num || ""}
onChange={(e) => updateFrac(i, "num", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Numerator ${i + 1}`}
/>
<div className="my-0.5 h-0.5 w-10 bg-foreground" />
<input
type="number"
value={f.den || ""}
onChange={(e) => updateFrac(i, "den", e.target.value)}
className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1"
aria-label={`Denominator ${i + 1}`}
/>
</div>
{i < ops.length && (
<select
value={ops[i]}
onChange={(e) => {
const next = [...ops];
next[i] = e.target.value;
setOps(next);
}}
className="rounded-lg border-2 border-border bg-surface px-2 py-2 text-center text-lg font-bold text-foreground outline-none focus:border-unit-1"
>
{opChoices.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
</div>
))}
<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-[200px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter an expression 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" />
</>
)}
</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">Final Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
</div>
);
}