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/src/manipulatives/algebra-tiles.jsx b/src/manipulatives/algebra-tiles.jsx new file mode 100644 index 0000000..3a642ef --- /dev/null +++ b/src/manipulatives/algebra-tiles.jsx @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +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', 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 = { + 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 + +// 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([]) // { id, type, side, ord } — position is derived + const [flash, setFlash] = useState(null) // { x, y, t } zero-pair spot + + // 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 + 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.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. + 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.' + + // `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) => { + setTiles((prev) => [...prev, { id: (seq += 1), type, side: 'L', ord: 1e6 + seq }]) + } + + 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) + + // Equals divider: tiles either side form the two sides of an equation. + const mid = size.w / 2 + ctx.save() + ctx.strokeStyle = hairline + 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 14px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + 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 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(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(px - info.w / 2, py - info.h / 2, info.w, info.h, 7) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = `900 ${info.kind === 'x' ? 16 : 13}px Inter, system-ui, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(info.label, px, py) + ctx.restore() + } + + tiles.forEach((t) => { + if (drag && t.id === drag.id) return + 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, drag.x, drag.y, true) + } + + // Zero-pair flash. + if (flash) { + ctx.save() + ctx.globalAlpha = 1 - flash.t + ctx.strokeStyle = '#9AA0AA' + ctx.fillStyle = '#6B7280' + ctx.font = '900 15px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('= 0', flash.x, flash.y - 30 * flash.t) + ctx.restore() + } + }, [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) => { + const start = performance.now() + const tick = (now) => { + const t = Math.min(1, (now - start) / 500) + setFlash({ x, y, t }) + if (t < 1) requestAnimationFrame(tick) + else setFlash(null) + } + requestAnimationFrame(tick) + } + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (size.w / rect.width), + y: (event.clientY - rect.top) * (size.h / rect.height), + } + } + + const handlePointerDown = (event) => { + const pt = getPoint(event) + for (let i = tiles.length - 1; i >= 0; i -= 1) { + const t = tiles[i] + const info = TYPES[t.type] + 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) + 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) + 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) { + 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) => { + 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)) + } else { + // 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))) + } + } + + return ( +
+
+ {leftExpr} + = + {rightExpr} +
+ +
+ +
+ +

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

+ +
+ + + + + setTiles([])} ariaLabel="Clear all tiles"> + Clear + +
+
+ ) +} diff --git a/src/manipulatives/balance-scale-equations.jsx b/src/manipulatives/balance-scale-equations.jsx new file mode 100644 index 0000000..4e22d27 --- /dev/null +++ b/src/manipulatives/balance-scale-equations.jsx @@ -0,0 +1,413 @@ +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' + +// 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 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 { 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)) + const [drag, setDrag] = useState(null) // { side, id, x, y, offX, offY } + + const canvasHeight = 360 + + 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 + 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 + + 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)) + 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 + 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 { cx, pivotY, 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]) + + // 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) + // 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 + angleRef.current = Math.abs(next - targetAngle) < 0.0006 ? targetAngle : next + drawRef.current() + if (angleRef.current !== targetAngle) rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + } + }, [targetAngle]) + + 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 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) + + // 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 */} +
+ {leftEq} + = + {rightEq} +
+ +
+ +
+ +

+ {message} +

