From 89914608d7018f57b29fa90ca1c9aee818c08916 Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 3 Jul 2026 13:36:53 +0200 Subject: [PATCH 01/16] Add Pizza Remainder and Multiplying Fractions (Area) manipulatives - pizza-remainder (4.NBT.B.6 / 5.NBT.B.6): share pizzas by dragging them onto friends' plates; what can't be shared equally lands in the Remainder tray. Live fairness coaching; deal-a-round / share-for-me helpers. - multiplying-fractions-area (5.NF.B.4): area model where the green overlap is the product; dimension brackets show each fraction as a part of the whole. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/index.js | 12 + .../multiplying-fractions-area.jsx | 297 +++++++++++++ src/manipulatives/pizza-remainder.jsx | 403 ++++++++++++++++++ 3 files changed, 712 insertions(+) create mode 100644 src/manipulatives/multiplying-fractions-area.jsx create mode 100644 src/manipulatives/pizza-remainder.jsx diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index fec55c1..f79ccb3 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -1,11 +1,23 @@ import CoordinateTreasureMap from './coordinate-treasure-map.jsx' import FactorTree from './factor-tree.jsx' import MeanBalancePoint from './mean-balance-point.jsx' +import MultiplyingFractionsArea from './multiplying-fractions-area.jsx' import NumberLineExplorer from './number-line-explorer.jsx' import ParallelogramArea from './parallelogram-area.jsx' +import PizzaRemainder from './pizza-remainder.jsx' import TwoFactorTrees from './two-factor-trees.jsx' export const manipulatives = [ + { + id: 'pizza-remainder', + name: 'Pizza Remainder', + component: PizzaRemainder, + }, + { + id: 'multiplying-fractions-area', + name: 'Multiplying Fractions (Area)', + component: MultiplyingFractionsArea, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', diff --git a/src/manipulatives/multiplying-fractions-area.jsx b/src/manipulatives/multiplying-fractions-area.jsx new file mode 100644 index 0000000..4396eda --- /dev/null +++ b/src/manipulatives/multiplying-fractions-area.jsx @@ -0,0 +1,297 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const blue = '#2563EB' +const orange = '#D97706' +const green = '#1D9E75' + +const MIN_DEN = 2 +const MAX_DEN = 8 + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) +const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)) + +// Dimension brackets (a line with two end caps) + labels. +function vBracket(ctx, x, yTop, yBot, color) { + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x, yTop) + ctx.lineTo(x, yBot) + ctx.moveTo(x, yTop) + ctx.lineTo(x + 7, yTop) + ctx.moveTo(x, yBot) + ctx.lineTo(x + 7, yBot) + ctx.stroke() +} + +function hBracket(ctx, y, xL, xR, color) { + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(xL, y) + ctx.lineTo(xR, y) + ctx.moveTo(xL, y) + ctx.lineTo(xL, y + 7) + ctx.moveTo(xR, y) + ctx.lineTo(xR, y + 7) + ctx.stroke() +} + +function vLabel(ctx, x, y, text, color) { + ctx.save() + ctx.translate(x, y) + ctx.rotate(-Math.PI / 2) + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(text, 0, 0) + ctx.restore() +} + +function hLabel(ctx, x, y, text, color) { + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(text, x, y) +} + +export default function MultiplyingFractionsArea() { + const wrapRef = useRef(null) + const canvasRef = useRef(null) + const [size, setSize] = useState({ w: 460, h: 360 }) + const [num1, setNum1] = useState(1) + const [den1, setDen1] = useState(2) + const [num2, setNum2] = useState(1) + const [den2, setDen2] = useState(3) + const [hideAnswer, setHideAnswer] = useState(false) + + const prodN = num1 * num2 + const prodD = den1 * den2 + const divisor = gcd(prodN, prodD) + const simpN = prodN / divisor + const simpD = prodD / divisor + const reduces = divisor > 1 + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => { + const rect = node.getBoundingClientRect() + setSize({ w: Math.max(300, Math.round(rect.width)), h: Math.max(260, Math.round(rect.height)) }) + } + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const geometry = useMemo(() => { + // Reserve margin on the left and top for the "1 whole" + fraction brackets. + const marginL = 56 + const marginT = 56 + const marginR = 16 + const marginB = 16 + const square = Math.max(80, Math.min(size.w - marginL - marginR, size.h - marginT - marginB)) + const x0 = marginL + const y0 = marginT + Math.max(0, (size.h - marginT - marginB - square) / 2) + return { square, x0, y0, cellH: square / den1, cellW: square / den2 } + }, [size, den1, den2]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== size.w * dpr || canvas.height !== size.h * dpr) { + canvas.width = size.w * dpr + canvas.height = size.h * dpr + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, size.w, size.h) + + const { square, x0, y0, cellH, cellW } = geometry + + // White unit square. + ctx.fillStyle = '#ffffff' + ctx.fillRect(x0, y0, square, square) + + // Fraction 1 — horizontal bands (top num1 of den1 rows). + ctx.fillStyle = 'rgba(37, 99, 235, 0.28)' + ctx.fillRect(x0, y0, square, num1 * cellH) + // Fraction 2 — vertical bands (left num2 of den2 columns). + ctx.fillStyle = 'rgba(217, 119, 6, 0.30)' + ctx.fillRect(x0, y0, num2 * cellW, square) + // Overlap = product. + ctx.fillStyle = 'rgba(29, 158, 117, 0.80)' + ctx.fillRect(x0, y0, num2 * cellW, num1 * cellH) + + // Grid lines. + ctx.strokeStyle = 'rgba(26, 26, 46, 0.22)' + ctx.lineWidth = 1 + for (let r = 1; r < den1; r += 1) { + ctx.beginPath() + ctx.moveTo(x0, y0 + r * cellH) + ctx.lineTo(x0 + square, y0 + r * cellH) + ctx.stroke() + } + for (let c = 1; c < den2; c += 1) { + ctx.beginPath() + ctx.moveTo(x0 + c * cellW, y0) + ctx.lineTo(x0 + c * cellW, y0 + square) + ctx.stroke() + } + + // Outer border. + ctx.strokeStyle = ink + ctx.lineWidth = 2.5 + ctx.strokeRect(x0, y0, square, square) + + // Dimension brackets: show each fraction as a PART OF the whole side length. + const gray = '#9AA0AA' + + // Brackets: gray = the whole side (1), colored = the shaded fraction. + vBracket(ctx, x0 - 42, y0, y0 + square, gray) + vBracket(ctx, x0 - 20, y0, y0 + num1 * cellH, blue) + hBracket(ctx, y0 - 38, x0, x0 + square, gray) + hBracket(ctx, y0 - 16, x0, x0 + num2 * cellW, orange) + + // Fraction labels. + ctx.font = '800 13px Inter, system-ui, sans-serif' + vLabel(ctx, x0 - 22, y0 + (num1 * cellH) / 2, `${num1}/${den1}`, blue) + hLabel(ctx, x0 + (num2 * cellW) / 2, y0 - 18, `${num2}/${den2}`, orange) + + // Whole-side labels: just "1", slightly smaller so they stay readable. + ctx.font = '800 11px Inter, system-ui, sans-serif' + vLabel(ctx, x0 - 44, y0 + square / 2, '1', gray) + hLabel(ctx, x0 + square / 2, y0 - 40, '1', gray) + }, [size, geometry, num1, den1, num2, den2]) + + useEffect(() => { + draw() + }, [draw]) + + const setDenClamped = (setNum, setDen, num) => (nextDen) => { + const d = clamp(nextDen, MIN_DEN, MAX_DEN) + setDen(d) + if (num > d) setNum(d) + } + + return ( +
+ {/* Equation */} +
+
+ + × + + = + {hideAnswer ? ( + ? + ) : ( +
+ + {reduces && ( + <> + = + + + )} +
+ )} +
+ +
+ + {/* Square + legend */} +
+
+ +
+
+ + + +

+ The whole is cut into {den1} × {den2} = {prodD} equal + pieces. The green overlap is{' '} + {hideAnswer ? '?' : `${num1} × ${num2} = ${prodN}`} of them. +

+
+
+ + {/* Controls */} +
+ setNum1(clamp(v, 1, den1))} + onDen={setDenClamped(setNum1, setDen1, num1)} + /> + setNum2(clamp(v, 1, den2))} + onDen={setDenClamped(setNum2, setDen2, num2)} + /> +
+
+ ) +} + +function Frac({ n, d, color }) { + return ( + + {n} + {d} + + ) +} + +function LegendRow({ color, label, solid }) { + return ( +
+ + {label} +
+ ) +} + +function MiniStepper({ value, onDec, onInc, color }) { + return ( +
+ + {value} + +
+ ) +} + +function FractionStepper({ label, color, num, den, onNum, onDen }) { + return ( +
+ {label} +
+ onNum(num - 1)} onInc={() => onNum(num + 1)} color={color} /> + + onDen(den - 1)} onInc={() => onDen(den + 1)} color={color} /> +
+
+ ) +} diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx new file mode 100644 index 0000000..aebd9f0 --- /dev/null +++ b/src/manipulatives/pizza-remainder.jsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const crust = '#E0A458' +const cheese = '#FBD45B' +const sauce = '#E8893B' +const pepperoni = '#C23B22' +const friendBlue = '#2563EB' +const quotientGreen = '#1D9E75' +const remOrange = '#D85A30' +const plateFill = '#EDEAE2' + +const MIN_PIZZAS = 1 +const MAX_PIZZAS = 12 +const MIN_FRIENDS = 1 +const MAX_FRIENDS = 6 + +const PEPS = [ + [-0.32, -0.3], + [0.3, -0.24], + [0.02, 0.04], + [-0.28, 0.32], + [0.34, 0.3], +] + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) +const pileArray = (n) => Array.from({ length: n }, () => ({ loc: 'pile' })) + +function drawPizza(ctx, x, y, r, leftover, lifted) { + ctx.save() + ctx.fillStyle = lifted ? 'rgba(26,26,46,0.22)' : 'rgba(26,26,46,0.10)' + ctx.beginPath() + ctx.ellipse(x, y + r * (lifted ? 0.9 : 0.62), r * 0.78, r * 0.26, 0, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = crust + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = sauce + ctx.beginPath() + ctx.arc(x, y, r * 0.86, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = cheese + ctx.beginPath() + ctx.arc(x, y, r * 0.76, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = pepperoni + PEPS.forEach(([px, py]) => { + ctx.beginPath() + ctx.arc(x + px * r, y + py * r, r * 0.13, 0, Math.PI * 2) + ctx.fill() + }) + if (leftover) { + ctx.strokeStyle = remOrange + ctx.lineWidth = Math.max(2, r * 0.16) + ctx.beginPath() + ctx.arc(x, y, r + ctx.lineWidth, 0, Math.PI * 2) + ctx.stroke() + } + ctx.restore() +} + +export default function PizzaRemainder() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [pizzas, setPizzas] = useState(7) + const [friends, setFriends] = useState(3) + const [place, setPlace] = useState(() => pileArray(7)) + const [hideAnswer, setHideAnswer] = useState(false) + + const canvasHeight = 360 + const quotient = Math.floor(pizzas / friends) + + const dragRef = useRef(null) // { index, offX, offY } + const dragPosRef = useRef(null) // { x, y } + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(360, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + // Counts / status derived from the current placement. + const status = useMemo(() => { + const counts = Array(friends).fill(0) + let pileCount = 0 + let trayCount = 0 + place.forEach((p) => { + if (p.loc === 'plate' && p.f < friends) counts[p.f] += 1 + else if (p.loc === 'tray') trayCount += 1 + else pileCount += 1 + }) + const maxC = Math.max(...counts) + const minC = Math.min(...counts) + const allEqual = maxC === minC + const solved = pileCount === 0 && allEqual && trayCount < friends + return { counts, pileCount, trayCount, maxC, allEqual, solved } + }, [place, friends]) + + const layout = useMemo(() => { + const PAD = 28 + const width = canvasWidth + const maxRem = Math.max(1, friends - 1) + const colW = (width - PAD * 2) / friends + const friendX = (f) => PAD + colW * (f + 0.5) + const plateBaseY = canvasHeight - 58 + + const maxStack = Math.max(quotient, status.maxC, 1) + const availV = plateBaseY - 30 - 118 + const rV = availV / (1.5 * maxStack + 0.5) + const rH = colW * 0.34 + const r = clamp(Math.min(rH, rV, 22), 7, 22) + const spacing = 1.5 * r + + const pileCols = Math.min(pizzas, 6) + const pileGap = 2 * r + 6 + const pileX0 = PAD + r + 2 + const pileY0 = 46 + r + const gap = 2 * r + 4 + + const trayInner = maxRem * gap + 16 + const trayW = Math.min(width * 0.34, Math.max(78, trayInner)) + const trayX2 = width - PAD + const trayX1 = trayX2 - trayW + const trayY1 = 28 + const trayY2 = trayY1 + Math.max(2 * r + 26, 68) + const tray = { x1: trayX1, y1: trayY1, x2: trayX2, y2: trayY2, cx: (trayX1 + trayX2) / 2, cy: (trayY1 + trayY2) / 2 + 4 } + + // Compute a resting position for every pizza from its placement. + const perPlateRow = Array(friends).fill(0) + let pileK = 0 + let trayK = 0 + const positions = place.map((p) => { + if (p.loc === 'plate' && p.f < friends) { + const row = perPlateRow[p.f] + perPlateRow[p.f] += 1 + return { x: friendX(p.f), y: plateBaseY - 26 - row * spacing, loc: 'plate' } + } + if (p.loc === 'tray') { + const k = trayK + trayK += 1 + const startX = tray.cx - (status.trayCount - 1) * gap * 0.5 + return { x: startX + k * gap, y: tray.cy, loc: 'tray' } + } + const col = pileK % pileCols + const row = Math.floor(pileK / pileCols) + pileK += 1 + return { x: pileX0 + col * pileGap, y: pileY0 + row * pileGap, loc: 'pile' } + }) + + return { PAD, width, colW, friendX, plateBaseY, r, spacing, tray, pileX0, pileY0, positions } + }, [canvasWidth, pizzas, friends, quotient, place, status.maxC, status.trayCount]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== canvasWidth * dpr || canvas.height !== canvasHeight * dpr) { + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { r, friendX, plateBaseY, colW, tray, positions } = layout + + // Plates + friend labels. + for (let f = 0; f < friends; f += 1) { + const cx = friendX(f) + ctx.fillStyle = plateFill + ctx.strokeStyle = '#C9D4EA' + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.ellipse(cx, plateBaseY, Math.min(colW * 0.42, r * 2.4), r * 0.62, 0, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = friendBlue + ctx.font = `700 ${colW > 96 ? 14 : 12}px Inter, system-ui, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.fillText(colW > 96 ? `Friend ${f + 1}` : `#${f + 1}`, cx, plateBaseY + r * 0.7 + 6) + } + + // Remainder tray. + ctx.save() + ctx.setLineDash([7, 6]) + ctx.strokeStyle = remOrange + ctx.lineWidth = 2 + ctx.beginPath() + ctx.roundRect(tray.x1, tray.y1, tray.x2 - tray.x1, tray.y2 - tray.y1, 12) + ctx.stroke() + ctx.restore() + ctx.fillStyle = remOrange + ctx.font = '800 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText('Remainder', tray.cx, tray.y1 - 4) + ctx.font = '600 10px Inter, system-ui, sans-serif' + ctx.fillStyle = muted + ctx.textBaseline = 'top' + ctx.fillText("what's left over", tray.cx, tray.y2 + 4) + + // Pile label. + if (status.pileCount > 0) { + ctx.fillStyle = muted + ctx.font = '800 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'bottom' + ctx.fillText('Pizzas to share — drag them out', layout.pileX0 - r, layout.pileY0 - r - 4) + } + + // Pizzas (skip the dragged one; draw it last, lifted). + const dragIndex = dragRef.current?.index ?? -1 + for (let i = 0; i < positions.length; i += 1) { + if (i === dragIndex) continue + const p = positions[i] + drawPizza(ctx, p.x, p.y, r, p.loc === 'tray', false) + } + if (dragIndex >= 0 && dragPosRef.current) { + drawPizza(ctx, dragPosRef.current.x, dragPosRef.current.y, r * 1.1, false, true) + } + }, [canvasWidth, layout, friends, status.pileCount]) + + useEffect(() => { + draw() + }, [draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + } + + const handlePointerDown = (event) => { + const pt = getPoint(event) + const { positions, r } = layout + for (let i = positions.length - 1; i >= 0; i -= 1) { + const p = positions[i] + if (Math.hypot(p.x - pt.x, p.y - pt.y) <= r * 1.25) { + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { index: i, offX: pt.x - p.x, offY: pt.y - p.y } + dragPosRef.current = { x: p.x, y: p.y } + draw() + return + } + } + } + + const handlePointerMove = (event) => { + if (!dragRef.current) return + const pt = getPoint(event) + dragPosRef.current = { x: pt.x - dragRef.current.offX, y: pt.y - dragRef.current.offY } + draw() + } + + const handlePointerUp = (event) => { + const drag = dragRef.current + if (!drag) return + const pt = getPoint(event) + const { tray, PAD, colW } = layout + + let next = { loc: 'pile' } + if (pt.x >= tray.x1 - 12 && pt.x <= tray.x2 + 12 && pt.y >= tray.y1 - 12 && pt.y <= tray.y2 + 12) { + next = { loc: 'tray' } + } else if (pt.y > canvasHeight * 0.42) { + next = { loc: 'plate', f: clamp(Math.round((pt.x - PAD) / colW - 0.5), 0, friends - 1) } + } + + setPlace((prev) => prev.map((p, i) => (i === drag.index ? next : p))) + dragRef.current = null + dragPosRef.current = null + } + + const reset = (n = pizzas) => { + setPlace(pileArray(n)) + setHideAnswer(false) + } + const changePizzas = (nextN) => { + const n = clamp(nextN, MIN_PIZZAS, MAX_PIZZAS) + setPizzas(n) + reset(n) + } + const changeFriends = (nextF) => { + setFriends(clamp(nextF, MIN_FRIENDS, MAX_FRIENDS)) + reset() + } + + const dealRound = () => { + setPlace((prev) => { + if (prev.filter((p) => p.loc === 'pile').length < friends) return prev + const next = [...prev] + for (let f = 0; f < friends; f += 1) { + const idx = next.findIndex((p) => p.loc === 'pile') + next[idx] = { loc: 'plate', f } + } + return next + }) + } + const shareForMe = () => { + setPlace((prev) => prev.map((_, i) => (i < quotient * friends ? { loc: 'plate', f: i % friends } : { loc: 'tray' }))) + } + + const canDeal = status.pileCount >= friends + const remainderVal = status.trayCount + const message = status.solved + ? `Shared! Each friend got ${status.counts[0]}. Remainder = ${remainderVal}.` + : !status.allEqual + ? 'Not fair yet — every friend must have the same number.' + : status.pileCount === 0 && status.trayCount >= friends + ? 'Everyone could still get one more — keep sharing.' + : status.pileCount > 0 && status.pileCount < friends && status.maxC > 0 + ? `Only ${status.pileCount} left — they can't be shared equally. Drag them to the Remainder tray.` + : 'Drag a pizza onto each friend’s plate. Everyone gets the same.' + + return ( +
+ {/* Equation */} +
+

+ {pizzas} + ÷ + {friends} + {status.solved && ( + hideAnswer ? ( + = ? + ) : ( + <> + = + {status.counts[0]} each + {remainderVal > 0 ? ( + <> + , + remainder {remainderVal} + + ) : ( + , no remainder + )} + + ) + )} +

+ {status.solved && ( + + )} +
+ + {/* Canvas */} +
+ +
+ +

+ {message} +

+ +
+ changePizzas(pizzas - 1)} onInc={() => changePizzas(pizzas + 1)} /> + changeFriends(friends - 1)} onInc={() => changeFriends(friends + 1)} /> + + + +
+
+ ) +} + +function Stepper({ label, value, accent, onDec, onInc }) { + return ( +
+ {label} +
+ + {value} + +
+
+ ) +} From cb94197b9f0a860741fb745c552850ec48600412 Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 3 Jul 2026 14:02:11 +0200 Subject: [PATCH 02/16] Add Balance Scale Equations manipulative - balance-scale-equations (6.EE.B.5 / 7.EE.B.4): solve linear equations on a balance. Drag x/unit tiles off the pans; removing from one side only tips the scale (unbalanced) so students learn to do the same to both sides. Live equation updates as tiles change; solve detection reveals x = N. Four preset problems including variables-on-both-sides. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/balance-scale-equations.jsx | 347 ++++++++++++++++++ src/manipulatives/index.js | 6 + 2 files changed, 353 insertions(+) create mode 100644 src/manipulatives/balance-scale-equations.jsx diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx new file mode 100644 index 0000000..cf8ef9f --- /dev/null +++ b/src/manipulatives/balance-scale-equations.jsx @@ -0,0 +1,347 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const xPurple = '#7C3AED' +const unitBlue = '#2563EB' +const beamWood = '#B07A3C' +const balancedGreen = '#1D9E75' +const offRed = '#D85A30' + +// ax + b = cx + d, solvable by removing equal tiles (|a - c| = 1). +const PROBLEMS = [ + { left: { x: 1, u: 3 }, right: { x: 0, u: 5 } }, // x + 3 = 5 -> x = 2 + { left: { x: 1, u: 5 }, right: { x: 0, u: 8 } }, // x + 5 = 8 -> x = 3 + { left: { x: 2, u: 1 }, right: { x: 1, u: 4 } }, // 2x + 1 = x + 4 -> x = 3 + { left: { x: 2, u: 3 }, right: { x: 1, u: 7 } }, // 2x + 3 = x + 7 -> x = 4 +] + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +let tileSeq = 0 +const buildTiles = ({ x, u }) => [ + ...Array.from({ length: x }, () => ({ id: (tileSeq += 1), type: 'x' })), + ...Array.from({ length: u }, () => ({ id: (tileSeq += 1), type: 'unit' })), +] + +const solveX = (p) => (p.right.u - p.left.u) / (p.left.x - p.right.x) + +const countType = (tiles, type) => tiles.filter((t) => t.type === type).length + +function sideLabel(xc, uc) { + const parts = [] + if (xc === 1) parts.push('x') + else if (xc > 1) parts.push(`${xc}x`) + if (uc > 0 || parts.length === 0) parts.push(String(uc)) + return parts.join(' + ') +} + +export default function BalanceScaleEquations() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [problemIndex, setProblemIndex] = useState(0) + const [left, setLeft] = useState(() => buildTiles(PROBLEMS[0].left)) + const [right, setRight] = useState(() => buildTiles(PROBLEMS[0].right)) + const [drag, setDrag] = useState(null) // { side, id, x, y, offX, offY } + + const canvasHeight = 360 + const solution = useMemo(() => solveX(PROBLEMS[problemIndex]), [problemIndex]) + + const leftX = countType(left, 'x') + const leftU = countType(left, 'unit') + const rightX = countType(right, 'x') + const rightU = countType(right, 'unit') + const leftW = leftX * solution + leftU + const rightW = rightX * solution + rightU + const balanced = leftW === rightW + const solved = + balanced && + ((leftX === 1 && leftU === 0 && rightX === 0 && rightU >= 1) || + (rightX === 1 && rightU === 0 && leftX === 0 && leftU >= 1)) + const answer = solved ? (leftX === 1 ? rightU : leftU) : null + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const loadProblem = (index) => { + setProblemIndex(index) + setLeft(buildTiles(PROBLEMS[index].left)) + setRight(buildTiles(PROBLEMS[index].right)) + setDrag(null) + } + + const geo = useMemo(() => { + const W = canvasWidth + const cx = W / 2 + const pivotY = 92 + const L = Math.min(W * 0.33, 240) + const stringLen = 66 + const panW = Math.min(L * 1.15, 190) + const baseY = 300 + return { W, cx, pivotY, L, stringLen, panW, baseY } + }, [canvasWidth]) + + const targetAngle = clamp((leftW - rightW) * 0.03, -0.19, 0.19) + const angleRef = useRef(targetAngle) + const rafRef = useRef(null) + + // Positions of every tile (used for drawing and hit-testing). + const layout = useMemo(() => { + const { cx, pivotY, L, stringLen, panW } = geo + const size = 26 + const gap = 5 + const perRow = Math.max(1, Math.floor(panW / (size + gap))) + + const place = (tiles, angle, dir) => { + const endX = cx + dir * L * Math.cos(angle) + const endY = pivotY - dir * L * Math.sin(angle) + const panCX = endX + const panTopY = endY + stringLen + const positions = tiles.map((t, i) => { + const row = Math.floor(i / perRow) + const col = i % perRow + const rowCount = Math.min(perRow, tiles.length - row * perRow) + const rowW = rowCount * (size + gap) - gap + return { + ...t, + x: panCX - rowW / 2 + col * (size + gap) + size / 2, + y: panTopY - size / 2 - 6 - row * (size + gap), + size, + } + }) + return { endX, endY, panCX, panTopY, positions } + } + + return { size, place } + }, [geo]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== canvasWidth * dpr || canvas.height !== canvasHeight * dpr) { + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { cx, pivotY, L, baseY } = geo + const angle = angleRef.current + + // Fulcrum (triangle) + stand. + ctx.fillStyle = '#9AA0AA' + ctx.beginPath() + ctx.moveTo(cx, pivotY) + ctx.lineTo(cx - 26, baseY) + ctx.lineTo(cx + 26, baseY) + ctx.closePath() + ctx.fill() + ctx.fillStyle = '#7C818C' + ctx.fillRect(cx - 46, baseY, 92, 8) + + const leftInfo = layout.place(left, angle, -1) + const rightInfo = layout.place(right, angle, 1) + + // Beam. + ctx.strokeStyle = beamWood + ctx.lineWidth = 9 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(leftInfo.endX, leftInfo.endY) + ctx.lineTo(rightInfo.endX, rightInfo.endY) + ctx.stroke() + // Pivot cap. + ctx.fillStyle = ink + ctx.beginPath() + ctx.arc(cx, pivotY, 6, 0, Math.PI * 2) + ctx.fill() + + // Strings + pans. + const drawPan = (info) => { + const hw = geo.panW / 2 + // Hanging string. + ctx.strokeStyle = '#C9C3B6' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(info.endX, info.endY) + ctx.lineTo(info.panCX, info.panTopY - 2) + ctx.stroke() + // Shallow bowl the tiles rest in. + ctx.strokeStyle = '#8A8478' + ctx.lineWidth = 4 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(info.panCX - hw, info.panTopY - 2) + ctx.quadraticCurveTo(info.panCX, info.panTopY + 20, info.panCX + hw, info.panTopY - 2) + ctx.stroke() + } + drawPan(leftInfo) + drawPan(rightInfo) + + // Tiles. + const dragId = drag?.id + const drawTile = (t) => { + const isX = t.type === 'x' + ctx.fillStyle = isX ? xPurple : unitBlue + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + roundRect(ctx, t.x - t.size / 2, t.y - t.size / 2, t.size, t.size, 6) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = `900 ${isX ? 15 : 12}px Inter, system-ui, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(isX ? 'x' : '1', t.x, t.y + 0.5) + } + ;[...leftInfo.positions, ...rightInfo.positions].forEach((t) => { + if (t.id === dragId) return + drawTile(t) + }) + if (drag) { + drawTile({ x: drag.x, y: drag.y, size: layout.size, type: drag.type }) + } + }, [canvasWidth, geo, layout, left, right, drag]) + + // Ease the tilt toward its target, then draw. + useEffect(() => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + const tick = () => { + const cur = angleRef.current + const next = cur + (targetAngle - cur) * 0.2 + angleRef.current = Math.abs(next - targetAngle) < 0.0006 ? targetAngle : next + draw() + if (angleRef.current !== targetAngle) rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + } + }, [targetAngle, draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + } + + const handlePointerDown = (event) => { + const pt = getPoint(event) + const angle = angleRef.current + const leftInfo = layout.place(left, angle, -1) + const rightInfo = layout.place(right, angle, 1) + const all = [ + ...leftInfo.positions.map((t) => ({ ...t, side: 'left' })), + ...rightInfo.positions.map((t) => ({ ...t, side: 'right' })), + ] + for (let i = all.length - 1; i >= 0; i -= 1) { + const t = all[i] + if (Math.abs(pt.x - t.x) <= t.size / 2 + 3 && Math.abs(pt.y - t.y) <= t.size / 2 + 3) { + event.currentTarget.setPointerCapture(event.pointerId) + setDrag({ side: t.side, id: t.id, type: t.type, x: t.x, y: t.y, offX: pt.x - t.x, offY: pt.y - t.y, homeX: t.x, homeY: t.y }) + return + } + } + } + + const handlePointerMove = (event) => { + if (!drag) return + const pt = getPoint(event) + setDrag((d) => (d ? { ...d, x: pt.x - d.offX, y: pt.y - d.offY } : d)) + } + + const handlePointerUp = () => { + if (!drag) return + // Removed if dragged well away from where it started (off the pan). + const movedOff = Math.hypot(drag.x - drag.homeX, drag.y - drag.homeY) > 46 + if (movedOff) { + if (drag.side === 'left') setLeft((ts) => ts.filter((t) => t.id !== drag.id)) + else setRight((ts) => ts.filter((t) => t.id !== drag.id)) + } + setDrag(null) + } + + const message = solved + ? `Solved! x = ${answer}` + : !balanced + ? 'Unbalanced! Take the same tile off the OTHER side to level it again.' + : 'Balanced ✓ Drag matching tiles off both sides until one x sits alone.' + + const leftEq = sideLabel(leftX, leftU) + const rightEq = sideLabel(rightX, rightU) + + return ( +
+ {/* Live equation */} +
+ {leftEq} + = + {rightEq} +
+ +
+ +
+ +

