594 lines
20 KiB
TypeScript
594 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|