+ +
+ {/* Build any equation: add tiles to either pan. */} + {['left', 'right'].map((side) => ( +
+ {side} + + +
+ ))} + + loadProblem(problemIndex)} ariaLabel="Reset to the current problem's starting tiles"> + Reset + + + Empty pans + +
+
+ ) +} + +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 fec55c1..682efed 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -1,11 +1,71 @@ +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 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' +import PizzaRemainder from './pizza-remainder.jsx' +import RoundingNumberLine from './rounding-number-line.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: 'balance-scale-equations', + name: 'Balance Scale Equations', + component: BalanceScaleEquations, + }, + { + id: 'rounding-number-line', + 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: '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/inequalities-number-line.jsx b/src/manipulatives/inequalities-number-line.jsx new file mode 100644 index 0000000..aab12c5 --- /dev/null +++ b/src/manipulatives/inequalities-number-line.jsx @@ -0,0 +1,256 @@ +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 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, 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 { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) + const [opKey, setOpKey] = useState('gt') + const [bound, setBound] = useState(3) + const [test, setTest] = useState(6) + // 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 + const op = OPS.find((o) => o.key === opKey) + + const testOk = op.right ? (op.inclusive ? test >= bound : test > bound) : op.inclusive ? test <= bound : test < bound + + // 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 + 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. + 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 + 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 (hideable, so the teacher can ask "is this a solution?") + 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, show]) + + 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))} /> + toggle('ray')} /> + toggle('check')} /> +
+
+ ) +} diff --git a/src/manipulatives/mean-absolute-deviation.jsx b/src/manipulatives/mean-absolute-deviation.jsx new file mode 100644 index 0000000..eca535b --- /dev/null +++ b/src/manipulatives/mean-absolute-deviation.jsx @@ -0,0 +1,300 @@ +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 axisColor = ink + +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 { 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. + const [show, setShow] = useState({ distances: true, mad: true }) + const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] })) + + const canvasHeight = 272 + + 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 + + // 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 + 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) + + // Shaded "typical" band: mean ± MAD (most points fall roughly this close). + 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)' + ctx.fillRect(bx0, axisY - 74, bx1 - bx0, 80) + } + + // 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 (!show.distances) return + 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 shown as a two-sided span (mean − MAD .. mean + MAD) below the axis. + 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)) + 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) => { + 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, show]) + + 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 ( +
+
+ {points.length === 0 ? ( + Click the line to add data points + ) : ( + <> + MAD = {show.mad ? round1(mad) : '?'} + {show.distances && ( + + = average distance from the mean ({distances.map((d) => round1(d)).join(' + ')} ÷ {distances.length}) + + )} + + )} +
+ +
+ +
+ +

+ 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. +

+ +
+ setPoints([8, 10, 12].map((v) => ({ id: (seq += 1), value: v })))} ariaLabel="Starter: bunched data"> + Bunched + + setPoints([1, 6, 14, 19].map((v) => ({ id: (seq += 1), value: v })))} ariaLabel="Starter: spread out data"> + Spread out + + setPoints([])} ariaLabel="Clear all data points">Clear + toggle('distances')} /> + toggle('mad')} /> +
+
+ ) +} diff --git a/src/manipulatives/mixed-numbers-improper.jsx b/src/manipulatives/mixed-numbers-improper.jsx new file mode 100644 index 0000000..db06943 --- /dev/null +++ b/src/manipulatives/mixed-numbers-improper.jsx @@ -0,0 +1,206 @@ +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 ToggleChip from './shared/ToggleChip' +import Stepper from './shared/Stepper' + +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 { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) + 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 + + // 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 + 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)) + } + + 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))} + decLabel="Fewer Pieces" + incLabel="More Pieces" + /> + changeDen(den - 1)} + onInc={() => changeDen(den + 1)} + decLabel="Fewer Denominator" + incLabel="More Denominator" + /> + setHideMixed((h) => !h)} /> +
+
+ ) +} diff --git a/src/manipulatives/multiplying-fractions-area.jsx b/src/manipulatives/multiplying-fractions-area.jsx new file mode 100644 index 0000000..c3382d1 --- /dev/null +++ b/src/manipulatives/multiplying-fractions-area.jsx @@ -0,0 +1,291 @@ +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 + +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 = useCanvasBox(wrapRef, { minW: 300, minH: 260 }) + 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 + + // 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. + 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 && ( + <> + = + + + )} +
+ )} +
+ setHideAnswer((h) => !h)} compact /> +
+ + {/* 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, decLabel, incLabel }) { + return ( +
+ + {value} + +
+ ) +} + +function FractionStepper({ label, color, num, den, onNum, onDen }) { + return ( +
+ {label} +
+ 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} + decLabel={`Decrease ${label} denominator`} + incLabel={`Increase ${label} denominator`} + /> +
+
+ ) +} diff --git a/src/manipulatives/pizza-remainder.jsx b/src/manipulatives/pizza-remainder.jsx new file mode 100644 index 0000000..32cacf1 --- /dev/null +++ b/src/manipulatives/pizza-remainder.jsx @@ -0,0 +1,401 @@ +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' +const cheese = '#FBD45B' +const sauce = '#E8893B' +const pepperoni = '#C23B22' +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 { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 360 }) + 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 dragRef = useRef(null) // { index, offX, offY } + const dragPosRef = useRef(null) // { x, y } + + // 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]) + + // 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 + 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 + + // 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 + 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 } + + // 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 perPlateIdx = Array(friends).fill(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] + 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 = 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 = next.pile % pileCols + const row = Math.floor(next.pile / pileCols) + next.pile += 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, place, 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) + // 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) { + 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, status.trayCount]) + + 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 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 && ( + setHideAnswer((h) => !h)} compact /> + )} +
+ + {/* Canvas */} +
+ +
+ +