+ {message} +

+ +
+
+ = x + = 1 +
+
+ Problem:{' '} + {PROBLEMS.map((p, i) => ( + + {i > 0 && ', '} + + + ))} +
+ +
+
+ ) +} + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.arcTo(x + w, y, x + w, y + h, r) + ctx.arcTo(x + w, y + h, x, y + h, r) + ctx.arcTo(x, y + h, x, y, r) + ctx.arcTo(x, y, x + w, y, r) + ctx.closePath() +} diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index f79ccb3..147df93 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -1,3 +1,4 @@ +import BalanceScaleEquations from './balance-scale-equations.jsx' import CoordinateTreasureMap from './coordinate-treasure-map.jsx' import FactorTree from './factor-tree.jsx' import MeanBalancePoint from './mean-balance-point.jsx' @@ -18,6 +19,11 @@ export const manipulatives = [ name: 'Multiplying Fractions (Area)', component: MultiplyingFractionsArea, }, + { + id: 'balance-scale-equations', + name: 'Balance Scale Equations', + component: BalanceScaleEquations, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', From 1da7991fc616359abcf646f51e29de97819bed3b Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 3 Jul 2026 14:18:54 +0200 Subject: [PATCH 03/16] Add Rounding on a Number Line manipulative - rounding-number-line (4.NBT.A.3 / 5.NBT.A.4): drag a number on the line between its two bracketing benchmarks; the dashed halfway mark is the visible tiebreaker and the nearer 10/100 is highlighted with a "rounds to" arrow. Toggle nearest-10 / nearest-100; hide-answer to predict. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/index.js | 6 + src/manipulatives/rounding-number-line.jsx | 272 +++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/manipulatives/rounding-number-line.jsx diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index 147df93..f7a727c 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -6,6 +6,7 @@ import MultiplyingFractionsArea from './multiplying-fractions-area.jsx' import NumberLineExplorer from './number-line-explorer.jsx' import ParallelogramArea from './parallelogram-area.jsx' import PizzaRemainder from './pizza-remainder.jsx' +import RoundingNumberLine from './rounding-number-line.jsx' import TwoFactorTrees from './two-factor-trees.jsx' export const manipulatives = [ @@ -24,6 +25,11 @@ export const manipulatives = [ name: 'Balance Scale Equations', component: BalanceScaleEquations, }, + { + id: 'rounding-number-line', + name: 'Rounding on a Number Line', + component: RoundingNumberLine, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx new file mode 100644 index 0000000..764c96a --- /dev/null +++ b/src/manipulatives/rounding-number-line.jsx @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const numberPurple = '#7C3AED' +const roundGreen = '#1D9E75' +const midGray = '#9AA0AA' +const axisColor = '#1A1A2E' + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +export default function RoundingNumberLine() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [base, setBase] = useState(10) // round to nearest 10 or 100 + const [n, setN] = useState(47) + const [hideAnswer, setHideAnswer] = useState(false) + const draggingRef = useRef(false) + + const canvasHeight = 260 + const min = 0 + const max = base === 10 ? 100 : 1000 + + const lower = Math.floor(n / base) * base + const upper = Math.min(max, lower + base) + const mid = lower + base / 2 + const rounded = n - lower >= base / 2 ? lower + base : lower + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const geo = useMemo(() => { + const PAD = 56 + const axisY = canvasHeight * 0.56 + const spanX = canvasWidth - PAD * 2 + const xFor = (v) => PAD + ((v - min) / (max - min)) * spanX + const vFor = (x) => clamp(Math.round(((x - PAD) / spanX) * (max - min) + min), min, max) + return { PAD, axisY, spanX, xFor, vFor } + }, [canvasWidth, max]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== canvasWidth * dpr || canvas.height !== canvasHeight * dpr) { + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { PAD, axisY, xFor } = geo + const minorStep = base / 10 + + // Highlight band for the bracketing interval [lower, upper]. + ctx.fillStyle = 'rgba(29, 158, 117, 0.08)' + ctx.fillRect(xFor(lower), axisY - 54, xFor(upper) - xFor(lower), 108) + + // Axis line + arrowheads. + ctx.strokeStyle = axisColor + ctx.fillStyle = axisColor + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(PAD - 14, axisY) + ctx.lineTo(canvasWidth - PAD + 14, axisY) + ctx.stroke() + ;[[PAD - 14, -1], [canvasWidth - PAD + 14, 1]].forEach(([x, dir]) => { + ctx.beginPath() + ctx.moveTo(x, axisY) + ctx.lineTo(x - dir * 9, axisY - 5) + ctx.lineTo(x - dir * 9, axisY + 5) + ctx.closePath() + ctx.fill() + }) + + // Ticks. + ctx.textAlign = 'center' + for (let v = min; v <= max + 1e-6; v += minorStep) { + const x = xFor(v) + const isMajor = v % base === 0 + ctx.strokeStyle = '#B9BDC6' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, axisY - (isMajor ? 10 : 5)) + ctx.lineTo(x, axisY + (isMajor ? 10 : 5)) + ctx.stroke() + if (isMajor) { + ctx.fillStyle = '#8B8F99' + ctx.font = '600 12px Inter, system-ui, sans-serif' + ctx.textBaseline = 'top' + ctx.fillText(String(v), x, axisY + 14) + } + } + + // Midpoint (the tiebreaker). + ctx.strokeStyle = midGray + ctx.setLineDash([5, 5]) + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(xFor(mid), axisY - 52) + ctx.lineTo(xFor(mid), axisY + 24) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = midGray + ctx.font = '700 12px Inter, system-ui, sans-serif' + ctx.textBaseline = 'bottom' + ctx.fillText(`halfway ${mid}`, xFor(mid), axisY - 54) + + // Bracketing benchmarks. + ;[lower, upper].forEach((v) => { + const isTarget = !hideAnswer && v === rounded + const x = xFor(v) + ctx.fillStyle = isTarget ? roundGreen : '#C4C8D0' + ctx.beginPath() + ctx.arc(x, axisY, isTarget ? 9 : 6, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = isTarget ? roundGreen : '#6B7280' + ctx.font = `${isTarget ? '900' : '700'} 15px Inter, system-ui, sans-serif` + ctx.textBaseline = 'bottom' + ctx.fillText(String(v), x, axisY + 42) + }) + + // "rounds to" arrow from the number to the target benchmark. + if (!hideAnswer && rounded !== n) { + const x1 = xFor(n) + const x2 = xFor(rounded) + const topY = axisY - 30 + ctx.strokeStyle = roundGreen + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(x1, topY) + ctx.quadraticCurveTo((x1 + x2) / 2, topY - 22, x2, topY) + ctx.stroke() + const dir = x2 >= x1 ? 1 : -1 + ctx.fillStyle = roundGreen + ctx.beginPath() + ctx.moveTo(x2, topY + 1) + ctx.lineTo(x2 - dir * 9, topY - 6) + ctx.lineTo(x2 - dir * 9, topY + 6) + ctx.closePath() + ctx.fill() + } + + // The draggable number marker. + const nx = xFor(n) + ctx.strokeStyle = numberPurple + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(nx, axisY - 6) + ctx.lineTo(nx, axisY + 6) + ctx.stroke() + ctx.fillStyle = numberPurple + ctx.beginPath() + ctx.arc(nx, axisY, 8, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.font = '900 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + // number label in a pill above + ctx.fillStyle = numberPurple + const label = String(n) + ctx.font = '900 16px Inter, system-ui, sans-serif' + const w = ctx.measureText(label).width + 16 + ctx.beginPath() + ctx.roundRect(nx - w / 2, axisY - 78, w, 26, 13) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.textBaseline = 'middle' + ctx.fillText(label, nx, axisY - 64) + // little pointer under the pill + ctx.fillStyle = numberPurple + ctx.beginPath() + ctx.moveTo(nx - 5, axisY - 52) + ctx.lineTo(nx + 5, axisY - 52) + ctx.lineTo(nx, axisY - 46) + ctx.closePath() + ctx.fill() + }, [canvasWidth, geo, base, n, lower, upper, mid, rounded, hideAnswer]) + + useEffect(() => { + draw() + }, [draw]) + + const getVal = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = (event.clientX - rect.left) * (canvasWidth / rect.width) + return geo.vFor(x) + } + + const handlePointerDown = (event) => { + event.currentTarget.setPointerCapture(event.pointerId) + draggingRef.current = true + setN(getVal(event)) + } + const handlePointerMove = (event) => { + if (!draggingRef.current) return + setN(getVal(event)) + } + const handlePointerUp = () => { + draggingRef.current = false + } + + const switchBase = (b) => { + setBase(b) + setN(b === 10 ? 47 : 472) + } + + return ( +
+ {/* Result */} +
+ {n} + rounds to + {hideAnswer ? ? : {rounded}} + (nearest {base}) +
+ +
+ +
+ +

+ Drag the number. Is it before or after the halfway mark? That decides which {base} it’s closer to. +

+ +
+
+ {[10, 100].map((b) => ( + + ))} +
+
+ Number +
+ + {n} + +
+
+ +
+
+ ) +} From 8c06f2fee3d1f78967152f054bd1ac8ee5a2d295 Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 3 Jul 2026 14:59:27 +0200 Subject: [PATCH 04/16] Revise pizza, balance scale, and rounding per review feedback Pizza Remainder: fixed pizza size (tall stacks now wrap into columns instead of shrinking); removed the "Share for me" shortcut; show a bold 0 in the Remainder tray when there is no remainder. Balance Scale: fixed the shaking (round canvas size to whole device pixels so it no longer resizes every frame; decouple the tilt-ease loop from re-renders so a drag can't restart it). Replaced the row of all equations with a dropdown. Rounding: wider spacing (0-50 for nearest 10, 0-500 for nearest 100); added a Nearest 1 mode that uses decimals (0-5); replaced the parabola with a straight green arrow whose head sits over the target; added a ghost-point animation that floats to the rounded value when you release the number. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/balance-scale-equations.jsx | 51 +++--- src/manipulatives/pizza-remainder.jsx | 49 ++++-- src/manipulatives/rounding-number-line.jsx | 166 +++++++++++++----- 3 files changed, 182 insertions(+), 84 deletions(-) diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index cf8ef9f..5fe82c4 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -128,9 +128,11 @@ export default function BalanceScaleEquations() { const canvas = canvasRef.current if (!canvas) return const dpr = window.devicePixelRatio || 1 - if (canvas.width !== canvasWidth * dpr || canvas.height !== canvasHeight * dpr) { - canvas.width = canvasWidth * dpr - canvas.height = canvasHeight * dpr + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch } const ctx = canvas.getContext('2d') ctx.setTransform(dpr, 0, 0, dpr, 0, 0) @@ -214,21 +216,29 @@ export default function BalanceScaleEquations() { } }, [canvasWidth, geo, layout, left, right, drag]) - // Ease the tilt toward its target, then draw. + // Redraw on any state change (keeps the dragged tile following the pointer). + const drawRef = useRef(draw) + useEffect(() => { + drawRef.current = draw + draw() + }, [draw]) + + // Ease the tilt toward its target. Depends only on targetAngle, so a drag + // (which changes `draw` every frame) never restarts this loop — no shaking. useEffect(() => { if (rafRef.current) cancelAnimationFrame(rafRef.current) const tick = () => { const cur = angleRef.current const next = cur + (targetAngle - cur) * 0.2 angleRef.current = Math.abs(next - targetAngle) < 0.0006 ? targetAngle : next - draw() + drawRef.current() if (angleRef.current !== targetAngle) rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } - }, [targetAngle, draw]) + }, [targetAngle]) const getPoint = (event) => { const rect = event.currentTarget.getBoundingClientRect() @@ -312,22 +322,21 @@ export default function BalanceScaleEquations() { = x = 1 -
- Problem:{' '} - {PROBLEMS.map((p, i) => ( - - {i > 0 && ', '} - diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx index aebd9f0..6ecfb6b 100644 --- a/src/manipulatives/pizza-remainder.jsx +++ b/src/manipulatives/pizza-remainder.jsx @@ -72,7 +72,6 @@ export default function PizzaRemainder() { const [hideAnswer, setHideAnswer] = useState(false) const canvasHeight = 360 - const quotient = Math.floor(pizzas / friends) const dragRef = useRef(null) // { index, offX, offY } const dragPosRef = useRef(null) // { x, y } @@ -112,12 +111,12 @@ export default function PizzaRemainder() { const friendX = (f) => PAD + colW * (f + 0.5) const plateBaseY = canvasHeight - 58 - const maxStack = Math.max(quotient, status.maxC, 1) - const availV = plateBaseY - 30 - 118 - const rV = availV / (1.5 * maxStack + 0.5) - const rH = colW * 0.34 - const r = clamp(Math.min(rH, rV, 22), 7, 22) - const spacing = 1.5 * r + // Fixed pizza size (never shrinks as a stack grows); tall stacks wrap into columns. + const r = clamp(Math.min(colW * 0.32, 20), 12, 20) + const spacing = 2 * r + 3 + const availV = plateBaseY - 26 - 122 + const perCol = Math.max(1, Math.floor(availV / spacing)) + const colGap = 2 * r + 5 const pileCols = Math.min(pizzas, 6) const pileGap = 2 * r + 6 @@ -133,15 +132,26 @@ export default function PizzaRemainder() { const trayY2 = trayY1 + Math.max(2 * r + 26, 68) const tray = { x1: trayX1, y1: trayY1, x2: trayX2, y2: trayY2, cx: (trayX1 + trayX2) / 2, cy: (trayY1 + trayY2) / 2 + 4 } + // Count per plate so a wrapped (multi-column) stack can be centered. + const plateCounts = Array(friends).fill(0) + place.forEach((p) => { if (p.loc === 'plate' && p.f < friends) plateCounts[p.f] += 1 }) + // Compute a resting position for every pizza from its placement. - const perPlateRow = Array(friends).fill(0) + const perPlateIdx = Array(friends).fill(0) let pileK = 0 let trayK = 0 const positions = place.map((p) => { if (p.loc === 'plate' && p.f < friends) { - const row = perPlateRow[p.f] - perPlateRow[p.f] += 1 - return { x: friendX(p.f), y: plateBaseY - 26 - row * spacing, loc: 'plate' } + const idx = perPlateIdx[p.f] + perPlateIdx[p.f] += 1 + const numCols = Math.ceil(plateCounts[p.f] / perCol) + const subCol = Math.floor(idx / perCol) + const rowInCol = idx % perCol + return { + x: friendX(p.f) + (subCol - (numCols - 1) / 2) * colGap, + y: plateBaseY - 26 - rowInCol * spacing, + loc: 'plate', + } } if (p.loc === 'tray') { const k = trayK @@ -156,7 +166,7 @@ export default function PizzaRemainder() { }) return { PAD, width, colW, friendX, plateBaseY, r, spacing, tray, pileX0, pileY0, positions } - }, [canvasWidth, pizzas, friends, quotient, place, status.maxC, status.trayCount]) + }, [canvasWidth, pizzas, friends, place, status.trayCount]) const draw = useCallback(() => { const canvas = canvasRef.current @@ -207,6 +217,14 @@ export default function PizzaRemainder() { ctx.fillStyle = muted ctx.textBaseline = 'top' ctx.fillText("what's left over", tray.cx, tray.y2 + 4) + // Show a bold 0 when nothing is left over. + if (status.trayCount === 0) { + ctx.fillStyle = remOrange + ctx.font = '900 24px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('0', tray.cx, tray.cy) + } // Pile label. if (status.pileCount > 0) { @@ -306,10 +324,6 @@ export default function PizzaRemainder() { return next }) } - const shareForMe = () => { - setPlace((prev) => prev.map((_, i) => (i < quotient * friends ? { loc: 'plate', f: i % friends } : { loc: 'tray' }))) - } - const canDeal = status.pileCount >= friends const remainderVal = status.trayCount const message = status.solved @@ -378,9 +392,6 @@ export default function PizzaRemainder() { - diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx index 764c96a..af22082 100644 --- a/src/manipulatives/rounding-number-line.jsx +++ b/src/manipulatives/rounding-number-line.jsx @@ -8,25 +8,47 @@ const roundGreen = '#1D9E75' const midGray = '#9AA0AA' const axisColor = '#1A1A2E' +const GHOST_MS = 680 const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) +const easeOut = (t) => 1 - Math.pow(1 - t, 3) + +// Range and defaults per rounding place. +const CONFIG = { + 1: { max: 5, step: 0.1, def: 2.7 }, + 10: { max: 50, step: 1, def: 27 }, + 100: { max: 500, step: 1, def: 270 }, +} export default function RoundingNumberLine() { const canvasRef = useRef(null) const wrapRef = useRef(null) const [canvasWidth, setCanvasWidth] = useState(720) - const [base, setBase] = useState(10) // round to nearest 10 or 100 - const [n, setN] = useState(47) + const [base, setBase] = useState(10) + const [n, setN] = useState(27) const [hideAnswer, setHideAnswer] = useState(false) const draggingRef = useRef(false) + const nRef = useRef(27) + const ghostRef = useRef(null) + const ghostRafRef = useRef(null) const canvasHeight = 260 const min = 0 - const max = base === 10 ? 100 : 1000 + const max = CONFIG[base].max + const step = CONFIG[base].step + + const fmt = (v) => (base === 1 ? (Number.isInteger(v) ? String(v) : v.toFixed(1)) : String(v)) + const roundOf = useCallback( + (v) => { + const lower = Math.floor(v / base) * base + return v - lower >= base / 2 ? lower + base : lower + }, + [base], + ) const lower = Math.floor(n / base) * base const upper = Math.min(max, lower + base) const mid = lower + base / 2 - const rounded = n - lower >= base / 2 ? lower + base : lower + const rounded = roundOf(n) useEffect(() => { const node = wrapRef.current @@ -43,17 +65,23 @@ export default function RoundingNumberLine() { const axisY = canvasHeight * 0.56 const spanX = canvasWidth - PAD * 2 const xFor = (v) => PAD + ((v - min) / (max - min)) * spanX - const vFor = (x) => clamp(Math.round(((x - PAD) / spanX) * (max - min) + min), min, max) + const vFor = (x) => { + const raw = ((x - PAD) / spanX) * (max - min) + min + const snapped = Math.round(raw / step) * step + return clamp(base === 1 ? Math.round(snapped * 10) / 10 : snapped, min, max) + } return { PAD, axisY, spanX, xFor, vFor } - }, [canvasWidth, max]) + }, [canvasWidth, max, step, base]) const draw = useCallback(() => { const canvas = canvasRef.current if (!canvas) return const dpr = window.devicePixelRatio || 1 - if (canvas.width !== canvasWidth * dpr || canvas.height !== canvasHeight * dpr) { - canvas.width = canvasWidth * dpr - canvas.height = canvasHeight * dpr + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch } const ctx = canvas.getContext('2d') ctx.setTransform(dpr, 0, 0, dpr, 0, 0) @@ -62,11 +90,11 @@ export default function RoundingNumberLine() { const { PAD, axisY, xFor } = geo const minorStep = base / 10 - // Highlight band for the bracketing interval [lower, upper]. + // Highlight band for the bracketing interval. ctx.fillStyle = 'rgba(29, 158, 117, 0.08)' ctx.fillRect(xFor(lower), axisY - 54, xFor(upper) - xFor(lower), 108) - // Axis line + arrowheads. + // Axis + arrowheads. ctx.strokeStyle = axisColor ctx.fillStyle = axisColor ctx.lineWidth = 2.5 @@ -87,7 +115,7 @@ export default function RoundingNumberLine() { ctx.textAlign = 'center' for (let v = min; v <= max + 1e-6; v += minorStep) { const x = xFor(v) - const isMajor = v % base === 0 + const isMajor = Math.abs(v / base - Math.round(v / base)) < 1e-6 ctx.strokeStyle = '#B9BDC6' ctx.lineWidth = 1 ctx.beginPath() @@ -98,7 +126,7 @@ export default function RoundingNumberLine() { ctx.fillStyle = '#8B8F99' ctx.font = '600 12px Inter, system-ui, sans-serif' ctx.textBaseline = 'top' - ctx.fillText(String(v), x, axisY + 14) + ctx.fillText(fmt(Math.round(v / base) * base), x, axisY + 14) } } @@ -114,7 +142,7 @@ export default function RoundingNumberLine() { ctx.fillStyle = midGray ctx.font = '700 12px Inter, system-ui, sans-serif' ctx.textBaseline = 'bottom' - ctx.fillText(`halfway ${mid}`, xFor(mid), axisY - 54) + ctx.fillText(`halfway ${fmt(mid)}`, xFor(mid), axisY - 54) // Bracketing benchmarks. ;[lower, upper].forEach((v) => { @@ -127,31 +155,46 @@ export default function RoundingNumberLine() { ctx.fillStyle = isTarget ? roundGreen : '#6B7280' ctx.font = `${isTarget ? '900' : '700'} 15px Inter, system-ui, sans-serif` ctx.textBaseline = 'bottom' - ctx.fillText(String(v), x, axisY + 42) + ctx.fillText(fmt(v), x, axisY + 42) }) - // "rounds to" arrow from the number to the target benchmark. + // Straight "rounds to" arrow: a horizontal green line ending in an + // arrowhead whose tip sits over the target benchmark. if (!hideAnswer && rounded !== n) { const x1 = xFor(n) const x2 = xFor(rounded) - const topY = axisY - 30 + const y = axisY - 30 + const dir = x2 >= x1 ? 1 : -1 ctx.strokeStyle = roundGreen ctx.lineWidth = 2.5 ctx.beginPath() - ctx.moveTo(x1, topY) - ctx.quadraticCurveTo((x1 + x2) / 2, topY - 22, x2, topY) + ctx.moveTo(x1, y) + ctx.lineTo(x2 - dir * 8, y) ctx.stroke() - const dir = x2 >= x1 ? 1 : -1 ctx.fillStyle = roundGreen ctx.beginPath() - ctx.moveTo(x2, topY + 1) - ctx.lineTo(x2 - dir * 9, topY - 6) - ctx.lineTo(x2 - dir * 9, topY + 6) + ctx.moveTo(x2, y) + ctx.lineTo(x2 - dir * 10, y - 6) + ctx.lineTo(x2 - dir * 10, y + 6) ctx.closePath() ctx.fill() } - // The draggable number marker. + // Ghost point floating from the number to its rounded value. + if (ghostRef.current) { + const g = ghostRef.current + const t = clamp((performance.now() - g.start) / GHOST_MS, 0, 1) + const gx = g.fromX + (g.toX - g.fromX) * easeOut(t) + const gy = axisY - 26 * Math.sin(Math.PI * t) + ctx.globalAlpha = 0.6 * (1 - t) + ctx.fillStyle = numberPurple + ctx.beginPath() + ctx.arc(gx, gy, 8, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 1 + } + + // Draggable number marker + pill. const nx = xFor(n) ctx.strokeStyle = numberPurple ctx.lineWidth = 2 @@ -163,14 +206,9 @@ export default function RoundingNumberLine() { ctx.beginPath() ctx.arc(nx, axisY, 8, 0, Math.PI * 2) ctx.fill() - ctx.fillStyle = '#ffffff' - ctx.font = '900 11px Inter, system-ui, sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - // number label in a pill above - ctx.fillStyle = numberPurple - const label = String(n) + const label = fmt(n) ctx.font = '900 16px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' const w = ctx.measureText(label).width + 16 ctx.beginPath() ctx.roundRect(nx - w / 2, axisY - 78, w, 26, 13) @@ -178,7 +216,6 @@ export default function RoundingNumberLine() { ctx.fillStyle = '#ffffff' ctx.textBaseline = 'middle' ctx.fillText(label, nx, axisY - 64) - // little pointer under the pill ctx.fillStyle = numberPurple ctx.beginPath() ctx.moveTo(nx - 5, axisY - 52) @@ -192,6 +229,38 @@ export default function RoundingNumberLine() { draw() }, [draw]) + useEffect(() => () => { + if (ghostRafRef.current) cancelAnimationFrame(ghostRafRef.current) + }, []) + + const runGhost = useCallback(() => { + if (ghostRafRef.current) cancelAnimationFrame(ghostRafRef.current) + const tick = () => { + const g = ghostRef.current + if (!g) return + if (performance.now() - g.start >= GHOST_MS) { + ghostRef.current = null + draw() + return + } + draw() + ghostRafRef.current = requestAnimationFrame(tick) + } + ghostRafRef.current = requestAnimationFrame(tick) + }, [draw]) + + const spawnGhost = (v) => { + const rv = roundOf(v) + if (rv === v) return + ghostRef.current = { fromX: geo.xFor(v), toX: geo.xFor(rv), start: performance.now() } + runGhost() + } + + const setNumber = (v) => { + nRef.current = v + setN(v) + } + const getVal = (event) => { const rect = event.currentTarget.getBoundingClientRect() const x = (event.clientX - rect.left) * (canvasWidth / rect.width) @@ -201,28 +270,36 @@ export default function RoundingNumberLine() { const handlePointerDown = (event) => { event.currentTarget.setPointerCapture(event.pointerId) draggingRef.current = true - setN(getVal(event)) + ghostRef.current = null + setNumber(getVal(event)) } const handlePointerMove = (event) => { if (!draggingRef.current) return - setN(getVal(event)) + setNumber(getVal(event)) } const handlePointerUp = () => { + if (!draggingRef.current) return draggingRef.current = false + spawnGhost(nRef.current) // ghost floats to the rounded value on release } const switchBase = (b) => { + ghostRef.current = null setBase(b) - setN(b === 10 ? 47 : 472) + setNumber(CONFIG[b].def) + } + + const stepNumber = (dir) => { + const v = clamp(Math.round((n + dir * step) / step) * step, min, max) + setNumber(base === 1 ? Math.round(v * 10) / 10 : v) } return (
- {/* Result */}
- {n} + {fmt(n)} rounds to - {hideAnswer ? ? : {rounded}} + {hideAnswer ? ? : {fmt(rounded)}} (nearest {base})
@@ -238,12 +315,13 @@ export default function RoundingNumberLine() {

- Drag the number. Is it before or after the halfway mark? That decides which {base} it’s closer to. + Drag the number. Is it before or after the halfway mark? That decides + {base === 1 ? ' which whole number' : ` which ${base}`} it’s closer to.

- {[10, 100].map((b) => ( + {[1, 10, 100].map((b) => ( - {n} - +
+ + {fmt(n)} +
+ ) + } + + return ( +
+
+ Expression = + {expr} +
+ +
+ +
+ +

+ Like tiles combine; an x and a −x make zero (same for 1 and −1). +

+ +
+ + + + + +
+
+ ) +} diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index f7a727c..cb6be13 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -1,7 +1,10 @@ +import AlgebraTiles from './algebra-tiles.jsx' import BalanceScaleEquations from './balance-scale-equations.jsx' import CoordinateTreasureMap from './coordinate-treasure-map.jsx' import FactorTree from './factor-tree.jsx' +import InequalitiesNumberLine from './inequalities-number-line.jsx' import MeanBalancePoint from './mean-balance-point.jsx' +import MixedNumbersImproper from './mixed-numbers-improper.jsx' import MultiplyingFractionsArea from './multiplying-fractions-area.jsx' import NumberLineExplorer from './number-line-explorer.jsx' import ParallelogramArea from './parallelogram-area.jsx' @@ -30,6 +33,21 @@ export const manipulatives = [ name: 'Rounding on a Number Line', component: RoundingNumberLine, }, + { + id: 'mixed-numbers-improper', + name: 'Mixed Numbers & Improper Fractions', + component: MixedNumbersImproper, + }, + { + id: 'inequalities-number-line', + name: 'Inequalities on a Number Line', + component: InequalitiesNumberLine, + }, + { + id: 'algebra-tiles', + name: 'Algebra Tiles', + component: AlgebraTiles, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx new file mode 100644 index 0000000..61d8297 --- /dev/null +++ b/src/manipulatives/inequalities-number-line.jsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const solGreen = '#1D9E75' +const boundPurple = '#7C3AED' +const testBlue = '#2563EB' +const noRed = '#D8402F' +const axisColor = '#1A1A2E' + +const MIN = -10 +const MAX = 10 +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +const OPS = [ + { key: 'lt', sym: '<', inclusive: false, right: false }, + { key: 'le', sym: '≤', inclusive: true, right: false }, + { key: 'gt', sym: '>', inclusive: false, right: true }, + { key: 'ge', sym: '≥', inclusive: true, right: true }, +] + +export default function InequalitiesNumberLine() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [opKey, setOpKey] = useState('gt') + const [bound, setBound] = useState(3) + const [test, setTest] = useState(6) + const dragRef = useRef(null) // 'bound' | 'test' + + const canvasHeight = 260 + const op = OPS.find((o) => o.key === opKey) + + const testOk = op.right ? (op.inclusive ? test >= bound : test > bound) : op.inclusive ? test <= bound : test < bound + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const geo = useMemo(() => { + const PAD = 54 + const axisY = canvasHeight * 0.58 + const spanX = canvasWidth - PAD * 2 + const xFor = (v) => PAD + ((v - MIN) / (MAX - MIN)) * spanX + const vFor = (x) => clamp(Math.round(((x - PAD) / spanX) * (MAX - MIN) + MIN), MIN, MAX) + return { PAD, axisY, spanX, xFor, vFor } + }, [canvasWidth]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { PAD, axisY, xFor } = geo + const bx = xFor(bound) + + // Axis + arrowheads. + ctx.strokeStyle = axisColor + ctx.fillStyle = axisColor + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(PAD - 14, axisY) + ctx.lineTo(canvasWidth - PAD + 14, axisY) + ctx.stroke() + ;[[PAD - 14, -1], [canvasWidth - PAD + 14, 1]].forEach(([x, dir]) => { + ctx.beginPath() + ctx.moveTo(x, axisY) + ctx.lineTo(x - dir * 9, axisY - 5) + ctx.lineTo(x - dir * 9, axisY + 5) + ctx.closePath() + ctx.fill() + }) + + // Ticks + labels. + for (let v = MIN; v <= MAX; v += 1) { + const x = xFor(v) + ctx.strokeStyle = '#B9BDC6' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, axisY - 6) + ctx.lineTo(x, axisY + 6) + ctx.stroke() + if (v % 2 === 0) { + ctx.fillStyle = '#8B8F99' + ctx.font = '600 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.fillText(String(v), x, axisY + 12) + } + } + + // Solution ray (shaded direction) + arrowhead. + const endX = op.right ? canvasWidth - PAD + 8 : PAD - 8 + ctx.strokeStyle = solGreen + ctx.lineWidth = 6 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(bx, axisY) + ctx.lineTo(endX, axisY) + ctx.stroke() + const dir = op.right ? 1 : -1 + ctx.fillStyle = solGreen + ctx.beginPath() + ctx.moveTo(endX + dir * 8, axisY) + ctx.lineTo(endX - dir * 6, axisY - 8) + ctx.lineTo(endX - dir * 6, axisY + 8) + ctx.closePath() + ctx.fill() + + // Boundary dot: open (strict) or closed (inclusive). + ctx.lineWidth = 3.5 + ctx.strokeStyle = boundPurple + ctx.beginPath() + ctx.arc(bx, axisY, 9, 0, Math.PI * 2) + if (op.inclusive) { + ctx.fillStyle = boundPurple + ctx.fill() + } else { + ctx.fillStyle = '#ffffff' + ctx.fill() + ctx.stroke() + } + ctx.fillStyle = boundPurple + ctx.font = '900 15px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(String(bound), bx, axisY - 16) + + // Test point above the line with a check / cross. + const tx = xFor(test) + const ty = axisY - 46 + ctx.strokeStyle = testBlue + ctx.setLineDash([3, 4]) + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(tx, ty + 10) + ctx.lineTo(tx, axisY - 2) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = testBlue + ctx.beginPath() + ctx.arc(tx, ty, 13, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(test), tx, ty) + // verdict badge + ctx.fillStyle = testOk ? solGreen : noRed + ctx.font = '900 18px Inter, system-ui, sans-serif' + ctx.fillText(testOk ? '✓' : '✗', tx + 22, ty) + }, [canvasWidth, geo, op, bound, test, testOk]) + + useEffect(() => { + draw() + }, [draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + } + const handlePointerDown = (event) => { + const pt = getPoint(event) + const bx = geo.xFor(bound) + const tx = geo.xFor(test) + const ty = geo.axisY - 46 + event.currentTarget.setPointerCapture(event.pointerId) + if (Math.hypot(pt.x - tx, pt.y - ty) <= 20) { + dragRef.current = 'test' + setTest(geo.vFor(pt.x)) + } else if (Math.abs(pt.x - bx) <= 18 || Math.abs(pt.y - geo.axisY) <= 22) { + dragRef.current = 'bound' + setBound(geo.vFor(pt.x)) + } + } + const handlePointerMove = (event) => { + if (!dragRef.current) return + const v = geo.vFor(getPoint(event).x) + if (dragRef.current === 'test') setTest(v) + else setBound(v) + } + const handlePointerUp = () => { + dragRef.current = null + } + + return ( +
+
+ x + {op.sym} + {bound} +
+ +
+ +
+ +

+ The {op.inclusive ? 'closed' : 'open'} dot shows {bound} is {op.inclusive ? 'included' : 'not included'}. Drag the blue test number — the green ray is every value that works. +

+ +
+
+ {OPS.map((o) => ( + + ))} +
+ setBound((v) => clamp(v - 1, MIN, MAX))} onInc={() => setBound((v) => clamp(v + 1, MIN, MAX))} /> + setTest((v) => clamp(v - 1, MIN, MAX))} onInc={() => setTest((v) => clamp(v + 1, MIN, MAX))} /> +
+
+ ) +} + +function Stepper({ label, value, color, onDec, onInc }) { + return ( +
+ {label} +
+ + {value} + +
+
+ ) +} diff --git a/src/manipulatives/mixed-numbers-improper.jsx b/src/manipulatives/mixed-numbers-improper.jsx new file mode 100644 index 0000000..8f9b162 --- /dev/null +++ b/src/manipulatives/mixed-numbers-improper.jsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const wholeGreen = '#1D9E75' +const fracOrange = '#D85A30' +const improperPurple = '#7C3AED' + +const MIN_DEN = 2 +const MAX_DEN = 8 + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +export default function MixedNumbersImproper() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [den, setDen] = useState(4) + const [num, setNum] = useState(7) + const [hideMixed, setHideMixed] = useState(false) + const draggingRef = useRef(false) + + const canvasHeight = 260 + const maxNum = den * 5 + const whole = Math.floor(num / den) + const rem = num % den + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const geo = useMemo(() => { + const pad = 30 + const gap = 14 + const displayBars = clamp(whole + (rem > 0 ? 1 : 0) + 1, 1, 5) + const barW = (canvasWidth - pad * 2 - gap * (displayBars - 1)) / displayBars + const barH = 66 + const barY = canvasHeight * 0.42 + const barX = (b) => pad + b * (barW + gap) + return { pad, gap, displayBars, barW, barH, barY, barX, segW: barW / den } + }, [canvasWidth, den, whole, rem]) + + const numFromPoint = useCallback( + (px) => { + const { displayBars, barW, barX, segW } = geo + for (let b = 0; b < displayBars; b += 1) { + const x0 = barX(b) + if (px < x0) return b * den // before this bar + if (px <= x0 + barW) { + const seg = clamp(Math.round((px - x0) / segW), 0, den) + return clamp(b * den + seg, 0, maxNum) + } + } + return clamp(displayBars * den, 0, maxNum) + }, + [geo, den, maxNum], + ) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { displayBars, barW, barH, barY, barX, segW } = geo + + for (let b = 0; b < displayBars; b += 1) { + const x0 = barX(b) + const isWhole = b < whole + const isPartial = b === whole && rem > 0 + + // Filled segments. + for (let s = 0; s < den; s += 1) { + const filled = b < whole || (b === whole && s < rem) + ctx.fillStyle = filled ? (isWhole ? '#3FBE93' : fracOrange) : '#ffffff' + ctx.fillRect(x0 + s * segW, barY, segW, barH) + ctx.strokeStyle = '#E0DDD6' + ctx.lineWidth = 1 + ctx.strokeRect(x0 + s * segW, barY, segW, barH) + } + + // Bar outline (green if a full whole). + ctx.strokeStyle = isWhole ? wholeGreen : isPartial ? fracOrange : '#C4C8D0' + ctx.lineWidth = isWhole || isPartial ? 3 : 2 + ctx.strokeRect(x0, barY, barW, barH) + + // Label under the bar. + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + if (isWhole) { + ctx.fillStyle = wholeGreen + ctx.font = '900 18px Inter, system-ui, sans-serif' + ctx.fillText('1 whole', x0 + barW / 2, barY + barH + 10) + } else if (isPartial) { + ctx.fillStyle = fracOrange + ctx.font = '900 16px Inter, system-ui, sans-serif' + ctx.fillText(`${rem}/${den}`, x0 + barW / 2, barY + barH + 10) + } + } + + // Hint above. + ctx.fillStyle = muted + ctx.font = '600 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'bottom' + ctx.fillText(`Each whole is split into ${den} equal parts. Drag across the bars or use the steppers.`, geo.pad, barY - 12) + }, [canvasWidth, geo, den, whole, rem]) + + useEffect(() => { + draw() + }, [draw]) + + const getX = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return (event.clientX - rect.left) * (canvasWidth / rect.width) + } + const handlePointerDown = (event) => { + event.currentTarget.setPointerCapture(event.pointerId) + draggingRef.current = true + setNum(numFromPoint(getX(event))) + } + const handlePointerMove = (event) => { + if (!draggingRef.current) return + setNum(numFromPoint(getX(event))) + } + const handlePointerUp = () => { + draggingRef.current = false + } + + const changeDen = (d) => { + const nd = clamp(d, MIN_DEN, MAX_DEN) + setDen(nd) + setNum((v) => clamp(v, 0, nd * 5)) + } + + // Mixed-number pieces for the equation. + const mixedParts = [] + if (whole > 0) mixedParts.push({ text: String(whole), color: wholeGreen }) + if (rem > 0) mixedParts.push({ text: `${rem}/${den}`, color: fracOrange }) + if (mixedParts.length === 0) mixedParts.push({ text: '0', color: muted }) + + return ( +
+
+ {num}/{den} + = + {hideMixed ? ( + ? + ) : ( + + {mixedParts.map((p, i) => ( + {p.text} + ))} + + )} +
+ +
+ +
+ +

+ The improper fraction and the mixed number are the same amount — every {den} parts make one whole. +

+ +
+ setNum((v) => clamp(v - 1, 0, maxNum))} onInc={() => setNum((v) => clamp(v + 1, 0, maxNum))} /> + changeDen(den - 1)} onInc={() => changeDen(den + 1)} /> + +
+
+ ) +} + +function Stepper({ label, value, color, onDec, onInc }) { + return ( +
+ {label} +
+ + {value} + +
+
+ ) +} From 22357428065bf6779cf6abe725de39e56f4cb8de Mon Sep 17 00:00:00 2001 From: nakorly Date: Sun, 12 Jul 2026 21:23:16 +0200 Subject: [PATCH 06/16] Harden balance-scale canvas sizing against ResizeObserver bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the unscaled contentRect (stable under the parent's scale transform), defer the update via requestAnimationFrame, and only commit whole-pixel changes — so the size observer can't feed back into itself and jitter the beam. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/balance-scale-equations.jsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index 5fe82c4..74c75c1 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -65,11 +65,25 @@ export default function BalanceScaleEquations() { useEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) - update() - const observer = new ResizeObserver(update) + let raf = 0 + // Only commit a whole-pixel change, and defer via rAF so the observer can + // never feed back into itself (the classic ResizeObserver "bounce"). + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width // unscaled content box — stable under parent transforms + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const loadProblem = (index) => { From fb2447f163e614c8505bb3f903c8b21282b9818b Mon Sep 17 00:00:00 2001 From: nakorly Date: Sun, 12 Jul 2026 21:40:08 +0200 Subject: [PATCH 07/16] Kill load-time bounce across all canvas manipulatives Measure the canvas size in useLayoutEffect (before first paint, so there's no default-width -> measured-width jump), and track the stable unscaled contentRect via an rAF-deferred, whole-pixel-guarded ResizeObserver. Verified in-browser: all 7 report 0 idle redraws and a single stable canvas width. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/algebra-tiles.jsx | 26 +++++++++++++------ src/manipulatives/balance-scale-equations.jsx | 8 +++--- .../inequalities-number-line.jsx | 24 ++++++++++++----- src/manipulatives/mixed-numbers-improper.jsx | 24 ++++++++++++----- .../multiplying-fractions-area.jsx | 26 +++++++++++++------ src/manipulatives/pizza-remainder.jsx | 26 ++++++++++++++----- src/manipulatives/rounding-number-line.jsx | 24 ++++++++++++----- 7 files changed, 114 insertions(+), 44 deletions(-) diff --git a/src/manipulatives/algebra-tiles.jsx b/src/manipulatives/algebra-tiles.jsx index ad6e33f..68ab4c6 100644 --- a/src/manipulatives/algebra-tiles.jsx +++ b/src/manipulatives/algebra-tiles.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -26,17 +26,27 @@ export default function AlgebraTiles() { const [flash, setFlash] = useState(null) // { x, y, t } zero-pair spot const flashRafRef = useRef(null) - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => { - const rect = node.getBoundingClientRect() - setSize({ w: Math.max(420, Math.round(rect.width)), h: Math.max(220, Math.round(rect.height)) }) + let raf = 0 + const commit = (box) => { + const w = Math.max(420, Math.round(box.width)) + const h = Math.max(220, Math.round(box.height)) + setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) } - update() - const observer = new ResizeObserver(update) + commit(node.getBoundingClientRect()) + const observer = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect + if (!cr) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(cr)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const xC = tiles.filter((t) => t.type === 'x').length - tiles.filter((t) => t.type === 'nx').length diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index 74c75c1..a325da3 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -62,12 +62,12 @@ export default function BalanceScaleEquations() { (rightX === 1 && rightU === 0 && leftX === 0 && leftU >= 1)) const answer = solved ? (leftX === 1 ? rightU : leftU) : null - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined let raf = 0 - // Only commit a whole-pixel change, and defer via rAF so the observer can - // never feed back into itself (the classic ResizeObserver "bounce"). + // Measure before first paint; only commit a whole-pixel change, and defer + // via rAF so the observer can never feed back into itself (ResizeObserver "bounce"). const commit = (w) => { const next = Math.max(420, Math.round(w)) setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx index 61d8297..51e7b71 100644 --- a/src/manipulatives/inequalities-number-line.jsx +++ b/src/manipulatives/inequalities-number-line.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -34,14 +34,26 @@ export default function InequalitiesNumberLine() { const testOk = op.right ? (op.inclusive ? test >= bound : test > bound) : op.inclusive ? test <= bound : test < bound - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) - update() - const observer = new ResizeObserver(update) + let raf = 0 + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const geo = useMemo(() => { diff --git a/src/manipulatives/mixed-numbers-improper.jsx b/src/manipulatives/mixed-numbers-improper.jsx index 8f9b162..83c2bfc 100644 --- a/src/manipulatives/mixed-numbers-improper.jsx +++ b/src/manipulatives/mixed-numbers-improper.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -26,14 +26,26 @@ export default function MixedNumbersImproper() { const whole = Math.floor(num / den) const rem = num % den - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) - update() - const observer = new ResizeObserver(update) + let raf = 0 + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const geo = useMemo(() => { diff --git a/src/manipulatives/multiplying-fractions-area.jsx b/src/manipulatives/multiplying-fractions-area.jsx index 4396eda..d505b36 100644 --- a/src/manipulatives/multiplying-fractions-area.jsx +++ b/src/manipulatives/multiplying-fractions-area.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -75,17 +75,27 @@ export default function MultiplyingFractionsArea() { const simpD = prodD / divisor const reduces = divisor > 1 - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => { - const rect = node.getBoundingClientRect() - setSize({ w: Math.max(300, Math.round(rect.width)), h: Math.max(260, Math.round(rect.height)) }) + let raf = 0 + const commit = (box) => { + const w = Math.max(300, Math.round(box.width)) + const h = Math.max(260, Math.round(box.height)) + setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) } - update() - const observer = new ResizeObserver(update) + commit(node.getBoundingClientRect()) + const observer = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect + if (!cr) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(cr)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const geometry = useMemo(() => { diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx index 6ecfb6b..172d0f5 100644 --- a/src/manipulatives/pizza-remainder.jsx +++ b/src/manipulatives/pizza-remainder.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -76,14 +76,28 @@ export default function PizzaRemainder() { const dragRef = useRef(null) // { index, offX, offY } const dragPosRef = useRef(null) // { x, y } - useEffect(() => { + // Measure before first paint (no initial width jump), then track the stable + // unscaled content box via an rAF-deferred observer (no settle bounce). + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => setCanvasWidth(Math.max(360, Math.round(node.getBoundingClientRect().width))) - update() - const observer = new ResizeObserver(update) + let raf = 0 + const commit = (w) => { + const next = Math.max(360, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) // Counts / status derived from the current placement. diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx index af22082..e16e719 100644 --- a/src/manipulatives/rounding-number-line.jsx +++ b/src/manipulatives/rounding-number-line.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' const cream = '#F8F6F0' const ink = '#1A1A2E' @@ -50,14 +50,26 @@ export default function RoundingNumberLine() { const mid = lower + base / 2 const rounded = roundOf(n) - useEffect(() => { + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined - const update = () => setCanvasWidth(Math.max(420, Math.round(node.getBoundingClientRect().width))) - update() - const observer = new ResizeObserver(update) + let raf = 0 + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) observer.observe(node) - return () => observer.disconnect() + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } }, []) const geo = useMemo(() => { From ddd128f32d2ea49e8f59840c951dc1217eacad8c Mon Sep 17 00:00:00 2001 From: nakorly Date: Wed, 15 Jul 2026 01:04:32 +0200 Subject: [PATCH 08/16] Add Probability Scale, Sample Space Tree, and Mean Absolute Deviation - probability-scale (7.SP.C.5): drag event chips onto a 0->1 likelihood line, then reveal the true positions and values. - sample-space-tree (7.SP.C.8): pick a compound experiment; a tree lists every outcome and counts them as a x b; click outcomes to form an event. - mean-absolute-deviation (6.SP.B.5): drag data points on a number line; each distance to the mean is drawn and averaged into the live MAD. Verified in-browser: math correct (MAD 3.5, 12=2x6), stable (0 idle redraws), no console errors. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/index.js | 18 ++ src/manipulatives/mean-absolute-deviation.jsx | 277 ++++++++++++++++ src/manipulatives/probability-scale.jsx | 299 ++++++++++++++++++ src/manipulatives/sample-space-tree.jsx | 227 +++++++++++++ 4 files changed, 821 insertions(+) create mode 100644 src/manipulatives/mean-absolute-deviation.jsx create mode 100644 src/manipulatives/probability-scale.jsx create mode 100644 src/manipulatives/sample-space-tree.jsx diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index cb6be13..682efed 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -3,8 +3,11 @@ import BalanceScaleEquations from './balance-scale-equations.jsx' import CoordinateTreasureMap from './coordinate-treasure-map.jsx' import FactorTree from './factor-tree.jsx' import InequalitiesNumberLine from './inequalities-number-line.jsx' +import MeanAbsoluteDeviation from './mean-absolute-deviation.jsx' import MeanBalancePoint from './mean-balance-point.jsx' import MixedNumbersImproper from './mixed-numbers-improper.jsx' +import ProbabilityScale from './probability-scale.jsx' +import SampleSpaceTree from './sample-space-tree.jsx' import MultiplyingFractionsArea from './multiplying-fractions-area.jsx' import NumberLineExplorer from './number-line-explorer.jsx' import ParallelogramArea from './parallelogram-area.jsx' @@ -48,6 +51,21 @@ export const manipulatives = [ name: 'Algebra Tiles', component: AlgebraTiles, }, + { + id: 'probability-scale', + name: 'Probability Scale Explorer', + component: ProbabilityScale, + }, + { + id: 'sample-space-tree', + name: 'Sample Space & Tree Diagrams', + component: SampleSpaceTree, + }, + { + id: 'mean-absolute-deviation', + name: 'Mean Absolute Deviation', + component: MeanAbsoluteDeviation, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx new file mode 100644 index 0000000..c9f0bef --- /dev/null +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -0,0 +1,277 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const pointPurple = '#7C3AED' +const meanGreen = '#1D9E75' +const distOrange = '#D85A30' +const axisColor = '#1A1A2E' + +const MIN = 0 +const MAX = 20 +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) +const round1 = (v) => Math.round(v * 10) / 10 +let seq = 0 + +export default function MeanAbsoluteDeviation() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [points, setPoints] = useState(() => [4, 7, 9, 16].map((v) => ({ id: (seq += 1), value: v }))) + const [drag, setDrag] = useState(null) // { id, startX, moved } + + const canvasHeight = 300 + + const values = points.map((p) => p.value) + const mean = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0 + const distances = values.map((v) => Math.abs(v - mean)) + const mad = distances.length ? distances.reduce((a, b) => a + b, 0) / distances.length : 0 + + useLayoutEffect(() => { + const node = wrapRef.current + if (!node) return undefined + let raf = 0 + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) + observer.observe(node) + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } + }, []) + + const geo = useMemo(() => { + const PAD = 46 + const axisY = canvasHeight - 74 + const spanX = canvasWidth - PAD * 2 + const xFor = (v) => PAD + ((v - MIN) / (MAX - MIN)) * spanX + const vFor = (x) => clamp(Math.round(((x - PAD) / spanX) * (MAX - MIN) + MIN), MIN, MAX) + return { PAD, axisY, spanX, xFor, vFor } + }, [canvasWidth]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { PAD, axisY, xFor } = geo + const meanX = xFor(mean) + + // Axis + ticks. + ctx.strokeStyle = axisColor + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(PAD - 12, axisY) + ctx.lineTo(canvasWidth - PAD + 12, axisY) + ctx.stroke() + for (let v = MIN; v <= MAX; v += 1) { + const x = xFor(v) + const major = v % 5 === 0 + ctx.strokeStyle = '#B9BDC6' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(x, axisY - (major ? 7 : 4)) + ctx.lineTo(x, axisY + (major ? 7 : 4)) + ctx.stroke() + if (major) { + ctx.fillStyle = '#8B8F99' + ctx.font = '600 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.fillText(String(v), x, axisY + 12) + } + } + + // Mean vertical line. + ctx.strokeStyle = meanGreen + ctx.setLineDash([6, 5]) + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(meanX, 40) + ctx.lineTo(meanX, axisY + 8) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = meanGreen + ctx.font = '900 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(`mean = ${round1(mean)}`, meanX, 36) + + // Distance connectors (staggered), each showing |value - mean|. + const ordered = points + .map((p, i) => ({ ...p, i })) + .sort((a, b) => Math.abs(b.value - mean) - Math.abs(a.value - mean)) + ordered.forEach((p, row) => { + if (drag && drag.id === p.id) return + const px = xFor(p.value) + const y = axisY - 22 - row * 20 + const dist = Math.abs(p.value - mean) + if (dist > 0.05) { + ctx.strokeStyle = distOrange + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(px, y) + ctx.lineTo(meanX, y) + ctx.stroke() + // little drop lines + ctx.setLineDash([2, 3]) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(px, y) + ctx.lineTo(px, axisY) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = distOrange + ctx.font = '800 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(round1(dist).toString(), (px + meanX) / 2, y - 2) + } + }) + + // MAD bar under the axis (average distance, from the mean outward). + const madX = xFor(mean + mad) + ctx.strokeStyle = meanGreen + ctx.lineWidth = 5 + ctx.beginPath() + ctx.moveTo(meanX, axisY + 30) + ctx.lineTo(madX, axisY + 30) + ctx.stroke() + ctx.fillStyle = meanGreen + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(`MAD = ${round1(mad)}`, madX + 8, axisY + 30) + + // Data points. + points.forEach((p) => { + if (drag && drag.id === p.id) return + const x = xFor(p.value) + ctx.fillStyle = pointPurple + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.arc(x, axisY, 11, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(p.value), x, axisY) + }) + if (drag) { + const p = points.find((q) => q.id === drag.id) + if (p) { + const x = xFor(p.value) + ctx.globalAlpha = 0.85 + ctx.fillStyle = pointPurple + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.arc(x, axisY, 13, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.fillText(String(p.value), x, axisY) + ctx.globalAlpha = 1 + } + } + }, [canvasWidth, geo, points, mean, mad, drag]) + + useEffect(() => { + draw() + }, [draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + } + + const handlePointerDown = (event) => { + const pt = getPoint(event) + const hit = points.find((p) => Math.hypot(geo.xFor(p.value) - pt.x, geo.axisY - pt.y) <= 15) + if (hit) { + event.currentTarget.setPointerCapture(event.pointerId) + setDrag({ id: hit.id, startX: pt.x, moved: false }) + return + } + // Click on the axis band adds a point. + if (Math.abs(pt.y - geo.axisY) <= 20 && points.length < 8) { + setPoints((prev) => [...prev, { id: (seq += 1), value: geo.vFor(pt.x) }]) + } + } + const handlePointerMove = (event) => { + if (!drag) return + const pt = getPoint(event) + const v = geo.vFor(pt.x) + setDrag((d) => ({ ...d, moved: d.moved || Math.abs(pt.x - d.startX) > 4 })) + setPoints((prev) => prev.map((p) => (p.id === drag.id ? { ...p, value: v } : p))) + } + const handlePointerUp = () => { + if (!drag) return + if (!drag.moved) setPoints((prev) => prev.filter((p) => p.id !== drag.id)) // click to remove + setDrag(null) + } + + return ( +
+
+ MAD = {round1(mad)} + + = the average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length || 1}) + +
+ +
+ +
+ +

+ Drag points to spread them out or bunch them up. Click the line to add a point, click a point to remove it. Spread out → MAD grows. +

+ +
+ + + +
+
+ ) +} diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx new file mode 100644 index 0000000..fa1bece --- /dev/null +++ b/src/manipulatives/probability-scale.jsx @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const chipPurple = '#7C3AED' +const trueGreen = '#1D9E75' +const axisColor = '#1A1A2E' + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +// Dice/coin events with exact probabilities (0..1). +const EVENTS = [ + { id: 'e0', label: 'Roll a 7', p: 0, disp: '0' }, + { id: 'e6', label: 'Roll a 6', p: 1 / 6, disp: '1/6' }, + { id: 'eh', label: 'Flip heads', p: 1 / 2, disp: '1/2' }, + { id: 'eg', label: 'Roll > 2', p: 4 / 6, disp: '4/6' }, + { id: 'ec', label: 'Roll 1–6', p: 1, disp: '1' }, +] + +const LANDMARKS = [ + { p: 0, label: 'Impossible' }, + { p: 0.25, label: 'Unlikely' }, + { p: 0.5, label: 'Even chance' }, + { p: 0.75, label: 'Likely' }, + { p: 1, label: 'Certain' }, +] + +export default function ProbabilityScale() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [placed, setPlaced] = useState({}) // id -> guess prob (0..1), else in tray + const [revealed, setRevealed] = useState(false) + const [drag, setDrag] = useState(null) // { id, offX, offY, x, y } + const dispRef = useRef({}) // id -> {x,y} current displayed center (for ease) + const rafRef = useRef(null) + + const canvasHeight = 320 + + useLayoutEffect(() => { + const node = wrapRef.current + if (!node) return undefined + let raf = 0 + const commit = (w) => { + const next = Math.max(420, Math.round(w)) + setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) + } + commit(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect?.width + if (!w) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(w)) + }) + observer.observe(node) + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } + }, []) + + const geo = useMemo(() => { + const PAD = 60 + const scaleY = 132 + const trayY = 250 + const spanX = canvasWidth - PAD * 2 + const xFor = (p) => PAD + p * spanX + const pFor = (x) => clamp((x - PAD) / spanX, 0, 1) + const chipW = Math.min(120, (canvasWidth - PAD * 2 - (EVENTS.length - 1) * 10) / EVENTS.length) + const trayX = (i) => PAD + chipW / 2 + i * (chipW + 10) + return { PAD, scaleY, trayY, spanX, xFor, pFor, chipW } + }, [canvasWidth]) + + // Target center for each chip given state. + const targets = useMemo(() => { + const t = {} + EVENTS.forEach((e, i) => { + if (revealed) t[e.id] = { x: geo.xFor(e.p), y: geo.scaleY - 42, on: true } + else if (placed[e.id] != null) t[e.id] = { x: geo.xFor(placed[e.id]), y: geo.scaleY - 42, on: true } + else t[e.id] = { x: geo.PAD + geo.chipW / 2 + i * (geo.chipW + 10), y: geo.trayY, on: false } + }) + return t + }, [geo, placed, revealed]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + const cw = Math.round(canvasWidth * dpr) + const ch = Math.round(canvasHeight * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const { PAD, scaleY, xFor, chipW } = geo + + // Scale bar 0..1 with a soft gradient (impossible -> certain). + const grad = ctx.createLinearGradient(xFor(0), 0, xFor(1), 0) + grad.addColorStop(0, '#FBE3D8') + grad.addColorStop(0.5, '#FDF3D6') + grad.addColorStop(1, '#D6F0E4') + ctx.fillStyle = grad + ctx.fillRect(xFor(0), scaleY - 7, xFor(1) - xFor(0), 14) + ctx.strokeStyle = axisColor + ctx.lineWidth = 2 + ctx.strokeRect(xFor(0), scaleY - 7, xFor(1) - xFor(0), 14) + + // Landmark ticks + labels + fraction/percent scale. + ctx.textAlign = 'center' + LANDMARKS.forEach((lm) => { + const x = xFor(lm.p) + ctx.strokeStyle = '#9AA0AA' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(x, scaleY - 12) + ctx.lineTo(x, scaleY + 12) + ctx.stroke() + ctx.fillStyle = muted + ctx.font = '700 12px Inter, system-ui, sans-serif' + ctx.textBaseline = 'top' + ctx.fillText(lm.label, x, scaleY + 16) + ctx.fillStyle = ink + ctx.font = '800 13px Inter, system-ui, sans-serif' + ctx.textBaseline = 'bottom' + ctx.fillText(`${Math.round(lm.p * 100)}%`, x, scaleY - 16) + }) + // Endpoint number labels 0 and 1. + ctx.fillStyle = ink + ctx.font = '900 15px Inter, system-ui, sans-serif' + ctx.textBaseline = 'bottom' + ctx.fillText('0', xFor(0), scaleY - 34) + ctx.fillText('1', xFor(1), scaleY - 34) + + // Tray label. + if (Object.keys(placed).length < EVENTS.length && !revealed) { + ctx.fillStyle = muted + ctx.font = '700 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'bottom' + ctx.fillText('Drag each event onto the scale:', PAD, geo.trayY - 26) + } + + // Chips. + const drawChip = (e, cx, cy, on, lifted) => { + const w = chipW + const h = 34 + ctx.save() + ctx.fillStyle = lifted ? 'rgba(26,26,46,0.20)' : 'rgba(26,26,46,0.10)' + ctx.beginPath() + ctx.roundRect(cx - w / 2 + 2, cy - h / 2 + 3, w, h, 9) + ctx.fill() + ctx.fillStyle = revealed ? trueGreen : chipPurple + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.roundRect(cx - w / 2, cy - h / 2, w, h, 9) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = '800 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(e.label, cx, cy - (revealed ? 5 : 0)) + if (revealed) { + ctx.font = '900 11px Inter, system-ui, sans-serif' + ctx.fillText(`= ${e.disp}`, cx, cy + 8) + } + // pointer to the line when on the scale + if (on) { + ctx.fillStyle = revealed ? trueGreen : chipPurple + ctx.beginPath() + ctx.moveTo(cx - 6, cy + h / 2) + ctx.lineTo(cx + 6, cy + h / 2) + ctx.lineTo(cx, cy + h / 2 + 8) + ctx.closePath() + ctx.fill() + } + ctx.restore() + } + + EVENTS.forEach((e) => { + if (drag && drag.id === e.id) return + const d = dispRef.current[e.id] || targets[e.id] + drawChip(e, d.x, d.y, targets[e.id].on, false) + }) + if (drag) { + const e = EVENTS.find((x) => x.id === drag.id) + drawChip(e, drag.x, drag.y, false, true) + } + }, [canvasWidth, geo, placed, revealed, drag, targets]) + + // Always draw on state change (the ease loop below only handles animation). + useEffect(() => { + draw() + }, [draw]) + + // Ease displayed chip positions toward targets. + useEffect(() => { + const start = () => { + cancelAnimationFrame(rafRef.current) + const tick = () => { + let moving = false + EVENTS.forEach((e) => { + const tgt = targets[e.id] + const cur = dispRef.current[e.id] || { ...tgt } + const nx = cur.x + (tgt.x - cur.x) * 0.25 + const ny = cur.y + (tgt.y - cur.y) * 0.25 + if (Math.hypot(tgt.x - nx, tgt.y - ny) > 0.5) moving = true + dispRef.current[e.id] = { x: Math.abs(tgt.x - nx) < 0.5 ? tgt.x : nx, y: Math.abs(tgt.y - ny) < 0.5 ? tgt.y : ny } + }) + draw() + if (moving) rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + } + start() + return () => cancelAnimationFrame(rafRef.current) + }, [targets, draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + } + + const handlePointerDown = (event) => { + if (revealed) return + const pt = getPoint(event) + for (let i = EVENTS.length - 1; i >= 0; i -= 1) { + const e = EVENTS[i] + const d = dispRef.current[e.id] || targets[e.id] + if (Math.abs(pt.x - d.x) <= geo.chipW / 2 && Math.abs(pt.y - d.y) <= 20) { + event.currentTarget.setPointerCapture(event.pointerId) + setDrag({ id: e.id, offX: pt.x - d.x, offY: pt.y - d.y, x: d.x, y: d.y }) + return + } + } + } + const handlePointerMove = (event) => { + if (!drag) return + const pt = getPoint(event) + setDrag((d) => (d ? { ...d, x: pt.x - d.offX, y: pt.y - d.offY } : d)) + } + const handlePointerUp = () => { + if (!drag) return + const onScale = drag.y < geo.scaleY + 30 + setPlaced((prev) => { + const next = { ...prev } + if (onScale) next[drag.id] = geo.pFor(drag.x) + else delete next[drag.id] + return next + }) + setDrag(null) + } + + const reset = () => { + setPlaced({}) + setRevealed(false) + } + + return ( +
+
+ Place each event: 0 = impossible, 1 = certain +
+ +
+ +
+ +

+ Probability is a number from 0 (impossible) to 1 (certain). Drag each event, then reveal the true spot. +

+ +
+ + +
+
+ ) +} diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx new file mode 100644 index 0000000..150021b --- /dev/null +++ b/src/manipulatives/sample-space-tree.jsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +const cream = '#F8F6F0' +const ink = '#1A1A2E' +const muted = '#5F5E5A' +const selBlue = '#2563EB' +const totalPurple = '#7C3AED' + +const OPT_COLORS = { + H: '#2563EB', T: '#D85A30', + R: '#D8402F', B: '#2563EB', G: '#1D9E75', +} +const numColor = '#7C3AED' +const colorOf = (o) => OPT_COLORS[o] || numColor + +const EXPERIMENTS = { + coins: { name: 'Two coins', s1: ['H', 'T'], s2: ['H', 'T'] }, + coindie: { name: 'Coin + Die', s1: ['H', 'T'], s2: ['1', '2', '3', '4', '5', '6'] }, + spinners: { name: 'Two spinners (R/B/G)', s1: ['R', 'B', 'G'], s2: ['R', 'B', 'G'] }, +} + +export default function SampleSpaceTree() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [size, setSize] = useState({ w: 720, h: 380 }) + const [expKey, setExpKey] = useState('coins') + const [selected, setSelected] = useState(() => new Set()) + + const exp = EXPERIMENTS[expKey] + const a = exp.s1.length + const b = exp.s2.length + const N = a * b + + useLayoutEffect(() => { + const node = wrapRef.current + if (!node) return undefined + let raf = 0 + const commit = (box) => { + const w = Math.max(420, Math.round(box.width)) + const h = Math.max(240, Math.round(box.height)) + setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) + } + commit(node.getBoundingClientRect()) + const observer = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect + if (!cr) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(cr)) + }) + observer.observe(node) + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } + }, []) + + const geo = useMemo(() => { + const { w, h } = size + const rootX = 46 + const s1X = w * 0.3 + const leafX = w * 0.58 + const topPad = 26 + const availH = h - topPad - 18 + const leafY = (k) => topPad + (N === 1 ? availH / 2 : (k / (N - 1)) * availH) + const s1Y = (j) => (leafY(j * b) + leafY(j * b + b - 1)) / 2 + return { rootX, s1X, leafX, topPad, availH, leafY, s1Y, w, h } + }, [size, N, b]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + const cw = Math.round(size.w * dpr) + const ch = Math.round(size.h * dpr) + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw + canvas.height = ch + } + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, size.w, size.h) + + const { rootX, s1X, leafX, leafY, s1Y } = geo + const rootY = size.h / 2 + + // Edges root -> stage 1. + ctx.strokeStyle = '#C9CDd6' + ctx.lineWidth = 1.5 + for (let j = 0; j < a; j += 1) { + ctx.beginPath() + ctx.moveTo(rootX + 14, rootY) + ctx.lineTo(s1X - 14, s1Y(j)) + ctx.stroke() + } + // Edges stage 1 -> leaves. + for (let k = 0; k < N; k += 1) { + const j = Math.floor(k / b) + ctx.strokeStyle = selected.has(k) ? selBlue : '#C9CDd6' + ctx.lineWidth = selected.has(k) ? 2.5 : 1.5 + ctx.beginPath() + ctx.moveTo(s1X + 14, s1Y(j)) + ctx.lineTo(leafX - 12, leafY(k)) + ctx.stroke() + } + + // Root node. + ctx.fillStyle = ink + ctx.beginPath() + ctx.arc(rootX, rootY, 14, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#fff' + ctx.font = '800 9px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('start', rootX, rootY) + + // Stage 1 nodes. + for (let j = 0; j < a; j += 1) { + const o = exp.s1[j] + ctx.fillStyle = colorOf(o) + ctx.beginPath() + ctx.arc(s1X, s1Y(j), 13, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#fff' + ctx.font = '900 13px Inter, system-ui, sans-serif' + ctx.fillText(o, s1X, s1Y(j)) + } + + // Leaves (stage 2) + outcome labels. + const r = N > 9 ? 10 : 12 + for (let k = 0; k < N; k += 1) { + const j = Math.floor(k / b) + const i = k % b + const o1 = exp.s1[j] + const o2 = exp.s2[i] + const y = leafY(k) + const on = selected.has(k) + ctx.fillStyle = colorOf(o2) + ctx.beginPath() + ctx.arc(leafX, y, r, 0, Math.PI * 2) + ctx.fill() + if (on) { + ctx.strokeStyle = selBlue + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(leafX, y, r + 3, 0, Math.PI * 2) + ctx.stroke() + } + ctx.fillStyle = '#fff' + ctx.font = `900 ${r > 10 ? 12 : 10}px Inter, system-ui, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(o2, leafX, y) + // outcome text + ctx.fillStyle = on ? selBlue : ink + ctx.font = `${on ? '900' : '700'} ${N > 9 ? 12 : 14}px Inter, system-ui, sans-serif` + ctx.textAlign = 'left' + ctx.fillText(`${o1}${o2}`, leafX + r + 8, y) + } + }, [size, geo, exp, a, b, N, selected]) + + useEffect(() => { + draw() + }, [draw]) + + const handleClick = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = (event.clientX - rect.left) * (size.w / rect.width) + const y = (event.clientY - rect.top) * (size.h / rect.height) + for (let k = 0; k < N; k += 1) { + const ly = geo.leafY(k) + if (x >= geo.leafX - 16 && x <= geo.leafX + 70 && Math.abs(y - ly) <= 16) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(k)) next.delete(k) + else next.add(k) + return next + }) + return + } + } + } + + const changeExp = (key) => { + setExpKey(key) + setSelected(new Set()) + } + + return ( +
+
+ {N} outcomes + = {a} × {b} + {selected.size > 0 && ( + · P(selected) = {selected.size}/{N} + )} +
+ +
+ +
+ +

