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

709 lines
24 KiB
TypeScript
Raw Permalink 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 Tab = "multiply" | "divide" | "thermometer";
interface Step {
label: string;
math: string;
signVisual?: { signA: "+" | "-"; signB: "+" | "-"; resultSign: "+" | "-" };
thermometer?: { startTemp: number; change: number; endTemp: number };
}
function getSign(n: number): "+" | "-" {
return n >= 0 ? "+" : "-";
}
function buildMultiplySteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const result = a * b;
const signA = getSign(a);
const signB = getSign(b);
const signR = getSign(result);
steps.push({
label: "Start with the multiplication",
math: `${a < 0 ? `(${a})` : a} \\times ${b < 0 ? `(${b})` : b}`,
});
// Step: Multiply the absolute values
steps.push({
label: `First, multiply the absolute values: ${Math.abs(a)} × ${Math.abs(b)}`,
math: `|${a}| \\times |${b}| = ${Math.abs(a)} \\times ${Math.abs(b)} = ${Math.abs(result)}`,
});
// Step: Determine the sign
const sameSign = (a >= 0 && b >= 0) || (a < 0 && b < 0);
if (sameSign) {
steps.push({
label: `Same signs (${signA} × ${signB}): the result is POSITIVE`,
math: `(${signA}) \\times (${signB}) = (+)`,
signVisual: { signA, signB, resultSign: "+" },
});
} else {
steps.push({
label: `Different signs (${signA} × ${signB}): the result is NEGATIVE`,
math: `(${signA}) \\times (${signB}) = (-)`,
signVisual: { signA, signB, resultSign: "-" },
});
}
// Step: Combine
steps.push({
label: "Combine the magnitude and sign",
math: `${a < 0 ? `(${a})` : a} \\times ${b < 0 ? `(${b})` : b} = ${result}`,
signVisual: { signA, signB, resultSign: signR },
});
// Story time for negative × negative
if (a < 0 && b < 0) {
steps.push({
label: "Why does negative × negative = positive? Think of it like a double-negative in English!",
math: `\\text{\"Do NOT not eat!\"} = \\text{\"Eat!\"} \\quad (-) \\times (-) = (+)`,
signVisual: { signA: "-", signB: "-", resultSign: "+" },
});
}
return steps;
}
function buildDivideSteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const result = a / b;
const signA = getSign(a);
const signB = getSign(b);
const signR = getSign(result);
const isWhole = Number.isInteger(result);
const displayResult = isWhole ? result.toString() : result.toFixed(2);
steps.push({
label: "Start with the division",
math: `${a < 0 ? `(${a})` : a} \\div ${b < 0 ? `(${b})` : b}`,
});
steps.push({
label: `First, divide the absolute values: ${Math.abs(a)} ÷ ${Math.abs(b)}`,
math: `|${a}| \\div |${b}| = ${Math.abs(a)} \\div ${Math.abs(b)} = ${isWhole ? Math.abs(result) : Math.abs(result).toFixed(2)}`,
});
const sameSign = (a >= 0 && b >= 0) || (a < 0 && b < 0);
if (sameSign) {
steps.push({
label: `Same signs (${signA} ÷ ${signB}): the result is POSITIVE`,
math: `(${signA}) \\div (${signB}) = (+)`,
signVisual: { signA, signB, resultSign: "+" },
});
} else {
steps.push({
label: `Different signs (${signA} ÷ ${signB}): the result is NEGATIVE`,
math: `(${signA}) \\div (${signB}) = (-)`,
signVisual: { signA, signB, resultSign: "-" },
});
}
steps.push({
label: "Combine the magnitude and sign",
math: `${a < 0 ? `(${a})` : a} \\div ${b < 0 ? `(${b})` : b} = ${displayResult}`,
signVisual: { signA, signB, resultSign: signR },
});
return steps;
}
function buildThermometerSteps(startTemp: number, change: number): Step[] {
const steps: Step[] = [];
const endTemp = startTemp + change;
const direction = change >= 0 ? "rose" : "dropped";
const absChange = Math.abs(change);
steps.push({
label: "Read the starting temperature on the thermometer",
math: `\\text{Starting temperature: } ${startTemp}°C`,
thermometer: { startTemp, change: 0, endTemp: startTemp },
});
steps.push({
label: `The temperature ${direction} by ${absChange}°C`,
math: change >= 0
? `${startTemp}°C + ${absChange}°C`
: `${startTemp}°C - ${absChange}°C`,
thermometer: { startTemp, change: 0, endTemp: startTemp },
});
steps.push({
label: change >= 0 ? "Rising temperature means we ADD" : "Dropping temperature means we SUBTRACT",
math: `${startTemp} + (${change}) = ${endTemp}`,
thermometer: { startTemp, change, endTemp },
});
steps.push({
label: `The new temperature is ${endTemp}°C`,
math: `\\text{New temperature: } ${endTemp}°C`,
thermometer: { startTemp, change, endTemp },
});
return steps;
}
function SignRuleGrid({ visual, operation }: { visual?: Step["signVisual"]; operation: "×" | "÷" }) {
const rules = [
{ a: "+", b: "+", result: "+", example: operation === "×" ? "3 × 2 = 6" : "6 ÷ 2 = 3" },
{ a: "-", b: "-", result: "+", example: operation === "×" ? "(-3) × (-2) = 6" : "(-6) ÷ (-2) = 3" },
{ a: "+", b: "-", result: "-", example: operation === "×" ? "3 × (-2) = -6" : "6 ÷ (-2) = -3" },
{ a: "-", b: "+", result: "-", example: operation === "×" ? "(-3) × 2 = -6" : "(-6) ÷ 2 = -3" },
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{rules.map((r) => {
const isActive = visual && visual.signA === r.a && visual.signB === r.b;
return (
<div
key={`${r.a}${r.b}`}
className={`rounded-xl border-2 p-3 text-center transition-all duration-300 ${
isActive
? "border-unit-5 bg-unit-5-light shadow-md scale-105"
: "border-border/40 bg-surface"
}`}
>
<p className={`text-lg font-extrabold ${isActive ? "text-unit-5-dark" : "text-foreground"}`}>
({r.a}) {operation} ({r.b}) = ({r.result})
</p>
<p className={`mt-1 text-xs ${isActive ? "text-unit-5-dark/70" : "text-muted"}`}>
{r.example}
</p>
</div>
);
})}
</div>
);
}
function Thermometer({ data }: { data?: Step["thermometer"] }) {
const minTemp = -20;
const maxTemp = 30;
const range = maxTemp - minTemp;
const startTemp = data?.startTemp ?? 0;
const endTemp = data?.endTemp ?? 0;
const startY = ((startTemp - minTemp) / range) * 100;
const endY = ((endTemp - minTemp) / range) * 100;
const fillY = ((endTemp - minTemp) / range) * 100;
const ticks = [];
for (let t = minTemp; t <= maxTemp; t += 5) {
ticks.push(t);
}
return (
<div className="mx-auto flex items-end gap-6">
{/* Thermometer */}
<div className="relative flex flex-col items-center">
<div className="relative h-64 w-8 overflow-hidden rounded-t-full border-2 border-foreground/30 bg-gradient-to-b from-red-50 to-blue-50">
{/* Mercury fill */}
<div
className="absolute bottom-0 left-0 right-0 rounded-t-sm transition-all duration-700"
style={{
height: `${Math.max(0, Math.min(100, fillY))}%`,
background: endTemp >= 0
? `linear-gradient(to top, #dc2626, #f97316)`
: `linear-gradient(to top, #2563eb, #60a5fa)`,
}}
/>
{/* Tick marks */}
{ticks.map((t) => {
const y = ((t - minTemp) / range) * 100;
return (
<div
key={t}
className="absolute left-0 right-0 flex items-center"
style={{ bottom: `${y}%`, transform: "translateY(50%)" }}
>
<div className={`h-px flex-1 ${t === 0 ? "bg-foreground" : "bg-foreground/20"}`} />
</div>
);
})}
</div>
{/* Bulb */}
<div className="relative -mt-1 flex h-12 w-12 items-center justify-center rounded-full border-2 border-foreground/30 bg-red-500">
<span className="text-[8px] font-bold text-white">°C</span>
</div>
</div>
{/* Scale labels */}
<div className="relative h-64">
{ticks.map((t) => {
const y = ((t - minTemp) / range) * 100;
const isStart = t === startTemp;
const isEnd = data && data.change !== 0 && t === endTemp;
return (
<div
key={t}
className="absolute flex items-center gap-1"
style={{ bottom: `${y}%`, transform: "translateY(50%)" }}
>
<span
className={`text-xs transition-all duration-300 ${
isEnd
? "font-extrabold text-correct text-sm"
: isStart
? "font-bold text-unit-5"
: t === 0
? "font-semibold text-foreground"
: "text-muted/60"
}`}
>
{t}°C
</span>
{isStart && (
<span className="rounded bg-unit-5 px-1.5 py-0.5 text-[9px] font-bold text-white">
START
</span>
)}
{isEnd && (
<span className="rounded bg-correct px-1.5 py-0.5 text-[9px] font-bold text-white">
END
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function ThermometerPractice() {
const scenarios = [
{ city: "London", startTemp: -3, change: 7, question: "The temperature rose by 7°C. What is the new temperature?" },
{ city: "Moscow", startTemp: -12, change: -5, question: "The temperature dropped by 5°C. What is the new temperature?" },
{ city: "Kingston", startTemp: 28, change: -10, question: "The temperature dropped by 10°C. What is the new temperature?" },
{ city: "Ottawa", startTemp: -8, change: 15, question: "The temperature rose by 15°C. What is the new temperature?" },
{ city: "Reykjavik", startTemp: -1, change: -6, question: "The temperature dropped by 6°C. What is the new temperature?" },
];
const [scenarioIdx, setScenarioIdx] = useState(0);
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const scenario = scenarios[scenarioIdx];
const correctAnswer = scenario.startTemp + scenario.change;
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
setFeedback(parsed === correctAnswer ? "correct" : "incorrect");
}
function next() {
setScenarioIdx((i) => (i + 1) % scenarios.length);
setUserAnswer("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">
Real-World Challenge
</p>
<div className="rounded-xl border-2 border-unit-5/20 bg-unit-5-light/50 p-4">
<p className="text-sm font-bold text-unit-5-dark">
In {scenario.city}, the temperature is {scenario.startTemp}°C.
</p>
<p className="mt-1 text-sm text-unit-5-dark/80">{scenario.question}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={userAnswer}
onChange={(e) => {
setUserAnswer(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-lg 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="°C"
aria-label="Temperature answer"
/>
<span className="text-sm font-semibold text-muted">°C</span>
{feedback === null ? (
<button
onClick={check}
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={next}
className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background transition-colors hover:bg-foreground/80"
>
Next City
</button>
)}
</div>
{feedback === "correct" && (
<p className="text-sm font-bold text-correct">
Correct! {scenario.startTemp} + ({scenario.change}) = {correctAnswer}°C
</p>
)}
{feedback === "incorrect" && (
<p className="text-sm font-bold text-incorrect">
Not quite. {scenario.startTemp} + ({scenario.change}) = {correctAnswer}°C
</p>
)}
</Card>
);
}
function MultiplyDividePractice() {
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)];
if (op === "×") {
const a = Math.floor(Math.random() * 25) - 12;
const b = Math.floor(Math.random() * 25) - 12;
return { a, b, op, answer: a * b };
} else {
// Generate division with clean integer results
const b = Math.floor(Math.random() * 11) - 5;
const nonZeroB = b === 0 ? 3 : b;
const answer = Math.floor(Math.random() * 21) - 10;
const a = answer * nonZeroB;
return { a, b: nonZeroB, op, answer };
}
}
function check() {
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 next() {
setProblem(generateProblem());
setUserAnswer("");
setFeedback(null);
}
const displayA = problem.a < 0 ? `(${problem.a})` : `${problem.a}`;
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">
{displayA} {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) next();
else check();
}
}}
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={check} 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={next} 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!</p>
)}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">
Not quite. The answer is {problem.answer}.
</p>
)}
</Card>
);
}
export function IntegerMultiplyDivideExplorer() {
const [tab, setTab] = useState<Tab>("multiply");
const [inputA, setInputA] = useState("-3");
const [inputB, setInputB] = useState("5");
const [startTemp, setStartTemp] = useState("-3");
const [tempChange, setTempChange] = useState("7");
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("");
if (tab === "thermometer") {
const st = parseInt(startTemp);
const ch = parseInt(tempChange);
if (isNaN(st) || isNaN(ch)) {
setError("Enter valid integers.");
return;
}
if (Math.abs(st) > 20 || Math.abs(ch) > 30) {
setError("Keep temperatures reasonable (-20 to 30).");
return;
}
const s = buildThermometerSteps(st, ch);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
return;
}
const a = parseInt(inputA);
const b = parseInt(inputB);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid integers.");
return;
}
if (tab === "divide" && b === 0) {
setError("Cannot divide by zero!");
return;
}
if (Math.abs(a) > 99 || Math.abs(b) > 99) {
setError("Keep numbers under 100.");
return;
}
if (tab === "divide" && !Number.isInteger(a / b)) {
setError("Choose numbers that divide evenly for now.");
return;
}
try {
const s = tab === "multiply" ? buildMultiplySteps(a, b) : buildDivideSteps(a, b);
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;
return (
<div className="space-y-4">
{/* Tab selector */}
<div className="flex flex-wrap gap-2">
{(["multiply", "divide", "thermometer"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => {
setTab(t);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
tab === t
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{t === "multiply"
? "Multiply (×)"
: t === "divide"
? "Divide (÷)"
: "Thermometer"}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
{tab === "thermometer" ? "Enter temperature scenario" : "Enter two integers"}
</p>
{tab === "thermometer" ? (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col">
<label className="mb-1 text-xs font-semibold text-muted">Start temp (°C)</label>
<input
type="number"
value={startTemp}
onChange={(e) => setStartTemp(e.target.value)}
className="w-24 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="Starting temperature"
/>
</div>
<div className="flex flex-col">
<label className="mb-1 text-xs font-semibold text-muted">Change (°C)</label>
<input
type="number"
value={tempChange}
onChange={(e) => setTempChange(e.target.value)}
className="w-24 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="Temperature change"
/>
</div>
<button
onClick={handleGo}
className="mt-5 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>
) : (
<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">
{tab === "multiply" ? "×" : "÷"}
</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 Grid (for multiply/divide) */}
{tab !== "thermometer" && (
<SignRuleGrid
visual={step?.signVisual}
operation={tab === "multiply" ? "×" : "÷"}
/>
)}
{/* Display Card */}
<Card className="flex min-h-[300px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter values 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" />
{step.thermometer && <Thermometer data={step.thermometer} />}
</>
)}
</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>
{/* Practice sections */}
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
</div>
);
}