form 1 topics added
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StepControls } from "./step-controls";
|
||||
import { MathDisplay } from "@/components/math/math-display";
|
||||
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
|
||||
import { toKatex, Fraction } from "@/lib/math/fractions";
|
||||
|
||||
interface FractionVal {
|
||||
num: number;
|
||||
@@ -30,24 +30,26 @@ const OP_PRIORITY: Record<string, number> = {
|
||||
};
|
||||
|
||||
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
|
||||
let result: [number, number];
|
||||
const fa = new Fraction(a.num, a.den);
|
||||
const fb = new Fraction(b.num, b.den);
|
||||
let result: Fraction;
|
||||
switch (op) {
|
||||
case "+":
|
||||
result = add(a.num, a.den, b.num, b.den);
|
||||
result = fa.plus(fb);
|
||||
break;
|
||||
case "−":
|
||||
result = subtract(a.num, a.den, b.num, b.den);
|
||||
result = fa.minus(fb);
|
||||
break;
|
||||
case "×":
|
||||
result = multiply(a.num, a.den, b.num, b.den);
|
||||
result = fa.times(fb);
|
||||
break;
|
||||
case "÷":
|
||||
result = divide(a.num, a.den, b.num, b.den);
|
||||
result = fa.dividedBy(fb);
|
||||
break;
|
||||
default:
|
||||
result = [0, 1];
|
||||
result = new Fraction(0, 1);
|
||||
}
|
||||
return { num: result[0], den: result[1] };
|
||||
return { num: result.n, den: result.d };
|
||||
}
|
||||
|
||||
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
|
||||
@@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] {
|
||||
|
||||
// Perform the operation
|
||||
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
|
||||
const [sn, sd] = simplify(result.num, result.den);
|
||||
const { n: sn, d: sd } = new Fraction(result.num, result.den).simplified();
|
||||
|
||||
const newFracs = [...fracs];
|
||||
newFracs.splice(opIdx, 2, { num: sn, den: sd });
|
||||
|
||||
181
components/explorers/cartesian-grid.tsx
Normal file
181
components/explorers/cartesian-grid.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export interface GridPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
label?: string;
|
||||
tone?: "solid" | "target" | "wrong" | "muted";
|
||||
}
|
||||
|
||||
export interface GridSegment {
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface GridArrow {
|
||||
from: { x: number; y: number };
|
||||
to: { x: number; y: number };
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
interface CartesianGridProps {
|
||||
points?: GridPoint[];
|
||||
segments?: GridSegment[];
|
||||
arrows?: GridArrow[];
|
||||
/** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
|
||||
highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
|
||||
onGridClick?: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
const MIN = -10;
|
||||
const MAX = 10;
|
||||
const UNIT = 20;
|
||||
const MARGIN = 20;
|
||||
const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
|
||||
|
||||
const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
|
||||
const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
|
||||
|
||||
const toneColor: Record<NonNullable<GridPoint["tone"]>, string> = {
|
||||
solid: "var(--unit-7)",
|
||||
target: "var(--correct)",
|
||||
wrong: "var(--incorrect)",
|
||||
muted: "var(--muted)",
|
||||
};
|
||||
|
||||
export function CartesianGrid({
|
||||
points = [],
|
||||
segments = [],
|
||||
arrows = [],
|
||||
highlight,
|
||||
onGridClick,
|
||||
}: CartesianGridProps) {
|
||||
const arrowHeadId = useId();
|
||||
const axisArrowId = useId();
|
||||
|
||||
const ticks: number[] = [];
|
||||
for (let v = MIN; v <= MAX; v++) ticks.push(v);
|
||||
const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
|
||||
|
||||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!onGridClick) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * SIZE;
|
||||
const py = ((e.clientY - rect.top) / rect.height) * SIZE;
|
||||
const x = Math.round((px - MARGIN) / UNIT + MIN);
|
||||
const y = Math.round(MAX - (py - MARGIN) / UNIT);
|
||||
if (x < MIN || x > MAX || y < MIN || y > MAX) return;
|
||||
onGridClick(x, y);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={`h-auto w-full max-w-md ${onGridClick ? "cursor-crosshair" : ""}`}
|
||||
onClick={handleClick}
|
||||
role="img"
|
||||
aria-label="Cartesian coordinate grid"
|
||||
>
|
||||
<defs>
|
||||
<marker id={arrowHeadId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
|
||||
<path d="M0 0 L8 4 L0 8 z" fill="var(--unit-7-dark)" />
|
||||
</marker>
|
||||
<marker id={axisArrowId} markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
|
||||
<path d="M0 0 L9 4.5 L0 9 z" fill="var(--foreground)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Light gridlines */}
|
||||
{ticks.map((v) => (
|
||||
<g key={`grid-${v}`}>
|
||||
<line x1={sx(v)} y1={sy(MIN)} x2={sx(v)} y2={sy(MAX)} stroke="var(--border)" strokeWidth={1} />
|
||||
<line x1={sx(MIN)} y1={sy(v)} x2={sx(MAX)} y2={sy(v)} stroke="var(--border)" strokeWidth={1} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Axes */}
|
||||
<line x1={12} y1={sy(0)} x2={SIZE - 8} y2={sy(0)} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
|
||||
<line x1={sx(0)} y1={SIZE - 8} x2={sx(0)} y2={12} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
|
||||
<text x={SIZE - 12} y={sy(0) - 8} fontSize={14} fontWeight={700} fill="var(--foreground)" textAnchor="end">x</text>
|
||||
<text x={sx(0) + 10} y={18} fontSize={14} fontWeight={700} fill="var(--foreground)">y</text>
|
||||
|
||||
{/* Axis labels */}
|
||||
{labelTicks.map((v) => (
|
||||
<text key={`xl-${v}`} x={sx(v)} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="middle">
|
||||
{v}
|
||||
</text>
|
||||
))}
|
||||
{labelTicks.map((v) => (
|
||||
<text key={`yl-${v}`} x={sx(0) - 7} y={sy(v) + 3.5} fontSize={10} fill="var(--muted)" textAnchor="end">
|
||||
{v}
|
||||
</text>
|
||||
))}
|
||||
<text x={sx(0) - 7} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="end">0</text>
|
||||
|
||||
{/* Guide lines */}
|
||||
{highlight && (
|
||||
<g>
|
||||
{(highlight.axis ?? "both") !== "y" && (
|
||||
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(highlight.x)} y2={sy(0)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
|
||||
)}
|
||||
{(highlight.axis ?? "both") !== "x" && (
|
||||
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(0)} y2={sy(highlight.y)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Segments */}
|
||||
{segments.map((s, i) => (
|
||||
<line
|
||||
key={`seg-${i}`}
|
||||
x1={sx(s.from.x)}
|
||||
y1={sy(s.from.y)}
|
||||
x2={sx(s.to.x)}
|
||||
y2={sy(s.to.y)}
|
||||
stroke="var(--unit-7)"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Movement arrows */}
|
||||
{arrows.map((a, i) => (
|
||||
<line
|
||||
key={`arr-${i}`}
|
||||
x1={sx(a.from.x)}
|
||||
y1={sy(a.from.y)}
|
||||
x2={sx(a.to.x)}
|
||||
y2={sy(a.to.y)}
|
||||
stroke="var(--unit-7-dark)"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={a.dashed ? "5 4" : undefined}
|
||||
markerEnd={`url(#${arrowHeadId})`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Points */}
|
||||
{points.map((p, i) => {
|
||||
const color = toneColor[p.tone ?? "solid"];
|
||||
return (
|
||||
<g key={`pt-${i}`}>
|
||||
<circle cx={sx(p.x)} cy={sy(p.y)} r={5} fill={color} stroke="white" strokeWidth={1.5} />
|
||||
{p.label && (
|
||||
<text
|
||||
x={sx(p.x) + 8}
|
||||
y={sy(p.y) - 8}
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
fill={color}
|
||||
>
|
||||
{p.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StepControls } from "./step-controls";
|
||||
import { MathDisplay } from "@/components/math/math-display";
|
||||
import { simplify, toKatex, gcd } from "@/lib/math/fractions";
|
||||
import { toKatex, gcd, Fraction } from "@/lib/math/fractions";
|
||||
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
|
||||
|
||||
type ConvertMode = "decToFrac" | "fracToDec";
|
||||
@@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] {
|
||||
});
|
||||
|
||||
// Simplify
|
||||
const [sn, sd] = simplify(numerator, denominator);
|
||||
const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified();
|
||||
if (sn !== numerator || sd !== denominator) {
|
||||
const g = gcd(numerator, denominator);
|
||||
steps.push({
|
||||
@@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] {
|
||||
|
||||
// Check for terminating vs repeating
|
||||
let tempDen = den;
|
||||
const [, simpDen] = simplify(num, den);
|
||||
const { d: simpDen } = new Fraction(num, den).simplified();
|
||||
tempDen = simpDen;
|
||||
// Remove factors of 2 and 5
|
||||
while (tempDen % 2 === 0) tempDen /= 2;
|
||||
|
||||
372
components/explorers/exponents-explorer.tsx
Normal file
372
components/explorers/exponents-explorer.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"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";
|
||||
|
||||
type Mode = "expand" | "index";
|
||||
type IndexOp = "multiply" | "divide";
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
}
|
||||
|
||||
function repeat(base: number, count: number): string {
|
||||
return Array.from({ length: count }, () => `${base}`).join(" \\times ");
|
||||
}
|
||||
|
||||
function buildExpandSteps(base: number, exp: number): Step[] {
|
||||
if (exp === 0) {
|
||||
return [
|
||||
{ label: "The power (index) is 0.", math: `${base}^{0}` },
|
||||
{ label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
|
||||
];
|
||||
}
|
||||
if (exp === 1) {
|
||||
return [
|
||||
{ label: "The power (index) is 1.", math: `${base}^{1}` },
|
||||
{ label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
|
||||
];
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
|
||||
math: `${base}^{${exp}}`,
|
||||
},
|
||||
];
|
||||
let running = base;
|
||||
for (let k = 2; k <= exp; k++) {
|
||||
running *= base;
|
||||
steps.push({
|
||||
label: `Multiply on another ${base}.`,
|
||||
math: `${repeat(base, k)} = ${running}`,
|
||||
});
|
||||
}
|
||||
steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
|
||||
return steps;
|
||||
}
|
||||
|
||||
function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
|
||||
if (op === "multiply") {
|
||||
const total = m + n;
|
||||
const value = Math.pow(base, total);
|
||||
const steps: Step[] = [
|
||||
{ label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
|
||||
{ label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
|
||||
{ label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
|
||||
];
|
||||
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
|
||||
return steps;
|
||||
}
|
||||
// divide, m >= n
|
||||
const diff = m - n;
|
||||
const value = Math.pow(base, diff);
|
||||
const steps: Step[] = [
|
||||
{ label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
|
||||
{ label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
|
||||
{ label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
|
||||
];
|
||||
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PracticeQ {
|
||||
prompt: string;
|
||||
answer: number;
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
const SUPERSCRIPT: Record<string, string> = {
|
||||
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
|
||||
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
|
||||
};
|
||||
function sup(n: number): string {
|
||||
return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
|
||||
}
|
||||
|
||||
function makeQuestion(): PracticeQ {
|
||||
const roll = Math.floor(Math.random() * 5);
|
||||
if (roll === 0) {
|
||||
const base = randInt(2, 7);
|
||||
const exp = randInt(2, 3);
|
||||
return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
|
||||
}
|
||||
if (roll === 1) {
|
||||
const base = randInt(2, 9);
|
||||
return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
|
||||
}
|
||||
if (roll === 2) {
|
||||
const base = randInt(2, 4);
|
||||
const a = randInt(2, 3);
|
||||
const b = 1;
|
||||
return { prompt: `What is ${base}${sup(a)} − ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
|
||||
}
|
||||
if (roll === 3) {
|
||||
const base = randInt(2, 5);
|
||||
const m = randInt(3, 6);
|
||||
const n = randInt(1, m - 1);
|
||||
return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
|
||||
}
|
||||
const base = randInt(2, 6);
|
||||
const m = randInt(1, 4);
|
||||
const n = randInt(1, 4);
|
||||
return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
|
||||
}
|
||||
|
||||
function QuickPractice() {
|
||||
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||
const [val, setVal] = useState("");
|
||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
function check() {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (isNaN(parsed)) return;
|
||||
const correct = parsed === q.answer;
|
||||
setFeedback(correct ? "correct" : "incorrect");
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}
|
||||
|
||||
function next() {
|
||||
setQ(makeQuestion());
|
||||
setVal("");
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
onChange={(e) => {
|
||||
setVal(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-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-8"
|
||||
}`}
|
||||
placeholder="?"
|
||||
aria-label="Your answer"
|
||||
/>
|
||||
{feedback === null ? (
|
||||
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||
Check
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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 {q.answer}.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExponentsExplorer() {
|
||||
const [mode, setMode] = useState<Mode>("expand");
|
||||
const [indexOp, setIndexOp] = useState<IndexOp>("multiply");
|
||||
const [base, setBase] = useState("2");
|
||||
const [power, setPower] = useState("4");
|
||||
const [m, setM] = useState("4");
|
||||
const [n, setN] = useState("3");
|
||||
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 switchMode(md: Mode) {
|
||||
setMode(md);
|
||||
setError("");
|
||||
reset();
|
||||
}
|
||||
|
||||
function handleGo() {
|
||||
setError("");
|
||||
const b = parseInt(base, 10);
|
||||
if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
|
||||
|
||||
if (mode === "expand") {
|
||||
const e = parseInt(power, 10);
|
||||
if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
|
||||
if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
|
||||
setSteps(buildExpandSteps(b, e));
|
||||
} else {
|
||||
const mm = parseInt(m, 10);
|
||||
const nn = parseInt(n, 10);
|
||||
if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
|
||||
if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
|
||||
setSteps(buildIndexSteps(b, mm, nn, indexOp));
|
||||
}
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["expand", "index"] as Mode[]).map((md) => (
|
||||
<button
|
||||
key={md}
|
||||
onClick={() => switchMode(md)}
|
||||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
mode === md
|
||||
? "border-unit-8 bg-unit-8 text-white"
|
||||
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{md === "expand" ? "Expand a Power" : "Index Laws"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Index op tabs */}
|
||||
{mode === "index" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["multiply", "divide"] as IndexOp[]).map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => {
|
||||
setIndexOp(o);
|
||||
reset();
|
||||
}}
|
||||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
indexOp === o
|
||||
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||||
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{o === "multiply" ? "Multiply (add powers)" : "Divide (subtract powers)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
{mode === "expand" ? (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">Enter a base and a power</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input type="number" value={base} onChange={(e) => setBase(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-8" aria-label="Base" />
|
||||
<span className="text-sm font-bold text-muted">to the power</span>
|
||||
<input type="number" value={power} onChange={(e) => setPower(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-8" aria-label="Power" />
|
||||
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
|
||||
Go →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||
Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted">base</span>
|
||||
<input type="number" value={base} onChange={(e) => setBase(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-8" aria-label="Base" />
|
||||
<span className="text-sm font-bold text-muted">powers</span>
|
||||
<input type="number" value={m} onChange={(e) => setM(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-8" aria-label="First power" />
|
||||
<span className="text-lg font-bold text-muted">{indexOp === "multiply" ? "×" : "÷"}</span>
|
||||
<input type="number" value={n} onChange={(e) => setN(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-8" aria-label="Second power" />
|
||||
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-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 values 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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StepControls } from "./step-controls";
|
||||
import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
|
||||
import { gcd, lcm, toKatex, Fraction } from "@/lib/math/fractions";
|
||||
import { MathDisplay } from "@/components/math/math-display";
|
||||
|
||||
type Operation = "add" | "subtract" | "multiply" | "divide";
|
||||
@@ -68,7 +68,7 @@ function buildSteps(
|
||||
});
|
||||
|
||||
// Step 4: Simplify if needed
|
||||
const [sn, sd] = simplify(resultNum, lcd);
|
||||
const { n: sn, d: sd } = new Fraction(resultNum, lcd).simplified();
|
||||
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
|
||||
const g = gcd(Math.abs(resultNum), lcd);
|
||||
steps.push({
|
||||
@@ -92,7 +92,7 @@ function buildSteps(
|
||||
});
|
||||
|
||||
// Step 2: Simplify
|
||||
const [sn, sd] = simplify(rn, rd);
|
||||
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
|
||||
if (sn !== rn || sd !== rd) {
|
||||
const g = gcd(Math.abs(rn), rd);
|
||||
steps.push({
|
||||
@@ -122,7 +122,7 @@ function buildSteps(
|
||||
barResult: { filled: rn, total: rd },
|
||||
});
|
||||
|
||||
const [sn, sd] = simplify(rn, rd);
|
||||
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
|
||||
if (sn !== rn || sd !== rd) {
|
||||
const g = gcd(Math.abs(rn), rd);
|
||||
steps.push({
|
||||
|
||||
331
components/explorers/identity-inverse-explorer.tsx
Normal file
331
components/explorers/identity-inverse-explorer.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"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";
|
||||
|
||||
type Mode = "identity" | "inverse" | "zero";
|
||||
type Op = "add" | "multiply";
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
function buildIdentitySteps(n: number, op: Op): Step[] {
|
||||
if (op === "add") {
|
||||
return [
|
||||
{ label: "The identity for addition is 0 — it leaves a number unchanged.", math: `${n} + 0` },
|
||||
{ label: "Adding 0 changes nothing.", math: `${n} + 0 = ${n}` },
|
||||
{ label: "So 0 is the identity element for addition.", math: `n + 0 = n` },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "The identity for multiplication is 1 — it leaves a number unchanged.", math: `${n} \\times 1` },
|
||||
{ label: "Multiplying by 1 changes nothing.", math: `${n} \\times 1 = ${n}` },
|
||||
{ label: "So 1 is the identity element for multiplication.", math: `n \\times 1 = n` },
|
||||
];
|
||||
}
|
||||
|
||||
function buildInverseSteps(n: number, op: Op): Step[] {
|
||||
if (op === "add") {
|
||||
const inv = -n;
|
||||
const invText = inv < 0 ? `(${inv})` : `${inv}`;
|
||||
return [
|
||||
{ label: "The additive inverse is the number that brings you back to the identity, 0.", math: `${n} + \\square = 0` },
|
||||
{ label: `Flip the sign of ${n} to get its additive inverse.`, math: `\\text{inverse of } ${n} = ${inv}` },
|
||||
{ label: "Check: adding a number and its inverse gives 0.", math: `${n} + ${invText} = 0` },
|
||||
];
|
||||
}
|
||||
// multiplicative inverse (reciprocal)
|
||||
const absN = Math.abs(n);
|
||||
const recip = n < 0 ? `-\\frac{1}{${absN}}` : `\\frac{1}{${absN}}`;
|
||||
const recipParen = n < 0 ? `\\left(-\\frac{1}{${absN}}\\right)` : `\\frac{1}{${absN}}`;
|
||||
return [
|
||||
{ label: "The multiplicative inverse (reciprocal) brings you back to the identity, 1.", math: `${n} \\times \\square = 1` },
|
||||
{ label: `Write ${n} as a fraction and flip it. The sign stays in front.`, math: `\\text{inverse of } ${n} = ${recip}` },
|
||||
{ label: "Check: a number times its reciprocal gives 1.", math: `${n} \\times ${recipParen} = 1` },
|
||||
];
|
||||
}
|
||||
|
||||
function buildZeroSteps(n: number): Step[] {
|
||||
return [
|
||||
{ label: "Multiplying any number by zero always gives zero.", math: `${n} \\times 0 = 0` },
|
||||
{ label: "Zero shared into any number of groups is still zero.", math: `0 \\div ${n === 0 ? 5 : n} = 0` },
|
||||
{
|
||||
label: "But you can never divide BY zero — there is no answer. It is undefined.",
|
||||
math: `${n} \\div 0 = \\text{undefined}`,
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PracticeQ {
|
||||
prompt: string;
|
||||
answer: number;
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function makeQuestion(): PracticeQ {
|
||||
const roll = Math.floor(Math.random() * 5);
|
||||
if (roll === 0) {
|
||||
const n = randInt(-12, 12) || 3;
|
||||
return { prompt: `What is the additive inverse of ${n}?`, answer: -n };
|
||||
}
|
||||
if (roll === 1) {
|
||||
const n = randInt(1, 20);
|
||||
return { prompt: `What is ${n} × 0?`, answer: 0 };
|
||||
}
|
||||
if (roll === 2) {
|
||||
return { prompt: `What number is the identity for addition?`, answer: 0 };
|
||||
}
|
||||
if (roll === 3) {
|
||||
return { prompt: `What number is the identity for multiplication?`, answer: 1 };
|
||||
}
|
||||
const n = randInt(2, 12);
|
||||
return { prompt: `The multiplicative inverse of ${n} is 1 over what number?`, answer: n };
|
||||
}
|
||||
|
||||
function QuickPractice() {
|
||||
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||
const [val, setVal] = useState("");
|
||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
function check() {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (isNaN(parsed)) return;
|
||||
const correct = parsed === q.answer;
|
||||
setFeedback(correct ? "correct" : "incorrect");
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}
|
||||
|
||||
function next() {
|
||||
setQ(makeQuestion());
|
||||
setVal("");
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
onChange={(e) => {
|
||||
setVal(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-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-8"
|
||||
}`}
|
||||
placeholder="?"
|
||||
aria-label="Your answer"
|
||||
/>
|
||||
{feedback === null ? (
|
||||
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||
Check
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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 {q.answer}.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function IdentityInverseExplorer() {
|
||||
const [mode, setMode] = useState<Mode>("identity");
|
||||
const [op, setOp] = useState<Op>("add");
|
||||
const [input, setInput] = useState("3");
|
||||
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 switchMode(m: Mode) {
|
||||
setMode(m);
|
||||
setError("");
|
||||
reset();
|
||||
}
|
||||
|
||||
function handleGo() {
|
||||
setError("");
|
||||
const n = parseInt(input, 10);
|
||||
if (isNaN(n)) return setError("Enter a valid whole number.");
|
||||
if (Math.abs(n) > 100) return setError("Keep the number between -100 and 100.");
|
||||
if (mode === "inverse" && op === "multiply" && n === 0) {
|
||||
return setError("Zero has no multiplicative inverse — try another number.");
|
||||
}
|
||||
if (mode === "identity") setSteps(buildIdentitySteps(n, op));
|
||||
else if (mode === "inverse") setSteps(buildInverseSteps(n, op));
|
||||
else setSteps(buildZeroSteps(n));
|
||||
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 showOp = mode !== "zero";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["identity", "inverse", "zero"] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => switchMode(m)}
|
||||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
mode === m
|
||||
? "border-unit-8 bg-unit-8 text-white"
|
||||
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{m === "identity" ? "Identity" : m === "inverse" ? "Inverse" : "Zero Facts"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Operation tabs */}
|
||||
{showOp && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["add", "multiply"] as Op[]).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-8-dark bg-unit-8-dark text-white"
|
||||
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{o === "add" ? "Addition" : "Multiplication"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||
{mode === "identity"
|
||||
? "See how the identity leaves a number unchanged"
|
||||
: mode === "inverse"
|
||||
? "Find the inverse of a number"
|
||||
: "Explore what happens with zero"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={input}
|
||||
onChange={(e) => setInput(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-8"
|
||||
aria-label="Number"
|
||||
/>
|
||||
<button
|
||||
onClick={handleGo}
|
||||
className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-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 a number 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-3xl ${step.danger ? "text-incorrect" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -282,21 +283,21 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 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;
|
||||
@@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() {
|
||||
</Card>
|
||||
|
||||
{/* Quick Practice */}
|
||||
<QuickPractice />
|
||||
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
|
||||
<QuickPractice />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) {
|
||||
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 = [];
|
||||
@@ -372,30 +371,30 @@ function ThermometerPractice() {
|
||||
);
|
||||
}
|
||||
|
||||
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 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;
|
||||
@@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() {
|
||||
</Card>
|
||||
|
||||
{/* Practice sections */}
|
||||
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
|
||||
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
|
||||
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
795
components/explorers/integer-order-compare-explorer.tsx
Normal file
795
components/explorers/integer-order-compare-explorer.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
"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 { shuffle } from "@/lib/utils";
|
||||
|
||||
type Mode = "number-line" | "order" | "compare";
|
||||
type Direction = "asc" | "desc";
|
||||
type Tone = "start" | "highlight" | "muted";
|
||||
|
||||
const MIN_VAL = -20;
|
||||
const MAX_VAL = 20;
|
||||
const RANGE = MAX_VAL - MIN_VAL;
|
||||
|
||||
interface Marker {
|
||||
value: number;
|
||||
label?: string;
|
||||
tone?: Tone;
|
||||
}
|
||||
|
||||
interface OrderChips {
|
||||
pool: number[];
|
||||
ordered: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
markers: Marker[];
|
||||
order?: OrderChips;
|
||||
}
|
||||
|
||||
// Real-world contexts for the "Number Line" mode
|
||||
const CONTEXTS = {
|
||||
number: {
|
||||
emoji: "🔢",
|
||||
label: "Number",
|
||||
describe: (n: number) =>
|
||||
n === 0
|
||||
? "0 is neither positive nor negative — it separates the two sides."
|
||||
: `${n} is ${Math.abs(n)} step${Math.abs(n) === 1 ? "" : "s"} to the ${n < 0 ? "LEFT" : "RIGHT"} of zero.`,
|
||||
},
|
||||
thermometer: {
|
||||
emoji: "🌡️",
|
||||
label: "Temperature",
|
||||
describe: (n: number) =>
|
||||
n === 0
|
||||
? "0 °C is freezing point — the boundary between warm and cold."
|
||||
: `${n} °C means ${Math.abs(n)} degree${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} freezing.`,
|
||||
},
|
||||
money: {
|
||||
emoji: "💰",
|
||||
label: "Money",
|
||||
describe: (n: number) =>
|
||||
n === 0
|
||||
? "$0 means you have nothing and owe nothing."
|
||||
: n < 0
|
||||
? `−$${Math.abs(n)} means you OWE $${Math.abs(n)} (an overdraft).`
|
||||
: `+$${n} means you HAVE $${n} in the bank.`,
|
||||
},
|
||||
sea: {
|
||||
emoji: "🌊",
|
||||
label: "Sea level",
|
||||
describe: (n: number) =>
|
||||
n === 0
|
||||
? "0 m is exactly at sea level."
|
||||
: `${n} m means ${Math.abs(n)} metre${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} sea level.`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ContextKey = keyof typeof CONTEXTS;
|
||||
|
||||
function buildNumberLineSteps(n: number, ctx: ContextKey): Step[] {
|
||||
const context = CONTEXTS[ctx];
|
||||
const typeWord = n === 0 ? "zero" : n < 0 ? "a NEGATIVE integer" : "a POSITIVE integer";
|
||||
return [
|
||||
{
|
||||
label: "Zero sits in the middle. Negative numbers are to its LEFT, positive numbers to its RIGHT.",
|
||||
math: `\\ldots -3,\\ -2,\\ -1,\\ 0,\\ 1,\\ 2,\\ 3 \\ldots`,
|
||||
markers: [{ value: 0, label: "0", tone: "start" }],
|
||||
},
|
||||
{
|
||||
label: `Find ${n} on the number line and mark it.`,
|
||||
math: `\\text{Mark } ${n}`,
|
||||
markers: [
|
||||
{ value: 0, tone: "muted" },
|
||||
{ value: n, label: `${n}`, tone: "highlight" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: `${context.emoji} ${context.describe(n)}`,
|
||||
math: `${n}`,
|
||||
markers: [{ value: n, label: `${n}`, tone: "start" }],
|
||||
},
|
||||
{
|
||||
label: `So ${n} is ${typeWord}.`,
|
||||
math: n === 0 ? `0` : `${n} \\text{ is } ${n < 0 ? "<" : ">"} 0`,
|
||||
markers: [{ value: n, label: `${n}`, tone: "start" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildOrderSteps(values: number[], direction: Direction): Step[] {
|
||||
const sorted = [...values].sort((a, b) => (direction === "asc" ? a - b : b - a));
|
||||
const sizeWord = direction === "asc" ? "smallest" : "largest";
|
||||
const posWord = direction === "asc" ? "furthest LEFT" : "furthest RIGHT";
|
||||
const total = values.length;
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: "Here are the numbers we need to put in order.",
|
||||
math: `\\{\\, ${values.join(",\\ ")} \\,\\}`,
|
||||
markers: values.map((v) => ({ value: v, tone: "muted" as Tone })),
|
||||
order: { pool: [...values], ordered: [], total },
|
||||
},
|
||||
{
|
||||
label: "Place every number on the number line. Smaller numbers sit further left.",
|
||||
math: `\\text{left = smaller} \\qquad \\text{right = larger}`,
|
||||
markers: values.map((v) => ({ value: v, tone: "highlight" as Tone })),
|
||||
order: { pool: [...values], ordered: [], total },
|
||||
},
|
||||
];
|
||||
|
||||
const pool = [...values];
|
||||
const ordered: number[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const pick = sorted[i];
|
||||
pool.splice(pool.indexOf(pick), 1);
|
||||
ordered.push(pick);
|
||||
steps.push({
|
||||
label: `The ${sizeWord} number still to place is the one ${posWord}: ${pick}.`,
|
||||
math: `${ordered.join(direction === "asc" ? " < " : " > ")}${pool.length ? "\\ \\ldots" : ""}`,
|
||||
markers: [
|
||||
...ordered.map((v) => ({ value: v, tone: "start" as Tone })),
|
||||
...pool.map((v) => ({ value: v, tone: "muted" as Tone })),
|
||||
],
|
||||
order: { pool: [...pool], ordered: [...ordered], total },
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
label:
|
||||
direction === "asc"
|
||||
? "Done! In ascending order: least → greatest."
|
||||
: "Done! In descending order: greatest → least.",
|
||||
math: `${sorted.join(direction === "asc" ? " < " : " > ")}`,
|
||||
markers: sorted.map((v) => ({ value: v, tone: "start" as Tone })),
|
||||
order: { pool: [], ordered: [...sorted], total },
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function buildCompareSteps(a: number, b: number): Step[] {
|
||||
const symbol = a < b ? "<" : a > b ? ">" : "=";
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: `Put both numbers on the number line.`,
|
||||
math: `${a} \\;\\square\\; ${b}`,
|
||||
markers: [
|
||||
{ value: a, label: `${a}`, tone: "highlight" },
|
||||
{ value: b, label: `${b}`, tone: "highlight" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (symbol === "=") {
|
||||
steps.push({
|
||||
label: "Both numbers land on the same spot, so they are equal.",
|
||||
math: `${a} = ${b}`,
|
||||
markers: [{ value: a, label: `${a}`, tone: "start" }],
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
const larger = a > b ? a : b;
|
||||
const smaller = a > b ? b : a;
|
||||
steps.push(
|
||||
{
|
||||
label: `The number further RIGHT is always larger. ${larger} is to the right of ${smaller}.`,
|
||||
math: `${larger} \\text{ is larger}`,
|
||||
markers: [
|
||||
{ value: smaller, label: `${smaller}`, tone: "muted" },
|
||||
{ value: larger, label: `${larger}`, tone: "start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: `The open mouth of the sign always points to the LARGER number.`,
|
||||
math: `${a} ${symbol} ${b}`,
|
||||
markers: [
|
||||
{ value: a, label: `${a}`, tone: a === larger ? "start" : "muted" },
|
||||
{ value: b, label: `${b}`, tone: b === larger ? "start" : "muted" },
|
||||
],
|
||||
},
|
||||
);
|
||||
return steps;
|
||||
}
|
||||
|
||||
function NumberLine({ markers }: { markers: Marker[] }) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl select-none">
|
||||
<div className="relative h-28 px-4">
|
||||
{/* Markers above the line */}
|
||||
{markers.map((m, i) => {
|
||||
const x = ((m.value - MIN_VAL) / RANGE) * 100;
|
||||
const tone = m.tone ?? "highlight";
|
||||
const bg =
|
||||
tone === "start" ? "bg-unit-5" : tone === "muted" ? "bg-muted/70" : "bg-correct";
|
||||
return (
|
||||
<div
|
||||
key={`${m.value}-${i}`}
|
||||
className="absolute bottom-14 -translate-x-1/2 transition-all duration-500"
|
||||
style={{ left: `calc(${x}% + 16px)` }}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
{m.label !== undefined && (
|
||||
<span
|
||||
className={`rounded-full ${bg} px-2 py-0.5 text-[10px] font-bold text-white shadow-md`}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
)}
|
||||
<div className={`h-3 w-0.5 ${bg}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Number line */}
|
||||
<div className="absolute bottom-8 left-4 right-4">
|
||||
<div className="h-0.5 bg-foreground/40" />
|
||||
{Array.from({ length: RANGE + 1 }, (_, i) => {
|
||||
const val = MIN_VAL + i;
|
||||
const x = (i / RANGE) * 100;
|
||||
const isZero = val === 0;
|
||||
const marked = markers.some((m) => m.value === val);
|
||||
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 ${
|
||||
marked
|
||||
? "h-4 bg-unit-5"
|
||||
: isZero
|
||||
? "bg-foreground"
|
||||
: "bg-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
{(val % 5 === 0 || marked) && (
|
||||
<span
|
||||
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
|
||||
marked
|
||||
? "font-extrabold text-unit-5"
|
||||
: isZero
|
||||
? "font-bold text-foreground"
|
||||
: "text-muted/70"
|
||||
}`}
|
||||
>
|
||||
{val}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Edge labels */}
|
||||
<div className="absolute bottom-0 left-2 right-2 flex justify-between text-xs text-muted/50">
|
||||
<span>← negative</span>
|
||||
<span>positive →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) {
|
||||
const emptySlots = order.total - order.ordered.length;
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted">Still to place</p>
|
||||
<div className="flex min-h-9 flex-wrap gap-2">
|
||||
{order.pool.length === 0 ? (
|
||||
<span className="text-sm text-muted/50">— all placed —</span>
|
||||
) : (
|
||||
order.pool.map((v, i) => (
|
||||
<span
|
||||
key={`${v}-${i}`}
|
||||
className="rounded-lg border-2 border-border bg-surface px-3 py-1.5 text-sm font-bold text-muted"
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||
Ordered ({direction === "asc" ? "small → large" : "large → small"})
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{order.ordered.map((v, i) => (
|
||||
<span key={`${v}-${i}`} className="flex items-center gap-2">
|
||||
{i > 0 && <span className="text-sm font-bold text-unit-5">{direction === "asc" ? "<" : ">"}</span>}
|
||||
<span className="rounded-lg border-2 border-unit-5 bg-unit-5-light px-3 py-1.5 text-sm font-extrabold text-unit-5-dark">
|
||||
{v}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }, (_, i) => (
|
||||
<span key={`empty-${i}`} className="flex items-center gap-2">
|
||||
{(order.ordered.length > 0 || i > 0) && (
|
||||
<span className="text-sm font-bold text-muted/40">{direction === "asc" ? "<" : ">"}</span>
|
||||
)}
|
||||
<span className="h-9 w-11 rounded-lg border-2 border-dashed border-border" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||
|
||||
const SEED_PAIRS: [number, number][] = [
|
||||
[-5, 5],
|
||||
[4, -16],
|
||||
[-6, -11],
|
||||
[0, -3],
|
||||
[8, -1],
|
||||
];
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function makePair(): [number, number] {
|
||||
if (Math.random() < 0.4) return SEED_PAIRS[Math.floor(Math.random() * SEED_PAIRS.length)];
|
||||
const a = randInt(-15, 15);
|
||||
let b = randInt(-15, 15);
|
||||
if (a === b) b = a + 1;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
function makeOrderValues(): number[] {
|
||||
const set = new Set<number>();
|
||||
while (set.size < 4) set.add(randInt(-12, 12));
|
||||
return shuffle([...set]);
|
||||
}
|
||||
|
||||
function ComparePractice({ onScore }: { onScore: (correct: boolean) => void }) {
|
||||
const [pair, setPair] = useState<[number, number]>(() => makePair());
|
||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||
|
||||
const [a, b] = pair;
|
||||
const answer = a < b ? "<" : ">";
|
||||
|
||||
function pick(sign: "<" | ">") {
|
||||
if (feedback === "correct") return;
|
||||
const correct = sign === answer;
|
||||
setFeedback(correct ? "correct" : "incorrect");
|
||||
if (correct) onScore(true);
|
||||
else onScore(false);
|
||||
}
|
||||
|
||||
function next() {
|
||||
setPair(makePair());
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-muted">Which sign makes it true?</p>
|
||||
<div className="flex items-center justify-center gap-4 text-3xl font-extrabold">
|
||||
<span>{a}</span>
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-xl border-2 border-dashed border-unit-5/50 text-unit-5">
|
||||
?
|
||||
</span>
|
||||
<span>{b}</span>
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
{(["<", ">"] as const).map((sign) => (
|
||||
<button
|
||||
key={sign}
|
||||
onClick={() => pick(sign)}
|
||||
disabled={feedback === "correct"}
|
||||
className={`h-14 w-14 rounded-xl border-2 text-2xl font-extrabold transition-colors ${
|
||||
feedback === "correct" && sign === answer
|
||||
? "border-correct bg-correct-light text-correct"
|
||||
: feedback === "incorrect" && sign !== answer
|
||||
? "border-border text-muted"
|
||||
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light disabled:opacity-50"
|
||||
}`}
|
||||
>
|
||||
{sign}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{feedback === "correct" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-correct">Correct! {a} {answer} {b}</p>
|
||||
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{feedback === "incorrect" && (
|
||||
<p className="text-sm font-bold text-incorrect">Not quite — remember, further right means larger. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) {
|
||||
const [values, setValues] = useState<number[]>(() => makeOrderValues());
|
||||
const [placed, setPlaced] = useState<number[]>([]);
|
||||
const [wrong, setWrong] = useState<number | null>(null);
|
||||
const [mistakes, setMistakes] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const nextExpected = sorted[placed.length];
|
||||
|
||||
function clickChip(v: number) {
|
||||
if (done || placed.includes(v)) return;
|
||||
if (v === nextExpected) {
|
||||
const nextPlaced = [...placed, v];
|
||||
setPlaced(nextPlaced);
|
||||
setWrong(null);
|
||||
if (nextPlaced.length === sorted.length) {
|
||||
setDone(true);
|
||||
onScore(mistakes === 0);
|
||||
}
|
||||
} else {
|
||||
setWrong(v);
|
||||
setMistakes((m) => m + 1);
|
||||
setTimeout(() => setWrong((w) => (w === v ? null : w)), 500);
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
setValues(makeOrderValues());
|
||||
setPlaced([]);
|
||||
setWrong(null);
|
||||
setMistakes(0);
|
||||
setDone(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-sm text-muted">Click the numbers in ascending order (smallest first).</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{values.map((v) => {
|
||||
const isPlaced = placed.includes(v);
|
||||
const isWrong = wrong === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => clickChip(v)}
|
||||
disabled={isPlaced || done}
|
||||
className={`h-12 w-14 rounded-xl border-2 text-lg font-extrabold transition-all ${
|
||||
isPlaced
|
||||
? "border-correct bg-correct-light text-correct"
|
||||
: isWrong
|
||||
? "border-incorrect bg-incorrect-light text-incorrect"
|
||||
: "border-unit-5/40 text-unit-5-dark hover:bg-unit-5-light"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-lg font-bold text-unit-5">
|
||||
{placed.map((v, i) => (
|
||||
<span key={v}>
|
||||
{i > 0 && <span className="mr-2 text-muted">{"<"}</span>}
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
{placed.length < sorted.length && <span className="text-muted/40">…</span>}
|
||||
</div>
|
||||
{done && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-correct">
|
||||
{mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`}
|
||||
</p>
|
||||
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickPractice() {
|
||||
const [kind, setKind] = useState<"compare" | "order">("compare");
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
const record = useCallback((correct: boolean) => {
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1.5">
|
||||
{(["compare", "order"] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setKind(k)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold transition-colors ${
|
||||
kind === k ? "bg-unit-5 text-white" : "text-unit-5 hover:bg-unit-5-light"
|
||||
}`}
|
||||
>
|
||||
{k === "compare" ? "Compare" : "Order"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
{kind === "compare" ? (
|
||||
<ComparePractice key={`c-${score.total}`} onScore={record} />
|
||||
) : (
|
||||
<OrderPractice key={`o-${score.total}`} onScore={record} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function IntegerOrderCompareExplorer() {
|
||||
const [mode, setMode] = useState<Mode>("number-line");
|
||||
|
||||
// number-line mode
|
||||
const [nlInput, setNlInput] = useState("-7");
|
||||
const [context, setContext] = useState<ContextKey>("thermometer");
|
||||
|
||||
// order mode
|
||||
const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5");
|
||||
const [direction, setDirection] = useState<Direction>("asc");
|
||||
|
||||
// compare mode
|
||||
const [cmpA, setCmpA] = useState("-5");
|
||||
const [cmpB, setCmpB] = 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;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSteps(null);
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
function switchMode(m: Mode) {
|
||||
setMode(m);
|
||||
setError("");
|
||||
reset();
|
||||
}
|
||||
|
||||
function handleGo() {
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "number-line") {
|
||||
const n = parseInt(nlInput, 10);
|
||||
if (isNaN(n)) return setError("Enter a valid integer.");
|
||||
if (n < MIN_VAL || n > MAX_VAL) return setError(`Keep the number between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||
setSteps(buildNumberLineSteps(n, context));
|
||||
} else if (mode === "order") {
|
||||
const parts = orderInput
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const nums = parts.map((s) => parseInt(s, 10));
|
||||
if (nums.some((n) => isNaN(n))) return setError("Use whole numbers separated by commas, e.g. -8, -10, 5.");
|
||||
if (nums.length < 3 || nums.length > 6) return setError("Enter between 3 and 6 numbers.");
|
||||
if (nums.some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep every number between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||
setSteps(buildOrderSteps(nums, direction));
|
||||
} else {
|
||||
const a = parseInt(cmpA, 10);
|
||||
const b = parseInt(cmpB, 10);
|
||||
if (isNaN(a) || isNaN(b)) return setError("Enter two valid integers.");
|
||||
if ([a, b].some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep both numbers between ${MIN_VAL} and ${MAX_VAL}.`);
|
||||
setSteps(buildCompareSteps(a, b));
|
||||
}
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
} catch {
|
||||
setError("Could not build the steps. Check your input.");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["number-line", "order", "compare"] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => switchMode(m)}
|
||||
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" : m === "order" ? "Order" : "Compare"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
{mode === "number-line" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||
Place an integer on the number line
|
||||
</p>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{(Object.keys(CONTEXTS) as ContextKey[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setContext(key);
|
||||
reset();
|
||||
}}
|
||||
className={`rounded-full border-2 px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
context === key
|
||||
? "border-unit-5-dark bg-unit-5-dark text-white"
|
||||
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
|
||||
}`}
|
||||
>
|
||||
{CONTEXTS[key].emoji} {CONTEXTS[key].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={nlInput}
|
||||
onChange={(e) => setNlInput(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="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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "order" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||
Enter 3–6 integers to order
|
||||
</p>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{(["asc", "desc"] as Direction[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => {
|
||||
setDirection(d);
|
||||
reset();
|
||||
}}
|
||||
className={`rounded-full border-2 px-4 py-1.5 text-xs font-semibold transition-colors ${
|
||||
direction === d
|
||||
? "border-unit-5-dark bg-unit-5-dark text-white"
|
||||
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
|
||||
}`}
|
||||
>
|
||||
{d === "asc" ? "Ascending (small → large)" : "Descending (large → small)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={orderInput}
|
||||
onChange={(e) => setOrderInput(e.target.value)}
|
||||
className="min-w-[16rem] flex-1 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="Comma-separated integers"
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "compare" && (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
|
||||
Compare two integers with < or >
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={cmpA}
|
||||
onChange={(e) => setCmpA(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">?</span>
|
||||
<input
|
||||
type="number"
|
||||
value={cmpB}
|
||||
onChange={(e) => setCmpB(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>
|
||||
|
||||
{/* Display card */}
|
||||
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
|
||||
{!step ? (
|
||||
<p className="text-muted/50">
|
||||
Set up the numbers 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" />
|
||||
<NumberLine markers={step.markers} />
|
||||
{step.order && <OrderChipsView order={step.order} direction={direction} />}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
393
components/explorers/negative-fractions-explorer.tsx
Normal file
393
components/explorers/negative-fractions-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
356
components/explorers/number-laws-explorer.tsx
Normal file
356
components/explorers/number-laws-explorer.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
"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";
|
||||
|
||||
type Mode = "commutative" | "associative" | "distributive";
|
||||
type Op = "+" | "-" | "\\times" | "\\div";
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
match?: boolean; // colour the result green (holds) / red (fails)
|
||||
}
|
||||
|
||||
const OP_META: Record<Op, { apply: (a: number, b: number) => number; commutative: boolean; associative: boolean; name: string }> = {
|
||||
"+": { apply: (a, b) => a + b, commutative: true, associative: true, name: "Addition" },
|
||||
"-": { apply: (a, b) => a - b, commutative: false, associative: false, name: "Subtraction" },
|
||||
"\\times": { apply: (a, b) => a * b, commutative: true, associative: true, name: "Multiplication" },
|
||||
"\\div": { apply: (a, b) => a / b, commutative: false, associative: false, name: "Division" },
|
||||
};
|
||||
|
||||
function fmt(x: number): string {
|
||||
if (Number.isInteger(x)) return `${x}`;
|
||||
return `${parseFloat(x.toFixed(3))}`;
|
||||
}
|
||||
|
||||
function buildCommutativeSteps(a: number, b: number, op: Op): Step[] {
|
||||
const meta = OP_META[op];
|
||||
const r1 = meta.apply(a, b);
|
||||
const r2 = meta.apply(b, a);
|
||||
const same = r1 === r2;
|
||||
return [
|
||||
{
|
||||
label: "Commutative law: does the ORDER of the numbers change the answer?",
|
||||
math: `${a} ${op} ${b} \\quad \\text{vs} \\quad ${b} ${op} ${a}`,
|
||||
},
|
||||
{ label: `Work out the first order.`, math: `${a} ${op} ${b} = ${fmt(r1)}` },
|
||||
{ label: `Now swap the order.`, math: `${b} ${op} ${a} = ${fmt(r2)}` },
|
||||
meta.commutative
|
||||
? {
|
||||
label: `Same answer! ${meta.name} IS commutative — order does not matter.`,
|
||||
math: `a ${op} b = b ${op} a`,
|
||||
match: true,
|
||||
}
|
||||
: {
|
||||
label: same
|
||||
? `They match here only because the numbers are special. In general, ${meta.name.toLowerCase()} is NOT commutative.`
|
||||
: `Different answers! ${meta.name} is NOT commutative — order matters.`,
|
||||
math: `a ${op} b \\neq b ${op} a`,
|
||||
match: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildAssociativeSteps(a: number, b: number, c: number, op: Op): Step[] {
|
||||
const meta = OP_META[op];
|
||||
const left = meta.apply(meta.apply(a, b), c);
|
||||
const right = meta.apply(a, meta.apply(b, c));
|
||||
return [
|
||||
{
|
||||
label: "Associative law: does where we put the BRACKETS change the answer?",
|
||||
math: `(${a} ${op} ${b}) ${op} ${c} \\quad \\text{vs} \\quad ${a} ${op} (${b} ${op} ${c})`,
|
||||
},
|
||||
{ label: "Do the brackets on the LEFT first.", math: `(${a} ${op} ${b}) ${op} ${c} = ${fmt(meta.apply(a, b))} ${op} ${c} = ${fmt(left)}` },
|
||||
{ label: "Now do the brackets on the RIGHT first.", math: `${a} ${op} (${b} ${op} ${c}) = ${a} ${op} ${fmt(meta.apply(b, c))} = ${fmt(right)}` },
|
||||
meta.associative
|
||||
? { label: `Same answer! ${meta.name} IS associative — the grouping does not matter.`, math: `(a ${op} b) ${op} c = a ${op} (b ${op} c)`, match: true }
|
||||
: { label: `Different answers! ${meta.name} is NOT associative — the grouping matters.`, math: `(a ${op} b) ${op} c \\neq a ${op} (b ${op} c)`, match: false },
|
||||
];
|
||||
}
|
||||
|
||||
function buildDistributiveSteps(a: number, b: number, c: number): Step[] {
|
||||
const inside = b + c;
|
||||
const answer = a * inside;
|
||||
return [
|
||||
{ label: "Distributive law: a number outside a bracket multiplies EVERYTHING inside.", math: `${a} \\times (${b} + ${c})` },
|
||||
{
|
||||
label: `Multiply the ${a} by each number inside the bracket separately.`,
|
||||
math: `${a} \\times ${b} \\; + \\; ${a} \\times ${c}`,
|
||||
},
|
||||
{ label: "Working the direct way: add inside the bracket first, then multiply.", math: `${a} \\times (${b} + ${c}) = ${a} \\times ${inside} = ${answer}` },
|
||||
{ label: "Working the expanded way gives the same answer.", math: `${a * b} + ${a * c} = ${answer}`, match: true },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PracticeQ {
|
||||
prompt: string;
|
||||
answer: number;
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function makeQuestion(): PracticeQ {
|
||||
const roll = Math.floor(Math.random() * 4);
|
||||
if (roll === 0) {
|
||||
const a = randInt(2, 9), b = randInt(1, 9), c = randInt(1, 9);
|
||||
return { prompt: `Fill the blank: ${a} × (${b} + ${c}) = ${a} × ${b} + ${a} × ▢`, answer: c };
|
||||
}
|
||||
if (roll === 1) {
|
||||
const a = randInt(3, 12), b = randInt(1, a - 1);
|
||||
return { prompt: `Subtraction is not commutative. What is ${a} − ${b}?`, answer: a - b };
|
||||
}
|
||||
if (roll === 2) {
|
||||
const a = randInt(2, 8), b = randInt(1, 6), c = randInt(1, 6);
|
||||
return { prompt: `Work out ${a} × (${b} + ${c})`, answer: a * (b + c) };
|
||||
}
|
||||
const a = randInt(2, 9), b = randInt(2, 9);
|
||||
return { prompt: `Multiplication is commutative, so ${a} × ${b} equals ${b} × ▢. What is ▢?`, answer: a };
|
||||
}
|
||||
|
||||
function QuickPractice() {
|
||||
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||
const [val, setVal] = useState("");
|
||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
function check() {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (isNaN(parsed)) return;
|
||||
const correct = parsed === q.answer;
|
||||
setFeedback(correct ? "correct" : "incorrect");
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}
|
||||
|
||||
function next() {
|
||||
setQ(makeQuestion());
|
||||
setVal("");
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
onChange={(e) => {
|
||||
setVal(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-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-8"
|
||||
}`}
|
||||
placeholder="?"
|
||||
aria-label="Your answer"
|
||||
/>
|
||||
{feedback === null ? (
|
||||
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
|
||||
Check
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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 {q.answer}.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function NumberLawsExplorer() {
|
||||
const [mode, setMode] = useState<Mode>("commutative");
|
||||
const [op, setOp] = useState<Op>("+");
|
||||
const [a, setA] = useState("8");
|
||||
const [b, setB] = useState("3");
|
||||
const [c, setC] = useState("2");
|
||||
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 switchMode(m: Mode) {
|
||||
setMode(m);
|
||||
setError("");
|
||||
// distributive is fixed to + inside; keep whatever op for the others
|
||||
reset();
|
||||
}
|
||||
|
||||
const opChoices: Op[] = mode === "associative" ? ["+", "\\times", "-"] : ["+", "-", "\\times", "\\div"];
|
||||
|
||||
function handleGo() {
|
||||
setError("");
|
||||
const na = parseInt(a, 10);
|
||||
const nb = parseInt(b, 10);
|
||||
const nc = parseInt(c, 10);
|
||||
if (isNaN(na) || isNaN(nb) || (mode !== "commutative" && isNaN(nc))) {
|
||||
return setError("Enter whole numbers in every box.");
|
||||
}
|
||||
const vals = mode === "commutative" ? [na, nb] : [na, nb, nc];
|
||||
if (vals.some((v) => Math.abs(v) > 50)) return setError("Keep the numbers between -50 and 50.");
|
||||
if ((op === "\\div") && (nb === 0 || (mode === "associative" && nc === 0))) {
|
||||
return setError("Cannot divide by zero — change the numbers.");
|
||||
}
|
||||
if (mode === "commutative") setSteps(buildCommutativeSteps(na, nb, op));
|
||||
else if (mode === "associative") setSteps(buildAssociativeSteps(na, nb, nc, op));
|
||||
else setSteps(buildDistributiveSteps(na, nb, nc));
|
||||
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 showThree = mode !== "commutative";
|
||||
const showOp = mode !== "distributive";
|
||||
|
||||
const opLabel: Record<Op, string> = { "+": "+", "-": "−", "\\times": "×", "\\div": "÷" };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["commutative", "associative", "distributive"] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => switchMode(m)}
|
||||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
|
||||
mode === m
|
||||
? "border-unit-8 bg-unit-8 text-white"
|
||||
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Operation tabs */}
|
||||
{showOp && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opChoices.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => {
|
||||
setOp(o);
|
||||
reset();
|
||||
}}
|
||||
className={`h-10 w-12 rounded-full border-2 text-lg font-bold transition-colors ${
|
||||
op === o
|
||||
? "border-unit-8-dark bg-unit-8-dark text-white"
|
||||
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
|
||||
}`}
|
||||
>
|
||||
{opLabel[o]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
|
||||
{mode === "distributive" ? "Enter a, b and c for a × (b + c)" : showThree ? "Enter three numbers" : "Enter two numbers"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input type="number" value={a} onChange={(e) => setA(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-8" aria-label="a" />
|
||||
<input type="number" value={b} onChange={(e) => setB(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-8" aria-label="b" />
|
||||
{showThree && (
|
||||
<input type="number" value={c} onChange={(e) => setC(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-8" aria-label="c" />
|
||||
)}
|
||||
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-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 numbers 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 ${step.match === true ? "text-correct" : step.match === false ? "text-incorrect" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
351
components/explorers/place-value-explorer.tsx
Normal file
351
components/explorers/place-value-explorer.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"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";
|
||||
|
||||
interface Column {
|
||||
exponent: number;
|
||||
placeValue: number;
|
||||
digit: string;
|
||||
contrib: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
activeCol: number | null;
|
||||
revealed: number; // how many columns show their contribution
|
||||
}
|
||||
|
||||
const PLACE_NAMES = ["ones", "tens", "hundreds", "thousands", "ten thousands", "hundred thousands"];
|
||||
|
||||
function columnsFor(n: number): Column[] {
|
||||
const str = `${n}`;
|
||||
const len = str.length;
|
||||
return str.split("").map((d, i) => {
|
||||
const exponent = len - 1 - i;
|
||||
return {
|
||||
exponent,
|
||||
placeValue: Math.pow(10, exponent),
|
||||
digit: d,
|
||||
contrib: parseInt(d, 10) * Math.pow(10, exponent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSteps(n: number): Step[] {
|
||||
const cols = columnsFor(n);
|
||||
const len = cols.length;
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: "In the denary (base 10) system, each digit has a place value that is a power of 10.",
|
||||
math: `${n}`,
|
||||
activeCol: null,
|
||||
revealed: 0,
|
||||
},
|
||||
];
|
||||
|
||||
cols.forEach((c, i) => {
|
||||
steps.push({
|
||||
label: `The ${PLACE_NAMES[c.exponent]} digit is ${c.digit}. Its place value is 10^${c.exponent} = ${c.placeValue.toLocaleString("en-US")}.`,
|
||||
math: `${c.digit} \\times 10^{${c.exponent}} = ${c.contrib.toLocaleString("en-US")}`,
|
||||
activeCol: i,
|
||||
revealed: i + 1,
|
||||
});
|
||||
});
|
||||
|
||||
const expanded = cols.map((c) => `${c.digit} \\times 10^{${c.exponent}}`).join(" + ");
|
||||
steps.push({
|
||||
label: "Put it all together — this is the number in expanded form.",
|
||||
math: `${n} = ${expanded}`,
|
||||
activeCol: null,
|
||||
revealed: len,
|
||||
});
|
||||
|
||||
const sumParts = cols.map((c) => c.contrib.toLocaleString("en-US")).join(" + ");
|
||||
steps.push({
|
||||
label: "Add every part back up and you return to the original number.",
|
||||
math: `${sumParts} = ${n.toLocaleString("en-US")}`,
|
||||
activeCol: null,
|
||||
revealed: len,
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
function PlaceValueChart({ columns, step }: { columns: Column[]; step: Step }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border-2 border-unit-6/30 bg-unit-6-light/40 p-3">
|
||||
<table className="mx-auto border-collapse font-mono">
|
||||
<tbody>
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<td
|
||||
key={`p-${i}`}
|
||||
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6-dark ${
|
||||
step.activeCol === i ? "bg-unit-6 text-white" : "bg-unit-6-light"
|
||||
}`}
|
||||
>
|
||||
10<sup>{c.exponent}</sup>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<td
|
||||
key={`v-${i}`}
|
||||
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6 ${
|
||||
step.activeCol === i ? "bg-unit-6-light" : "bg-surface"
|
||||
}`}
|
||||
>
|
||||
{c.placeValue.toLocaleString("en-US")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<td
|
||||
key={`d-${i}`}
|
||||
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-2xl font-extrabold ${
|
||||
step.activeCol === i ? "bg-unit-6-light text-unit-6-dark" : "bg-surface text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.digit}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<td
|
||||
key={`c-${i}`}
|
||||
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold ${
|
||||
i < step.revealed ? "bg-correct-light text-correct" : "bg-[#fafbfc] text-muted/50"
|
||||
}`}
|
||||
>
|
||||
{i < step.revealed ? c.contrib.toLocaleString("en-US") : "_"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quick practice ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PracticeQ {
|
||||
prompt: string;
|
||||
answer: number;
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function makeQuestion(): PracticeQ {
|
||||
if (Math.random() < 0.5) {
|
||||
// value of a digit
|
||||
const n = randInt(1000, 99999);
|
||||
const cols = columnsFor(n);
|
||||
const nonZero = cols.filter((c) => c.digit !== "0");
|
||||
const pick = nonZero[Math.floor(Math.random() * nonZero.length)];
|
||||
return {
|
||||
prompt: `In ${n.toLocaleString("en-US")}, what is the VALUE of the digit ${pick.digit}?`,
|
||||
answer: pick.contrib,
|
||||
};
|
||||
}
|
||||
// exponent of a named column
|
||||
const exp = randInt(0, 5);
|
||||
return {
|
||||
prompt: `Which power of 10 is the ${PLACE_NAMES[exp]} column? Enter the exponent.`,
|
||||
answer: exp,
|
||||
};
|
||||
}
|
||||
|
||||
function QuickPractice() {
|
||||
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
|
||||
const [val, setVal] = useState("");
|
||||
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
function check() {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (isNaN(parsed)) return;
|
||||
const correct = parsed === q.answer;
|
||||
setFeedback(correct ? "correct" : "incorrect");
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}
|
||||
|
||||
function next() {
|
||||
setQ(makeQuestion());
|
||||
setVal("");
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-6">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-6-light px-3 py-1 text-xs font-bold text-unit-6-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">{q.prompt}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={val}
|
||||
onChange={(e) => {
|
||||
setVal(e.target.value);
|
||||
setFeedback(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (feedback !== null) next();
|
||||
else check();
|
||||
}
|
||||
}}
|
||||
className={`w-32 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-6"
|
||||
}`}
|
||||
placeholder="?"
|
||||
aria-label="Your answer"
|
||||
/>
|
||||
{feedback === null ? (
|
||||
<button onClick={check} className="rounded-lg bg-unit-6 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-6-dark">
|
||||
Check
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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 {q.answer.toLocaleString("en-US")}.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlaceValueExplorer() {
|
||||
const [input, setInput] = useState("983275");
|
||||
const [error, setError] = useState("");
|
||||
const [columns, setColumns] = useState<Column[] | null>(null);
|
||||
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);
|
||||
setColumns(null);
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
function handleGo() {
|
||||
setError("");
|
||||
const cleaned = input.replace(/[,\s]/g, "");
|
||||
const n = parseInt(cleaned, 10);
|
||||
if (isNaN(n) || !/^\d+$/.test(cleaned)) return setError("Enter a whole number (digits only).");
|
||||
if (n < 1 || n > 999999) return setError("Use a whole number between 1 and 999,999.");
|
||||
setColumns(columnsFor(n));
|
||||
setSteps(buildSteps(n));
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-6">
|
||||
Enter a whole number (up to 999,999)
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGo();
|
||||
}}
|
||||
className="w-44 rounded-lg border-2 border-unit-6 bg-surface px-3 py-2 text-center font-mono text-2xl font-bold text-unit-6 outline-none focus:border-unit-6-dark"
|
||||
aria-label="Number input"
|
||||
/>
|
||||
<button
|
||||
onClick={handleGo}
|
||||
className="rounded-lg bg-unit-6 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-6-dark"
|
||||
>
|
||||
Break Down →
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||
</Card>
|
||||
|
||||
{/* Display card */}
|
||||
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
|
||||
{!step || !columns ? (
|
||||
<p className="text-muted/50">
|
||||
Enter a number above and click <strong>Break Down</strong>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||
<MathDisplay math={step.math} className="text-xl" />
|
||||
<PlaceValueChart columns={columns} step={step} />
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
418
components/explorers/plot-points-explorer.tsx
Normal file
418
components/explorers/plot-points-explorer.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
"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 { CartesianGrid, type GridPoint, type GridSegment, type GridArrow } from "./cartesian-grid";
|
||||
|
||||
type Mode = "plot" | "shape";
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
points: GridPoint[];
|
||||
arrows?: GridArrow[];
|
||||
segments?: GridSegment[];
|
||||
highlight?: { x: number; y: number };
|
||||
}
|
||||
|
||||
// ── Plot a single point ──────────────────────────────────────────────────────
|
||||
|
||||
function buildPlotSteps(x: number, y: number): Step[] {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: "Start at the origin (0, 0) — where the two axes cross.",
|
||||
math: `(0,\\ 0)`,
|
||||
points: [{ x: 0, y: 0, label: "start", tone: "muted" }],
|
||||
},
|
||||
];
|
||||
|
||||
// Move along x
|
||||
if (x === 0) {
|
||||
steps.push({
|
||||
label: "The x-coordinate is 0, so stay on the y-axis — don't move left or right.",
|
||||
math: `x = 0`,
|
||||
points: [{ x: 0, y: 0, tone: "muted" }],
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
label: `Move ALONG the x-axis: ${Math.abs(x)} to the ${x > 0 ? "RIGHT" : "LEFT"}.`,
|
||||
math: `x = ${x}`,
|
||||
points: [{ x: 0, y: 0, tone: "muted" }],
|
||||
arrows: [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
|
||||
});
|
||||
}
|
||||
|
||||
// Move along y
|
||||
if (y === 0) {
|
||||
steps.push({
|
||||
label: "The y-coordinate is 0, so stay on the x-axis — don't move up or down.",
|
||||
math: `y = 0`,
|
||||
points: [{ x, y: 0, tone: "muted" }],
|
||||
arrows: x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
label: `Now move parallel to the y-axis: ${Math.abs(y)} ${y > 0 ? "UP" : "DOWN"}.`,
|
||||
math: `y = ${y}`,
|
||||
points: [{ x, y: 0, tone: "muted" }],
|
||||
arrows: [
|
||||
...(x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }]),
|
||||
{ from: { x, y: 0 }, to: { x, y } },
|
||||
],
|
||||
highlight: { x, y },
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
label: `Mark the point (${x}, ${y}). Remember: the x-coordinate always comes first!`,
|
||||
math: `(${x},\\ ${y})`,
|
||||
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
|
||||
highlight: { x, y },
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ── Shape reveal presets ─────────────────────────────────────────────────────
|
||||
|
||||
interface Shape {
|
||||
name: string;
|
||||
points: { label: string; x: number; y: number }[];
|
||||
}
|
||||
|
||||
const SHAPES: Record<string, Shape> = {
|
||||
mystery: {
|
||||
name: "Mystery Shape",
|
||||
points: [
|
||||
{ label: "A", x: -4, y: -8 },
|
||||
{ label: "B", x: -3, y: -9 },
|
||||
{ label: "C", x: -2, y: -8 },
|
||||
{ label: "D", x: -2, y: 0 },
|
||||
{ label: "E", x: -5, y: 0 },
|
||||
{ label: "F", x: -6, y: 4 },
|
||||
{ label: "G", x: -2, y: 5 },
|
||||
{ label: "H", x: 2, y: 4 },
|
||||
{ label: "I", x: 5, y: 0 },
|
||||
],
|
||||
},
|
||||
square: {
|
||||
name: "Square",
|
||||
points: [
|
||||
{ label: "A", x: 2, y: 2 },
|
||||
{ label: "B", x: 7, y: 2 },
|
||||
{ label: "C", x: 7, y: 7 },
|
||||
{ label: "D", x: 2, y: 7 },
|
||||
],
|
||||
},
|
||||
house: {
|
||||
name: "House",
|
||||
points: [
|
||||
{ label: "A", x: -5, y: -4 },
|
||||
{ label: "B", x: 5, y: -4 },
|
||||
{ label: "C", x: 5, y: 3 },
|
||||
{ label: "D", x: 0, y: 7 },
|
||||
{ label: "E", x: -5, y: 3 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function buildShapeSteps(shape: Shape): Step[] {
|
||||
const pts = shape.points;
|
||||
const steps: Step[] = [];
|
||||
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const plotted = pts.slice(0, i + 1);
|
||||
const segments: GridSegment[] = [];
|
||||
for (let j = 1; j <= i; j++) {
|
||||
segments.push({ from: pts[j - 1], to: pts[j] });
|
||||
}
|
||||
const p = pts[i];
|
||||
steps.push({
|
||||
label:
|
||||
i === 0
|
||||
? `Plot the first point ${p.label} at (${p.x}, ${p.y}).`
|
||||
: `Plot ${p.label} at (${p.x}, ${p.y}) and join it to ${pts[i - 1].label}.`,
|
||||
math: `${p.label} = (${p.x},\\ ${p.y})`,
|
||||
points: plotted.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
|
||||
segments,
|
||||
highlight: { x: p.x, y: p.y },
|
||||
});
|
||||
}
|
||||
|
||||
// Closing segment
|
||||
const allSegments: GridSegment[] = [];
|
||||
for (let j = 1; j < pts.length; j++) allSegments.push({ from: pts[j - 1], to: pts[j] });
|
||||
allSegments.push({ from: pts[pts.length - 1], to: pts[0] });
|
||||
steps.push({
|
||||
label: `Finally, join ${pts[pts.length - 1].label} back to ${pts[0].label} to close the shape. What is it?`,
|
||||
math: `${pts[pts.length - 1].label} \\to ${pts[0].label}`,
|
||||
points: pts.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
|
||||
segments: allSegments,
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ── Quick practice: click the point ──────────────────────────────────────────
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function ClickPractice() {
|
||||
const [target, setTarget] = useState(() => ({ x: randInt(-8, 8), y: randInt(-8, 8) }));
|
||||
const [clicked, setClicked] = useState<{ x: number; y: number } | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
const isCorrect = clicked && clicked.x === target.x && clicked.y === target.y;
|
||||
|
||||
function handleClick(x: number, y: number) {
|
||||
if (clicked) return;
|
||||
const correct = x === target.x && y === target.y;
|
||||
setClicked({ x, y });
|
||||
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
|
||||
}
|
||||
|
||||
function next() {
|
||||
setTarget({ x: randInt(-8, 8), y: randInt(-8, 8) });
|
||||
setClicked(null);
|
||||
}
|
||||
|
||||
const points: GridPoint[] = [];
|
||||
if (clicked) {
|
||||
if (isCorrect) {
|
||||
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
|
||||
} else {
|
||||
points.push({ x: clicked.x, y: clicked.y, label: "you", tone: "wrong" });
|
||||
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">
|
||||
Click where <span className="text-unit-7-dark">({target.x}, {target.y})</span> belongs
|
||||
</p>
|
||||
<div className="mx-auto max-w-md">
|
||||
<CartesianGrid points={points} highlight={isCorrect ? target : undefined} onGridClick={clicked ? undefined : handleClick} />
|
||||
</div>
|
||||
{clicked && (
|
||||
<div className="space-y-2 text-center">
|
||||
{isCorrect ? (
|
||||
<p className="text-sm font-bold text-correct">Spot on! That is ({target.x}, {target.y}).</p>
|
||||
) : (
|
||||
<p className="text-sm font-bold text-incorrect">
|
||||
You clicked ({clicked.x}, {clicked.y}). The target ({target.x}, {target.y}) is shown in green.
|
||||
</p>
|
||||
)}
|
||||
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlotPointsExplorer() {
|
||||
const [mode, setMode] = useState<Mode>("plot");
|
||||
const [inputX, setInputX] = useState("4");
|
||||
const [inputY, setInputY] = useState("3");
|
||||
const [shapeKey, setShapeKey] = useState<keyof typeof SHAPES>("mystery");
|
||||
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 switchMode(m: Mode) {
|
||||
setMode(m);
|
||||
setError("");
|
||||
reset();
|
||||
}
|
||||
|
||||
function handlePlot() {
|
||||
setError("");
|
||||
const x = parseInt(inputX, 10);
|
||||
const y = parseInt(inputY, 10);
|
||||
if (isNaN(x) || isNaN(y)) return setError("Enter whole numbers for x and y.");
|
||||
if ([x, y].some((v) => v < -10 || v > 10)) return setError("Keep both coordinates between -10 and 10.");
|
||||
setSteps(buildPlotSteps(x, y));
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
function handleShape() {
|
||||
setError("");
|
||||
setSteps(buildShapeSteps(SHAPES[shapeKey]));
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["plot", "shape"] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => switchMode(m)}
|
||||
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
mode === m
|
||||
? "border-unit-7 bg-unit-7 text-white"
|
||||
: "border-unit-7/40 text-unit-7 hover:bg-unit-7-light"
|
||||
}`}
|
||||
>
|
||||
{m === "plot" ? "Plot a Point" : "Shape Reveal"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input card */}
|
||||
<Card>
|
||||
{mode === "plot" ? (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
|
||||
Enter the coordinates to plot
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-bold text-muted">(</span>
|
||||
<input
|
||||
type="number"
|
||||
value={inputX}
|
||||
onChange={(e) => setInputX(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-7"
|
||||
aria-label="x coordinate"
|
||||
/>
|
||||
<span className="text-lg font-bold text-muted">,</span>
|
||||
<input
|
||||
type="number"
|
||||
value={inputY}
|
||||
onChange={(e) => setInputY(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-7"
|
||||
aria-label="y coordinate"
|
||||
/>
|
||||
<span className="text-lg font-bold text-muted">)</span>
|
||||
<button
|
||||
onClick={handlePlot}
|
||||
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
|
||||
>
|
||||
Plot →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
|
||||
Pick a shape, then step through the points to reveal it
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={shapeKey}
|
||||
onChange={(e) => {
|
||||
setShapeKey(e.target.value as keyof typeof SHAPES);
|
||||
reset();
|
||||
}}
|
||||
className="rounded-lg border-2 border-border bg-surface px-3 py-2 text-sm font-bold outline-none focus:border-unit-7"
|
||||
aria-label="Choose a shape"
|
||||
>
|
||||
{Object.entries(SHAPES).map(([key, s]) => (
|
||||
<option key={key} value={key}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleShape}
|
||||
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
|
||||
>
|
||||
Start →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
|
||||
</Card>
|
||||
|
||||
{/* Display card */}
|
||||
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
|
||||
{!step ? (
|
||||
<p className="text-muted/50">
|
||||
{mode === "plot" ? (
|
||||
<>Enter coordinates above and click <strong>Plot</strong></>
|
||||
) : (
|
||||
<>Pick a shape and click <strong>Start</strong></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||
<MathDisplay math={step.math} className="text-2xl" />
|
||||
<div className="w-full max-w-md">
|
||||
<CartesianGrid
|
||||
points={step.points}
|
||||
segments={step.segments}
|
||||
arrows={step.arrows}
|
||||
highlight={step.highlight}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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]" />}>
|
||||
<ClickPractice />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
components/explorers/read-coordinates-explorer.tsx
Normal file
258
components/explorers/read-coordinates-explorer.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"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 { CartesianGrid, type GridPoint } from "./cartesian-grid";
|
||||
|
||||
interface Step {
|
||||
label: string;
|
||||
math: string;
|
||||
points: GridPoint[];
|
||||
highlight?: { x: number; y: number };
|
||||
guide?: "x" | "y" | "both";
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function quadrantName(x: number, y: number): string {
|
||||
if (x === 0 && y === 0) return "the origin";
|
||||
if (x === 0) return "on the y-axis";
|
||||
if (y === 0) return "on the x-axis";
|
||||
if (x > 0 && y > 0) return "the top-right quadrant";
|
||||
if (x < 0 && y > 0) return "the top-left quadrant";
|
||||
if (x < 0 && y < 0) return "the bottom-left quadrant";
|
||||
return "the bottom-right quadrant";
|
||||
}
|
||||
|
||||
function buildReadSteps(x: number, y: number): Step[] {
|
||||
const point: GridPoint = { x, y, tone: "solid" };
|
||||
return [
|
||||
{
|
||||
label: "Here is a point. Let's read off its coordinates.",
|
||||
math: `(?,\\ ?)`,
|
||||
points: [point],
|
||||
},
|
||||
{
|
||||
label: `Trace straight ${y >= 0 ? "DOWN" : "UP"} to the x-axis. It lands on ${x}.`,
|
||||
math: `x = ${x}`,
|
||||
points: [point],
|
||||
highlight: { x, y },
|
||||
guide: "x",
|
||||
},
|
||||
{
|
||||
label: `Now trace straight across to the y-axis. It lands on ${y}.`,
|
||||
math: `y = ${y}`,
|
||||
points: [point],
|
||||
highlight: { x, y },
|
||||
guide: "both",
|
||||
},
|
||||
{
|
||||
label: `The coordinates are (${x}, ${y}) — x first, then y. This point is in ${quadrantName(x, y)}.`,
|
||||
math: `(${x},\\ ${y})`,
|
||||
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
|
||||
highlight: { x, y },
|
||||
guide: "both",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Quick practice: read the coordinates ─────────────────────────────────────
|
||||
|
||||
function ReadPractice() {
|
||||
const [target, setTarget] = useState(() => ({ x: randInt(-9, 9), y: randInt(-9, 9) }));
|
||||
const [xVal, setXVal] = useState("");
|
||||
const [yVal, setYVal] = useState("");
|
||||
const [feedback, setFeedback] = useState<"correct" | "swapped" | "incorrect" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
function check() {
|
||||
const gx = parseInt(xVal, 10);
|
||||
const gy = parseInt(yVal, 10);
|
||||
if (isNaN(gx) || isNaN(gy)) return;
|
||||
if (gx === target.x && gy === target.y) {
|
||||
setFeedback("correct");
|
||||
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
|
||||
} else if (gx === target.y && gy === target.x && target.x !== target.y) {
|
||||
setFeedback("swapped");
|
||||
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
|
||||
} else {
|
||||
setFeedback("incorrect");
|
||||
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
setTarget({ x: randInt(-9, 9), y: randInt(-9, 9) });
|
||||
setXVal("");
|
||||
setYVal("");
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
|
||||
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
|
||||
{score.correct}/{score.total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-lg font-bold">What are the coordinates of this point?</p>
|
||||
<div className="mx-auto max-w-md">
|
||||
<CartesianGrid points={[{ x: target.x, y: target.y, tone: "solid" }]} highlight={feedback ? target : undefined} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<span className="text-lg font-bold text-muted">(</span>
|
||||
<input
|
||||
type="number"
|
||||
value={xVal}
|
||||
onChange={(e) => {
|
||||
setXVal(e.target.value);
|
||||
setFeedback(null);
|
||||
}}
|
||||
disabled={feedback === "correct"}
|
||||
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-7"
|
||||
placeholder="x"
|
||||
aria-label="x coordinate"
|
||||
/>
|
||||
<span className="text-lg font-bold text-muted">,</span>
|
||||
<input
|
||||
type="number"
|
||||
value={yVal}
|
||||
onChange={(e) => {
|
||||
setYVal(e.target.value);
|
||||
setFeedback(null);
|
||||
}}
|
||||
disabled={feedback === "correct"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (feedback === "correct") next();
|
||||
else check();
|
||||
}
|
||||
}}
|
||||
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-7"
|
||||
placeholder="y"
|
||||
aria-label="y coordinate"
|
||||
/>
|
||||
<span className="text-lg font-bold text-muted">)</span>
|
||||
{feedback === "correct" ? (
|
||||
<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-7 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-7-dark">
|
||||
Check
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct! ({target.x}, {target.y})</p>}
|
||||
{feedback === "swapped" && (
|
||||
<p className="text-center text-sm font-bold text-incorrect">You swapped them — the x-coordinate comes first! Try again.</p>
|
||||
)}
|
||||
{feedback === "incorrect" && (
|
||||
<p className="text-center text-sm font-bold text-incorrect">Not quite. Trace down to the x-axis, then across to the y-axis.</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main explorer ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReadCoordinatesExplorer() {
|
||||
const [point, setPoint] = useState(() => ({ x: 3, y: 2 }));
|
||||
const [steps, setSteps] = useState<Step[] | null>(() => buildReadSteps(3, 2));
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const done = steps ? currentStep >= steps.length - 1 : false;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
function newPoint() {
|
||||
let x = randInt(-9, 9);
|
||||
const y = randInt(-9, 9);
|
||||
if (x === point.x && y === point.y) x = -x || 1;
|
||||
setPoint({ x, y });
|
||||
setSteps(buildReadSteps(x, y));
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
|
||||
Read the coordinates of the point
|
||||
</p>
|
||||
<button
|
||||
onClick={newPoint}
|
||||
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
|
||||
>
|
||||
New Point ↻
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
{/* Display card */}
|
||||
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
|
||||
{step && (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
|
||||
<MathDisplay math={step.math} className="text-2xl" />
|
||||
<div className="w-full max-w-md">
|
||||
<CartesianGrid
|
||||
points={step.points}
|
||||
highlight={
|
||||
step.guide && step.highlight
|
||||
? { ...step.highlight, axis: step.guide === "x" ? "x" : "both" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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]" />}>
|
||||
<ReadPractice />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user