+ The tree lists every combined outcome exactly once — count them by multiplying the branches. Click outcomes to pick an event. +

+ +
+ + +
+
+ ) +} From d4c34cde7a041dd1f09f01495e16a2c848e8c11f Mon Sep 17 00:00:00 2001 From: nakorly Date: Wed, 15 Jul 2026 08:01:59 +0200 Subject: [PATCH 09/16] Polish the 3 stats/probability manipulatives - MAD: add a shaded mean +/- MAD band hugging the number line and a two-sided span label; tighten vertical layout; friendly empty state. - Sample space tree: label branch probabilities (1/a, 1/b) and each outcome as 1/N; highlight the full root->branch->leaf path when an outcome is selected. - Probability scale: tighten the scale/tray spacing, add the 1/2 anchor (0.5 = 1/2 = 50% = even chance), and on reveal mark where you guessed vs the true green spot. Verified: math correct, no console errors across empty/switch/reveal edge cases. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/mean-absolute-deviation.jsx | 68 ++++++++++++++----- src/manipulatives/probability-scale.jsx | 21 ++++-- src/manipulatives/sample-space-tree.jsx | 39 ++++++++++- 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx index c9f0bef..a60be7d 100644 --- a/src/manipulatives/mean-absolute-deviation.jsx +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -21,7 +21,7 @@ export default function MeanAbsoluteDeviation() { const [points, setPoints] = useState(() => [4, 7, 9, 16].map((v) => ({ id: (seq += 1), value: v }))) const [drag, setDrag] = useState(null) // { id, startX, moved } - const canvasHeight = 300 + const canvasHeight = 272 const values = points.map((p) => p.value) const mean = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0 @@ -76,6 +76,14 @@ export default function MeanAbsoluteDeviation() { const { PAD, axisY, xFor } = geo const meanX = xFor(mean) + // Shaded "typical" band: mean ± MAD (most points fall roughly this close). + if (points.length && mad > 0.05) { + const bx0 = xFor(Math.max(MIN, mean - mad)) + const bx1 = xFor(Math.min(MAX, mean + mad)) + ctx.fillStyle = 'rgba(29,158,117,0.12)' + ctx.fillRect(bx0, axisY - 74, bx1 - bx0, 80) + } + // Axis + ticks. ctx.strokeStyle = axisColor ctx.lineWidth = 2.5 @@ -148,19 +156,37 @@ export default function MeanAbsoluteDeviation() { } }) - // MAD bar under the axis (average distance, from the mean outward). - const madX = xFor(mean + mad) - ctx.strokeStyle = meanGreen - ctx.lineWidth = 5 - ctx.beginPath() - ctx.moveTo(meanX, axisY + 30) - ctx.lineTo(madX, axisY + 30) - ctx.stroke() - ctx.fillStyle = meanGreen - ctx.font = '900 12px Inter, system-ui, sans-serif' - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.fillText(`MAD = ${round1(mad)}`, madX + 8, axisY + 30) + // MAD shown as a two-sided span (mean − MAD .. mean + MAD) below the axis. + if (points.length && mad > 0.05) { + const y2 = axisY + 34 + const lx = xFor(Math.max(MIN, mean - mad)) + const rx = xFor(Math.min(MAX, mean + mad)) + ctx.strokeStyle = meanGreen + ctx.lineWidth = 4 + ctx.beginPath() + ctx.moveTo(lx, y2) + ctx.lineTo(rx, y2) + ctx.stroke() + ;[[lx, -1], [rx, 1]].forEach(([x, d]) => { + ctx.fillStyle = meanGreen + ctx.beginPath() + ctx.moveTo(x, y2) + ctx.lineTo(x - d * 8, y2 - 5) + ctx.lineTo(x - d * 8, y2 + 5) + ctx.closePath() + ctx.fill() + }) + // tick down from the mean + ctx.beginPath() + ctx.moveTo(meanX, axisY + 10) + ctx.lineTo(meanX, y2) + ctx.stroke() + ctx.fillStyle = meanGreen + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.fillText(`MAD = ${round1(mad)} on each side of the mean`, meanX, y2 + 7) + } // Data points. points.forEach((p) => { @@ -240,10 +266,16 @@ export default function MeanAbsoluteDeviation() { return (
- MAD = {round1(mad)} - - = the average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length || 1}) - + {points.length === 0 ? ( + Click the line to add data points + ) : ( + <> + MAD = {round1(mad)} + + = average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length}) + + + )}
diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index fa1bece..42a32e5 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -36,7 +36,7 @@ export default function ProbabilityScale() { const dispRef = useRef({}) // id -> {x,y} current displayed center (for ease) const rafRef = useRef(null) - const canvasHeight = 320 + const canvasHeight = 268 useLayoutEffect(() => { const node = wrapRef.current @@ -62,8 +62,8 @@ export default function ProbabilityScale() { const geo = useMemo(() => { const PAD = 60 - const scaleY = 132 - const trayY = 250 + const scaleY = 116 + const trayY = 202 const spanX = canvasWidth - PAD * 2 const xFor = (p) => PAD + p * spanX const pFor = (x) => clamp((x - PAD) / spanX, 0, 1) @@ -131,11 +131,24 @@ export default function ProbabilityScale() { }) // Endpoint number labels 0 and 1. ctx.fillStyle = ink - ctx.font = '900 15px Inter, system-ui, sans-serif' + ctx.font = '900 16px Inter, system-ui, sans-serif' ctx.textBaseline = 'bottom' ctx.fillText('0', xFor(0), scaleY - 34) + ctx.fillText('½', xFor(0.5), scaleY - 34) ctx.fillText('1', xFor(1), scaleY - 34) + // On reveal, mark where each event was guessed (hollow) so the gap to the true spot shows. + if (revealed) { + Object.values(placed).forEach((gp) => { + const gx = xFor(gp) + ctx.strokeStyle = '#B9A6E8' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(gx, scaleY, 6, 0, Math.PI * 2) + ctx.stroke() + }) + } + // Tray label. if (Object.keys(placed).length < EVENTS.length && !revealed) { ctx.fillStyle = muted diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx index 150021b..992addc 100644 --- a/src/manipulatives/sample-space-tree.jsx +++ b/src/manipulatives/sample-space-tree.jsx @@ -84,9 +84,10 @@ export default function SampleSpaceTree() { const rootY = size.h / 2 // Edges root -> stage 1. - ctx.strokeStyle = '#C9CDd6' - ctx.lineWidth = 1.5 for (let j = 0; j < a; j += 1) { + const anySel = [...selected].some((k) => Math.floor(k / b) === j) + ctx.strokeStyle = anySel ? selBlue : '#C9CDd6' + ctx.lineWidth = anySel ? 2.5 : 1.5 ctx.beginPath() ctx.moveTo(rootX + 14, rootY) ctx.lineTo(s1X - 14, s1Y(j)) @@ -103,6 +104,31 @@ export default function SampleSpaceTree() { ctx.stroke() } + // Branch probability labels (each branch is equally likely). + ctx.font = '700 10px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = muted + for (let j = 0; j < a; j += 1) { + const mx = rootX + 14 + (s1X - 14 - (rootX + 14)) * 0.42 + const my = rootY + (s1Y(j) - rootY) * 0.42 + ctx.fillText(`1/${a}`, mx, my - 7) + } + if (b <= 4) { + for (let k = 0; k < N; k += 1) { + const j = Math.floor(k / b) + const mx = s1X + 14 + (leafX - 12 - (s1X + 14)) * 0.45 + const my = s1Y(j) + (leafY(k) - s1Y(j)) * 0.45 + ctx.fillText(`1/${b}`, mx, my - 6) + } + } + // Per-outcome probability note. + ctx.fillStyle = muted + ctx.font = '800 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + ctx.fillText(`each outcome = 1/${N}`, leafX - 12, 6) + // Root node. ctx.fillStyle = ink ctx.beginPath() @@ -117,12 +143,21 @@ export default function SampleSpaceTree() { // Stage 1 nodes. for (let j = 0; j < a; j += 1) { const o = exp.s1[j] + if ([...selected].some((k) => Math.floor(k / b) === j)) { + ctx.strokeStyle = selBlue + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(s1X, s1Y(j), 16, 0, Math.PI * 2) + ctx.stroke() + } ctx.fillStyle = colorOf(o) ctx.beginPath() ctx.arc(s1X, s1Y(j), 13, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = '#fff' ctx.font = '900 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' ctx.fillText(o, s1X, s1Y(j)) } From fc18a8b0fd5a09c09b9cce7b39b9934288d79132 Mon Sep 17 00:00:00 2001 From: nakorly Date: Wed, 15 Jul 2026 08:10:09 +0200 Subject: [PATCH 10/16] Rework probability-scale reveal to avoid chip overlap Events with close true probabilities (0 & 1/6, 1/2 & 2/3) made the wide chips collide when snapping to true positions on reveal. Now chips stay where the student guessed (spread out), thin green ticks mark the true spots, dashed lines link each guess to its truth, and the score reports how many landed within 10%. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/probability-scale.jsx | 42 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index 42a32e5..12a2e04 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -76,12 +76,11 @@ export default function ProbabilityScale() { const targets = useMemo(() => { const t = {} EVENTS.forEach((e, i) => { - if (revealed) t[e.id] = { x: geo.xFor(e.p), y: geo.scaleY - 42, on: true } - else if (placed[e.id] != null) t[e.id] = { x: geo.xFor(placed[e.id]), y: geo.scaleY - 42, on: true } + if (placed[e.id] != null) t[e.id] = { x: geo.xFor(placed[e.id]), y: geo.scaleY - 42, on: true } else t[e.id] = { x: geo.PAD + geo.chipW / 2 + i * (geo.chipW + 10), y: geo.trayY, on: false } }) return t - }, [geo, placed, revealed]) + }, [geo, placed]) const draw = useCallback(() => { const canvas = canvasRef.current @@ -137,14 +136,26 @@ export default function ProbabilityScale() { ctx.fillText('½', xFor(0.5), scaleY - 34) ctx.fillText('1', xFor(1), scaleY - 34) - // On reveal, mark where each event was guessed (hollow) so the gap to the true spot shows. + // On reveal: a green tick marks each true spot; a dashed line links your guess to it. if (revealed) { - Object.values(placed).forEach((gp) => { - const gx = xFor(gp) - ctx.strokeStyle = '#B9A6E8' - ctx.lineWidth = 2 + EVENTS.forEach((e) => { + const tx = xFor(e.p) + if (placed[e.id] != null) { + const gx = xFor(placed[e.id]) + ctx.strokeStyle = '#B9BDC6' + ctx.lineWidth = 1.5 + ctx.setLineDash([4, 4]) + ctx.beginPath() + ctx.moveTo(gx, scaleY - 26) + ctx.lineTo(tx, scaleY) + ctx.stroke() + ctx.setLineDash([]) + } + ctx.strokeStyle = trueGreen + ctx.lineWidth = 3 ctx.beginPath() - ctx.arc(gx, scaleY, 6, 0, Math.PI * 2) + ctx.moveTo(tx, scaleY - 13) + ctx.lineTo(tx, scaleY + 13) ctx.stroke() }) } @@ -167,7 +178,7 @@ export default function ProbabilityScale() { ctx.beginPath() ctx.roundRect(cx - w / 2 + 2, cy - h / 2 + 3, w, h, 9) ctx.fill() - ctx.fillStyle = revealed ? trueGreen : chipPurple + ctx.fillStyle = chipPurple ctx.strokeStyle = '#ffffff' ctx.lineWidth = 2 ctx.beginPath() @@ -185,7 +196,7 @@ export default function ProbabilityScale() { } // pointer to the line when on the scale if (on) { - ctx.fillStyle = revealed ? trueGreen : chipPurple + ctx.fillStyle = chipPurple ctx.beginPath() ctx.moveTo(cx - 6, cy + h / 2) ctx.lineTo(cx + 6, cy + h / 2) @@ -278,6 +289,9 @@ export default function ProbabilityScale() { setRevealed(false) } + const placedIds = Object.keys(placed) + const closeCount = EVENTS.filter((e) => placed[e.id] != null && Math.abs(placed[e.id] - e.p) <= 0.1).length + return (
@@ -295,8 +309,10 @@ export default function ProbabilityScale() { />
-

- Probability is a number from 0 (impossible) to 1 (certain). Drag each event, then reveal the true spot. +

+ {revealed && placedIds.length > 0 + ? `You placed ${closeCount} of ${placedIds.length} within 10% of the true probability. The hollow marks show your guesses.` + : 'Probability is a number from 0 (impossible) to 1 (certain). Drag each event, then reveal the true spot.'}

From 80096cfa713ac0637961220b9cf49fe45c1cef63 Mon Sep 17 00:00:00 2001 From: nakorly Date: Thu, 16 Jul 2026 22:58:45 +0200 Subject: [PATCH 11/16] Open up the manipulatives: no challenges, generic inputs, hide toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback from the all-hands: a manipulative is "the carrot and the knife" — it visualises the concept; the teacher supplies the procedure and the question. No steps, no challenges, and nothing hardcoded (the worksheet AI supplies the example, so presets are only starting points). - balance-scale: x is now solved from whatever tiles are on the pans, so any equation works. Add x/1 tiles to either pan, empty the pans, or load a preset as a starter. Correctly reports identities and no-solution equations. - algebra-tiles: added the equals divider so both sides of an equation can be modelled; zero pairs only cancel on the same side. - sample-space-tree: stages are now free text, so any two-stage experiment can be defined; the three experiments remain as starters. - probability-scale: events are editable (accepts 1/6, 0.5 or 50%); dropped the score/quiz in favour of a plain Show/Hide answers toggle. - Added Hide answer to MAD, inequalities and the tree so the teacher can pose the question themselves. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/algebra-tiles.jsx | 62 ++++-- src/manipulatives/balance-scale-equations.jsx | 62 ++++-- .../inequalities-number-line.jsx | 16 +- src/manipulatives/mean-absolute-deviation.jsx | 30 ++- src/manipulatives/probability-scale.jsx | 195 +++++++++++------- src/manipulatives/sample-space-tree.jsx | 79 +++++-- 6 files changed, 309 insertions(+), 135 deletions(-) diff --git a/src/manipulatives/algebra-tiles.jsx b/src/manipulatives/algebra-tiles.jsx index 68ab4c6..4634e1e 100644 --- a/src/manipulatives/algebra-tiles.jsx +++ b/src/manipulatives/algebra-tiles.jsx @@ -49,20 +49,26 @@ export default function AlgebraTiles() { } }, []) - const xC = tiles.filter((t) => t.type === 'x').length - tiles.filter((t) => t.type === 'nx').length - const c = tiles.filter((t) => t.type === 'u').length - tiles.filter((t) => t.type === 'nu').length - - let expr = '' - if (xC !== 0) expr += (xC < 0 ? '−' : '') + (Math.abs(xC) === 1 ? 'x' : `${Math.abs(xC)}x`) - if (c !== 0) expr += (expr ? (c < 0 ? ' − ' : ' + ') : c < 0 ? '−' : '') + Math.abs(c) - if (expr === '') expr = '0' + // Tiles left of the divider form one side of the equation, right the other. + const midX = size.w / 2 + const fmt = (ts) => { + const xC = ts.filter((t) => t.type === 'x').length - ts.filter((t) => t.type === 'nx').length + const c = ts.filter((t) => t.type === 'u').length - ts.filter((t) => t.type === 'nu').length + let e = '' + if (xC !== 0) e += (xC < 0 ? '−' : '') + (Math.abs(xC) === 1 ? 'x' : `${Math.abs(xC)}x`) + if (c !== 0) e += (e ? (c < 0 ? ' − ' : ' + ') : c < 0 ? '−' : '') + Math.abs(c) + return e || '0' + } + const leftExpr = fmt(tiles.filter((t) => t.x < midX)) + const rightExpr = fmt(tiles.filter((t) => t.x >= midX)) const addTile = (type) => { - const t = TYPES[type] const n = tiles.length + // Spawn on the left of the equals; drag across to build the other side. + const span = Math.max(110, size.w / 2 - 70) setTiles((prev) => [ ...prev, - { id: (seq += 1), type, x: 46 + ((n * 46) % Math.max(120, size.w - 90)), y: 44 + Math.floor((n * 46) / Math.max(120, size.w - 90)) * 76 }, + { id: (seq += 1), type, x: 40 + ((n * 46) % span), y: 56 + Math.floor((n * 46) / span) * 74 }, ]) } @@ -80,12 +86,34 @@ export default function AlgebraTiles() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, size.w, size.h) + // Equals divider: tiles either side form the two sides of an equation. + const mid = size.w / 2 + ctx.save() + ctx.strokeStyle = '#C9CDD6' + ctx.setLineDash([7, 6]) + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(mid, 14) + ctx.lineTo(mid, size.h - 14) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.arc(mid, size.h / 2, 17, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#8B8F99' + ctx.font = '900 22px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('=', mid, size.h / 2) + ctx.restore() + if (tiles.length === 0) { ctx.fillStyle = '#B9BDC6' - ctx.font = '600 15px Inter, system-ui, sans-serif' + ctx.font = '600 14px Inter, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText('Add tiles below, then drag an x onto a −x (or 1 onto −1) to make a zero pair.', size.w / 2, size.h / 2) + ctx.fillText('Add tiles, drag them either side of the =, and drop an x onto a −x to zero-pair.', size.w / 2, 30) } const drawTile = (t, lifted) => { @@ -186,8 +214,13 @@ export default function AlgebraTiles() { return } const opp = TYPES[dragged.type].opp + // Zero pairs only cancel on the SAME side of the equals. const hit = tiles.find( - (t) => t.id !== dragged.id && t.type === opp && Math.hypot(t.x - drag.x, t.y - drag.y) <= 34, + (t) => + t.id !== dragged.id && + t.type === opp && + Math.hypot(t.x - drag.x, t.y - drag.y) <= 34 && + (t.x < midX) === (drag.x < midX), ) if (hit) { setTiles((prev) => prev.filter((t) => t.id !== dragged.id && t.id !== hit.id)) @@ -215,8 +248,9 @@ export default function AlgebraTiles() { return (
- Expression = - {expr} + {leftExpr} + = + {rightExpr}
diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index a325da3..9520c9a 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -25,8 +25,6 @@ const buildTiles = ({ x, u }) => [ ...Array.from({ length: u }, () => ({ id: (tileSeq += 1), type: 'unit' })), ] -const solveX = (p) => (p.right.u - p.left.u) / (p.left.x - p.right.x) - const countType = (tiles, type) => tiles.filter((t) => t.type === type).length function sideLabel(xc, uc) { @@ -47,12 +45,17 @@ export default function BalanceScaleEquations() { const [drag, setDrag] = useState(null) // { side, id, x, y, offX, offY } const canvasHeight = 360 - const solution = useMemo(() => solveX(PROBLEMS[problemIndex]), [problemIndex]) const leftX = countType(left, 'x') const leftU = countType(left, 'unit') const rightX = countType(right, 'x') const rightU = countType(right, 'unit') + // x is whatever value makes the equation currently on the pans true, so any + // equation the teacher builds works — nothing is hardcoded to a preset. + // If both sides hold the same number of x's there is no unique x; a nominal 1 + // then correctly shows an identity as level and a no-solution as permanently tipped. + const denom = leftX - rightX + const solution = denom !== 0 ? (rightU - leftU) / denom : 1 const leftW = leftX * solution + leftU const rightW = rightX * solution + rightU const balanced = leftW === rightW @@ -86,6 +89,18 @@ export default function BalanceScaleEquations() { } }, []) + const addTile = (side, type) => { + const tile = { id: (tileSeq += 1), type } + if (side === 'left') setLeft((prev) => [...prev, tile]) + else setRight((prev) => [...prev, tile]) + } + + const clearAll = () => { + setLeft([]) + setRight([]) + setDrag(null) + } + const loadProblem = (index) => { setProblemIndex(index) setLeft(buildTiles(PROBLEMS[index].left)) @@ -298,11 +313,19 @@ export default function BalanceScaleEquations() { setDrag(null) } - const message = solved - ? `Solved! x = ${answer}` - : !balanced - ? 'Unbalanced! Take the same tile off the OTHER side to level it again.' - : 'Balanced ✓ Drag matching tiles off both sides until one x sits alone.' + const isEmpty = left.length === 0 && right.length === 0 + const noUniqueX = denom === 0 && !isEmpty + const message = isEmpty + ? 'Add x and 1 tiles to either pan to build an equation.' + : solved + ? `Solved! x = ${answer}` + : noUniqueX + ? leftU === rightU + ? 'Both sides are identical — this is true for every value of x.' + : 'These sides can never balance — no value of x makes this true.' + : !balanced + ? 'Unbalanced! Take the same tile off the OTHER side to level it again.' + : 'Balanced ✓ Drag matching tiles off both sides until one x sits alone.' const leftEq = sideLabel(leftX, leftU) const rightEq = sideLabel(rightX, rightU) @@ -331,17 +354,21 @@ export default function BalanceScaleEquations() { {message}

-
-
- = x - = 1 -
+
+ {/* Build any equation: add tiles to either pan. */} + {['left', 'right'].map((side) => ( +
+ {side} + + +
+ ))} - +
) diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx index 51e7b71..9f16869 100644 --- a/src/manipulatives/inequalities-number-line.jsx +++ b/src/manipulatives/inequalities-number-line.jsx @@ -27,6 +27,7 @@ export default function InequalitiesNumberLine() { const [opKey, setOpKey] = useState('gt') const [bound, setBound] = useState(3) const [test, setTest] = useState(6) + const [hideAnswer, setHideAnswer] = useState(false) const dragRef = useRef(null) // 'bound' | 'test' const canvasHeight = 260 @@ -174,11 +175,13 @@ export default function InequalitiesNumberLine() { ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(String(test), tx, ty) - // verdict badge - ctx.fillStyle = testOk ? solGreen : noRed - ctx.font = '900 18px Inter, system-ui, sans-serif' - ctx.fillText(testOk ? '✓' : '✗', tx + 22, ty) - }, [canvasWidth, geo, op, bound, test, testOk]) + // verdict badge (hideable, so the teacher can ask "is this a solution?") + if (!hideAnswer) { + ctx.fillStyle = testOk ? solGreen : noRed + ctx.font = '900 18px Inter, system-ui, sans-serif' + ctx.fillText(testOk ? '✓' : '✗', tx + 22, ty) + } + }, [canvasWidth, geo, op, bound, test, testOk, hideAnswer]) useEffect(() => { draw() @@ -254,6 +257,9 @@ export default function InequalitiesNumberLine() {
setBound((v) => clamp(v - 1, MIN, MAX))} onInc={() => setBound((v) => clamp(v + 1, MIN, MAX))} /> setTest((v) => clamp(v - 1, MIN, MAX))} onInc={() => setTest((v) => clamp(v + 1, MIN, MAX))} /> +
) diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx index a60be7d..3503ab9 100644 --- a/src/manipulatives/mean-absolute-deviation.jsx +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -20,6 +20,7 @@ export default function MeanAbsoluteDeviation() { const [canvasWidth, setCanvasWidth] = useState(720) const [points, setPoints] = useState(() => [4, 7, 9, 16].map((v) => ({ id: (seq += 1), value: v }))) const [drag, setDrag] = useState(null) // { id, startX, moved } + const [hideAnswer, setHideAnswer] = useState(false) const canvasHeight = 272 @@ -148,11 +149,13 @@ export default function MeanAbsoluteDeviation() { ctx.lineTo(px, axisY) ctx.stroke() ctx.setLineDash([]) - ctx.fillStyle = distOrange - ctx.font = '800 11px Inter, system-ui, sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'bottom' - ctx.fillText(round1(dist).toString(), (px + meanX) / 2, y - 2) + if (!hideAnswer) { + ctx.fillStyle = distOrange + ctx.font = '800 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(round1(dist).toString(), (px + meanX) / 2, y - 2) + } } }) @@ -185,7 +188,7 @@ export default function MeanAbsoluteDeviation() { ctx.font = '900 12px Inter, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'top' - ctx.fillText(`MAD = ${round1(mad)} on each side of the mean`, meanX, y2 + 7) + ctx.fillText(hideAnswer ? 'MAD = ? on each side of the mean' : `MAD = ${round1(mad)} on each side of the mean`, meanX, y2 + 7) } // Data points. @@ -223,7 +226,7 @@ export default function MeanAbsoluteDeviation() { ctx.globalAlpha = 1 } } - }, [canvasWidth, geo, points, mean, mad, drag]) + }, [canvasWidth, geo, points, mean, mad, drag, hideAnswer]) useEffect(() => { draw() @@ -270,10 +273,12 @@ export default function MeanAbsoluteDeviation() { Click the line to add data points ) : ( <> - MAD = {round1(mad)} - - = average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length}) - + MAD = {hideAnswer ? '?' : round1(mad)} + {!hideAnswer && ( + + = average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length}) + + )} )}
@@ -303,6 +308,9 @@ export default function MeanAbsoluteDeviation() { +
) diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index 12a2e04..dfaa09d 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -9,15 +9,29 @@ const axisColor = '#1A1A2E' const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) -// Dice/coin events with exact probabilities (0..1). -const EVENTS = [ - { id: 'e0', label: 'Roll a 7', p: 0, disp: '0' }, - { id: 'e6', label: 'Roll a 6', p: 1 / 6, disp: '1/6' }, - { id: 'eh', label: 'Flip heads', p: 1 / 2, disp: '1/2' }, - { id: 'eg', label: 'Roll > 2', p: 4 / 6, disp: '4/6' }, - { id: 'ec', label: 'Roll 1–6', p: 1, disp: '1' }, +// Starter set only — the teacher (or the worksheet AI) can edit these freely. +const DEFAULT_EVENTS = [ + { id: 'e0', label: 'Roll a 7', disp: '0' }, + { id: 'e6', label: 'Roll a 6', disp: '1/6' }, + { id: 'eh', label: 'Flip heads', disp: '1/2' }, + { id: 'eg', label: 'Roll > 2', disp: '4/6' }, + { id: 'ec', label: 'Roll 1-6', disp: '1' }, ] +// Accepts "1/6", "0.5" or "50%". +const parseProb = (text) => { + const s = String(text).trim() + if (!s) return 0 + if (/^-?\d*\.?\d+\s*%$/.test(s)) return clamp(parseFloat(s) / 100, 0, 1) + const frac = s.match(/^(-?\d*\.?\d+)\s*\/\s*(-?\d*\.?\d+)$/) + if (frac) { + const b = Number(frac[2]) + return b ? clamp(Number(frac[1]) / b, 0, 1) : 0 + } + const v = Number(s) + return Number.isFinite(v) ? clamp(v, 0, 1) : 0 +} + const LANDMARKS = [ { p: 0, label: 'Impossible' }, { p: 0.25, label: 'Unlikely' }, @@ -26,18 +40,24 @@ const LANDMARKS = [ { p: 1, label: 'Certain' }, ] +let evSeq = 0 + export default function ProbabilityScale() { const canvasRef = useRef(null) const wrapRef = useRef(null) const [canvasWidth, setCanvasWidth] = useState(720) + const [events, setEvents] = useState(DEFAULT_EVENTS) const [placed, setPlaced] = useState({}) // id -> guess prob (0..1), else in tray - const [revealed, setRevealed] = useState(false) - const [drag, setDrag] = useState(null) // { id, offX, offY, x, y } - const dispRef = useRef({}) // id -> {x,y} current displayed center (for ease) + const [showAnswers, setShowAnswers] = useState(false) + const [editing, setEditing] = useState(false) + const [drag, setDrag] = useState(null) + const dispRef = useRef({}) const rafRef = useRef(null) const canvasHeight = 268 + const EV = useMemo(() => events.map((e) => ({ ...e, p: parseProb(e.disp) })), [events]) + useLayoutEffect(() => { const node = wrapRef.current if (!node) return undefined @@ -67,20 +87,19 @@ export default function ProbabilityScale() { const spanX = canvasWidth - PAD * 2 const xFor = (p) => PAD + p * spanX const pFor = (x) => clamp((x - PAD) / spanX, 0, 1) - const chipW = Math.min(120, (canvasWidth - PAD * 2 - (EVENTS.length - 1) * 10) / EVENTS.length) - const trayX = (i) => PAD + chipW / 2 + i * (chipW + 10) + const n = Math.max(1, EV.length) + const chipW = Math.max(64, Math.min(120, (canvasWidth - PAD * 2 - (n - 1) * 10) / n)) return { PAD, scaleY, trayY, spanX, xFor, pFor, chipW } - }, [canvasWidth]) + }, [canvasWidth, EV.length]) - // Target center for each chip given state. const targets = useMemo(() => { const t = {} - EVENTS.forEach((e, i) => { + EV.forEach((e, i) => { if (placed[e.id] != null) t[e.id] = { x: geo.xFor(placed[e.id]), y: geo.scaleY - 42, on: true } else t[e.id] = { x: geo.PAD + geo.chipW / 2 + i * (geo.chipW + 10), y: geo.trayY, on: false } }) return t - }, [geo, placed]) + }, [geo, placed, EV]) const draw = useCallback(() => { const canvas = canvasRef.current @@ -98,7 +117,7 @@ export default function ProbabilityScale() { const { PAD, scaleY, xFor, chipW } = geo - // Scale bar 0..1 with a soft gradient (impossible -> certain). + // Scale bar 0..1. const grad = ctx.createLinearGradient(xFor(0), 0, xFor(1), 0) grad.addColorStop(0, '#FBE3D8') grad.addColorStop(0.5, '#FDF3D6') @@ -109,7 +128,6 @@ export default function ProbabilityScale() { ctx.lineWidth = 2 ctx.strokeRect(xFor(0), scaleY - 7, xFor(1) - xFor(0), 14) - // Landmark ticks + labels + fraction/percent scale. ctx.textAlign = 'center' LANDMARKS.forEach((lm) => { const x = xFor(lm.p) @@ -128,7 +146,6 @@ export default function ProbabilityScale() { ctx.textBaseline = 'bottom' ctx.fillText(`${Math.round(lm.p * 100)}%`, x, scaleY - 16) }) - // Endpoint number labels 0 and 1. ctx.fillStyle = ink ctx.font = '900 16px Inter, system-ui, sans-serif' ctx.textBaseline = 'bottom' @@ -136,9 +153,9 @@ export default function ProbabilityScale() { ctx.fillText('½', xFor(0.5), scaleY - 34) ctx.fillText('1', xFor(1), scaleY - 34) - // On reveal: a green tick marks each true spot; a dashed line links your guess to it. - if (revealed) { - EVENTS.forEach((e) => { + // Answers: a green tick at the true spot; a dashed line links a guess to it. + if (showAnswers) { + EV.forEach((e) => { const tx = xFor(e.p) if (placed[e.id] != null) { const gx = xFor(placed[e.id]) @@ -160,8 +177,7 @@ export default function ProbabilityScale() { }) } - // Tray label. - if (Object.keys(placed).length < EVENTS.length && !revealed) { + if (Object.keys(placed).length < EV.length) { ctx.fillStyle = muted ctx.font = '700 12px Inter, system-ui, sans-serif' ctx.textAlign = 'left' @@ -169,7 +185,6 @@ export default function ProbabilityScale() { ctx.fillText('Drag each event onto the scale:', PAD, geo.trayY - 26) } - // Chips. const drawChip = (e, cx, cy, on, lifted) => { const w = chipW const h = 34 @@ -189,12 +204,11 @@ export default function ProbabilityScale() { ctx.font = '800 12px Inter, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText(e.label, cx, cy - (revealed ? 5 : 0)) - if (revealed) { + ctx.fillText(e.label, cx, cy - (showAnswers ? 5 : 0)) + if (showAnswers) { ctx.font = '900 11px Inter, system-ui, sans-serif' ctx.fillText(`= ${e.disp}`, cx, cy + 8) } - // pointer to the line when on the scale if (on) { ctx.fillStyle = chipPurple ctx.beginPath() @@ -207,44 +221,43 @@ export default function ProbabilityScale() { ctx.restore() } - EVENTS.forEach((e) => { + EV.forEach((e) => { if (drag && drag.id === e.id) return const d = dispRef.current[e.id] || targets[e.id] - drawChip(e, d.x, d.y, targets[e.id].on, false) + if (d && targets[e.id]) drawChip(e, d.x, d.y, targets[e.id].on, false) }) if (drag) { - const e = EVENTS.find((x) => x.id === drag.id) - drawChip(e, drag.x, drag.y, false, true) + const e = EV.find((x) => x.id === drag.id) + if (e) drawChip(e, drag.x, drag.y, false, true) } - }, [canvasWidth, geo, placed, revealed, drag, targets]) + }, [canvasWidth, geo, placed, showAnswers, drag, targets, EV]) - // Always draw on state change (the ease loop below only handles animation). useEffect(() => { draw() }, [draw]) - // Ease displayed chip positions toward targets. useEffect(() => { - const start = () => { - cancelAnimationFrame(rafRef.current) - const tick = () => { - let moving = false - EVENTS.forEach((e) => { - const tgt = targets[e.id] - const cur = dispRef.current[e.id] || { ...tgt } - const nx = cur.x + (tgt.x - cur.x) * 0.25 - const ny = cur.y + (tgt.y - cur.y) * 0.25 - if (Math.hypot(tgt.x - nx, tgt.y - ny) > 0.5) moving = true - dispRef.current[e.id] = { x: Math.abs(tgt.x - nx) < 0.5 ? tgt.x : nx, y: Math.abs(tgt.y - ny) < 0.5 ? tgt.y : ny } - }) - draw() - if (moving) rafRef.current = requestAnimationFrame(tick) - } - rafRef.current = requestAnimationFrame(tick) + cancelAnimationFrame(rafRef.current) + const tick = () => { + let moving = false + EV.forEach((e) => { + const tgt = targets[e.id] + if (!tgt) return + const cur = dispRef.current[e.id] || { ...tgt } + const nx = cur.x + (tgt.x - cur.x) * 0.25 + const ny = cur.y + (tgt.y - cur.y) * 0.25 + if (Math.hypot(tgt.x - nx, tgt.y - ny) > 0.5) moving = true + dispRef.current[e.id] = { + x: Math.abs(tgt.x - nx) < 0.5 ? tgt.x : nx, + y: Math.abs(tgt.y - ny) < 0.5 ? tgt.y : ny, + } + }) + draw() + if (moving) rafRef.current = requestAnimationFrame(tick) } - start() + rafRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafRef.current) - }, [targets, draw]) + }, [targets, draw, EV]) const getPoint = (event) => { const rect = event.currentTarget.getBoundingClientRect() @@ -255,11 +268,11 @@ export default function ProbabilityScale() { } const handlePointerDown = (event) => { - if (revealed) return const pt = getPoint(event) - for (let i = EVENTS.length - 1; i >= 0; i -= 1) { - const e = EVENTS[i] + for (let i = EV.length - 1; i >= 0; i -= 1) { + const e = EV[i] const d = dispRef.current[e.id] || targets[e.id] + if (!d) continue if (Math.abs(pt.x - d.x) <= geo.chipW / 2 && Math.abs(pt.y - d.y) <= 20) { event.currentTarget.setPointerCapture(event.pointerId) setDrag({ id: e.id, offX: pt.x - d.x, offY: pt.y - d.y, x: d.x, y: d.y }) @@ -284,13 +297,20 @@ export default function ProbabilityScale() { setDrag(null) } - const reset = () => { - setPlaced({}) - setRevealed(false) + const updateEvent = (id, field, value) => { + setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, [field]: value } : e))) + } + const removeEvent = (id) => { + setEvents((prev) => prev.filter((e) => e.id !== id)) + setPlaced((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + } + const addEvent = () => { + setEvents((prev) => [...prev, { id: `new${(evSeq += 1)}`, label: 'New event', disp: '1/2' }]) } - - const placedIds = Object.keys(placed) - const closeCount = EVENTS.filter((e) => placed[e.id] != null && Math.abs(placed[e.id] - e.p) <= 0.1).length return (
@@ -307,19 +327,56 @@ export default function ProbabilityScale() { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} /> + {editing && ( +
+

+ Set your own events — probability accepts 1/6, 0.5 or 50%. +

+ {events.map((e) => ( +
+ updateEvent(e.id, 'label', ev.target.value)} + className="min-w-0 flex-1 rounded-lg border border-[#E0DDD6] px-2 py-1.5 text-sm font-bold outline-none" + placeholder="Event name" + /> + updateEvent(e.id, 'disp', ev.target.value)} + className="w-20 rounded-lg border border-[#E0DDD6] px-2 py-1.5 text-center text-sm font-black outline-none" + style={{ color: trueGreen }} + placeholder="1/6" + /> + +
+ ))} + +
+ )}
-

