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>
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,78 @@ const art: Record<string, ReactNode> = {
|
||||
<text x="50" y="44" fontSize="11" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>base 4</text>
|
||||
</>
|
||||
),
|
||||
|
||||
// Unit 5 — Integers (Order & Compare)
|
||||
"unit-5-integers/order-compare": (
|
||||
<>
|
||||
<path d="M14 44 H86 M24 44 v-6 M44 44 v-6 M64 44 v-6 M84 44 v-6" stroke="white" strokeWidth="2.5" strokeLinecap="round" fill="none" />
|
||||
<circle cx="30" cy="44" r="4" fill="white" opacity="0.6" />
|
||||
<circle cx="70" cy="44" r="4" fill="white" />
|
||||
<text x="50" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}><</text>
|
||||
</>
|
||||
),
|
||||
|
||||
"unit-5-integers/negative-fractions": (
|
||||
<>
|
||||
<text x="34" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}>−</text>
|
||||
<text x="52" y="18" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
|
||||
<rect x="44" y="26" width="18" height="3" rx="1.5" fill="white" />
|
||||
<text x="52" y="40" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
|
||||
</>
|
||||
),
|
||||
|
||||
// Unit 6 — Number System (Place Value)
|
||||
"unit-6-number-system/place-value": (
|
||||
<>
|
||||
<text x="30" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
|
||||
<text x="50" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>8</text>
|
||||
<text x="70" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>5</text>
|
||||
<line x1="18" y1="33" x2="82" y2="33" stroke="white" strokeWidth="1.5" opacity="0.5" />
|
||||
<text x="30" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10²</text>
|
||||
<text x="50" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10¹</text>
|
||||
<text x="70" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10⁰</text>
|
||||
</>
|
||||
),
|
||||
|
||||
// Unit 7 — Coordinates
|
||||
"unit-7-coordinates/plot-points": (
|
||||
<>
|
||||
<path d="M20 12 V60 M35 12 V60 M50 12 V60 M65 12 V60 M80 12 V60 M20 12 H80 M20 24 H80 M20 36 H80 M20 48 H80 M20 60 H80" stroke="white" strokeWidth="1" opacity="0.25" fill="none" />
|
||||
<path d="M50 12 V60 M20 36 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
|
||||
<path d="M65 24 V36 M50 24 H65" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.75" fill="none" />
|
||||
<circle cx="65" cy="24" r="4.5" fill="white" />
|
||||
</>
|
||||
),
|
||||
"unit-7-coordinates/read-coordinates": (
|
||||
<>
|
||||
<path d="M50 14 V62 M20 38 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
|
||||
<path d="M64 26 V38 M50 26 H64" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.6" fill="none" />
|
||||
<circle cx="64" cy="26" r="4" fill="white" />
|
||||
<text x="64" y="17" fontSize="11" textAnchor="middle" dominantBaseline="middle" {...glyphText}>(3, 2)</text>
|
||||
</>
|
||||
),
|
||||
|
||||
// Unit 8 — Number Properties
|
||||
"unit-8-number-properties/identity-inverse": (
|
||||
<>
|
||||
<text x="50" y="24" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>n + 0 = n</text>
|
||||
<text x="50" y="46" fontSize="12" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>3 + (−3) = 0</text>
|
||||
</>
|
||||
),
|
||||
"unit-8-number-properties/number-laws": (
|
||||
<>
|
||||
<text x="50" y="24" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>a + b = b + a</text>
|
||||
<path d="M36 34 C42 44, 58 44, 64 34" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
<path d="M64 34 l-4 1 M64 34 l-1 -4" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
"unit-8-number-properties/exponents": (
|
||||
<>
|
||||
<text x="34" y="34" fontSize="30" textAnchor="middle" dominantBaseline="middle" {...glyphText}>2</text>
|
||||
<text x="50" y="22" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
|
||||
<text x="73" y="34" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>= 16</text>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const fallback: ReactNode = (
|
||||
|
||||
74
components/layout/collapsible-sidebar.tsx
Normal file
74
components/layout/collapsible-sidebar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared state + styling for a sidebar that collapses in place on large screens
|
||||
* and opens as a slide-in drawer on small ones. One toggle drives both, choosing
|
||||
* the right behaviour by media query at click time. Defaults: open on desktop
|
||||
* (CSS `lg:` classes), closed on mobile (state) — so SSR and hydration agree.
|
||||
*/
|
||||
export function useCollapsibleSidebar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
|
||||
|
||||
function toggle() {
|
||||
if (typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches) {
|
||||
setDesktopCollapsed((c) => !c);
|
||||
} else {
|
||||
setMobileOpen((o) => !o);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mobileOpen,
|
||||
desktopCollapsed,
|
||||
toggle,
|
||||
closeMobile: () => setMobileOpen(false),
|
||||
};
|
||||
}
|
||||
|
||||
export function sidebarAsideClass(mobileOpen: boolean, desktopCollapsed: boolean) {
|
||||
return cn(
|
||||
"z-40 w-64 shrink-0 border-r border-border/60 bg-surface",
|
||||
// Small screens: a fixed slide-in drawer sitting below the header.
|
||||
"fixed bottom-0 left-0 top-[4.125rem] transition-transform duration-200",
|
||||
mobileOpen ? "translate-x-0 shadow-[var(--shadow-lg)]" : "-translate-x-full",
|
||||
// Large screens: part of the layout flow, collapsible in place.
|
||||
"lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-none",
|
||||
desktopCollapsed ? "lg:hidden" : "lg:block",
|
||||
);
|
||||
}
|
||||
|
||||
export const sidebarNavClass =
|
||||
"h-full overflow-y-auto p-4 lg:sticky lg:top-[4.125rem] lg:h-[calc(100vh-4.125rem)]";
|
||||
|
||||
export function SidebarBackdrop({ show, onClick }: { show: boolean; onClick: () => void }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-foreground/30 lg:hidden"
|
||||
onClick={onClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarToggle({ onClick, className }: { onClick: () => void; className?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label="Toggle topics menu"
|
||||
className={cn(
|
||||
"mb-5 inline-flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3 py-2 text-sm font-bold text-muted shadow-[var(--shadow-sm)] transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
Topics
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const navItems = [
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
|
||||
<div className="mx-auto max-w-6xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
|
||||
<div className="mx-auto max-w-7xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<Link href="/" className="group flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">
|
||||
|
||||
22
components/layout/lessons-shell.tsx
Normal file
22
components/layout/lessons-shell.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle } from "./collapsible-sidebar";
|
||||
|
||||
export function LessonsShell({ children }: { children: ReactNode }) {
|
||||
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
|
||||
|
||||
return (
|
||||
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
|
||||
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
|
||||
<Sidebar mobileOpen={mobileOpen} desktopCollapsed={desktopCollapsed} onNavigate={closeMobile} />
|
||||
<main className="min-w-0 flex-1 bg-white">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SidebarToggle onClick={toggle} />
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { curriculum } from "@/lib/curriculum";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
const unitColorMap = {
|
||||
"unit-1": "text-unit-1-dark",
|
||||
"unit-2": "text-unit-2-dark",
|
||||
"unit-3": "text-unit-3-dark",
|
||||
"unit-4": "text-unit-4-dark",
|
||||
"unit-5": "text-unit-5-dark",
|
||||
"unit-6": "text-unit-6-dark",
|
||||
};
|
||||
|
||||
const unitDotColor = {
|
||||
"unit-1": "bg-unit-1",
|
||||
"unit-2": "bg-unit-2",
|
||||
"unit-3": "bg-unit-3",
|
||||
"unit-4": "bg-unit-4",
|
||||
"unit-5": "bg-unit-5",
|
||||
"unit-6": "bg-unit-6",
|
||||
};
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3.5 py-2 text-sm font-medium shadow-[var(--shadow-sm)] transition-all hover:shadow-[var(--shadow-md)]"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{isOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
Topics
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 right-0 top-full z-40 max-h-[70vh] overflow-y-auto border-b border-border/60 bg-surface p-4 shadow-[var(--shadow-lg)]">
|
||||
<Link
|
||||
href="/lessons"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
All Topics
|
||||
</Link>
|
||||
<div className="mb-3 h-px bg-border/60" />
|
||||
{curriculum.map((unit) => (
|
||||
<div key={unit.slug} className="mb-4">
|
||||
<p className={cn("mb-1.5 flex items-center gap-2 px-3 text-xs font-bold uppercase tracking-wider", unitColorMap[unit.color])}>
|
||||
<span className={cn("h-2 w-2 rounded-full", unitDotColor[unit.color])} />
|
||||
Unit {unit.number}: {unit.title}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{unit.topics.map((topic) => {
|
||||
const href = `/lessons/${unit.slug}/${topic.slug}`;
|
||||
return (
|
||||
<Link
|
||||
key={topic.slug}
|
||||
href={href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
"block rounded-lg px-3 py-1.5 text-sm transition-colors",
|
||||
pathname === href
|
||||
? "bg-foreground text-background font-medium"
|
||||
: "text-muted hover:bg-background hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{topic.shortTitle}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { curriculum } from "@/lib/curriculum";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { sidebarAsideClass, sidebarNavClass } from "./collapsible-sidebar";
|
||||
|
||||
const unitColorMap = {
|
||||
"unit-1": {
|
||||
@@ -43,9 +44,29 @@ const unitColorMap = {
|
||||
heading: "text-unit-6-dark",
|
||||
hoverBg: "hover:bg-unit-6-light/50",
|
||||
},
|
||||
"unit-7": {
|
||||
active: "bg-unit-7-light text-unit-7-dark border-unit-7/20",
|
||||
dot: "bg-unit-7",
|
||||
heading: "text-unit-7-dark",
|
||||
hoverBg: "hover:bg-unit-7-light/50",
|
||||
},
|
||||
"unit-8": {
|
||||
active: "bg-unit-8-light text-unit-8-dark border-unit-8/20",
|
||||
dot: "bg-unit-8",
|
||||
heading: "text-unit-8-dark",
|
||||
hoverBg: "hover:bg-unit-8-light/50",
|
||||
},
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({
|
||||
mobileOpen = false,
|
||||
desktopCollapsed = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
mobileOpen?: boolean;
|
||||
desktopCollapsed?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
|
||||
const open = new Set<number>();
|
||||
@@ -68,10 +89,11 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border/60 bg-surface lg:block">
|
||||
<nav className="sticky top-[4.125rem] h-[calc(100vh-4.125rem)] overflow-y-auto p-4">
|
||||
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
|
||||
<nav className={sidebarNavClass}>
|
||||
<Link
|
||||
href="/lessons"
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
|
||||
pathname === "/lessons"
|
||||
@@ -79,9 +101,6 @@ export function Sidebar() {
|
||||
: "text-muted hover:bg-background hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
All Topics
|
||||
</Link>
|
||||
|
||||
@@ -126,6 +145,7 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={topic.slug}
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
|
||||
isActive
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Keep only digits and a single leading minus sign, so junk like "1-2" can't be typed. */
|
||||
function sanitizeSigned(value: string): string {
|
||||
const cleaned = value.replace(/[^0-9-]/g, "");
|
||||
return cleaned.replace(/(?!^)-/g, ""); // drop any minus that isn't at the start
|
||||
}
|
||||
|
||||
interface FractionInputProps {
|
||||
numerator: string;
|
||||
denominator: string;
|
||||
onNumeratorChange: (value: string) => void;
|
||||
onDenominatorChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -16,31 +24,50 @@ export function FractionInput({
|
||||
denominator,
|
||||
onNumeratorChange,
|
||||
onDenominatorChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
className,
|
||||
}: FractionInputProps) {
|
||||
const denominatorRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const inputClass =
|
||||
"w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none";
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9-]*"
|
||||
value={numerator}
|
||||
onChange={(e) => onNumeratorChange(e.target.value)}
|
||||
onChange={(e) => onNumeratorChange(sanitizeSigned(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
// "/" is how pupils think of a fraction — jump to the denominator.
|
||||
if (e.key === "/" || e.key === "Enter") {
|
||||
if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
denominatorRef.current?.focus();
|
||||
} else {
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
|
||||
className={inputClass}
|
||||
placeholder="?"
|
||||
aria-label="Numerator"
|
||||
/>
|
||||
<div className="h-0.5 w-16 bg-foreground" />
|
||||
<input
|
||||
ref={denominatorRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9-]*"
|
||||
value={denominator}
|
||||
onChange={(e) => onDenominatorChange(e.target.value)}
|
||||
onChange={(e) => onDenominatorChange(sanitizeSigned(e.target.value))}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
|
||||
className={inputClass}
|
||||
placeholder="?"
|
||||
aria-label="Denominator"
|
||||
/>
|
||||
@@ -51,17 +78,21 @@ export function FractionInput({
|
||||
interface DecimalInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
|
||||
export function DecimalInput({ value, onChange, onSubmit, disabled, className }: DecimalInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
parseStrictDecimal,
|
||||
parseStrictSignedDecimal,
|
||||
parseStrictSignedInt,
|
||||
wrong,
|
||||
SOLVED,
|
||||
type AnswerResult,
|
||||
} from "@/lib/math/validation";
|
||||
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
|
||||
@@ -22,7 +24,7 @@ import katex from "katex";
|
||||
interface PracticeSectionProps {
|
||||
title: string;
|
||||
generator: (difficulty: Difficulty) => MathProblem;
|
||||
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
|
||||
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-8";
|
||||
}
|
||||
|
||||
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
|
||||
@@ -32,6 +34,9 @@ const activeDifficultyStyle: Record<UnitColor, string> = {
|
||||
"unit-2": "bg-unit-2 text-white",
|
||||
"unit-3": "bg-unit-3 text-white",
|
||||
"unit-4": "bg-unit-4 text-white",
|
||||
"unit-5": "bg-unit-5 text-white",
|
||||
"unit-6": "bg-unit-6 text-white",
|
||||
"unit-8": "bg-unit-8 text-white",
|
||||
};
|
||||
|
||||
const RECENT_PROBLEM_LIMIT = 8;
|
||||
@@ -54,7 +59,11 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
const [hintIndex, setHintIndex] = useState(0);
|
||||
const [showSolution, setShowSolution] = useState(false);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
const isFullyCorrect = result?.correct === true && result.simplified === true;
|
||||
const isFullyCorrect = result?.status === "solved";
|
||||
// Per-problem scoring guards: a problem is counted at most once, when solved,
|
||||
// and only counts as "correct" if solved with no earlier wrong attempt or peek.
|
||||
const missedCurrentRef = useRef(false);
|
||||
const scoredCurrentRef = useRef(false);
|
||||
|
||||
const generateUniqueProblem = useCallback((diff: Difficulty): MathProblem => {
|
||||
const recentProblemKeys = recentProblemKeysRef.current ?? [getProblemKey(problem)];
|
||||
@@ -86,6 +95,8 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
setShowHint(false);
|
||||
setHintIndex(0);
|
||||
setShowSolution(false);
|
||||
missedCurrentRef.current = false;
|
||||
scoredCurrentRef.current = false;
|
||||
}, [generateUniqueProblem]);
|
||||
|
||||
function checkAnswer() {
|
||||
@@ -97,7 +108,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
const num = parseStrictSignedInt(userAnswer.numerator || "");
|
||||
const den = parseStrictSignedInt(userAnswer.denominator || "");
|
||||
if (num === null || den === null) {
|
||||
res = { correct: false, message: "Enter valid numbers" };
|
||||
res = wrong("Enter valid numbers");
|
||||
} else {
|
||||
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
|
||||
}
|
||||
@@ -106,7 +117,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
case "decimal": {
|
||||
const val = parseStrictSignedDecimal(userAnswer.value || "");
|
||||
if (val === null) {
|
||||
res = { correct: false, message: "Enter a valid number" };
|
||||
res = wrong("Enter a valid number");
|
||||
} else {
|
||||
res = checkDecimalAnswer(val, answer.value);
|
||||
}
|
||||
@@ -115,7 +126,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
case "integer": {
|
||||
const val = parseStrictSignedInt(userAnswer.value || "");
|
||||
if (val === null) {
|
||||
res = { correct: false, message: "Enter a valid number" };
|
||||
res = wrong("Enter a valid number");
|
||||
} else {
|
||||
res = checkIntegerAnswer(val, answer.value);
|
||||
}
|
||||
@@ -124,7 +135,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
case "ratio": {
|
||||
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
|
||||
if (parts.some(isNaN)) {
|
||||
res = { correct: false, message: "Enter valid ratio parts" };
|
||||
res = wrong("Enter valid ratio parts");
|
||||
} else {
|
||||
res = checkRatioAnswer(parts, answer.parts);
|
||||
}
|
||||
@@ -134,27 +145,44 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
const coeff = parseStrictDecimal(userAnswer.coefficient || "");
|
||||
const exp = parseStrictSignedInt(userAnswer.exponent || "");
|
||||
if (coeff === null || exp === null) {
|
||||
res = { correct: false, message: "Enter valid numbers" };
|
||||
res = wrong("Enter valid numbers");
|
||||
} else if (
|
||||
Math.abs(coeff - answer.coefficient) < 0.01 &&
|
||||
exp === answer.exponent
|
||||
) {
|
||||
res = { correct: true, simplified: true };
|
||||
res = SOLVED;
|
||||
} else {
|
||||
res = { correct: false, message: "That's not quite right. Try again!" };
|
||||
res = wrong("That's not quite right. Try again!");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setResult(res);
|
||||
if (res.correct) {
|
||||
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
|
||||
} else {
|
||||
setScore((s) => ({ ...s, total: s.total + 1 }));
|
||||
|
||||
// Score each problem exactly once, when it is solved. A wrong attempt (or an
|
||||
// "unsimplified" nudge that never gets finished) never scores on its own; it
|
||||
// just marks the current problem as missed so the eventual solve counts as
|
||||
// incorrect. This makes the score "problems solved / first-time right" and
|
||||
// makes double-counting impossible.
|
||||
if (res.status === "wrong") {
|
||||
missedCurrentRef.current = true;
|
||||
} else if (res.status === "solved" && !scoredCurrentRef.current) {
|
||||
scoredCurrentRef.current = true;
|
||||
const gotFirstTime = !missedCurrentRef.current;
|
||||
setScore((s) => ({
|
||||
correct: s.correct + (gotFirstTime ? 1 : 0),
|
||||
total: s.total + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key: check the answer, or move to the next problem once it's solved.
|
||||
function submit() {
|
||||
if (isFullyCorrect) generateNew(difficulty);
|
||||
else checkAnswer();
|
||||
}
|
||||
|
||||
function nextHint() {
|
||||
if (!showHint) {
|
||||
setShowHint(true);
|
||||
@@ -210,6 +238,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
denominator={userAnswer.denominator || ""}
|
||||
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
|
||||
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
|
||||
onSubmit={submit}
|
||||
disabled={isFullyCorrect}
|
||||
/>
|
||||
)}
|
||||
@@ -218,6 +247,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
<DecimalInput
|
||||
value={userAnswer.value || ""}
|
||||
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
|
||||
onSubmit={submit}
|
||||
disabled={isFullyCorrect}
|
||||
/>
|
||||
)}
|
||||
@@ -284,7 +314,14 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
<Button variant="secondary" size="sm" onClick={nextHint}>
|
||||
Hint
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
missedCurrentRef.current = true;
|
||||
setShowSolution(true);
|
||||
}}
|
||||
>
|
||||
Show Solution
|
||||
</Button>
|
||||
</>
|
||||
@@ -304,18 +341,18 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
|
||||
result.correct
|
||||
? result.simplified
|
||||
? "bg-correct-light text-correct"
|
||||
: "bg-hint-light text-hint"
|
||||
: "bg-incorrect-light text-incorrect"
|
||||
result.status === "solved"
|
||||
? "bg-correct-light text-correct"
|
||||
: result.status === "unsimplified"
|
||||
? "bg-hint-light text-hint"
|
||||
: "bg-incorrect-light text-incorrect"
|
||||
}`}
|
||||
>
|
||||
{result.correct
|
||||
? result.simplified
|
||||
? "Correct!"
|
||||
: "Correct, but can you simplify further?"
|
||||
: result.message}
|
||||
{result.status === "solved"
|
||||
? "Correct!"
|
||||
: result.status === "unsimplified"
|
||||
? "Correct, but can you simplify further?"
|
||||
: result.message}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type HTMLAttributes } from "react";
|
||||
|
||||
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
|
||||
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
@@ -15,6 +15,8 @@ const variantStyles: Record<BadgeVariant, string> = {
|
||||
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
|
||||
"unit-5": "border border-unit-5/20 bg-unit-5-light text-unit-5-dark",
|
||||
"unit-6": "border border-unit-6/20 bg-unit-6-light text-unit-6-dark",
|
||||
"unit-7": "border border-unit-7/20 bg-unit-7-light text-unit-7-dark",
|
||||
"unit-8": "border border-unit-8/20 bg-unit-8-light text-unit-8-dark",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ButtonHTMLAttributes } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5";
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@@ -18,6 +18,9 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
|
||||
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
|
||||
"unit-5": "bg-unit-5 text-white shadow-[var(--shadow-sm)] hover:bg-unit-5-dark hover:shadow-[var(--shadow-lg)]",
|
||||
"unit-6": "bg-unit-6 text-white shadow-[var(--shadow-sm)] hover:bg-unit-6-dark hover:shadow-[var(--shadow-lg)]",
|
||||
"unit-7": "bg-unit-7 text-white shadow-[var(--shadow-sm)] hover:bg-unit-7-dark hover:shadow-[var(--shadow-lg)]",
|
||||
"unit-8": "bg-unit-8 text-white shadow-[var(--shadow-sm)] hover:bg-unit-8-dark hover:shadow-[var(--shadow-lg)]",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils";
|
||||
import { type HTMLAttributes } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
|
||||
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const accentStyles = {
|
||||
"unit-4": "border-l-4 border-l-unit-4",
|
||||
"unit-5": "border-l-4 border-l-unit-5",
|
||||
"unit-6": "border-l-4 border-l-unit-6",
|
||||
"unit-7": "border-l-4 border-l-unit-7",
|
||||
"unit-8": "border-l-4 border-l-unit-8",
|
||||
};
|
||||
|
||||
export function Card({
|
||||
|
||||
29
components/ui/client-only.tsx
Normal file
29
components/ui/client-only.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore, type ReactNode } from "react";
|
||||
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
/**
|
||||
* Renders its children only after hydration on the client. Use this to wrap UI
|
||||
* whose initial render is non-deterministic (e.g. seeded with Math.random()),
|
||||
* which would otherwise cause a server/client hydration mismatch. The
|
||||
* `fallback` is shown during SSR and the first (hydrating) client render.
|
||||
*
|
||||
* Uses useSyncExternalStore rather than a mount effect so there is no
|
||||
* setState-in-effect and the server/client snapshots stay explicit.
|
||||
*/
|
||||
export function ClientOnly({
|
||||
children,
|
||||
fallback = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}) {
|
||||
const isServer = useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => false,
|
||||
() => true,
|
||||
);
|
||||
return <>{isServer ? fallback : children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user