Files
cabrits-math/components/explorers/integer-add-subtract-explorer.tsx
2026-03-26 08:50:17 -04:00

594 lines
20 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 { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "number-line" | "rules";
interface Step {
label: string;
math: string;
numberLineHighlight?: {
start: number;
end: number;
direction: "left" | "right";
moves: number;
};
}
function buildAddSubtractSteps(a: number, b: number, op: "add" | "subtract"): Step[] {
const steps: Step[] = [];
const opSymbol = op === "add" ? "+" : "-";
const expression = `${a} ${opSymbol} ${b < 0 ? `(${b})` : b}`;
// Step 0: Show the original expression
steps.push({
label: "Start with the expression",
math: expression,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (op === "add") {
// Adding
steps.push({
label: `Place your finger on ${a} on the number line`,
math: `\\text{Start at } ${a}`,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (b >= 0) {
steps.push({
label: `Adding a positive number: move ${b} steps to the RIGHT`,
math: `${a} + ${b} \\longrightarrow \\text{move right } ${b}`,
numberLineHighlight: { start: a, end: a + b, direction: "right", moves: b },
});
} else {
steps.push({
label: `Adding a negative number: move ${Math.abs(b)} steps to the LEFT`,
math: `${a} + (${b}) \\longrightarrow \\text{move left } ${Math.abs(b)}`,
numberLineHighlight: { start: a, end: a + b, direction: "left", moves: Math.abs(b) },
});
}
const result = a + b;
steps.push({
label: `You land on ${result}`,
math: `${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: b >= 0 ? "right" : "left", moves: Math.abs(b) },
});
// Sign rule explanation
if (a >= 0 && b >= 0) {
steps.push({
label: "Rule: Positive + Positive = Positive",
math: `(+) + (+) = (+) \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "right", moves: Math.abs(b) },
});
} else if (a < 0 && b < 0) {
steps.push({
label: "Rule: Negative + Negative = Negative (add magnitudes, keep negative sign)",
math: `(-) + (-) = (-) \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "left", moves: Math.abs(b) },
});
} else {
steps.push({
label: "Rule: Different signs? Subtract the smaller from the larger, keep the sign of the larger",
math: `|${Math.abs(a)}| - |${Math.abs(b)}| = ${Math.abs(Math.abs(a) - Math.abs(b))} \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: result >= a ? "right" : "left", moves: Math.abs(b) },
});
}
} else {
// Subtracting
steps.push({
label: `Place your finger on ${a} on the number line`,
math: `\\text{Start at } ${a}`,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (b >= 0) {
steps.push({
label: `Subtracting a positive number: move ${b} steps to the LEFT`,
math: `${a} - ${b} \\longrightarrow \\text{move left } ${b}`,
numberLineHighlight: { start: a, end: a - b, direction: "left", moves: b },
});
} else {
steps.push({
label: `Subtracting a negative number is the same as ADDING a positive! Move ${Math.abs(b)} steps RIGHT`,
math: `${a} - (${b}) = ${a} + ${Math.abs(b)} \\longrightarrow \\text{move right } ${Math.abs(b)}`,
numberLineHighlight: { start: a, end: a - b, direction: "right", moves: Math.abs(b) },
});
}
const result = a - b;
steps.push({
label: `You land on ${result}`,
math: `${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: result >= a ? "right" : "left", moves: Math.abs(b) },
});
// Two like/unlike signs rule
if (b >= 0) {
steps.push({
label: "Two unlike signs (+ -) becomes negative: subtract means move left",
math: `+(-)\\text{ becomes } - \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "left", moves: Math.abs(b) },
});
} else {
steps.push({
label: "Two like signs (- -) becomes positive: subtracting a negative means move right!",
math: `-(-) \\text{ becomes } + \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "right", moves: Math.abs(b) },
});
}
}
return steps;
}
function NumberLine({
highlight,
animate,
}: {
highlight?: Step["numberLineHighlight"];
animate: boolean;
}) {
const minVal = -15;
const maxVal = 15;
const range = maxVal - minVal;
const start = highlight?.start ?? 0;
const end = highlight?.end ?? 0;
const startX = ((start - minVal) / range) * 100;
const endX = ((end - minVal) / range) * 100;
const leftX = Math.min(startX, endX);
const width = Math.abs(endX - startX);
return (
<div className="mx-auto w-full max-w-2xl select-none">
<div className="relative h-24 px-4">
{/* Arrow path highlight */}
{highlight && highlight.moves > 0 && (
<div
className="absolute top-5 h-3 rounded-full transition-all duration-700"
style={{
left: `calc(${leftX}% + 16px)`,
width: `${width}%`,
backgroundColor: highlight.direction === "right"
? "rgba(124, 58, 237, 0.25)"
: "rgba(220, 38, 38, 0.25)",
}}
/>
)}
{/* Direction arrow */}
{highlight && highlight.moves > 0 && (
<div
className="absolute top-1 text-xs font-bold transition-all duration-500"
style={{
left: `calc(${(startX + endX) / 2}% + 16px)`,
transform: "translateX(-50%)",
color: highlight.direction === "right" ? "var(--unit-5)" : "var(--incorrect)",
}}
>
{highlight.direction === "right" ? "→ right →" : "← left ←"}
<div className="text-center text-[10px] font-semibold opacity-70">
{highlight.moves} step{highlight.moves !== 1 ? "s" : ""}
</div>
</div>
)}
{/* Number line */}
<div className="absolute bottom-8 left-4 right-4">
<div className="h-0.5 bg-foreground/40" />
{/* Tick marks */}
{Array.from({ length: range + 1 }, (_, i) => {
const val = minVal + i;
const x = (i / range) * 100;
const isZero = val === 0;
const isHighlighted = highlight && (val === start || val === end);
const isInRange = highlight && highlight.moves > 0 &&
val >= Math.min(start, end) && val <= Math.max(start, end);
return (
<div
key={val}
className="absolute -translate-x-1/2"
style={{ left: `${x}%` }}
>
<div
className={`h-3 w-0.5 -translate-y-1.5 transition-colors duration-300 ${
isHighlighted
? "bg-unit-5 h-4"
: isZero
? "bg-foreground"
: isInRange
? "bg-unit-5/50"
: "bg-foreground/30"
}`}
/>
{(val % 5 === 0 || isHighlighted) && (
<span
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
isHighlighted
? "text-xs font-extrabold text-unit-5"
: isZero
? "font-bold text-foreground"
: "text-muted/70"
}`}
>
{val}
</span>
)}
</div>
);
})}
</div>
{/* Start marker */}
{highlight && (
<div
className="absolute bottom-16 -translate-x-1/2 transition-all duration-500"
style={{ left: `calc(${startX}% + 16px)` }}
>
<div className="flex flex-col items-center">
<span className="rounded-full bg-unit-5 px-2 py-0.5 text-[10px] font-bold text-white shadow-md">
Start
</span>
<div className="h-3 w-0.5 bg-unit-5" />
</div>
</div>
)}
{/* End marker */}
{highlight && highlight.moves > 0 && animate && (
<div
className="absolute bottom-16 -translate-x-1/2 transition-all duration-700"
style={{ left: `calc(${endX}% + 16px)` }}
>
<div className="flex flex-col items-center">
<span className="rounded-full bg-correct px-2 py-0.5 text-[10px] font-bold text-white shadow-md">
End: {end}
</span>
<div className="h-3 w-0.5 bg-correct" />
</div>
</div>
)}
</div>
{/* Arrows on edges */}
<div className="flex justify-between px-2 text-xs text-muted/50">
<span> negative</span>
<span>positive </span>
</div>
</div>
);
}
function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
return (
<div
className={`rounded-xl border-2 px-3 py-2 text-center text-sm font-semibold transition-all duration-300 ${
active
? "border-unit-5 bg-unit-5-light text-unit-5-dark scale-105 shadow-md"
: "border-border/40 bg-surface text-muted"
}`}
>
{rule}
</div>
);
}
function QuickPractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function generateProblem() {
const ops = ["+", "-"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
const a = Math.floor(Math.random() * 21) - 10;
const b = Math.floor(Math.random() * 21) - 10;
const answer = op === "+" ? a + b : a - b;
return { a, b, op, answer };
}
function checkAnswer() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
const isCorrect = parsed === problem.answer;
setFeedback(isCorrect ? "correct" : "incorrect");
setScore((prev) => ({
correct: prev.correct + (isCorrect ? 1 : 0),
total: prev.total + 1,
}));
}
function nextProblem() {
setProblem(generateProblem());
setUserAnswer("");
setFeedback(null);
}
const displayB = problem.b < 0 ? `(${problem.b})` : `${problem.b}`;
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>
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="text-2xl font-extrabold">
{problem.a} {problem.op} {displayB} =
</span>
<input
type="number"
value={userAnswer}
onChange={(e) => {
setUserAnswer(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) nextProblem();
else checkAnswer();
}
}}
className={`w-20 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-5"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button
onClick={checkAnswer}
className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Check
</button>
) : (
<button
onClick={nextProblem}
className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background transition-colors hover:bg-foreground/80"
>
Next
</button>
)}
</div>
{feedback === "correct" && (
<p className="text-center text-sm font-bold text-correct">Correct! Well done!</p>
)}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">
Not quite. The answer is {problem.answer}. Try the next one!
</p>
)}
</Card>
);
}
export function IntegerAddSubtractExplorer() {
const [mode, setMode] = useState<Mode>("number-line");
const [op, setOp] = useState<"add" | "subtract">("add");
const [inputA, setInputA] = useState("-3");
const [inputB, setInputB] = useState("5");
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 a = parseInt(inputA);
const b = parseInt(inputB);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid integers.");
return;
}
if (Math.abs(a) > 15 || Math.abs(b) > 15) {
setError("Keep numbers between -15 and 15 for the number line.");
return;
}
const result = op === "add" ? a + b : a - b;
if (Math.abs(result) > 15) {
setError("Result goes off the number line. Try smaller numbers.");
return;
}
try {
const s = buildAddSubtractSteps(a, b, op);
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;
const addRules = [
{ rule: "(+) + (+) = (+)", active: step?.label.includes("Positive + Positive") ?? false },
{ rule: "(-) + (-) = (-)", active: step?.label.includes("Negative + Negative") ?? false },
{ rule: "Different signs? Subtract, keep sign of larger", active: step?.label.includes("Different signs") ?? false },
];
const subRules = [
{ rule: "+() becomes ", active: step?.label.includes("unlike signs") ?? false },
{ rule: "() becomes +", active: step?.label.includes("like signs") ?? false },
];
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["number-line", "rules"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{m === "number-line" ? "Number Line" : "Sign Rules"}
</button>
))}
</div>
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract"] as const).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{o === "add" ? "Add (+)" : "Subtract ()"}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter two integers
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={inputA}
onChange={(e) => setInputA(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="First integer"
/>
<span className="text-xl font-bold text-muted">
{op === "add" ? "+" : ""}
</span>
<input
type="number"
value={inputB}
onChange={(e) => setInputB(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Second integer"
/>
<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>
{/* Sign Rules Reference */}
{mode === "rules" && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(op === "add" ? addRules : subRules).map((r) => (
<SignRuleCard key={r.rule} rule={r.rule} active={r.active} />
))}
</div>
)}
{/* Display Card with Number Line */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter integers 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" />
{mode === "number-line" && (
<NumberLine
highlight={step.numberLineHighlight}
animate={currentStep > 0}
/>
)}
</>
)}
</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>
{/* Quick Practice */}
<QuickPractice />
</div>
);
}