- {revealed && placedIds.length > 0 - ? `You placed ${closeCount} of ${placedIds.length} within 10% of the true probability. The hollow marks show your guesses.` - : 'Probability is a number from 0 (impossible) to 1 (certain). Drag each event, then reveal the true spot.'} +

+ Probability is a number from 0 (impossible) to 1 (certain). Drag each event where you think it belongs.

- + -
diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx index 992addc..db5fd52 100644 --- a/src/manipulatives/sample-space-tree.jsx +++ b/src/manipulatives/sample-space-tree.jsx @@ -19,17 +19,30 @@ const EXPERIMENTS = { spinners: { name: 'Two spinners (R/B/G)', s1: ['R', 'B', 'G'], s2: ['R', 'B', 'G'] }, } +// Comma-separated options -> labels (max 6 a stage so the tree stays readable). +const parseOpts = (text) => { + const o = text.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6) + return o.length ? o : ['?'] +} + export default function SampleSpaceTree() { const canvasRef = useRef(null) const wrapRef = useRef(null) const [size, setSize] = useState({ w: 720, h: 380 }) - const [expKey, setExpKey] = useState('coins') + const [s1Text, setS1Text] = useState('H,T') + const [s2Text, setS2Text] = useState('H,T') const [selected, setSelected] = useState(() => new Set()) + const [hideAnswer, setHideAnswer] = useState(false) - const exp = EXPERIMENTS[expKey] - const a = exp.s1.length - const b = exp.s2.length + // Stages are free text, so a teacher (or the worksheet AI) can define any + // experiment; the presets below just load a starting pair. + const s1 = useMemo(() => parseOpts(s1Text), [s1Text]) + const s2 = useMemo(() => parseOpts(s2Text), [s2Text]) + const a = s1.length + const b = s2.length const N = a * b + const matchKey = + Object.entries(EXPERIMENTS).find(([, v]) => v.s1.join(',') === s1.join(',') && v.s2.join(',') === s2.join(','))?.[0] || 'custom' useLayoutEffect(() => { const node = wrapRef.current @@ -127,7 +140,7 @@ export default function SampleSpaceTree() { ctx.font = '800 11px Inter, system-ui, sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'top' - ctx.fillText(`each outcome = 1/${N}`, leafX - 12, 6) + ctx.fillText(hideAnswer ? 'each outcome = 1/?' : `each outcome = 1/${N}`, leafX - 12, 6) // Root node. ctx.fillStyle = ink @@ -142,7 +155,7 @@ export default function SampleSpaceTree() { // Stage 1 nodes. for (let j = 0; j < a; j += 1) { - const o = exp.s1[j] + const o = s1[j] if ([...selected].some((k) => Math.floor(k / b) === j)) { ctx.strokeStyle = selBlue ctx.lineWidth = 3 @@ -166,8 +179,8 @@ export default function SampleSpaceTree() { for (let k = 0; k < N; k += 1) { const j = Math.floor(k / b) const i = k % b - const o1 = exp.s1[j] - const o2 = exp.s2[i] + const o1 = s1[j] + const o2 = s2[i] const y = leafY(k) const on = selected.has(k) ctx.fillStyle = colorOf(o2) @@ -192,7 +205,7 @@ export default function SampleSpaceTree() { ctx.textAlign = 'left' ctx.fillText(`${o1}${o2}`, leafX + r + 8, y) } - }, [size, geo, exp, a, b, N, selected]) + }, [size, geo, s1, s2, a, b, N, selected, hideAnswer]) useEffect(() => { draw() @@ -216,17 +229,20 @@ export default function SampleSpaceTree() { } } - const changeExp = (key) => { - setExpKey(key) + const loadStarter = (key) => { + const p = EXPERIMENTS[key] + if (!p) return + setS1Text(p.s1.join(',')) + setS2Text(p.s2.join(',')) setSelected(new Set()) } return (
- {N} outcomes - = {a} × {b} - {selected.size > 0 && ( + {hideAnswer ? '?' : N} outcomes + {!hideAnswer && = {a} × {b}} + {!hideAnswer && selected.size > 0 && ( · P(selected) = {selected.size}/{N} )}
@@ -240,22 +256,45 @@ export default function SampleSpaceTree() {

-
) From b330c98932a59c0d9c0e8639b21f1192ed8b687d Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 17 Jul 2026 01:09:08 +0200 Subject: [PATCH 12/16] Replace hide-answer buttons with layered reveal toggles MAD, Sample Space Tree and Inequalities each had one all-or-nothing "Hide answer" button. Each actually has two independent ideas, so split them into per-layer toggle chips the teacher can peel back one at a time: - MAD: Show distances (orange) / Show MAD (green) - Tree: Show count (purple) / Show probabilities (blue) - Inequalities: Show solution ray (green) / Show check (blue) Each chip carries a dot in its layer's colour so the control and the thing it reveals read as the same idea. Follows the pattern in Asha's polygon-interior-angles. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 11 +++ package-lock.json | 42 ---------- .../inequalities-number-line.jsx | 64 +++++++++------ src/manipulatives/mean-absolute-deviation.jsx | 51 ++++++++---- src/manipulatives/sample-space-tree.jsx | 78 ++++++++++++------- 5 files changed, 136 insertions(+), 110 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..dc4142b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "sandbox", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/package-lock.json b/package-lock.json index cb1ddf3..d53054a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -665,9 +665,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -685,9 +682,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -705,9 +699,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -725,9 +716,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -745,9 +733,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -765,9 +750,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -987,9 +969,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1007,9 +986,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1027,9 +1003,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1047,9 +1020,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2086,9 +2056,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2110,9 +2077,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2134,9 +2098,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2158,9 +2119,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx index 9f16869..2dca99f 100644 --- a/src/manipulatives/inequalities-number-line.jsx +++ b/src/manipulatives/inequalities-number-line.jsx @@ -27,7 +27,9 @@ export default function InequalitiesNumberLine() { const [opKey, setOpKey] = useState('gt') const [bound, setBound] = useState(3) const [test, setTest] = useState(6) - const [hideAnswer, setHideAnswer] = useState(false) + // Layered reveals: the teacher peels back one idea at a time. + const [show, setShow] = useState({ ray: true, check: true }) + const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] })) const dragRef = useRef(null) // 'bound' | 'test' const canvasHeight = 260 @@ -119,22 +121,24 @@ export default function InequalitiesNumberLine() { } // Solution ray (shaded direction) + arrowhead. - const endX = op.right ? canvasWidth - PAD + 8 : PAD - 8 - ctx.strokeStyle = solGreen - ctx.lineWidth = 6 - ctx.lineCap = 'round' - ctx.beginPath() - ctx.moveTo(bx, axisY) - ctx.lineTo(endX, axisY) - ctx.stroke() - const dir = op.right ? 1 : -1 - ctx.fillStyle = solGreen - ctx.beginPath() - ctx.moveTo(endX + dir * 8, axisY) - ctx.lineTo(endX - dir * 6, axisY - 8) - ctx.lineTo(endX - dir * 6, axisY + 8) - ctx.closePath() - ctx.fill() + if (show.ray) { + const endX = op.right ? canvasWidth - PAD + 8 : PAD - 8 + ctx.strokeStyle = solGreen + ctx.lineWidth = 6 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(bx, axisY) + ctx.lineTo(endX, axisY) + ctx.stroke() + const dir = op.right ? 1 : -1 + ctx.fillStyle = solGreen + ctx.beginPath() + ctx.moveTo(endX + dir * 8, axisY) + ctx.lineTo(endX - dir * 6, axisY - 8) + ctx.lineTo(endX - dir * 6, axisY + 8) + ctx.closePath() + ctx.fill() + } // Boundary dot: open (strict) or closed (inclusive). ctx.lineWidth = 3.5 @@ -176,12 +180,12 @@ export default function InequalitiesNumberLine() { ctx.textBaseline = 'middle' ctx.fillText(String(test), tx, ty) // verdict badge (hideable, so the teacher can ask "is this a solution?") - if (!hideAnswer) { + if (show.check) { ctx.fillStyle = testOk ? solGreen : noRed ctx.font = '900 18px Inter, system-ui, sans-serif' ctx.fillText(testOk ? '✓' : '✗', tx + 22, ty) } - }, [canvasWidth, geo, op, bound, test, testOk, hideAnswer]) + }, [canvasWidth, geo, op, bound, test, testOk, show]) useEffect(() => { draw() @@ -257,14 +261,30 @@ export default function InequalitiesNumberLine() {
setBound((v) => clamp(v - 1, MIN, MAX))} onInc={() => setBound((v) => clamp(v + 1, MIN, MAX))} /> setTest((v) => clamp(v - 1, MIN, MAX))} onInc={() => setTest((v) => clamp(v + 1, MIN, MAX))} /> - + toggle('ray')} /> + toggle('check')} />
) } +// One reveal layer. The dot carries the layer's colour so the chip and the +// thing it reveals read as the same idea. +function ToggleChip({ label, color, on, onClick }) { + return ( + + ) +} + function Stepper({ label, value, color, onDec, onInc }) { return (
diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx index 3503ab9..5a7117b 100644 --- a/src/manipulatives/mean-absolute-deviation.jsx +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -20,7 +20,9 @@ export default function MeanAbsoluteDeviation() { const [canvasWidth, setCanvasWidth] = useState(720) const [points, setPoints] = useState(() => [4, 7, 9, 16].map((v) => ({ id: (seq += 1), value: v }))) const [drag, setDrag] = useState(null) // { id, startX, moved } - const [hideAnswer, setHideAnswer] = useState(false) + // Layered reveals: the teacher peels back one idea at a time. + const [show, setShow] = useState({ distances: true, mad: true }) + const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] })) const canvasHeight = 272 @@ -78,7 +80,7 @@ export default function MeanAbsoluteDeviation() { const meanX = xFor(mean) // Shaded "typical" band: mean ± MAD (most points fall roughly this close). - if (points.length && mad > 0.05) { + if (show.mad && points.length && mad > 0.05) { const bx0 = xFor(Math.max(MIN, mean - mad)) const bx1 = xFor(Math.min(MAX, mean + mad)) ctx.fillStyle = 'rgba(29,158,117,0.12)' @@ -130,6 +132,7 @@ export default function MeanAbsoluteDeviation() { .map((p, i) => ({ ...p, i })) .sort((a, b) => Math.abs(b.value - mean) - Math.abs(a.value - mean)) ordered.forEach((p, row) => { + if (!show.distances) return if (drag && drag.id === p.id) return const px = xFor(p.value) const y = axisY - 22 - row * 20 @@ -149,18 +152,16 @@ export default function MeanAbsoluteDeviation() { ctx.lineTo(px, axisY) ctx.stroke() ctx.setLineDash([]) - if (!hideAnswer) { - ctx.fillStyle = distOrange - ctx.font = '800 11px Inter, system-ui, sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'bottom' - ctx.fillText(round1(dist).toString(), (px + meanX) / 2, y - 2) - } + ctx.fillStyle = distOrange + ctx.font = '800 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillText(round1(dist).toString(), (px + meanX) / 2, y - 2) } }) // MAD shown as a two-sided span (mean − MAD .. mean + MAD) below the axis. - if (points.length && mad > 0.05) { + if (show.mad && points.length && mad > 0.05) { const y2 = axisY + 34 const lx = xFor(Math.max(MIN, mean - mad)) const rx = xFor(Math.min(MAX, mean + mad)) @@ -188,7 +189,7 @@ export default function MeanAbsoluteDeviation() { ctx.font = '900 12px Inter, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'top' - ctx.fillText(hideAnswer ? 'MAD = ? on each side of the mean' : `MAD = ${round1(mad)} on each side of the mean`, meanX, y2 + 7) + ctx.fillText(`MAD = ${round1(mad)} on each side of the mean`, meanX, y2 + 7) } // Data points. @@ -226,7 +227,7 @@ export default function MeanAbsoluteDeviation() { ctx.globalAlpha = 1 } } - }, [canvasWidth, geo, points, mean, mad, drag, hideAnswer]) + }, [canvasWidth, geo, points, mean, mad, drag, show]) useEffect(() => { draw() @@ -273,8 +274,8 @@ export default function MeanAbsoluteDeviation() { Click the line to add data points ) : ( <> - MAD = {hideAnswer ? '?' : round1(mad)} - {!hideAnswer && ( + MAD = {show.mad ? round1(mad) : '?'} + {show.distances && ( = average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length}) @@ -308,10 +309,26 @@ export default function MeanAbsoluteDeviation() { - + toggle('distances')} /> + toggle('mad')} />
) } + +// One reveal layer. The dot carries the layer's colour so the chip and the +// thing it reveals read as the same idea. +function ToggleChip({ label, color, on, onClick }) { + return ( + + ) +} diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx index db5fd52..a7894d8 100644 --- a/src/manipulatives/sample-space-tree.jsx +++ b/src/manipulatives/sample-space-tree.jsx @@ -32,7 +32,9 @@ export default function SampleSpaceTree() { const [s1Text, setS1Text] = useState('H,T') const [s2Text, setS2Text] = useState('H,T') const [selected, setSelected] = useState(() => new Set()) - const [hideAnswer, setHideAnswer] = useState(false) + // Layered reveals: the teacher peels back one idea at a time. + const [show, setShow] = useState({ count: true, prob: true }) + const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] })) // Stages are free text, so a teacher (or the worksheet AI) can define any // experiment; the presets below just load a starting pair. @@ -118,29 +120,31 @@ export default function SampleSpaceTree() { } // Branch probability labels (each branch is equally likely). - ctx.font = '700 10px Inter, system-ui, sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillStyle = muted - for (let j = 0; j < a; j += 1) { - const mx = rootX + 14 + (s1X - 14 - (rootX + 14)) * 0.42 - const my = rootY + (s1Y(j) - rootY) * 0.42 - ctx.fillText(`1/${a}`, mx, my - 7) - } - if (b <= 4) { - for (let k = 0; k < N; k += 1) { - const j = Math.floor(k / b) - const mx = s1X + 14 + (leafX - 12 - (s1X + 14)) * 0.45 - const my = s1Y(j) + (leafY(k) - s1Y(j)) * 0.45 - ctx.fillText(`1/${b}`, mx, my - 6) + if (show.prob) { + ctx.font = '700 10px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = selBlue + for (let j = 0; j < a; j += 1) { + const mx = rootX + 14 + (s1X - 14 - (rootX + 14)) * 0.42 + const my = rootY + (s1Y(j) - rootY) * 0.42 + ctx.fillText(`1/${a}`, mx, my - 7) + } + if (b <= 4) { + for (let k = 0; k < N; k += 1) { + const j = Math.floor(k / b) + const mx = s1X + 14 + (leafX - 12 - (s1X + 14)) * 0.45 + const my = s1Y(j) + (leafY(k) - s1Y(j)) * 0.45 + ctx.fillText(`1/${b}`, mx, my - 6) + } } + // Per-outcome probability note. + ctx.fillStyle = selBlue + ctx.font = '800 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'top' + ctx.fillText(`each outcome = 1/${N}`, leafX - 12, 6) } - // Per-outcome probability note. - ctx.fillStyle = muted - ctx.font = '800 11px Inter, system-ui, sans-serif' - ctx.textAlign = 'left' - ctx.textBaseline = 'top' - ctx.fillText(hideAnswer ? 'each outcome = 1/?' : `each outcome = 1/${N}`, leafX - 12, 6) // Root node. ctx.fillStyle = ink @@ -205,7 +209,7 @@ export default function SampleSpaceTree() { ctx.textAlign = 'left' ctx.fillText(`${o1}${o2}`, leafX + r + 8, y) } - }, [size, geo, s1, s2, a, b, N, selected, hideAnswer]) + }, [size, geo, s1, s2, a, b, N, selected, show]) useEffect(() => { draw() @@ -240,9 +244,9 @@ export default function SampleSpaceTree() { return (
- {hideAnswer ? '?' : N} outcomes - {!hideAnswer && = {a} × {b}} - {!hideAnswer && selected.size > 0 && ( + {show.count ? N : '?'} outcomes + {show.count && = {a} × {b}} + {show.prob && selected.size > 0 && ( · P(selected) = {selected.size}/{N} )}
@@ -292,10 +296,26 @@ export default function SampleSpaceTree() { - + toggle('count')} /> + toggle('prob')} />
) } + +// One reveal layer. The dot carries the layer's colour so the chip and the +// thing it reveals read as the same idea. +function ToggleChip({ label, color, on, onClick }) { + return ( + + ) +} From 115836964baec550d04cf0aa7d1d96e1b3956968 Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 17 Jul 2026 01:18:51 +0200 Subject: [PATCH 13/16] Drop local-only files from the manipulatives branch package-lock.json: reverted. Running npm on Windows stripped the libc: ["glibc"] fields from Linux-only optional deps, which would break installs for anyone on Linux and in CI. .claude/launch.json: local dev-server config, now gitignored. Co-Authored-By: Claude Opus 4.8 --- .claude/launch.json | 11 ----------- .gitignore | 2 ++ package-lock.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index dc4142b..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "sandbox", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 5173 - } - ] -} diff --git a/.gitignore b/.gitignore index a547bf3..7bc8784 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.claude/ diff --git a/package-lock.json b/package-lock.json index d53054a..cb1ddf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -665,6 +665,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -682,6 +685,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -699,6 +705,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -716,6 +725,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -733,6 +745,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -750,6 +765,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -969,6 +987,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -986,6 +1007,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1003,6 +1027,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1020,6 +1047,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2056,6 +2086,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2077,6 +2110,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2098,6 +2134,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2119,6 +2158,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ From ed058c2fc265f193e7ac08cb78aaf282118b7575 Mon Sep 17 00:00:00 2001 From: nakorly Date: Fri, 17 Jul 2026 10:03:44 +0200 Subject: [PATCH 14/16] Extract shared module; give every canvas a text equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ten manipulatives had drifted into ten copies of the same code. The canvas-sizing effect in particular was copy-pasted everywhere, which meant each of its three bug fixes (measure before paint, use the unscaled contentRect, rAF-defer and commit whole pixels) had to be applied ten times. Extracted to src/manipulatives/shared/: - palette.js the three neutrals were identical in all ten files, and the accents were too, just under a different name each time (solGreen, meanGreen, posX, roundGreen = all #1D9E75). Imported with aliases so each file still names a colour for what it means there. Domain colours (pizza cheese, beam wood) stay local — they mean nothing outside their file. - useCanvasBox.js the sizing effect, with the reasons for its shape written down once instead of zero times. - ToggleChip.jsx was duplicated three ways after the layered-reveal change. - Stepper.jsx was written three times and had drifted (different column widths, `color` vs `accent`, different font sizes). - GhostButton.jsx the neutral preset/reset pill, repeated in every file. Accessibility: every canvas was an unlabelled box to a screen reader, which in a manipulative means the entire content was missing. Each now carries role="img" and an aria-label describing the live maths state ("Balance scale: left pan holds x + 3, right pan holds 5..."). Symbol-only buttons (the < ≤ > ≥ operator row, the +x/-x/+1/-1 tile buttons) got labels and aria-pressed state; the event-editor inputs got accessible names. No user-facing behaviour change. Verified all ten live: canvas dimensions stable across frames (no sizing bounce), all drawing, console clean. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/algebra-tiles.jsx | 60 +++++------- src/manipulatives/balance-scale-equations.jsx | 86 +++++++++-------- .../inequalities-number-line.jsx | 89 +++++------------- src/manipulatives/mean-absolute-deviation.jsx | 76 +++++---------- src/manipulatives/mixed-numbers-improper.jsx | 94 ++++++++----------- .../multiplying-fractions-area.jsx | 66 ++++++------- src/manipulatives/pizza-remainder.jsx | 74 +++++---------- src/manipulatives/probability-scale.jsx | 57 +++++------ src/manipulatives/rounding-number-line.jsx | 52 ++++------ src/manipulatives/sample-space-tree.jsx | 79 +++++----------- src/manipulatives/shared/GhostButton.jsx | 18 ++++ src/manipulatives/shared/Stepper.jsx | 47 ++++++++++ src/manipulatives/shared/ToggleChip.jsx | 26 +++++ src/manipulatives/shared/palette.js | 29 ++++++ src/manipulatives/shared/useCanvasBox.js | 46 +++++++++ 15 files changed, 441 insertions(+), 458 deletions(-) create mode 100644 src/manipulatives/shared/GhostButton.jsx create mode 100644 src/manipulatives/shared/Stepper.jsx create mode 100644 src/manipulatives/shared/ToggleChip.jsx create mode 100644 src/manipulatives/shared/palette.js create mode 100644 src/manipulatives/shared/useCanvasBox.js diff --git a/src/manipulatives/algebra-tiles.jsx b/src/manipulatives/algebra-tiles.jsx index 4634e1e..5cbfbee 100644 --- a/src/manipulatives/algebra-tiles.jsx +++ b/src/manipulatives/algebra-tiles.jsx @@ -1,12 +1,7 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' - -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const posX = '#1D9E75' -const negX = '#D8402F' -const pos1 = '#2563EB' -const neg1 = '#D97706' +import { useCallback, useEffect, useRef, useState } from 'react' +import { cream, ink, muted, border, green as posX, red as negX, blue as pos1, amber as neg1 } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' const TYPES = { x: { color: posX, label: 'x', w: 30, h: 64, opp: 'nx' }, @@ -15,40 +10,24 @@ const TYPES = { nu: { color: neg1, label: '−1', w: 32, h: 32, opp: 'u' }, } +const ADD_LABELS = { + x: 'Add an x tile', + nx: 'Add a negative x tile', + u: 'Add a 1 tile', + nu: 'Add a negative 1 tile', +} + let seq = 0 export default function AlgebraTiles() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [size, setSize] = useState({ w: 720, h: 300 }) + const size = useCanvasBox(wrapRef, { minW: 420, minH: 220 }) const [tiles, setTiles] = useState([]) const [drag, setDrag] = useState(null) // { id, offX, offY, x, y } const [flash, setFlash] = useState(null) // { x, y, t } zero-pair spot const flashRafRef = useRef(null) - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (box) => { - const w = Math.max(420, Math.round(box.width)) - const h = Math.max(220, Math.round(box.height)) - setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) - } - commit(node.getBoundingClientRect()) - const observer = new ResizeObserver((entries) => { - const cr = entries[0]?.contentRect - if (!cr) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(cr)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) - // Tiles left of the divider form one side of the equation, right the other. const midX = size.w / 2 const fmt = (ts) => { @@ -62,6 +41,12 @@ export default function AlgebraTiles() { const leftExpr = fmt(tiles.filter((t) => t.x < midX)) const rightExpr = fmt(tiles.filter((t) => t.x >= midX)) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = tiles.length + ? `Algebra tile mat. Left side of the equation: ${leftExpr}. Right side: ${rightExpr}.` + : 'Empty algebra tile mat. Add x, negative x, 1, or negative 1 tiles and drag them either side of the equals sign to build both sides of an equation.' + const addTile = (type) => { const n = tiles.length // Spawn on the left of the equals; drag across to build the other side. @@ -239,6 +224,7 @@ export default function AlgebraTiles() { onClick={() => addTile(type)} className="rounded-lg px-4 py-2 text-sm font-black text-white" style={{ background: info.color }} + aria-label={ADD_LABELS[type]} > + {info.label} @@ -253,9 +239,11 @@ export default function AlgebraTiles() { {rightExpr} -
+
- +
) diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index 9520c9a..240d54a 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -1,13 +1,9 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, purple as xPurple, blue as unitBlue, green as balancedGreen, orange as offRed } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const xPurple = '#7C3AED' -const unitBlue = '#2563EB' const beamWood = '#B07A3C' -const balancedGreen = '#1D9E75' -const offRed = '#D85A30' // ax + b = cx + d, solvable by removing equal tiles (|a - c| = 1). const PROBLEMS = [ @@ -38,7 +34,7 @@ function sideLabel(xc, uc) { export default function BalanceScaleEquations() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [problemIndex, setProblemIndex] = useState(0) const [left, setLeft] = useState(() => buildTiles(PROBLEMS[0].left)) const [right, setRight] = useState(() => buildTiles(PROBLEMS[0].right)) @@ -65,30 +61,6 @@ export default function BalanceScaleEquations() { (rightX === 1 && rightU === 0 && leftX === 0 && leftU >= 1)) const answer = solved ? (leftX === 1 ? rightU : leftU) : null - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - // Measure before first paint; only commit a whole-pixel change, and defer - // via rAF so the observer can never feed back into itself (ResizeObserver "bounce"). - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width // unscaled content box — stable under parent transforms - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) - const addTile = (side, type) => { const tile = { id: (tileSeq += 1), type } if (side === 'left') setLeft((prev) => [...prev, tile]) @@ -330,6 +302,22 @@ export default function BalanceScaleEquations() { const leftEq = sideLabel(leftX, leftU) const rightEq = sideLabel(rightX, rightU) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = isEmpty + ? 'Empty balance scale. No tiles on either pan.' + : `Balance scale: left pan holds ${leftEq}, right pan holds ${rightEq}. ${ + solved + ? `Solved — x equals ${answer}.` + : noUniqueX + ? leftU === rightU + ? 'Both sides are identical, true for every value of x.' + : 'These sides can never balance, no value of x works.' + : balanced + ? 'The scale is balanced.' + : 'The scale is tipped — the two sides are not equal.' + }` + return (
{/* Live equation */} @@ -339,9 +327,11 @@ export default function BalanceScaleEquations() { {rightEq}
-
+
(
{side} - - + +
))} - - +
) diff --git a/src/manipulatives/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx index 2dca99f..aab12c5 100644 --- a/src/manipulatives/inequalities-number-line.jsx +++ b/src/manipulatives/inequalities-number-line.jsx @@ -1,29 +1,26 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, green as solGreen, purple as boundPurple, blue as testBlue, red as noRed } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import ToggleChip from './shared/ToggleChip' +import Stepper from './shared/Stepper' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const solGreen = '#1D9E75' -const boundPurple = '#7C3AED' -const testBlue = '#2563EB' -const noRed = '#D8402F' -const axisColor = '#1A1A2E' +const axisColor = ink const MIN = -10 const MAX = 10 const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) const OPS = [ - { key: 'lt', sym: '<', inclusive: false, right: false }, - { key: 'le', sym: '≤', inclusive: true, right: false }, - { key: 'gt', sym: '>', inclusive: false, right: true }, - { key: 'ge', sym: '≥', inclusive: true, right: true }, + { key: 'lt', sym: '<', inclusive: false, right: false, label: 'Less than' }, + { key: 'le', sym: '≤', inclusive: true, right: false, label: 'Less than or equal to' }, + { key: 'gt', sym: '>', inclusive: false, right: true, label: 'Greater than' }, + { key: 'ge', sym: '≥', inclusive: true, right: true, label: 'Greater than or equal to' }, ] export default function InequalitiesNumberLine() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [opKey, setOpKey] = useState('gt') const [bound, setBound] = useState(3) const [test, setTest] = useState(6) @@ -37,27 +34,13 @@ export default function InequalitiesNumberLine() { const testOk = op.right ? (op.inclusive ? test >= bound : test > bound) : op.inclusive ? test <= bound : test < bound - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = + `Number line from ${MIN} to ${MAX} showing x ${op.sym} ${bound}, meaning x is ${op.label.toLowerCase()} ${bound}. ` + + `The boundary at ${bound} is ${op.inclusive ? 'a closed dot, so it is included' : 'an open dot, so it is not included'}.` + + `${show.ray ? ` The solution ray is shaded ${op.right ? 'to the right' : 'to the left'} of the boundary.` : ''} ` + + `The test point at ${test} ${testOk ? 'satisfies' : 'does not satisfy'} the inequality${show.check ? ` and is marked with a ${testOk ? 'check' : 'cross'}` : ' (the check mark is hidden)'}.` const geo = useMemo(() => { const PAD = 54 @@ -230,9 +213,11 @@ export default function InequalitiesNumberLine() { {bound} -
+
setOpKey(o.key)} + aria-label={o.label} + aria-pressed={opKey === o.key} className="px-4 py-2 text-lg font-black" style={{ background: opKey === o.key ? boundPurple : '#ffffff', color: opKey === o.key ? '#ffffff' : muted }} > @@ -267,33 +254,3 @@ export default function InequalitiesNumberLine() {
) } - -// One reveal layer. The dot carries the layer's colour so the chip and the -// thing it reveals read as the same idea. -function ToggleChip({ label, color, on, onClick }) { - return ( - - ) -} - -function Stepper({ label, value, color, onDec, onInc }) { - return ( -
- {label} -
- - {value} - -
-
- ) -} diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx index 5a7117b..eca535b 100644 --- a/src/manipulatives/mean-absolute-deviation.jsx +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, purple as pointPurple, green as meanGreen, orange as distOrange } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import ToggleChip from './shared/ToggleChip' +import GhostButton from './shared/GhostButton' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const pointPurple = '#7C3AED' -const meanGreen = '#1D9E75' -const distOrange = '#D85A30' -const axisColor = '#1A1A2E' +const axisColor = ink const MIN = 0 const MAX = 20 @@ -17,7 +15,7 @@ let seq = 0 export default function MeanAbsoluteDeviation() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [points, setPoints] = useState(() => [4, 7, 9, 16].map((v) => ({ id: (seq += 1), value: v }))) const [drag, setDrag] = useState(null) // { id, startX, moved } // Layered reveals: the teacher peels back one idea at a time. @@ -31,27 +29,12 @@ export default function MeanAbsoluteDeviation() { const distances = values.map((v) => Math.abs(v - mean)) const mad = distances.length ? distances.reduce((a, b) => a + b, 0) / distances.length : 0 - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = points.length + ? `Dot plot from ${MIN} to ${MAX}. ${points.length} points at ${values.join(', ')}. ` + + `Mean ${round1(mean)}.${show.mad ? ` Mean absolute deviation ${round1(mad)}.` : ''}` + : `Empty dot plot from ${MIN} to ${MAX}. Click the number line to add a data point.` const geo = useMemo(() => { const PAD = 46 @@ -284,9 +267,11 @@ export default function MeanAbsoluteDeviation() { )}
-
+
- - - + + setPoints([])} ariaLabel="Clear all data points">Clear toggle('distances')} /> toggle('mad')} />
) } - -// One reveal layer. The dot carries the layer's colour so the chip and the -// thing it reveals read as the same idea. -function ToggleChip({ label, color, on, onClick }) { - return ( - - ) -} diff --git a/src/manipulatives/mixed-numbers-improper.jsx b/src/manipulatives/mixed-numbers-improper.jsx index 83c2bfc..b9101d3 100644 --- a/src/manipulatives/mixed-numbers-improper.jsx +++ b/src/manipulatives/mixed-numbers-improper.jsx @@ -1,11 +1,8 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' - -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const wholeGreen = '#1D9E75' -const fracOrange = '#D85A30' -const improperPurple = '#7C3AED' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, green as wholeGreen, orange as fracOrange, purple as improperPurple } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' +import Stepper from './shared/Stepper' const MIN_DEN = 2 const MAX_DEN = 8 @@ -15,7 +12,7 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) export default function MixedNumbersImproper() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [den, setDen] = useState(4) const [num, setNum] = useState(7) const [hideMixed, setHideMixed] = useState(false) @@ -26,27 +23,17 @@ export default function MixedNumbersImproper() { const whole = Math.floor(num / den) const rem = num % den - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // Mixed-number pieces for the equation (and the canvas's text equivalent). + const mixedParts = [] + if (whole > 0) mixedParts.push({ text: String(whole), color: wholeGreen }) + if (rem > 0) mixedParts.push({ text: `${rem}/${den}`, color: fracOrange }) + if (mixedParts.length === 0) mixedParts.push({ text: '0', color: muted }) + + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = `Bar model showing ${num}/${den}, split into ${den} equal parts per bar. ${ + hideMixed ? 'The mixed-number equivalent is hidden.' : `That equals ${mixedParts.map((p) => p.text).join(' and ')} as a mixed number.` + }` const geo = useMemo(() => { const pad = 30 @@ -160,12 +147,6 @@ export default function MixedNumbersImproper() { setNum((v) => clamp(v, 0, nd * 5)) } - // Mixed-number pieces for the equation. - const mixedParts = [] - if (whole > 0) mixedParts.push({ text: String(whole), color: wholeGreen }) - if (rem > 0) mixedParts.push({ text: `${rem}/${den}`, color: fracOrange }) - if (mixedParts.length === 0) mixedParts.push({ text: '0', color: muted }) - return (
@@ -182,9 +163,11 @@ export default function MixedNumbersImproper() { )}
-
+
- setNum((v) => clamp(v - 1, 0, maxNum))} onInc={() => setNum((v) => clamp(v + 1, 0, maxNum))} /> - changeDen(den - 1)} onInc={() => changeDen(den + 1)} /> - -
-
- ) -} - -function Stepper({ label, value, color, onDec, onInc }) { - return ( -
- {label} -
- - {value} - +
) diff --git a/src/manipulatives/multiplying-fractions-area.jsx b/src/manipulatives/multiplying-fractions-area.jsx index d505b36..e021e08 100644 --- a/src/manipulatives/multiplying-fractions-area.jsx +++ b/src/manipulatives/multiplying-fractions-area.jsx @@ -1,11 +1,6 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' - -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const blue = '#2563EB' -const orange = '#D97706' -const green = '#1D9E75' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, blue, amber as orange, green } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' const MIN_DEN = 2 const MAX_DEN = 8 @@ -61,7 +56,7 @@ function hLabel(ctx, x, y, text, color) { export default function MultiplyingFractionsArea() { const wrapRef = useRef(null) const canvasRef = useRef(null) - const [size, setSize] = useState({ w: 460, h: 360 }) + const size = useCanvasBox(wrapRef, { minW: 300, minH: 260 }) const [num1, setNum1] = useState(1) const [den1, setDen1] = useState(2) const [num2, setNum2] = useState(1) @@ -75,28 +70,9 @@ export default function MultiplyingFractionsArea() { const simpD = prodD / divisor const reduces = divisor > 1 - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (box) => { - const w = Math.max(300, Math.round(box.width)) - const h = Math.max(260, Math.round(box.height)) - setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) - } - commit(node.getBoundingClientRect()) - const observer = new ResizeObserver((entries) => { - const cr = entries[0]?.contentRect - if (!cr) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(cr)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = `Area model: a square split into ${den1} rows and ${den2} columns, ${prodD} equal pieces total. ${num1}/${den1} of the height is shaded blue, ${num2}/${den2} of the width is shaded orange, and their green overlap of ${prodN} piece${prodN === 1 ? '' : 's'} shows the product ${num1}/${den1} times ${num2}/${den2}${reduces ? `, which simplifies to ${simpN}/${simpD}` : ''}.` const geometry = useMemo(() => { // Reserve margin on the left and top for the "1 whole" + fraction brackets. @@ -224,8 +200,8 @@ export default function MultiplyingFractionsArea() { {/* Square + legend */}
-
- +
+
@@ -283,12 +259,12 @@ function LegendRow({ color, label, solid }) { ) } -function MiniStepper({ value, onDec, onInc, color }) { +function MiniStepper({ value, onDec, onInc, color, decLabel, incLabel }) { return (
- + {value} - +
) } @@ -298,9 +274,23 @@ function FractionStepper({ label, color, num, den, onNum, onDen }) {
{label}
- onNum(num - 1)} onInc={() => onNum(num + 1)} color={color} /> + onNum(num - 1)} + onInc={() => onNum(num + 1)} + color={color} + decLabel={`Decrease ${label} numerator`} + incLabel={`Increase ${label} numerator`} + /> - onDen(den - 1)} onInc={() => onDen(den + 1)} color={color} /> + onDen(den - 1)} + onInc={() => onDen(den + 1)} + color={color} + decLabel={`Decrease ${label} denominator`} + incLabel={`Increase ${label} denominator`} + />
) diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx index 172d0f5..af9c686 100644 --- a/src/manipulatives/pizza-remainder.jsx +++ b/src/manipulatives/pizza-remainder.jsx @@ -1,15 +1,13 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, blue as friendBlue, green as quotientGreen, orange as remOrange } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' +import Stepper from './shared/Stepper' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' const crust = '#E0A458' const cheese = '#FBD45B' const sauce = '#E8893B' const pepperoni = '#C23B22' -const friendBlue = '#2563EB' -const quotientGreen = '#1D9E75' -const remOrange = '#D85A30' const plateFill = '#EDEAE2' const MIN_PIZZAS = 1 @@ -65,7 +63,7 @@ function drawPizza(ctx, x, y, r, leftover, lifted) { export default function PizzaRemainder() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 360 }) const [pizzas, setPizzas] = useState(7) const [friends, setFriends] = useState(3) const [place, setPlace] = useState(() => pileArray(7)) @@ -76,30 +74,6 @@ export default function PizzaRemainder() { const dragRef = useRef(null) // { index, offX, offY } const dragPosRef = useRef(null) // { x, y } - // Measure before first paint (no initial width jump), then track the stable - // unscaled content box via an rAF-deferred observer (no settle bounce). - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(360, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) - // Counts / status derived from the current placement. const status = useMemo(() => { const counts = Array(friends).fill(0) @@ -117,6 +91,17 @@ export default function PizzaRemainder() { return { counts, pileCount, trayCount, maxC, allEqual, solved } }, [place, friends]) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = (() => { + const plateDesc = status.counts.map((c, i) => `friend ${i + 1} has ${c}`).join(', ') + const pileDesc = status.pileCount > 0 ? `, ${status.pileCount} pizza${status.pileCount === 1 ? '' : 's'} still in the pile` : '' + const trayDesc = status.trayCount > 0 ? `, ${status.trayCount} in the remainder tray` : '' + return `${pizzas} pizzas divided among ${friends} friends: ${plateDesc}${pileDesc}${trayDesc}. ${ + status.solved ? `Solved — ${status.counts[0]} each, remainder ${status.trayCount}.` : 'Not yet solved.' + }` + })() + const layout = useMemo(() => { const PAD = 28 const width = canvasWidth @@ -385,9 +370,11 @@ export default function PizzaRemainder() {
{/* Canvas */} -
+
- changePizzas(pizzas - 1)} onInc={() => changePizzas(pizzas + 1)} /> - changeFriends(friends - 1)} onInc={() => changeFriends(friends + 1)} /> + changePizzas(pizzas - 1)} onInc={() => changePizzas(pizzas + 1)} decLabel="Fewer Pizzas" incLabel="More Pizzas" /> + changeFriends(friends - 1)} onInc={() => changeFriends(friends + 1)} decLabel="Fewer Friends" incLabel="More Friends" /> - -
-
- ) -} - -function Stepper({ label, value, accent, onDec, onInc }) { - return ( -
- {label} -
- - {value} - + reset()} ariaLabel="Reset pizza placement">Reset
) diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index dfaa09d..cb2f825 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, purple as chipPurple, green as trueGreen } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const chipPurple = '#7C3AED' -const trueGreen = '#1D9E75' -const axisColor = '#1A1A2E' +const axisColor = ink const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) @@ -45,7 +43,7 @@ let evSeq = 0 export default function ProbabilityScale() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [events, setEvents] = useState(DEFAULT_EVENTS) const [placed, setPlaced] = useState({}) // id -> guess prob (0..1), else in tray const [showAnswers, setShowAnswers] = useState(false) @@ -58,27 +56,18 @@ export default function ProbabilityScale() { const EV = useMemo(() => events.map((e) => ({ ...e, p: parseProb(e.disp) })), [events]) - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const placedCount = Object.keys(placed).length + const summary = EV.length + ? `Probability scale from 0, impossible, to 1, certain. ${EV.length} event${EV.length === 1 ? '' : 's'}, ${placedCount} placed: ` + + EV.map((e) => { + const g = placed[e.id] + const guess = g != null ? `guessed at ${Math.round(g * 100)}%` : 'not yet placed' + return `${e.label} (${guess}${showAnswers ? `, actual probability ${e.disp}` : ''})` + }).join('; ') + + '.' + : 'Empty probability scale from 0, impossible, to 1, certain. No events defined yet.' const geo = useMemo(() => { const PAD = 60 @@ -318,9 +307,11 @@ export default function ProbabilityScale() { Place each event: 0 = impossible, 1 = certain
-
+
updateEvent(e.id, 'label', ev.target.value)} className="min-w-0 flex-1 rounded-lg border border-[#E0DDD6] px-2 py-1.5 text-sm font-bold outline-none" placeholder="Event name" + aria-label="Event name" /> - +
) diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx index e16e719..c99b9cf 100644 --- a/src/manipulatives/rounding-number-line.jsx +++ b/src/manipulatives/rounding-number-line.jsx @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, purple as numberPurple, green as roundGreen } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const numberPurple = '#7C3AED' -const roundGreen = '#1D9E75' const midGray = '#9AA0AA' -const axisColor = '#1A1A2E' +const axisColor = ink const GHOST_MS = 680 const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) @@ -22,7 +20,7 @@ const CONFIG = { export default function RoundingNumberLine() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [canvasWidth, setCanvasWidth] = useState(720) + const { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) const [base, setBase] = useState(10) const [n, setN] = useState(27) const [hideAnswer, setHideAnswer] = useState(false) @@ -50,27 +48,15 @@ export default function RoundingNumberLine() { const mid = lower + base / 2 const rounded = roundOf(n) - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (w) => { - const next = Math.max(420, Math.round(w)) - setCanvasWidth((prev) => (Math.abs(prev - next) >= 1 ? next : prev)) - } - commit(node.getBoundingClientRect().width) - const observer = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect?.width - if (!w) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(w)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = `Number line from ${fmt(min)} to ${fmt(max)}, rounding to the nearest ${base}. ${fmt(n)} is ${ + n === mid + ? `exactly at the halfway point of ${fmt(mid)}` + : n < mid + ? `before the halfway point of ${fmt(mid)}, closer to ${fmt(lower)}` + : `after the halfway point of ${fmt(mid)}, closer to ${fmt(upper)}` + }.${hideAnswer ? ' The rounded answer is hidden.' : ` It rounds to ${fmt(rounded)}.`}` const geo = useMemo(() => { const PAD = 56 @@ -315,9 +301,11 @@ export default function RoundingNumberLine() { (nearest {base})
-
+
stepNumber(1)} className="h-10 text-2xl font-black" style={{ color: roundGreen }} aria-label="Increase number">+
- +
) diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx index a7894d8..e28c380 100644 --- a/src/manipulatives/sample-space-tree.jsx +++ b/src/manipulatives/sample-space-tree.jsx @@ -1,16 +1,14 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' - -const cream = '#F8F6F0' -const ink = '#1A1A2E' -const muted = '#5F5E5A' -const selBlue = '#2563EB' -const totalPurple = '#7C3AED' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cream, ink, muted, border, blue as selBlue, purple as totalPurple } from './shared/palette' +import { useCanvasBox } from './shared/useCanvasBox' +import GhostButton from './shared/GhostButton' +import ToggleChip from './shared/ToggleChip' const OPT_COLORS = { H: '#2563EB', T: '#D85A30', R: '#D8402F', B: '#2563EB', G: '#1D9E75', } -const numColor = '#7C3AED' +const numColor = totalPurple const colorOf = (o) => OPT_COLORS[o] || numColor const EXPERIMENTS = { @@ -28,7 +26,7 @@ const parseOpts = (text) => { export default function SampleSpaceTree() { const canvasRef = useRef(null) const wrapRef = useRef(null) - const [size, setSize] = useState({ w: 720, h: 380 }) + const size = useCanvasBox(wrapRef, { minW: 420, minH: 240 }) const [s1Text, setS1Text] = useState('H,T') const [s2Text, setS2Text] = useState('H,T') const [selected, setSelected] = useState(() => new Set()) @@ -46,28 +44,12 @@ export default function SampleSpaceTree() { const matchKey = Object.entries(EXPERIMENTS).find(([, v]) => v.s1.join(',') === s1.join(',') && v.s2.join(',') === s2.join(','))?.[0] || 'custom' - useLayoutEffect(() => { - const node = wrapRef.current - if (!node) return undefined - let raf = 0 - const commit = (box) => { - const w = Math.max(420, Math.round(box.width)) - const h = Math.max(240, Math.round(box.height)) - setSize((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) - } - commit(node.getBoundingClientRect()) - const observer = new ResizeObserver((entries) => { - const cr = entries[0]?.contentRect - if (!cr) return - cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => commit(cr)) - }) - observer.observe(node) - return () => { - cancelAnimationFrame(raf) - observer.disconnect() - } - }, []) + // The canvas is the whole point of this manipulative, so it needs a text + // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. + const summary = + `Sample space tree. Stage 1: ${s1.join(', ')} (${a} option${a === 1 ? '' : 's'}). ` + + `Stage 2: ${s2.join(', ')} (${b} option${b === 1 ? '' : 's'}). ${N} total outcome${N === 1 ? '' : 's'}, ${a} × ${b}. ` + + (selected.size > 0 ? `${selected.size} outcome${selected.size === 1 ? '' : 's'} selected.` : 'No outcomes selected.') const geo = useMemo(() => { const { w, h } = size @@ -251,8 +233,14 @@ export default function SampleSpaceTree() { )}
-
- +
+

@@ -293,29 +281,12 @@ export default function SampleSpaceTree() { style={{ color: ink }} /> - - toggle('count')} /> - toggle('prob')} /> + + toggle('count')} /> + toggle('prob')} />

) } - -// One reveal layer. The dot carries the layer's colour so the chip and the -// thing it reveals read as the same idea. -function ToggleChip({ label, color, on, onClick }) { - return ( - - ) -} diff --git a/src/manipulatives/shared/GhostButton.jsx b/src/manipulatives/shared/GhostButton.jsx new file mode 100644 index 0000000..311f2f7 --- /dev/null +++ b/src/manipulatives/shared/GhostButton.jsx @@ -0,0 +1,18 @@ +import { border, muted } from './palette' + +// The neutral pill used for presets, resets and "clear" across the set. +// Deliberately quiet: these are the controls that set a scene up, not the +// ones a student is meant to reach for while thinking. +export default function GhostButton({ children, onClick, ariaLabel, compact = false }) { + return ( + + ) +} diff --git a/src/manipulatives/shared/Stepper.jsx b/src/manipulatives/shared/Stepper.jsx new file mode 100644 index 0000000..94aef64 --- /dev/null +++ b/src/manipulatives/shared/Stepper.jsx @@ -0,0 +1,47 @@ +import { border, green, orange } from './palette' + +// −/value/+ control. Was written three times with cosmetic drift between the +// copies (different column widths, `color` vs `accent`, different font sizes). +// +// decLabel/incLabel exist because the right screen-reader wording depends on +// what is being counted: "More pizzas" reads naturally, "Increase pizzas" +// does not, and for an abstract boundary value it is the other way round. +export default function Stepper({ label, value, color, onDec, onInc, decLabel, incLabel }) { + return ( +
+ + {label} + +
+ + + {value} + + +
+
+ ) +} diff --git a/src/manipulatives/shared/ToggleChip.jsx b/src/manipulatives/shared/ToggleChip.jsx new file mode 100644 index 0000000..4e96750 --- /dev/null +++ b/src/manipulatives/shared/ToggleChip.jsx @@ -0,0 +1,26 @@ +import { border, hairline, muted } from './palette' + +// One reveal layer. +// +// The dot carries the layer's own colour, so the control and the thing it +// reveals read as the same idea. A teacher peels ideas back one at a time +// instead of flipping a single all-or-nothing "show answer". +// +// aria-pressed (not just visual colour) is what tells a screen reader this is +// a two-state control rather than a button that does something. +export default function ToggleChip({ label, color, on, onClick, compact = false }) { + return ( + + ) +} diff --git a/src/manipulatives/shared/palette.js b/src/manipulatives/shared/palette.js new file mode 100644 index 0000000..8235e0c --- /dev/null +++ b/src/manipulatives/shared/palette.js @@ -0,0 +1,29 @@ +// Shared palette for the mjones manipulatives. +// +// The three neutrals were declared identically in all ten files. The accents +// were too, but under a different name each time (solGreen, meanGreen, posX, +// roundGreen... all #1D9E75). Import and alias at the point of use so each +// manipulative still names a colour for what it MEANS there: +// +// import { green as solGreen, blue as testBlue } from './shared/palette' +// +// Colour carries meaning in these manipulatives, so a colour should keep its +// job across the set: green = the true/solved thing, purple = the unknown or +// the total, blue = the thing you are testing or have selected, orange/red = +// the leftover, the distance, the negative. +// +// Domain colours (pizza cheese, balance-beam wood) stay in their own file — +// they mean nothing outside it. + +export const cream = '#F8F6F0' +export const ink = '#1A1A2E' +export const muted = '#5F5E5A' +export const border = '#E0DDD6' +export const hairline = '#C9CDD6' + +export const green = '#1D9E75' +export const purple = '#7C3AED' +export const blue = '#2563EB' +export const orange = '#D85A30' +export const amber = '#D97706' +export const red = '#D8402F' diff --git a/src/manipulatives/shared/useCanvasBox.js b/src/manipulatives/shared/useCanvasBox.js new file mode 100644 index 0000000..6304dd8 --- /dev/null +++ b/src/manipulatives/shared/useCanvasBox.js @@ -0,0 +1,46 @@ +import { useLayoutEffect, useState } from 'react' + +// Measures the element `ref` points at and keeps a canvas-sized box in state. +// +// This was copy-pasted into all ten manipulatives, because getting it right +// took three separate bug fixes and each one had to be applied everywhere: +// +// 1. useLayoutEffect, not useEffect — measure before first paint, or the +// canvas draws at its default 300x150 and visibly snaps to size. +// 2. contentRect, not getBoundingClientRect, inside the observer — the +// shared ManipulativeCanvas frame scales its children with a CSS +// transform, and contentRect is the unscaled box. getBoundingClientRect +// returns the scaled one, which feeds back into the observer and bounces. +// 3. rAF-defer the callback and only commit whole-pixel changes — a +// sub-pixel width change must not be able to retrigger the observer. +// +// Returns a { w, h } object whose identity is stable until a dimension +// actually changes, so it is safe to put straight into a deps array. +export function useCanvasBox(ref, { minW = 420, minH = 220 } = {}) { + const [box, setBox] = useState({ w: 720, h: minH }) + + useLayoutEffect(() => { + const node = ref.current + if (!node) return undefined + let raf = 0 + const commit = (rect) => { + const w = Math.max(minW, Math.round(rect.width)) + const h = Math.max(minH, Math.round(rect.height)) + setBox((prev) => (Math.abs(prev.w - w) >= 1 || Math.abs(prev.h - h) >= 1 ? { w, h } : prev)) + } + commit(node.getBoundingClientRect()) + const observer = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect + if (!cr) return + cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => commit(cr)) + }) + observer.observe(node) + return () => { + cancelAnimationFrame(raf) + observer.disconnect() + } + }, [ref, minW, minH]) + + return box +} From 68a7f7e776c8b346de6b29038bbb8cbfbdb84259 Mon Sep 17 00:00:00 2001 From: nakorly Date: Sun, 19 Jul 2026 22:30:23 +0200 Subject: [PATCH 15/16] Snap algebra tiles into ordered lanes; fix lint; respect reduced motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Algebra tiles (Pulkit asked for placement/order snapping): tiles used to stay wherever the hand let go, so a built expression was scattered across the mat and you could not read 2x + 3 = 7 in it. Tiles now snap into two lanes per side — x tiles above, units below — and ease into place. Position is derived from state rather than stored in it: a tile carries which side it is on and an `ord` (the x it was dropped at), so dropping a tile between two others slots it between them. Spacing tightens rather than wrapping, so a side stays one readable row however many tiles are on it. Reduced motion: added shared/motion.js. Every easing loop (tiles, balance beam, probability chips, rounding ghost) now jumps straight to the end state when the viewer asks for reduced motion — a manipulative that slides things around is exactly what that setting is for. It also short-circuits when the document is hidden, where requestAnimationFrame never fires: easing must not be the only path to a correct frame, or a manipulative set up in a hidden tab is still mid-flight when it is first shown. Lint: `npm run lint` was already failing before this branch — 7 errors and 2 warnings, all in our files. Now clean: - pizza: counters reassigned inside a map callback -> stepped on an object, matching what the neighbouring perPlateIdx already did. - rounding: performance.now() called in the render body -> the animation start is stamped by its first frame instead. - algebra tiles: AddBtn was declared during render, making it a new component type every render (remount instead of update) -> module scope. - balance: unused destructured binding. - the two exhaustive-deps warnings, one of which (pizza's missing status.trayCount) was a real staleness bug in the tray layout. Verified live: all ten stable and drawing, console clean. Tile snapping checked by measuring rendered tile centres (evenly spaced, correct lanes), plus dragging across the equals and onto an opposite tile to zero-pair. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/algebra-tiles.jsx | 257 +++++++++++++----- src/manipulatives/balance-scale-equations.jsx | 9 +- src/manipulatives/pizza-remainder.jsx | 18 +- src/manipulatives/probability-scale.jsx | 10 + src/manipulatives/rounding-number-line.jsx | 20 +- src/manipulatives/shared/motion.js | 15 + 6 files changed, 247 insertions(+), 82 deletions(-) create mode 100644 src/manipulatives/shared/motion.js diff --git a/src/manipulatives/algebra-tiles.jsx b/src/manipulatives/algebra-tiles.jsx index 5cbfbee..3a642ef 100644 --- a/src/manipulatives/algebra-tiles.jsx +++ b/src/manipulatives/algebra-tiles.jsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { cream, ink, muted, border, green as posX, red as negX, blue as pos1, amber as neg1 } from './shared/palette' +import { cream, ink, muted, border, hairline, green as posX, red as negX, blue as pos1, amber as neg1 } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' +import { skipMotion } from './shared/motion' import GhostButton from './shared/GhostButton' const TYPES = { - x: { color: posX, label: 'x', w: 30, h: 64, opp: 'nx' }, - nx: { color: negX, label: '−x', w: 30, h: 64, opp: 'x' }, - u: { color: pos1, label: '1', w: 32, h: 32, opp: 'nu' }, - nu: { color: neg1, label: '−1', w: 32, h: 32, opp: 'u' }, + x: { color: posX, label: 'x', w: 30, h: 64, opp: 'nx', kind: 'x' }, + nx: { color: negX, label: '−x', w: 30, h: 64, opp: 'x', kind: 'x' }, + u: { color: pos1, label: '1', w: 32, h: 32, opp: 'nu', kind: 'u' }, + nu: { color: neg1, label: '−1', w: 32, h: 32, opp: 'u', kind: 'u' }, } const ADD_LABELS = { @@ -19,17 +20,80 @@ const ADD_LABELS = { let seq = 0 +// At module scope, not inside the component: a component declared during +// render is a new type on every render, so React unmounts and remounts it +// each time instead of updating it. +function AddBtn({ type, onAdd }) { + const info = TYPES[type] + return ( + + ) +} + +// Where a tile belongs, given what is on its side of the equals. Tiles are +// laid in two lanes — x tiles above, units below — and ordered by `ord`, +// which is just the x the tile was last dropped at. Dropping a tile between +// two others therefore slots it in between them. +// +// An expression you can't read is an expression you can't reason about, so +// the mat tidies itself: dropped tiles snap into their lane rather than +// staying wherever the hand let go. Spacing tightens instead of wrapping, so +// a side stays one readable row however many tiles are on it. +function packTargets(tiles, size) { + const targets = new Map() + const mid = size.w / 2 + const laneY = { x: size.h * 0.36, u: size.h * 0.72 } + const pad = 26 + + ;['L', 'R'].forEach((side) => { + const x0 = side === 'L' ? pad : mid + pad + const x1 = side === 'L' ? mid - pad : size.w - pad + ;['x', 'u'].forEach((kind) => { + const row = tiles + .filter((t) => t.side === side && TYPES[t.type].kind === kind) + .sort((a, b) => a.ord - b.ord) + if (!row.length) return + const tileW = TYPES[row[0].type].w + const ideal = tileW + 8 + const room = x1 - x0 + // Tighten rather than wrap or overflow the side. + const step = row.length > 1 ? Math.min(ideal, (room - tileW) / (row.length - 1)) : 0 + const used = step * (row.length - 1) + tileW + const startX = x0 + (room - used) / 2 + tileW / 2 + row.forEach((t, i) => { + targets.set(t.id, { x: startX + i * step, y: laneY[kind] }) + }) + }) + }) + return targets +} + export default function AlgebraTiles() { const canvasRef = useRef(null) const wrapRef = useRef(null) const size = useCanvasBox(wrapRef, { minW: 420, minH: 220 }) - const [tiles, setTiles] = useState([]) - const [drag, setDrag] = useState(null) // { id, offX, offY, x, y } + const [tiles, setTiles] = useState([]) // { id, type, side, ord } — position is derived const [flash, setFlash] = useState(null) // { x, y, t } zero-pair spot - const flashRafRef = useRef(null) - // Tiles left of the divider form one side of the equation, right the other. + // Rendered positions live in a ref, not state: they change every frame while + // tiles ease into their snapped slots, and re-rendering React 60 times a + // second to move a rectangle would be silly. + const posRef = useRef(new Map()) + const dragRef = useRef(null) // { id, offX, offY, x, y } + const rafRef = useRef(0) + const drawRef = useRef(() => {}) + const [, forceDraw] = useState(0) // nudges the effect that owns the canvas + const midX = size.w / 2 + const fmt = (ts) => { const xC = ts.filter((t) => t.type === 'x').length - ts.filter((t) => t.type === 'nx').length const c = ts.filter((t) => t.type === 'u').length - ts.filter((t) => t.type === 'nu').length @@ -38,8 +102,8 @@ export default function AlgebraTiles() { if (c !== 0) e += (e ? (c < 0 ? ' − ' : ' + ') : c < 0 ? '−' : '') + Math.abs(c) return e || '0' } - const leftExpr = fmt(tiles.filter((t) => t.x < midX)) - const rightExpr = fmt(tiles.filter((t) => t.x >= midX)) + const leftExpr = fmt(tiles.filter((t) => t.side === 'L')) + const rightExpr = fmt(tiles.filter((t) => t.side === 'R')) // The canvas is the whole point of this manipulative, so it needs a text // equivalent — otherwise a screen-reader user gets an unlabelled rectangle. @@ -47,14 +111,11 @@ export default function AlgebraTiles() { ? `Algebra tile mat. Left side of the equation: ${leftExpr}. Right side: ${rightExpr}.` : 'Empty algebra tile mat. Add x, negative x, 1, or negative 1 tiles and drag them either side of the equals sign to build both sides of an equation.' + // `ord` is normally the x a tile was dropped at, so a fresh tile takes an ord + // above any possible x to land at the end of its lane. New tiles start on the + // left; drag across the equals to build the other side. const addTile = (type) => { - const n = tiles.length - // Spawn on the left of the equals; drag across to build the other side. - const span = Math.max(110, size.w / 2 - 70) - setTiles((prev) => [ - ...prev, - { id: (seq += 1), type, x: 40 + ((n * 46) % span), y: 56 + Math.floor((n * 46) / span) * 74 }, - ]) + setTiles((prev) => [...prev, { id: (seq += 1), type, side: 'L', ord: 1e6 + seq }]) } const draw = useCallback(() => { @@ -74,7 +135,7 @@ export default function AlgebraTiles() { // Equals divider: tiles either side form the two sides of an equation. const mid = size.w / 2 ctx.save() - ctx.strokeStyle = '#C9CDD6' + ctx.strokeStyle = hairline ctx.setLineDash([7, 6]) ctx.lineWidth = 2 ctx.beginPath() @@ -101,35 +162,37 @@ export default function AlgebraTiles() { ctx.fillText('Add tiles, drag them either side of the =, and drop an x onto a −x to zero-pair.', size.w / 2, 30) } - const drawTile = (t, lifted) => { + const drag = dragRef.current + const drawTile = (t, px, py, lifted) => { const info = TYPES[t.type] ctx.save() ctx.fillStyle = lifted ? 'rgba(26,26,46,0.20)' : 'rgba(26,26,46,0.10)' ctx.beginPath() - ctx.roundRect(t.x - info.w / 2 + 2, t.y - info.h / 2 + 4, info.w, info.h, 7) + ctx.roundRect(px - info.w / 2 + 2, py - info.h / 2 + (lifted ? 6 : 4), info.w, info.h, 7) ctx.fill() ctx.fillStyle = info.color ctx.strokeStyle = '#ffffff' ctx.lineWidth = 2 ctx.beginPath() - ctx.roundRect(t.x - info.w / 2, t.y - info.h / 2, info.w, info.h, 7) + ctx.roundRect(px - info.w / 2, py - info.h / 2, info.w, info.h, 7) ctx.fill() ctx.stroke() ctx.fillStyle = '#ffffff' - ctx.font = `900 ${t.type === 'x' || t.type === 'nx' ? 16 : 13}px Inter, system-ui, sans-serif` + ctx.font = `900 ${info.kind === 'x' ? 16 : 13}px Inter, system-ui, sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText(info.label, t.x, t.y) + ctx.fillText(info.label, px, py) ctx.restore() } tiles.forEach((t) => { if (drag && t.id === drag.id) return - drawTile(t, false) + const p = posRef.current.get(t.id) + if (p) drawTile(t, p.x, p.y, false) }) if (drag) { const t = tiles.find((x) => x.id === drag.id) - if (t) drawTile({ ...t, x: drag.x, y: drag.y }, true) + if (t) drawTile(t, drag.x, drag.y, true) } // Zero-pair flash. @@ -144,28 +207,88 @@ export default function AlgebraTiles() { ctx.fillText('= 0', flash.x, flash.y - 30 * flash.t) ctx.restore() } - }, [size, tiles, drag, flash]) + }, [size, tiles, flash]) + + // Assigned in an effect, not during render: the animation loop below calls + // whatever draw() was current at its last commit, and writing a ref while + // rendering is a React-rules violation the linter (rightly) rejects. + useEffect(() => { + drawRef.current = draw + }) + + // Ease every tile toward its snapped slot. New tiles enter from above the + // mat so it reads as the tile arriving, not blinking into existence. + useEffect(() => { + const targets = packTargets(tiles, size) + const pos = posRef.current + const still = skipMotion() + tiles.forEach((t) => { + if (!pos.has(t.id)) { + const tgt = targets.get(t.id) + // Enter from above the mat, unless we are not animating at all. + pos.set(t.id, { x: tgt ? tgt.x : size.w / 4, y: still && tgt ? tgt.y : -40 }) + } + }) + // Drop entries for tiles that no longer exist (zero-paired or cleared). + pos.forEach((_, id) => { + if (!tiles.some((t) => t.id === id)) pos.delete(id) + }) + + if (still) { + targets.forEach((tgt, id) => { + if (dragRef.current && dragRef.current.id === id) return + const p = pos.get(id) + if (p) { + p.x = tgt.x + p.y = tgt.y + } + }) + drawRef.current() + return undefined + } + + const step = () => { + let moving = false + targets.forEach((tgt, id) => { + if (dragRef.current && dragRef.current.id === id) return + const p = pos.get(id) + if (!p) return + const dx = tgt.x - p.x + const dy = tgt.y - p.y + if (Math.abs(dx) < 0.4 && Math.abs(dy) < 0.4) { + p.x = tgt.x + p.y = tgt.y + return + } + p.x += dx * 0.22 + p.y += dy * 0.22 + moving = true + }) + drawRef.current() + rafRef.current = moving ? requestAnimationFrame(step) : 0 + } + cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(step) + return () => cancelAnimationFrame(rafRef.current) + }, [tiles, size]) useEffect(() => { draw() }, [draw]) + useEffect(() => () => cancelAnimationFrame(rafRef.current), []) + const runFlash = (x, y) => { - if (flashRafRef.current) cancelAnimationFrame(flashRafRef.current) const start = performance.now() const tick = (now) => { const t = Math.min(1, (now - start) / 500) setFlash({ x, y, t }) - if (t < 1) flashRafRef.current = requestAnimationFrame(tick) + if (t < 1) requestAnimationFrame(tick) else setFlash(null) } - flashRafRef.current = requestAnimationFrame(tick) + requestAnimationFrame(tick) } - useEffect(() => () => { - if (flashRafRef.current) cancelAnimationFrame(flashRafRef.current) - }, []) - const getPoint = (event) => { const rect = event.currentTarget.getBoundingClientRect() return { @@ -179,56 +302,56 @@ export default function AlgebraTiles() { for (let i = tiles.length - 1; i >= 0; i -= 1) { const t = tiles[i] const info = TYPES[t.type] - if (Math.abs(pt.x - t.x) <= info.w / 2 + 2 && Math.abs(pt.y - t.y) <= info.h / 2 + 2) { + const p = posRef.current.get(t.id) + if (!p) continue + if (Math.abs(pt.x - p.x) <= info.w / 2 + 2 && Math.abs(pt.y - p.y) <= info.h / 2 + 2) { event.currentTarget.setPointerCapture(event.pointerId) - setDrag({ id: t.id, offX: pt.x - t.x, offY: pt.y - t.y, x: t.x, y: t.y }) + dragRef.current = { id: t.id, offX: pt.x - p.x, offY: pt.y - p.y, x: p.x, y: p.y } + forceDraw((n) => n + 1) return } } } + const handlePointerMove = (event) => { + const drag = dragRef.current if (!drag) return const pt = getPoint(event) - setDrag((d) => (d ? { ...d, x: pt.x - d.offX, y: pt.y - d.offY } : d)) + drag.x = pt.x - drag.offX + drag.y = pt.y - drag.offY + const p = posRef.current.get(drag.id) + if (p) { + p.x = drag.x + p.y = drag.y + } + drawRef.current() } + const handlePointerUp = () => { + const drag = dragRef.current if (!drag) return const dragged = tiles.find((t) => t.id === drag.id) if (!dragged) { - setDrag(null) + dragRef.current = null return } + const side = drag.x < midX ? 'L' : 'R' const opp = TYPES[dragged.type].opp // Zero pairs only cancel on the SAME side of the equals. - const hit = tiles.find( - (t) => - t.id !== dragged.id && - t.type === opp && - Math.hypot(t.x - drag.x, t.y - drag.y) <= 34 && - (t.x < midX) === (drag.x < midX), - ) + const hit = tiles.find((t) => { + if (t.id === dragged.id || t.type !== opp || t.side !== side) return false + const p = posRef.current.get(t.id) + return p && Math.hypot(p.x - drag.x, p.y - drag.y) <= 34 + }) + dragRef.current = null if (hit) { + const p = posRef.current.get(hit.id) + runFlash((drag.x + p.x) / 2, (drag.y + p.y) / 2) setTiles((prev) => prev.filter((t) => t.id !== dragged.id && t.id !== hit.id)) - runFlash((drag.x + hit.x) / 2, (drag.y + hit.y) / 2) } else { - setTiles((prev) => prev.map((t) => (t.id === dragged.id ? { ...t, x: drag.x, y: drag.y } : t))) + // ord is the drop x, so a tile dropped between two others lands between them. + setTiles((prev) => prev.map((t) => (t.id === dragged.id ? { ...t, side, ord: drag.x } : t))) } - setDrag(null) - } - - const AddBtn = ({ type }) => { - const info = TYPES[type] - return ( - - ) } return ( @@ -257,10 +380,10 @@ export default function AlgebraTiles() {

