Files
cabrits-math/components/explorers/negative-fractions-explorer.tsx
2026-07-05 11:12:45 -04:00

394 lines
14 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, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { FractionInput } from "@/components/practice/fraction-input";
import { Fraction, lcm } from "@/lib/math/fractions";
import { checkFractionAnswer } from "@/lib/math/validation";
type Op = "+" | "-" | "\\times" | "\\div";
interface Step {
label: string;
math: string;
}
const OP_LABEL: Record<Op, string> = { "+": "+", "-": "", "\\times": "×", "\\div": "÷" };
/** Wrap a negative operand in parentheses so "a + (-b)" reads clearly. */
function operand(f: Fraction): string {
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
}
function signWord(f: Fraction): string {
return f.n < 0 ? "negative" : "positive";
}
function buildAddSubtractSteps(a: Fraction, b: Fraction, op: "+" | "-"): Step[] {
const opSym = op;
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${opSym} ${operand(b)}`,
},
];
const lcd = lcm(a.d, b.d);
let an = a.n;
let bn = b.n;
if (a.d !== b.d) {
an = a.n * (lcd / a.d);
bn = b.n * (lcd / b.d);
steps.push({
label: `The denominators are different. Find the common denominator: the LCM of ${a.d} and ${b.d} is ${lcd}.`,
math: `\\text{LCD} = ${lcd}`,
});
steps.push({
label: "Rewrite both fractions over the common denominator (the sign travels with the numerator).",
math: `${a.toSignedKatex()} = ${new Fraction(an, lcd).toSignedKatex()} \\quad ${b.toSignedKatex()} = ${new Fraction(bn, lcd).toSignedKatex()}`,
});
}
const rawNum = op === "+" ? an + bn : an - bn;
const bnShown = bn < 0 ? `\\left(${bn}\\right)` : `${bn}`;
steps.push({
label:
op === "+"
? "Add the numerators using the integer rules, keeping the common denominator."
: "Subtract the numerators using the integer rules (subtracting a negative adds), keeping the common denominator.",
math: `\\frac{${an} ${opSym} ${bnShown}}{${lcd}} = \\frac{${rawNum}}{${lcd}}`,
});
const raw = new Fraction(rawNum, lcd);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: `The answer is ${result.n < 0 ? "negative" : "positive"}.`,
math: `${a.toSignedKatex()} ${opSym} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
function buildMultiplyDivideSteps(a: Fraction, b: Fraction, op: "\\times" | "\\div"): Step[] {
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${op} ${operand(b)}`,
},
];
let working = b;
if (op === "\\div") {
working = new Fraction(b.d, b.n); // reciprocal keeps the sign
steps.push({
label: "Dividing by a fraction is the same as multiplying by its reciprocal — keep, change, flip.",
math: `${a.toSignedKatex()} \\times ${operand(working)}`,
});
}
const negatives = (a.n < 0 ? 1 : 0) + (working.n < 0 ? 1 : 0);
const resultNegative = negatives % 2 === 1;
steps.push({
label: `Decide the sign: ${signWord(a)} and ${signWord(working)}. ${
negatives === 1 ? "Different signs give a NEGATIVE answer." : "The signs match, so the answer is POSITIVE."
}`,
math: `(${a.n < 0 ? "-" : "+"}) \\times (${working.n < 0 ? "-" : "+"}) = (${resultNegative ? "-" : "+"})`,
});
const absNum = Math.abs(a.n) * Math.abs(working.n);
const den = a.d * working.d;
steps.push({
label: "Multiply the numerators, multiply the denominators, then apply the sign.",
math: `\\frac{${Math.abs(a.n)} \\times ${Math.abs(working.n)}}{${a.d} \\times ${working.d}} = ${new Fraction(resultNegative ? -absNum : absNum, den).toSignedKatex()}`,
});
const raw = new Fraction(resultNegative ? -absNum : absNum, den);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: "Final answer.",
math: `${a.toSignedKatex()} ${op} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
// ── Quick practice ───────────────────────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/** A proper-fraction numerator with a random sign: magnitude 1..den-1. */
function properSigned(den: number): number {
const mag = randInt(1, Math.max(1, den - 1));
return Math.random() < 0.5 ? -mag : mag;
}
interface PracticeProblem {
a: Fraction;
b: Fraction;
op: Op;
answer: Fraction;
}
function makeProblem(): PracticeProblem {
const op = (["+", "-", "\\times", "\\div"] as Op[])[randInt(0, 3)];
const d1 = randInt(2, 6);
const d2 = randInt(2, 6);
let n1 = properSigned(d1);
let n2 = properSigned(d2);
// This is the negative-fractions topic — make sure at least one operand is negative.
if (n1 > 0 && n2 > 0) {
if (Math.random() < 0.5) n1 = -n1;
else n2 = -n2;
}
const a = new Fraction(n1, d1);
const b = new Fraction(n2, d2);
let answer: Fraction;
if (op === "+") answer = a.plus(b).simplified();
else if (op === "-") answer = a.minus(b).simplified();
else if (op === "\\times") answer = a.times(b).simplified();
else answer = a.dividedBy(b).simplified();
return { a, b, op, answer };
}
function QuickPractice() {
const [problem, setProblem] = useState<PracticeProblem>(() => makeProblem());
const [num, setNum] = useState("");
const [den, setDen] = useState("");
const [status, setStatus] = useState<"solved" | "unsimplified" | "wrong" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const [missed, setMissed] = useState(false);
const [scored, setScored] = useState(false);
function check() {
const n = parseInt(num, 10);
const d = parseInt(den, 10);
if (isNaN(n) || isNaN(d)) return;
const res = checkFractionAnswer(n, d, problem.answer.n, problem.answer.d);
setStatus(res.status);
if (res.status === "wrong") {
setMissed(true);
} else if (res.status === "solved" && !scored) {
setScored(true);
setScore((s) => ({ correct: s.correct + (missed ? 0 : 1), total: s.total + 1 }));
}
}
function next() {
setProblem(makeProblem());
setNum("");
setDen("");
setStatus(null);
setMissed(false);
setScored(false);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">Quick Practice</p>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-sm text-muted">Work it out and give your answer in its simplest form.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<MathDisplay
math={`${problem.a.toSignedKatex()} ${problem.op} ${problem.b.n < 0 ? `\\left(${problem.b.toSignedKatex()}\\right)` : problem.b.toSignedKatex()} =`}
className="text-2xl"
/>
<FractionInput
numerator={num}
denominator={den}
onNumeratorChange={setNum}
onDenominatorChange={setDen}
onSubmit={status === "solved" ? next : check}
disabled={status === "solved"}
/>
{status === "solved" ? (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
) : (
<button onClick={check} className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-5-dark">
Check
</button>
)}
</div>
{status === "solved" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{status === "unsimplified" && (
<p className="text-center text-sm font-bold text-hint">Right value now write it in its simplest form.</p>
)}
{status === "wrong" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite. Watch the signs, then try again.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function NegativeFractionsExplorer() {
const [op, setOp] = useState<Op>("+");
const [n1, setN1] = useState("-1");
const [d1, setD1] = useState("2");
const [n2, setN2] = useState("3");
const [d2, setD2] = 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;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function handleGo() {
setError("");
const pn1 = parseInt(n1, 10);
const pd1 = parseInt(d1, 10);
const pn2 = parseInt(n2, 10);
const pd2 = parseInt(d2, 10);
if ([pn1, pd1, pn2, pd2].some((v) => isNaN(v))) return setError("Fill in every box with a whole number.");
if (pd1 === 0 || pd2 === 0) return setError("A denominator cannot be zero.");
if ([pn1, pd1, pn2, pd2].some((v) => Math.abs(v) > 20)) return setError("Keep the numbers between -20 and 20.");
if (op === "\\div" && pn2 === 0) return setError("You cannot divide by zero.");
const a = new Fraction(pn1, pd1);
const b = new Fraction(pn2, pd2);
setSteps(
op === "+" || op === "-"
? buildAddSubtractSteps(a, b, op)
: buildMultiplyDivideSteps(a, b, op),
);
setCurrentStep(0);
setIsPlaying(false);
}
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 step = steps ? steps[currentStep] : null;
const fieldClass =
"w-16 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-5";
return (
<div className="space-y-4">
{/* Operation selector */}
<div className="flex flex-wrap gap-2">
{(["+", "-", "\\times", "\\div"] as Op[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`h-11 w-14 rounded-full border-2 text-xl font-bold transition-colors ${
op === o
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
aria-label={OP_LABEL[o]}
>
{OP_LABEL[o]}
</button>
))}
</div>
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter two fractions (numerators can be negative)
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n1} onChange={(e) => setN1(e.target.value)} className={fieldClass} aria-label="First numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d1} onChange={(e) => setD1(e.target.value)} className={fieldClass} aria-label="First denominator" />
</div>
<span className="text-2xl font-bold text-muted">{OP_LABEL[op]}</span>
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n2} onChange={(e) => setN2(e.target.value)} className={fieldClass} aria-label="Second numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d2} onChange={(e) => setD2(e.target.value)} className={fieldClass} aria-label="Second denominator" />
</div>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter two fractions above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center 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>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}