form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View File

@@ -0,0 +1,393 @@
"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>
);
}