- - - - + + + + setTiles([])} ariaLabel="Clear all tiles"> Clear diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx index 240d54a..4e22d27 100644 --- a/src/manipulatives/balance-scale-equations.jsx +++ b/src/manipulatives/balance-scale-equations.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, purple as xPurple, blue as unitBlue, green as balancedGreen, orange as offRed } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' +import { skipMotion } from './shared/motion' import GhostButton from './shared/GhostButton' const beamWood = '#B07A3C' @@ -139,7 +140,7 @@ export default function BalanceScaleEquations() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, canvasWidth, canvasHeight) - const { cx, pivotY, L, baseY } = geo + const { cx, pivotY, baseY } = geo const angle = angleRef.current // Fulcrum (triangle) + stand. @@ -228,6 +229,12 @@ export default function BalanceScaleEquations() { // (which changes `draw` every frame) never restarts this loop — no shaking. useEffect(() => { if (rafRef.current) cancelAnimationFrame(rafRef.current) + // The beam still ends up at the right tilt, it just gets there at once. + if (skipMotion()) { + angleRef.current = targetAngle + drawRef.current() + return undefined + } const tick = () => { const cur = angleRef.current const next = cur + (targetAngle - cur) * 0.2 diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx index af9c686..88a961c 100644 --- a/src/manipulatives/pizza-remainder.jsx +++ b/src/manipulatives/pizza-remainder.jsx @@ -137,8 +137,10 @@ export default function PizzaRemainder() { // Compute a resting position for every pizza from its placement. const perPlateIdx = Array(friends).fill(0) - let pileK = 0 - let trayK = 0 + // Counters live on an object rather than as `let`s: the callback below + // reassigning a captured variable is a React-rules violation, while + // stepping a property (as perPlateIdx already does) is not. + const next = { pile: 0, tray: 0 } const positions = place.map((p) => { if (p.loc === 'plate' && p.f < friends) { const idx = perPlateIdx[p.f] @@ -153,14 +155,14 @@ export default function PizzaRemainder() { } } if (p.loc === 'tray') { - const k = trayK - trayK += 1 + const k = next.tray + next.tray += 1 const startX = tray.cx - (status.trayCount - 1) * gap * 0.5 return { x: startX + k * gap, y: tray.cy, loc: 'tray' } } - const col = pileK % pileCols - const row = Math.floor(pileK / pileCols) - pileK += 1 + const col = next.pile % pileCols + const row = Math.floor(next.pile / pileCols) + next.pile += 1 return { x: pileX0 + col * pileGap, y: pileY0 + row * pileGap, loc: 'pile' } }) @@ -244,7 +246,7 @@ export default function PizzaRemainder() { if (dragIndex >= 0 && dragPosRef.current) { drawPizza(ctx, dragPosRef.current.x, dragPosRef.current.y, r * 1.1, false, true) } - }, [canvasWidth, layout, friends, status.pileCount]) + }, [canvasWidth, layout, friends, status.pileCount, status.trayCount]) useEffect(() => { draw() diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index cb2f825..e22c2ad 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, purple as chipPurple, green as trueGreen } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' +import { skipMotion } from './shared/motion' import GhostButton from './shared/GhostButton' const axisColor = ink @@ -227,6 +228,15 @@ export default function ProbabilityScale() { useEffect(() => { cancelAnimationFrame(rafRef.current) + // Chips still land on their marks, they just do not slide there. + if (skipMotion()) { + EV.forEach((e) => { + const tgt = targets[e.id] + if (tgt) dispRef.current[e.id] = { ...tgt } + }) + draw() + return undefined + } const tick = () => { let moving = false EV.forEach((e) => { diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx index c99b9cf..0834273 100644 --- a/src/manipulatives/rounding-number-line.jsx +++ b/src/manipulatives/rounding-number-line.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, purple as numberPurple, green as roundGreen } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' +import { skipMotion } from './shared/motion' import GhostButton from './shared/GhostButton' const midGray = '#9AA0AA' @@ -34,7 +35,8 @@ export default function RoundingNumberLine() { const max = CONFIG[base].max const step = CONFIG[base].step - const fmt = (v) => (base === 1 ? (Number.isInteger(v) ? String(v) : v.toFixed(1)) : String(v)) + // Memoised so it can be a stable dependency of draw(). + const fmt = useCallback((v) => (base === 1 ? (Number.isInteger(v) ? String(v) : v.toFixed(1)) : String(v)), [base]) const roundOf = useCallback( (v) => { const lower = Math.floor(v / base) * base @@ -181,7 +183,7 @@ export default function RoundingNumberLine() { // Ghost point floating from the number to its rounded value. if (ghostRef.current) { const g = ghostRef.current - const t = clamp((performance.now() - g.start) / GHOST_MS, 0, 1) + const t = g.start === null ? 0 : clamp((performance.now() - g.start) / GHOST_MS, 0, 1) const gx = g.fromX + (g.toX - g.fromX) * easeOut(t) const gy = axisY - 26 * Math.sin(Math.PI * t) ctx.globalAlpha = 0.6 * (1 - t) @@ -221,7 +223,7 @@ export default function RoundingNumberLine() { ctx.lineTo(nx, axisY - 46) ctx.closePath() ctx.fill() - }, [canvasWidth, geo, base, n, lower, upper, mid, rounded, hideAnswer]) + }, [canvasWidth, geo, base, n, lower, upper, mid, rounded, hideAnswer, fmt, max]) useEffect(() => { draw() @@ -233,10 +235,11 @@ export default function RoundingNumberLine() { const runGhost = useCallback(() => { if (ghostRafRef.current) cancelAnimationFrame(ghostRafRef.current) - const tick = () => { + const tick = (now) => { const g = ghostRef.current if (!g) return - if (performance.now() - g.start >= GHOST_MS) { + if (g.start === null) g.start = now // the first frame starts the clock + if (now - g.start >= GHOST_MS) { ghostRef.current = null draw() return @@ -250,7 +253,12 @@ export default function RoundingNumberLine() { const spawnGhost = (v) => { const rv = roundOf(v) if (rv === v) return - ghostRef.current = { fromX: geo.xFor(v), toX: geo.xFor(rv), start: performance.now() } + // Reduced motion: the rounded value still lands on its tick, it just does + // not travel there. + if (skipMotion()) return + // `start` is stamped by the first frame rather than read from the clock + // here — reading it now would be an impure call in the render body. + ghostRef.current = { fromX: geo.xFor(v), toX: geo.xFor(rv), start: null } runGhost() } diff --git a/src/manipulatives/shared/motion.js b/src/manipulatives/shared/motion.js new file mode 100644 index 0000000..485d1d8 --- /dev/null +++ b/src/manipulatives/shared/motion.js @@ -0,0 +1,15 @@ +// Should this frame animate, or just arrive? +// +// Two reasons to skip the motion and jump straight to the end state: +// +// - the viewer asked for reduced motion, and a manipulative that slides +// things around is exactly what that setting is for; +// - the document is hidden, where requestAnimationFrame does not fire at +// all. Animating a tab nobody is looking at buys nothing, and easing must +// never be the only path to a correct frame — otherwise a manipulative +// built while hidden is still mid-flight when it is first shown. +export function skipMotion() { + if (typeof window === 'undefined') return true + if (typeof document !== 'undefined' && document.hidden) return true + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true +} From 7f853f9d18f6ecc210d5704f091871dffa66febd Mon Sep 17 00:00:00 2001 From: nakorly Date: Sun, 19 Jul 2026 22:39:56 +0200 Subject: [PATCH 16/16] Make every reveal control the same chip Five manipulatives still hid things behind a plain "Hide answer" button while the other three had moved to coloured toggle chips, so the same idea looked like two different controls depending on which one a teacher opened. Rounding, mixed numbers, multiplying fractions, probability and pizza now use the shared ToggleChip too, each in the colour of the thing it reveals. Kept as single toggles rather than split into layers: these each reveal one idea, and inventing a second layer to match the multi-layer ones would be change for its own sake. Also moved the last raw #E0DDD6 in a style prop onto the palette token; the remaining ones are Tailwind class literals, which are consistent with each other and not worth the churn. Verified all ten live after the change: stable, drawing, aria-labelled, every reveal control now reporting aria-pressed. Lint and build clean. Co-Authored-By: Claude Opus 4.8 --- src/manipulatives/mixed-numbers-improper.jsx | 6 ++---- src/manipulatives/multiplying-fractions-area.jsx | 10 ++-------- src/manipulatives/pizza-remainder.jsx | 5 ++--- src/manipulatives/probability-scale.jsx | 7 +++---- src/manipulatives/rounding-number-line.jsx | 6 ++---- 5 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/manipulatives/mixed-numbers-improper.jsx b/src/manipulatives/mixed-numbers-improper.jsx index b9101d3..db06943 100644 --- a/src/manipulatives/mixed-numbers-improper.jsx +++ b/src/manipulatives/mixed-numbers-improper.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, green as wholeGreen, orange as fracOrange, purple as improperPurple } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' -import GhostButton from './shared/GhostButton' +import ToggleChip from './shared/ToggleChip' import Stepper from './shared/Stepper' const MIN_DEN = 2 @@ -199,9 +199,7 @@ export default function MixedNumbersImproper() { decLabel="Fewer Denominator" incLabel="More Denominator" /> - setHideMixed((h) => !h)}> - {hideMixed ? 'Show mixed number' : 'Hide mixed number'} - + setHideMixed((h) => !h)} />
) diff --git a/src/manipulatives/multiplying-fractions-area.jsx b/src/manipulatives/multiplying-fractions-area.jsx index e021e08..c3382d1 100644 --- a/src/manipulatives/multiplying-fractions-area.jsx +++ b/src/manipulatives/multiplying-fractions-area.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, blue, amber as orange, green } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' +import ToggleChip from './shared/ToggleChip' const MIN_DEN = 2 const MAX_DEN = 8 @@ -188,14 +189,7 @@ export default function MultiplyingFractionsArea() { )} - + setHideAnswer((h) => !h)} compact /> {/* Square + legend */} diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx index 88a961c..32cacf1 100644 --- a/src/manipulatives/pizza-remainder.jsx +++ b/src/manipulatives/pizza-remainder.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, blue as friendBlue, green as quotientGreen, orange as remOrange } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' import GhostButton from './shared/GhostButton' +import ToggleChip from './shared/ToggleChip' import Stepper from './shared/Stepper' const crust = '#E0A458' @@ -365,9 +366,7 @@ export default function PizzaRemainder() { )}