+ {message} +

+ +
+ 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" /> + + reset()} ariaLabel="Reset pizza placement">Reset +
+
+ ) +} diff --git a/src/manipulatives/probability-scale.jsx b/src/manipulatives/probability-scale.jsx new file mode 100644 index 0000000..1b1b866 --- /dev/null +++ b/src/manipulatives/probability-scale.jsx @@ -0,0 +1,387 @@ +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' +import ToggleChip from './shared/ToggleChip' + +const axisColor = ink + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +// 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' }, + { p: 0.5, label: 'Even chance' }, + { p: 0.75, label: 'Likely' }, + { p: 1, label: 'Certain' }, +] + +let evSeq = 0 + +export default function ProbabilityScale() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + 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) + 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]) + + // 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 + 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) + 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, EV.length]) + + const targets = useMemo(() => { + const t = {} + 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, EV]) + + 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. + 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) + + 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) + }) + ctx.fillStyle = ink + 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) + + // 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]) + 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.moveTo(tx, scaleY - 13) + ctx.lineTo(tx, scaleY + 13) + ctx.stroke() + }) + } + + if (Object.keys(placed).length < EV.length) { + 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) + } + + 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 = 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 - (showAnswers ? 5 : 0)) + if (showAnswers) { + ctx.font = '900 11px Inter, system-ui, sans-serif' + ctx.fillText(`= ${e.disp}`, cx, cy + 8) + } + if (on) { + ctx.fillStyle = 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() + } + + EV.forEach((e) => { + if (drag && drag.id === e.id) return + const d = dispRef.current[e.id] || targets[e.id] + if (d && targets[e.id]) drawChip(e, d.x, d.y, targets[e.id].on, false) + }) + if (drag) { + const e = EV.find((x) => x.id === drag.id) + if (e) drawChip(e, drag.x, drag.y, false, true) + } + }, [canvasWidth, geo, placed, showAnswers, drag, targets, EV]) + + useEffect(() => { + draw() + }, [draw]) + + 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) => { + 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) + } + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, [targets, draw, EV]) + + 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) + 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 }) + 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 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' }]) + } + + return ( +
+
+ Place each event: 0 = impossible, 1 = certain +
+ +
+ + {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" + aria-label="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" + aria-label={`Probability for ${e.label || 'this event'}`} + /> + +
+ ))} + +
+ )} +
+ +

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

