394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|