{status.solved && ( - + setHideAnswer((h) => !h)} compact /> )} diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx index e22c2ad..1b1b866 100644 --- a/src/manipulatives/probability-scale.jsx +++ b/src/manipulatives/probability-scale.jsx @@ -3,6 +3,7 @@ import { cream, ink, muted, border, purple as chipPurple, green as trueGreen } f import { useCanvasBox } from './shared/useCanvasBox' import { skipMotion } from './shared/motion' import GhostButton from './shared/GhostButton' +import ToggleChip from './shared/ToggleChip' const axisColor = ink @@ -373,10 +374,8 @@ export default function ProbabilityScale() {

- - { setPlaced({}); setShowAnswers(false) }} ariaLabel="Reset the probability scale"> diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx index 0834273..f03aa07 100644 --- a/src/manipulatives/rounding-number-line.jsx +++ b/src/manipulatives/rounding-number-line.jsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cream, ink, muted, border, purple as numberPurple, green as roundGreen } from './shared/palette' import { useCanvasBox } from './shared/useCanvasBox' import { skipMotion } from './shared/motion' -import GhostButton from './shared/GhostButton' +import ToggleChip from './shared/ToggleChip' const midGray = '#9AA0AA' const axisColor = ink @@ -349,9 +349,7 @@ export default function RoundingNumberLine() {
- setHideAnswer((h) => !h)}> - {hideAnswer ? 'Show answer' : 'Hide answer'} - + setHideAnswer((h) => !h)} /> )