+ +
+ setShowAnswers((s) => !s)} /> + + { setPlaced({}); setShowAnswers(false) }} ariaLabel="Reset the probability scale"> + Reset + +
+
+ ) +} diff --git a/src/manipulatives/rounding-number-line.jsx b/src/manipulatives/rounding-number-line.jsx new file mode 100644 index 0000000..f03aa07 --- /dev/null +++ b/src/manipulatives/rounding-number-line.jsx @@ -0,0 +1,356 @@ +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 ToggleChip from './shared/ToggleChip' + +const midGray = '#9AA0AA' +const axisColor = ink + +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 { w: canvasWidth } = useCanvasBox(wrapRef, { minW: 420 }) + 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 = CONFIG[base].max + const step = CONFIG[base].step + + // 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 + 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 = roundOf(n) + + // 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 + const axisY = canvasHeight * 0.56 + const spanX = canvasWidth - PAD * 2 + const xFor = (v) => PAD + ((v - min) / (max - min)) * spanX + 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, step, base]) + + 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 minorStep = base / 10 + + // 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 + 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 = Math.abs(v / base - Math.round(v / base)) < 1e-6 + 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(fmt(Math.round(v / base) * base), 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 ${fmt(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(fmt(v), x, axisY + 42) + }) + + // 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 y = axisY - 30 + const dir = x2 >= x1 ? 1 : -1 + ctx.strokeStyle = roundGreen + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(x1, y) + ctx.lineTo(x2 - dir * 8, y) + ctx.stroke() + ctx.fillStyle = roundGreen + ctx.beginPath() + ctx.moveTo(x2, y) + ctx.lineTo(x2 - dir * 10, y - 6) + ctx.lineTo(x2 - dir * 10, y + 6) + ctx.closePath() + ctx.fill() + } + + // Ghost point floating from the number to its rounded value. + if (ghostRef.current) { + const g = ghostRef.current + 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) + 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 + 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() + 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) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.textBaseline = 'middle' + ctx.fillText(label, nx, axisY - 64) + 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, fmt, max]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (ghostRafRef.current) cancelAnimationFrame(ghostRafRef.current) + }, []) + + const runGhost = useCallback(() => { + if (ghostRafRef.current) cancelAnimationFrame(ghostRafRef.current) + const tick = (now) => { + const g = ghostRef.current + if (!g) return + if (g.start === null) g.start = now // the first frame starts the clock + if (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 + // 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() + } + + 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) + return geo.vFor(x) + } + + const handlePointerDown = (event) => { + event.currentTarget.setPointerCapture(event.pointerId) + draggingRef.current = true + ghostRef.current = null + setNumber(getVal(event)) + } + const handlePointerMove = (event) => { + if (!draggingRef.current) return + 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) + 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 ( +
+
+ {fmt(n)} + rounds to + {hideAnswer ? ? : {fmt(rounded)}} + (nearest {base}) +
+ +
+ +
+ +

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

+ +
+
+ {[1, 10, 100].map((b) => ( + + ))} +
+
+ Number +
+ + {fmt(n)} + +
+
+ setHideAnswer((h) => !h)} /> +
+
+ ) +} diff --git a/src/manipulatives/sample-space-tree.jsx b/src/manipulatives/sample-space-tree.jsx new file mode 100644 index 0000000..e28c380 --- /dev/null +++ b/src/manipulatives/sample-space-tree.jsx @@ -0,0 +1,292 @@ +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 = totalPurple +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'] }, +} + +// 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 = useCanvasBox(wrapRef, { minW: 420, minH: 240 }) + const [s1Text, setS1Text] = useState('H,T') + const [s2Text, setS2Text] = useState('H,T') + const [selected, setSelected] = useState(() => new Set()) + // 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. + 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' + + // 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 + 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. + 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)) + 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() + } + + // Branch probability labels (each branch is equally likely). + 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) + } + + // 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 = 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)) + } + + // 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 = s1[j] + const o2 = 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, s1, s2, a, b, N, selected, show]) + + 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 loadStarter = (key) => { + const p = EXPERIMENTS[key] + if (!p) return + setS1Text(p.s1.join(',')) + setS2Text(p.s2.join(',')) + setSelected(new Set()) + } + + return ( +
+
+ {show.count ? N : '?'} outcomes + {show.count && = {a} × {b}} + {show.prob && 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. +

+ +
+ + {/* Any experiment: type the outcomes for each stage. */} + + + setSelected(new Set())}> + Clear selection + + toggle('count')} /> + toggle('prob')} /> +
+
+ ) +} 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/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 +} 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 +}