diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b851c0f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [asha-new-manipulatives-v2] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm run build + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/src/App.jsx b/src/App.jsx index ec84f22..16bdfcc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,17 +3,17 @@ import ManipulativeCanvas from './ManipulativeCanvas.jsx' import { manipulatives } from './manipulatives/index.js' export default function App() { - const [activeId, setActiveId] = useState(manipulatives[0].id) + const [activeId, setActiveId] = useState('percent-park-designer') const active = manipulatives.find((m) => m.id === activeId) ?? manipulatives[0] const ActiveComponent = active.component return (
+ + Manipulatives - + {manipulatives.map((m) => ( = 0) row[decIdx] = { t: 'dec', active } + return row +} + +function linkSequence(ids, nextMap, prevMap) { + ids.forEach((id, i) => { + nextMap[id] = i < ids.length - 1 ? ids[i + 1] : null + prevMap[id] = i > 0 ? ids[i - 1] : null + }) +} + +function buildColumnar(operator, problem) { + const { a, b, result } = problem + const da = splitDigits(a) + const db = splitDigits(b) + const dr = splitDigits(result) + const isAddSub = operator === '+' || operator === '−' + const minIntCols = isAddSub ? 4 : 0 + const minFracCols = isAddSub ? 3 : 0 + const intCols = Math.max(minIntCols, da.int.length, db.int.length, dr.int.length) + const fracCols = Math.max(minFracCols, da.frac.length, db.frac.length, dr.frac.length) + const hasDecimal = fracCols > 0 + const totalCols = intCols + (hasDecimal ? 1 : 0) + fracCols + const decIdx = hasDecimal ? intCols : -1 + + function digitRow(d, { crossable } = {}) { + const row = new Array(totalCols).fill(null) + const offInt = intCols - d.int.length + d.int.forEach((digit, i) => { + row[offInt + i] = { t: 'static', v: digit, crossable: Boolean(crossable) } + }) + d.frac.forEach((digit, i) => { + if (i < fracCols) row[decIdx + 1 + i] = { t: 'static', v: digit, crossable: Boolean(crossable) } + }) + return finalizeRow(row, decIdx, true) + } + + const rowA = digitRow(da, { crossable: true }) + const rowB = digitRow(db) + + const carryRow = new Array(totalCols).fill(null) + for (let col = 0; col < totalCols; col += 1) { + if (col === decIdx) continue + carryRow[col] = { t: 'input', id: `carry:${col}`, variant: 'carry' } + } + finalizeRow(carryRow, decIdx, false) + + const expectedMap = {} + const answerRow = new Array(totalCols).fill(null) + const offIntR = intCols - dr.int.length + dr.int.forEach((digit, i) => { + const col = offIntR + i + const id = `ans:${col}` + expectedMap[id] = digit + answerRow[col] = { t: 'input', id, expected: digit, variant: 'answer' } + }) + dr.frac.forEach((digit, i) => { + if (i >= fracCols) return + const col = decIdx + 1 + i + const id = `ans:${col}` + expectedMap[id] = digit + answerRow[col] = { t: 'input', id, expected: digit, variant: 'answer' } + }) + for (let col = 0; col < totalCols; col += 1) { + if (col === decIdx) continue + if (!answerRow[col]) answerRow[col] = { t: 'blank' } + } + finalizeRow(answerRow, decIdx, true) + + const nextMap = {} + const prevMap = {} + const answerIds = [] + for (let col = totalCols - 1; col >= 0; col -= 1) { + const cell = answerRow[col] + if (cell && cell.t === 'input') answerIds.push(cell.id) + } + linkSequence(answerIds, nextMap, prevMap) + const carryIds = [] + for (let col = totalCols - 1; col >= 0; col -= 1) { + if (col === decIdx) continue + carryIds.push(`carry:${col}`) + } + linkSequence(carryIds, nextMap, prevMap) + + return { + mode: 'columnar', + operator, + layout: { intCols, fracCols, totalCols, decIdx }, + rowA, + rowB, + carryRow, + answerRow, + expectedMap, + nextMap, + prevMap, + display: { a, b, result }, + } +} + +function buildDivision(problem) { + const { a: dividend, b: divisor, quotient } = problem + const dd = splitDigits(dividend) + const intCols = dd.int.length + const fracCols = dd.frac.length + const hasDecimal = fracCols > 0 + const totalCols = intCols + (hasDecimal ? 1 : 0) + fracCols + const decIdx = hasDecimal ? intCols : -1 + const digitColIndex = (idx) => (idx < intCols ? idx : idx + (hasDecimal ? 1 : 0)) + const digits = [...dd.int, ...dd.frac].map(Number) + + const dividendRow = new Array(totalCols).fill(null) + digits.forEach((d, idx) => { + dividendRow[digitColIndex(idx)] = { t: 'static', v: String(d) } + }) + finalizeRow(dividendRow, decIdx, true) + + let remainder = 0 + let pending = [] + const quotientDigits = new Array(digits.length).fill(0) + const cycles = [] + for (let idx = 0; idx < digits.length; idx += 1) { + pending.push(idx) + let cur = remainder + pending.forEach((i) => { + cur = cur * 10 + digits[i] + }) + const qDigit = Math.floor(cur / divisor) + const isLast = idx === digits.length - 1 + if (qDigit === 0 && !isLast) { + quotientDigits[idx] = 0 + continue + } + const subtractVal = qDigit * divisor + const newRemainder = cur - subtractVal + quotientDigits[idx] = qDigit + cycles.push({ + digitIndexes: [...pending], + quotientDigit: qDigit, + subtractVal, + remainderBefore: remainder, + newRemainder, + }) + remainder = newRemainder + pending = [] + } + + const expectedMap = {} + + const quotientRow = new Array(totalCols).fill(null) + for (let idx = 0; idx < digits.length; idx += 1) { + const id = `quo:${idx}` + const expected = String(quotientDigits[idx]) + expectedMap[id] = expected + quotientRow[digitColIndex(idx)] = { t: 'input', id, expected, variant: 'division' } + } + finalizeRow(quotientRow, decIdx, true) + + const cycleBlocks = cycles.map((cycle, i) => { + const lastPrevIdx = i > 0 ? cycles[i - 1].digitIndexes[cycles[i - 1].digitIndexes.length - 1] : null + const cols = i > 0 ? [lastPrevIdx, ...cycle.digitIndexes] : [...cycle.digitIndexes] + const gridCols = cols.map((idx) => digitColIndex(idx)) + const width = cols.length + + let resultRow = null + const resultIds = [] + if (i > 0) { + resultRow = new Array(totalCols).fill(null) + const rowValues = [cycle.remainderBefore, ...cycle.digitIndexes.map((di) => digits[di])] + gridCols.forEach((gridCol, k) => { + const id = `work:${i}:res:${k}` + const expected = String(rowValues[k]) + expectedMap[id] = expected + resultRow[gridCol] = { t: 'input', id, expected, variant: 'division' } + resultIds.push(id) + }) + finalizeRow(resultRow, decIdx, false) + } + + const subtractRow = new Array(totalCols).fill(null) + const subtractIds = [] + const padded = String(cycle.subtractVal).padStart(width, '0').split('') + gridCols.forEach((gridCol, k) => { + const id = `work:${i}:sub:${k}` + const expected = padded[k] + expectedMap[id] = expected + subtractRow[gridCol] = { t: 'input', id, expected, variant: 'division' } + subtractIds.push(id) + }) + finalizeRow(subtractRow, decIdx, false) + + return { resultRow, subtractRow, cellIds: [...resultIds, ...subtractIds] } + }) + + const lastCycle = cycles[cycles.length - 1] + const terminalCol = digitColIndex(lastCycle.digitIndexes[lastCycle.digitIndexes.length - 1]) + const terminalRow = new Array(totalCols).fill(null) + expectedMap.term = String(remainder) + terminalRow[terminalCol] = { t: 'input', id: 'term', expected: String(remainder), variant: 'division' } + finalizeRow(terminalRow, decIdx, false) + + const nextMap = {} + const prevMap = {} + const quotientIds = [] + for (let idx = 0; idx < digits.length; idx += 1) quotientIds.push(`quo:${idx}`) + linkSequence(quotientIds, nextMap, prevMap) + + const stepIds = [] + cycleBlocks.forEach((block) => stepIds.push(...block.cellIds)) + stepIds.push('term') + linkSequence(stepIds, nextMap, prevMap) + + return { + mode: 'division', + divisor, + layout: { intCols, fracCols, totalCols, decIdx }, + dividendRow, + quotientRow, + cycleBlocks, + terminalRow, + expectedMap, + nextMap, + prevMap, + display: { dividend, divisor, quotient }, + } +} + +function OperatorButton({ symbol, size, onClick, disabled }) { + return ( + + {symbol} + + ) +} + +function CellBox({ cell, col, size, ctx }) { + const gridBorder = `1px solid ${colors.gridLine}` + + if (!cell) return + + if (cell.t === 'dec') { + return ( + + {cell.active ? ( + + . + + ) : null} + + ) + } + + if (cell.t === 'static') { + const isCrossed = cell.crossable && ctx.crossed.has(col) + const digit = ( + + {cell.v} + {isCrossed ? ( + + ) : null} + + ) + if (cell.crossable) { + return ( + ctx.toggleCross(col)} + style={{ width: size, height: size, border: gridBorder }} + className="flex items-center justify-center" + aria-label="Cross out digit" + > + {digit} + + ) + } + return ( + + {digit} + + ) + } + + if (cell.t === 'blank') return + + const value = ctx.values[cell.id] ?? '' + const hasStatus = ctx.checked && cell.expected !== undefined + const status = hasStatus ? (value === cell.expected ? 'correct' : 'wrong') : null + const isCarry = cell.variant === 'carry' + + let bg = '#ffffff' + let border = colors.border + let text = colors.ink + if (isCarry) { + bg = colors.carryBg + border = colors.carryBorder + text = colors.carryText + } + if (status === 'correct') { + bg = colors.correctBg + border = colors.correctText + text = colors.correctText + } else if (status === 'wrong') { + bg = colors.wrongBg + border = colors.wrongText + text = colors.wrongText + } + + const boxWidth = Math.round(isCarry ? size * 0.8 : size * 0.82) + const boxHeight = Math.round(isCarry ? size * 0.56 : size * 0.82) + + return ( + + ctx.registerRef(cell.id, node)} + inputMode="numeric" + maxLength={isCarry ? 2 : 1} + value={value} + disabled={ctx.disabled} + onChange={(event) => { + if (isCarry) { + ctx.typeDigit(cell.id, event.target.value.replace(/[^0-9]/g, '').slice(0, 2)) + return + } + const digit = event.target.value.replace(/[^0-9]/g, '').slice(-1) + ctx.typeDigit(cell.id, digit) + if (digit) { + const nextId = ctx.nextMap[cell.id] + if (nextId) ctx.goFocus(nextId) + } + }} + onKeyDown={(event) => { + if (isCarry) return + if (event.key === 'Backspace' && !value) { + const prevId = ctx.prevMap[cell.id] + if (prevId) { + ctx.typeDigit(prevId, '') + ctx.goFocus(prevId) + } + } else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + const nextId = ctx.nextMap[cell.id] + if (nextId) { + event.preventDefault() + ctx.goFocus(nextId) + } + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + const prevId = ctx.prevMap[cell.id] + if (prevId) { + event.preventDefault() + ctx.goFocus(prevId) + } + } + }} + className={`text-center font-mono font-black outline-none transition focus:shadow-[0_0_0_3px_rgba(123,63,158,.4)] disabled:cursor-not-allowed ${ + isCarry ? 'rounded-md border-2 border-dashed' : 'rounded-md border-2' + }`} + style={{ width: boxWidth, height: boxHeight, background: bg, borderColor: border, color: text, fontSize: Math.max(11, size * (isCarry ? 0.3 : 0.38)) }} + /> + + ) +} + +function RowLine({ cells, size, ctx, prefix, underline }) { + return ( + + {prefix} + {cells.map((cell, col) => ( + + ))} + + ) +} + +function ColumnarBlock({ engine, size, ctx, onCycleOperator }) { + const { rowA, rowB, carryRow, answerRow, operator } = engine + const spacer = + return ( + + + + } + /> + + + ) +} + +function DivisionBlock({ engine, size, ctx, revealCount, showTerminal, onCycleOperator }) { + const { quotientRow, dividendRow, cycleBlocks, terminalRow, divisor } = engine + const prefixWidth = size * 2 + + return ( + + + + + {quotientRow.map((cell, col) => ( + + ))} + + + + + + + {divisor} + + + {dividendRow.map((cell, col) => ( + + ))} + + + + {cycleBlocks.slice(0, revealCount).map((block, i) => ( + + {block.resultRow ? ( + + + + {block.resultRow.map((cell, col) => ( + + ))} + + + ) : null} + + + + − + + + {block.subtractRow.map((cell, col) => ( + + ))} + + + + ))} + + {showTerminal ? ( + + + + {terminalRow.map((cell, col) => ( + + ))} + + + ) : null} + + ) +} + +export default function ColumnArithmeticGrid() { + const [operator, setOperator] = useState('+') + const [problemIndex, setProblemIndex] = useState(0) + + const problem = PROBLEMS[operator][problemIndex] + const engine = useMemo( + () => (operator === '\u00f7' ? buildDivision(problem) : buildColumnar(operator, problem)), + [operator, problem], + ) + + function cycleOperator() { + const idx = OPS.indexOf(operator) + setOperator(OPS[(idx + 1) % OPS.length]) + setProblemIndex(0) + } + + function newProblem() { + setProblemIndex((i) => (i + 1) % PROBLEMS[operator].length) + } + + return ( + + ) +} + +function ColumnArithmeticBoard({ engine, operator, onCycleOperator, onNewProblem }) { + const [values, setValues] = useState({}) + const [checked, setChecked] = useState(false) + const [solved, setSolved] = useState(false) + const [crossed, setCrossed] = useState(() => new Set()) + const [cellSize, setCellSize] = useState(48) + + const wrapRef = useRef(null) + const cellRefs = useRef({}) + + useEffect(() => { + const el = wrapRef.current + if (!el) return undefined + const cols = engine.layout.totalCols + let rows = 4 + if (engine.mode === 'division') { + rows = 2 + engine.cycleBlocks.forEach((block) => { + rows += block.resultRow ? 2 : 1 + }) + rows += 1 + } + const update = () => { + const width = el.clientWidth || 400 + const height = el.clientHeight || 300 + const byWidth = Math.floor(width / (cols + 1)) + const byHeight = Math.floor(height / rows) + setCellSize(Math.max(26, Math.min(52, byWidth, byHeight))) + } + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, [engine]) + + const revealCount = useMemo(() => { + if (engine.mode !== 'division') return 0 + let count = 1 + for (let i = 0; i < engine.cycleBlocks.length - 1; i += 1) { + const filled = engine.cycleBlocks[i].cellIds.every((id) => (values[id] ?? '') !== '') + if (!filled) break + count = i + 2 + } + return Math.min(count, engine.cycleBlocks.length) + }, [engine, values]) + + const showTerminal = useMemo(() => { + if (engine.mode !== 'division') return false + if (revealCount !== engine.cycleBlocks.length) return false + const last = engine.cycleBlocks[engine.cycleBlocks.length - 1] + return last.cellIds.every((id) => (values[id] ?? '') !== '') + }, [engine, revealCount, values]) + + function registerRef(id, node) { + if (node) cellRefs.current[id] = node + else delete cellRefs.current[id] + } + + function goFocus(id) { + const node = cellRefs.current[id] + if (node) node.focus() + } + + function typeDigit(id, digit) { + setValues((prev) => ({ ...prev, [id]: digit })) + setChecked(false) + } + + function toggleCross(col) { + setCrossed((prev) => { + const next = new Set(prev) + if (next.has(col)) next.delete(col) + else next.add(col) + return next + }) + goFocus(`carry:${col}`) + setChecked(false) + } + + function handleCheck() { + setChecked(true) + const ids = Object.keys(engine.expectedMap) + const allCorrect = ids.every((id) => values[id] === engine.expectedMap[id]) + setSolved(allCorrect) + } + + const ctx = { + values, + checked, + disabled: solved, + typeDigit, + registerRef, + goFocus, + nextMap: engine.nextMap, + prevMap: engine.prevMap, + crossed, + toggleCross, + } + + const fmt = (n) => String(n) + const taskText = engine.mode === 'division' + ? `Solve ${fmt(engine.display.dividend)} \u00f7 ${fmt(engine.display.divisor)}` + : `Solve ${fmt(engine.display.a)} ${operator} ${fmt(engine.display.b)}` + const equationText = engine.mode === 'division' + ? `${fmt(engine.display.dividend)} \u00f7 ${fmt(engine.display.divisor)} = ${fmt(engine.display.quotient)}` + : `${fmt(engine.display.a)} ${operator} ${fmt(engine.display.b)} = ${fmt(engine.display.result)}` + + return ( + + + + {engine.mode === 'columnar' ? ( + + ) : ( + + )} + + + + + {solved ? ( + <> + \u2713 Correct! + {equationText} + > + ) : ( + taskText + )} + + + New problem + + + Check + + + + + + {HINTS[operator]} + + + ) +} diff --git a/src/manipulatives/adding-unlike-fractions.jsx b/src/manipulatives/adding-unlike-fractions.jsx new file mode 100644 index 0000000..ccaa876 --- /dev/null +++ b/src/manipulatives/adding-unlike-fractions.jsx @@ -0,0 +1,512 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + ruby: '#B23050', + rubyLight: '#F6D5DC', + teal: '#1E5F74', + tealLight: '#CDE4EB', + purple: '#7B3F9E', + amber: '#8A4A12', + amberLight: '#FBEEDD', + border: '#E0DDD6', + muted: '#5F5E5A', +} + +const canvasHeight = 286 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const t = y + y = x % y + x = t + } + return x || 1 +} + +function lcm(a, b) { + return (a * b) / gcd(a, b) +} + +function easeInOut(t) { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2 +} + +function makeSafePair(nextA, nextB, changed) { + const a = { ...nextA, d: clamp(nextA.d, 2, 12) } + const b = { ...nextB, d: clamp(nextB.d, 2, 12) } + a.n = clamp(a.n, 1, a.d - 1) + b.n = clamp(b.n, 1, b.d - 1) + + while (a.n / a.d + b.n / b.d > 1) { + if (changed === 'a' && a.n > 1) a.n -= 1 + else if (changed === 'b' && b.n > 1) b.n -= 1 + else if (a.n >= b.n && a.n > 1) a.n -= 1 + else if (b.n > 1) b.n -= 1 + else break + } + return { a, b } +} + +function reduceFraction(n, d) { + if (n === d) return { n: 1, d: 1 } + const factor = gcd(n, d) + return { n: n / factor, d: d / factor } +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function drawBar(ctx, x, y, width, height, denominator, filled, fill, light, progress = 1) { + drawRoundRect(ctx, x, y, width, height, 10) + ctx.fillStyle = '#ffffff' + ctx.fill() + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.stroke() + + const visibleDenominator = Math.max(1, Math.round(denominator * progress)) + const pieceW = width / denominator + for (let i = 0; i < denominator; i += 1) { + ctx.fillStyle = i < filled ? fill : light + ctx.fillRect(x + i * pieceW, y, pieceW, height) + } + + ctx.save() + ctx.beginPath() + drawRoundRect(ctx, x, y, width, height, 10) + ctx.clip() + ctx.strokeStyle = 'rgba(26, 26, 46, 0.25)' + ctx.lineWidth = 1 + for (let i = 1; i < visibleDenominator; i += 1) { + const lineX = x + (i / visibleDenominator) * width + ctx.beginPath() + ctx.moveTo(lineX, y) + ctx.lineTo(lineX, y + height) + ctx.stroke() + } + ctx.restore() + + drawRoundRect(ctx, x, y, width, height, 10) + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.stroke() +} + +function drawResliceBar(ctx, x, y, width, height, originalDenominator, originalFilled, finalDenominator, fill, light, progress) { + const drawPhase = easeInOut(clamp(progress / 0.38, 0, 1)) + const holdPhase = clamp((progress - 0.38) / 0.38, 0, 1) + const shrinkPhase = easeInOut(clamp((progress - 0.76) / 0.24, 0, 1)) + drawRoundRect(ctx, x, y, width, height, 10) + ctx.fillStyle = '#ffffff' + ctx.fill() + + ctx.save() + ctx.beginPath() + drawRoundRect(ctx, x, y, width, height, 10) + ctx.clip() + + ctx.fillStyle = light + ctx.fillRect(x, y, width, height) + ctx.fillStyle = fill + ctx.fillRect(x, y, (originalFilled / originalDenominator) * width, height) + + ctx.strokeStyle = 'rgba(26, 26, 46, 0.34)' + ctx.lineWidth = 1.3 + for (let i = 1; i < originalDenominator; i += 1) { + const lineX = x + (i / originalDenominator) * width + ctx.beginPath() + ctx.moveTo(lineX, y) + ctx.lineTo(lineX, y + height) + ctx.stroke() + } + + ctx.lineCap = 'round' + for (let i = 1; i < finalDenominator; i += 1) { + const isOriginalCut = (i * originalDenominator) % finalDenominator === 0 + if (isOriginalCut) continue + const stagger = (i / finalDenominator) * 0.22 + const lineProgress = clamp((drawPhase - stagger) / 0.28, 0, 1) + if (lineProgress <= 0) continue + const lineX = x + (i / finalDenominator) * width + const pulse = lineProgress >= 1 + ? 0.5 + 0.5 * Math.sin(holdPhase * Math.PI * 2) + : 0.5 + 0.5 * Math.sin(lineProgress * Math.PI * 6) + const holdGlow = (lineProgress >= 1 ? 0.45 + pulse * 0.55 : pulse) * (1 - shrinkPhase) + const thickWidth = 3.6 + holdGlow * 3.2 + ctx.globalAlpha = Math.max(0.3, 0.9 * (1 - shrinkPhase)) + ctx.strokeStyle = colors.amber + ctx.lineWidth = thickWidth + ctx.shadowColor = colors.amber + ctx.shadowBlur = 12 * Math.max(holdGlow, 0.35) + ctx.beginPath() + ctx.moveTo(lineX, y) + ctx.lineTo(lineX, y + height * lineProgress) + ctx.stroke() + + ctx.globalAlpha = lineProgress * (0.22 + 0.68 * shrinkPhase) + ctx.strokeStyle = 'rgba(26, 26, 46, 0.45)' + ctx.lineWidth = 1.2 + 0.2 * shrinkPhase + ctx.shadowBlur = 0 + ctx.beginPath() + ctx.moveTo(lineX, y) + ctx.lineTo(lineX, y + height * lineProgress) + ctx.stroke() + } + ctx.globalAlpha = 1 + ctx.shadowBlur = 0 + ctx.restore() + + drawRoundRect(ctx, x, y, width, height, 10) + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.stroke() +} + +function drawGridOverlay(ctx, x, y, width, height, denominator, stroke = colors.ink) { + ctx.save() + ctx.beginPath() + drawRoundRect(ctx, x, y, width, height, 10) + ctx.clip() + ctx.strokeStyle = 'rgba(26, 26, 46, 0.24)' + ctx.lineWidth = 1 + for (let i = 1; i < denominator; i += 1) { + const lineX = x + (i / denominator) * width + ctx.beginPath() + ctx.moveTo(lineX, y) + ctx.lineTo(lineX, y + height) + ctx.stroke() + } + ctx.restore() + + drawRoundRect(ctx, x, y, width, height, 10) + ctx.strokeStyle = stroke + ctx.lineWidth = 2 + ctx.stroke() +} + +function drawPartitionedSegment(ctx, x, y, width, height, pieces, fill) { + if (pieces <= 0 || width <= 0) return + ctx.fillStyle = fill + ctx.fillRect(x, y, width, height) + + ctx.save() + ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)' + ctx.lineWidth = 1.4 + const pieceW = width / pieces + for (let i = 1; i < pieces; i += 1) { + const lineX = x + i * pieceW + ctx.beginPath() + ctx.moveTo(lineX, y + 2) + ctx.lineTo(lineX, y + height - 2) + ctx.stroke() + } + + ctx.strokeStyle = 'rgba(26, 26, 46, 0.32)' + ctx.lineWidth = 1.2 + ctx.strokeRect(x, y, width, height) + ctx.restore() +} + +function drawCanvas(ctx, width, data, step, progress) { + const { a, b, L, mA, mB, total } = data + ctx.clearRect(0, 0, width, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, canvasHeight) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const barX = 58 + const barW = width - 116 + const barH = 50 + + if (step === 1) { + drawBar(ctx, barX, 66, barW, barH, a.d, a.n, colors.ruby, colors.rubyLight) + drawBar(ctx, barX, 166, barW, barH, b.d, b.n, colors.teal, colors.tealLight) + ctx.font = '900 18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.fillStyle = colors.ruby + ctx.fillText(`${a.n}/${a.d}`, barX - 30, 91) + ctx.fillStyle = colors.teal + ctx.fillText(`${b.n}/${b.d}`, barX - 30, 191) + return + } + + if (step === 2) { + drawResliceBar(ctx, barX, 66, barW, barH, a.d, a.n, L, colors.ruby, colors.rubyLight, progress) + drawResliceBar(ctx, barX, 166, barW, barH, b.d, b.n, L, colors.teal, colors.tealLight, progress) + + ctx.save() + ctx.globalAlpha = 0.35 + easeInOut(progress) * 0.65 + ctx.fillStyle = colors.amber + ctx.font = '900 18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.fillText(`x${mA}`, width - 76, 38) + ctx.fillText(`x${mB}`, width - 76, 138) + ctx.fillStyle = colors.ruby + ctx.fillText(`${a.n}/${a.d} = ${a.n * mA}/${L}`, width / 2, 38) + ctx.fillStyle = colors.teal + ctx.fillText(`${b.n}/${b.d} = ${b.n * mB}/${L}`, width / 2, 138) + + ctx.fillStyle = colors.amberLight + drawRoundRect(ctx, 112, 244, width - 224, 28, 14) + ctx.fill() + ctx.fillStyle = colors.amber + ctx.font = '800 14px Inter, system-ui, sans-serif' + ctx.fillText(`Smallest common denominator: ${L}`, width / 2, 258) + ctx.restore() + return + } + + const rubyPieces = a.n * mA + const tealPieces = b.n * mB + const stepTwoA = 66 + const stepTwoB = 166 + const stepTwoH = 50 + const sourceA = 52 + const sourceB = 112 + const finalY = 194 + const moveH = 44 + const pieceW = barW / L + const prep = easeInOut(clamp(progress / 0.28, 0, 1)) + const glide = easeInOut(clamp((progress - 0.28) / 0.55, 0, 1)) + const finalReveal = easeInOut(clamp((progress - 0.83) / 0.17, 0, 1)) + + ctx.fillStyle = colors.purple + ctx.font = '900 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.fillText(`${rubyPieces}/${L} + ${tealPieces}/${L} = ${total}/${L}`, width / 2, 26) + + if (prep < 1) { + const yA = stepTwoA + (sourceA - stepTwoA) * prep + const yB = stepTwoB + (sourceB - stepTwoB) * prep + const h = stepTwoH + (moveH - stepTwoH) * prep + drawBar(ctx, barX, yA, barW, h, L, rubyPieces, colors.ruby, colors.rubyLight) + drawBar(ctx, barX, yB, barW, h, L, tealPieces, colors.teal, colors.tealLight) + return + } + + if (glide < 0.98) { + ctx.save() + ctx.globalAlpha = 1 - glide * 0.72 + drawBar(ctx, barX, sourceA, barW, moveH, L, rubyPieces, colors.ruby, colors.rubyLight) + drawBar(ctx, barX, sourceB, barW, moveH, L, tealPieces, colors.teal, colors.tealLight) + ctx.restore() + } + + ctx.save() + ctx.beginPath() + drawRoundRect(ctx, barX, 40, barW, finalY + moveH - 40, 10) + ctx.clip() + drawPartitionedSegment( + ctx, + barX, + sourceA + (finalY - sourceA) * glide, + rubyPieces * pieceW, + moveH, + rubyPieces, + colors.ruby, + ) + drawPartitionedSegment( + ctx, + barX + rubyPieces * pieceW * glide, + sourceB + (finalY - sourceB) * glide, + tealPieces * pieceW, + moveH, + tealPieces, + colors.teal, + ) + ctx.restore() + + if (finalReveal > 0) { + ctx.save() + ctx.globalAlpha = finalReveal + drawGridOverlay(ctx, barX, finalY, barW, moveH, L, colors.purple) + ctx.restore() + } + + ctx.fillStyle = colors.purple + ctx.font = '900 17px Inter, system-ui, sans-serif' + ctx.fillText(total === L ? '= 1 whole' : `= ${reduceFraction(total, L).n}/${reduceFraction(total, L).d}`, width / 2, 264) +} + +export default function AddingUnlikeFractions() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [a, setA] = useState({ n: 1, d: 2 }) + const [b, setB] = useState({ n: 1, d: 3 }) + const [step, setStep] = useState(1) + const [progress, setProgress] = useState(1) + + const data = useMemo(() => { + const L = lcm(a.d, b.d) + const mA = L / a.d + const mB = L / b.d + const total = a.n * mA + b.n * mB + const reduced = reduceFraction(total, L) + return { a, b, L, mA, mB, total, reduced } + }, [a, b]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + drawCanvas(ctx, canvasWidth, data, step, progress) + }, [canvasWidth, data, progress, step]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return undefined + const observer = new ResizeObserver(([entry]) => setCanvasWidth(Math.max(340, Math.floor(entry.contentRect.width)))) + observer.observe(wrap) + return () => observer.disconnect() + }, []) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const resetToStepOne = () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + setStep(1) + setProgress(1) + } + + const changeFraction = (which, field, delta) => { + const nextA = which === 'a' ? { ...a, [field]: a[field] + delta } : a + const nextB = which === 'b' ? { ...b, [field]: b[field] + delta } : b + const safe = makeSafePair(nextA, nextB, which) + setA(safe.a) + setB(safe.b) + resetToStepOne() + } + + const goStep = (nextStep) => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + setStep(nextStep) + if (nextStep === 1) { + setProgress(1) + return + } + setProgress(0) + const duration = nextStep === 2 ? 2600 : nextStep === 3 ? 1920 : 1200 + let start = null + const tick = (now) => { + if (start === null) start = now + const nextProgress = Math.min(1, (now - start) / duration) + setProgress(nextProgress) + if (nextProgress < 1) frameRef.current = requestAnimationFrame(tick) + } + frameRef.current = requestAnimationFrame(tick) + } + + const resultText = step < 3 ? '?/?' : data.total === data.L ? '1' : `${data.reduced.n}/${data.reduced.d}` + const hint = + step === 1 + ? 'First compare the two bars: the pieces are not the same size yet.' + : step === 2 + ? `Re-slice into ${data.L} equal pieces: multiply top and bottom by the same number.` + : data.total === data.L + ? `Now the pieces match: ${data.total}/${data.L} equals 1 whole.` + : `Count the matching pieces, then simplify if possible: ${data.total}/${data.L} becomes ${data.reduced.n}/${data.reduced.d}.` + + return ( + + + changeFraction('a', field, delta)} /> + + + changeFraction('b', field, delta)} /> + = + + {resultText} + + + + + {[1, 2, 3].map((item) => ( + goStep(item)} + className="rounded-full border px-3 py-2 text-sm font-black transition" + style={{ + borderColor: step === item ? colors.purple : colors.border, + background: step === item ? colors.purple : '#ffffff', + color: step === item ? '#ffffff' : colors.ink, + }} + > + Step {item}: {item === 1 ? 'Different pieces' : item === 2 ? 'Re-slice' : 'Combine'} + + ))} + + + + + + + + {hint} + + + ) +} + +function FractionControl({ label, fraction, color, onChange }) { + return ( + + + {label} + + + onChange('n', -1)}>- + + {fraction.n} + + onChange('n', 1)}>+ + + + + onChange('d', -1)}>- + + {fraction.d} + + onChange('d', 1)}>+ + + + ) +} + +function StepButton({ color, onClick, children }) { + return ( + + {children} + + ) +} diff --git a/src/manipulatives/angle-relationships.jsx b/src/manipulatives/angle-relationships.jsx new file mode 100644 index 0000000..0649ec4 --- /dev/null +++ b/src/manipulatives/angle-relationships.jsx @@ -0,0 +1,767 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + parallel: '#1E2D5A', + transversal: '#6B7280', + grid: '#E8E5DE', + label: '#9CA3AF', + angleText: '#5F6B7A', + faintArc: '#4B5563', + corresponding: '#7C3AED', + alternateInterior: '#D97706', + alternateExterior: '#059669', + coInterior: '#DC2626', +} + +const angleTypes = [ + { + id: 'corresponding', + label: 'Corresponding', + sublabel: 'Equal - F shape', + color: colors.corresponding, + hint: 'Same corner at both crossings. They are equal.', + }, + { + id: 'alternateInterior', + label: 'Alternate interior', + sublabel: 'Equal - Z shape', + color: colors.alternateInterior, + hint: 'Inside the lines, opposite sides. They are equal.', + }, + { + id: 'alternateExterior', + label: 'Alternate exterior', + sublabel: 'Equal - Z shape', + color: colors.alternateExterior, + hint: 'Outside the lines, opposite sides. They are equal.', + }, + { + id: 'coInterior', + label: 'Co-interior', + sublabel: 'Sum to 180° - C shape', + color: colors.coInterior, + hint: 'Inside the lines, same side. They add to 180°.', + }, +] + +const wedgePairs = { + corresponding: [['upper', 'topLeft'], ['lower', 'topLeft']], + alternateInterior: [['upper', 'bottomRight'], ['lower', 'topLeft']], + alternateExterior: [['upper', 'topLeft'], ['lower', 'bottomRight']], + coInterior: [['upper', 'bottomRight'], ['lower', 'topRight']], +} + +const allWedgePairs = { + corresponding: [ + [['upper', 'topLeft'], ['lower', 'topLeft']], + [['upper', 'topRight'], ['lower', 'topRight']], + [['upper', 'bottomLeft'], ['lower', 'bottomLeft']], + [['upper', 'bottomRight'], ['lower', 'bottomRight']], + ], + alternateInterior: [ + [['upper', 'bottomRight'], ['lower', 'topLeft']], + [['upper', 'bottomLeft'], ['lower', 'topRight']], + ], + alternateExterior: [ + [['upper', 'topLeft'], ['lower', 'bottomRight']], + [['upper', 'topRight'], ['lower', 'bottomLeft']], + ], + coInterior: [ + [['upper', 'bottomRight'], ['lower', 'topRight']], + [['upper', 'bottomLeft'], ['lower', 'topLeft']], + ], +} + +const secondPairColors = { + corresponding: '#5B21B6', + alternateInterior: '#92400E', + alternateExterior: '#065F46', + coInterior: '#7F1D1D', +} + +const pairShadeColors = { + corresponding: ['#7C3AED', '#5B21B6', '#8B5CF6', '#4C1D95'], + alternateInterior: ['#D97706', '#92400E'], + alternateExterior: ['#059669', '#065F46'], + coInterior: ['#DC2626', '#7F1D1D'], +} + +const getPairTotal = (typeId) => allWedgePairs[typeId].length + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function normalizeAngle(angle) { + let next = angle + while (next < 0) next += Math.PI * 2 + while (next >= Math.PI * 2) next -= Math.PI * 2 + return next +} + +function shortestSweep(start, end) { + const from = normalizeAngle(start) + let to = normalizeAngle(end) + let sweep = to - from + if (sweep <= 0) sweep += Math.PI * 2 + if (sweep > Math.PI) { + to = from - (Math.PI * 2 - sweep) + } + return { start: from, end: to } +} + +function pointToSegmentDistance(point, start, end) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSq = dx * dx + dy * dy + if (lengthSq === 0) return Math.hypot(point.x - start.x, point.y - start.y) + const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq, 0, 1) + const x = start.x + t * dx + const y = start.y + t * dy + return Math.hypot(point.x - x, point.y - y) +} + +function getIntersectionAtY(top, bottom, y) { + const dy = bottom.y - top.y || 1 + const t = (y - top.y) / dy + return { + x: top.x + (bottom.x - top.x) * t, + y, + } +} + +function drawSector(ctx, center, firstAngle, secondAngle, color, radius, fillAlpha = '33', strokeAlpha = 'ff') { + const { start, end } = shortestSweep(firstAngle, secondAngle) + ctx.save() + ctx.beginPath() + ctx.moveTo(center.x, center.y) + ctx.arc(center.x, center.y, radius, start, end, false) + ctx.closePath() + ctx.fillStyle = `${color}${fillAlpha}` + ctx.strokeStyle = `${color}${strokeAlpha}` + ctx.lineWidth = 2 + ctx.fill() + ctx.stroke() + ctx.restore() +} + +function drawGuideArc(ctx, center, firstAngle, secondAngle) { + const { start, end } = shortestSweep(firstAngle, secondAngle) + const outerRadius = 29 + const innerRadius = 27 + + ctx.save() + ctx.beginPath() + ctx.arc(center.x, center.y, outerRadius, start, end, false) + ctx.arc(center.x, center.y, innerRadius, end, start, true) + ctx.closePath() + ctx.fillStyle = `${colors.faintArc}18` + ctx.strokeStyle = `${colors.faintArc}88` + ctx.lineWidth = 0.8 + ctx.fill() + ctx.stroke() + ctx.restore() +} + +function drawHandle(ctx, x, y, fillColor) { + ctx.save() + ctx.fillStyle = fillColor + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(x, y, 8, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.restore() +} + +function labelAngle(ctx, center, angle, value, color) { + const distance = 46 + ctx.fillStyle = color + ctx.font = '700 15px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${value}°`, center.x + Math.cos(angle) * distance, center.y + Math.sin(angle) * distance) +} + +export default function AngleRelationships() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const pulseFrameRef = useRef(null) + const countTimersRef = useRef({}) + const pairDemoTimersRef = useRef([]) + const latestPointerRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(580) + const [lineFractions, setLineFractions] = useState({ p: 0.34, q: 0.66 }) + const [transversalFractions, setTransversalFractions] = useState({ + top: { x: 0.26, y: 0.12 }, + bottom: { x: 0.74, y: 0.88 }, + }) + const [activeTypes, setActiveTypes] = useState({ + corresponding: false, + alternateInterior: false, + alternateExterior: false, + coInterior: false, + }) + const [dragTarget, setDragTarget] = useState(null) + const [hoverTarget, setHoverTarget] = useState(null) + const [pairCounts, setPairCounts] = useState({ + corresponding: 0, + alternateInterior: 0, + alternateExterior: 0, + coInterior: 0, + }) + const [expandedPairType, setExpandedPairType] = useState(null) + const [pairDemo, setPairDemo] = useState(null) + const [pulseState, setPulseState] = useState(null) + const [pulseNow, setPulseNow] = useState(0) + + const canvasHeight = 400 + + const getGeometry = useCallback(() => { + const width = canvasWidth + const height = canvasHeight + const edgePad = width * 0.05 + const lineLeft = edgePad + const lineRight = width - edgePad + const topY = clamp(lineFractions.p * height, height * 0.16, height * 0.72) + const bottomY = clamp(lineFractions.q * height, topY + 60, height * 0.84) + const topHandle = { + x: clamp(transversalFractions.top.x * width, edgePad, width - edgePad), + y: clamp(transversalFractions.top.y * height, height * 0.06, topY - 18), + } + const bottomHandle = { + x: clamp(transversalFractions.bottom.x * width, edgePad, width - edgePad), + y: clamp(transversalFractions.bottom.y * height, bottomY + 18, height * 0.94), + } + const upperIntersection = getIntersectionAtY(topHandle, bottomHandle, topY) + const lowerIntersection = getIntersectionAtY(topHandle, bottomHandle, bottomY) + const theta = Math.round(Math.atan2(Math.abs(bottomHandle.y - topHandle.y), Math.abs(bottomHandle.x - topHandle.x)) * 180 / Math.PI) + const acute = clamp(theta, 1, 90) + const obtuse = 180 - acute + + return { + width, + height, + edgePad, + lineLeft, + lineRight, + topY, + bottomY, + topHandle, + bottomHandle, + upperIntersection, + lowerIntersection, + acute, + obtuse, + } + }, [canvasWidth, lineFractions, transversalFractions]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!ctx) return + + const geo = getGeometry() + const { + width, + height, + edgePad, + lineLeft, + lineRight, + topY, + bottomY, + topHandle, + bottomHandle, + upperIntersection, + lowerIntersection, + acute, + obtuse, + } = geo + + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = colors.page + ctx.fillRect(0, 0, width, height) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + const gridStep = 40 + for (let y = gridStep; y < height; y += gridStep) { + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(width, y) + ctx.stroke() + } + + ctx.strokeStyle = colors.parallel + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(lineLeft, topY) + ctx.lineTo(lineRight, topY) + ctx.moveTo(lineLeft, bottomY) + ctx.lineTo(lineRight, bottomY) + ctx.stroke() + + ctx.fillStyle = colors.label + ctx.font = 'italic 700 17px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'bottom' + ctx.fillText('p', edgePad * 0.35, topY - 8) + ctx.fillText('q', edgePad * 0.35, bottomY - 8) + + ctx.strokeStyle = colors.transversal + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(topHandle.x, topHandle.y) + ctx.lineTo(bottomHandle.x, bottomHandle.y) + ctx.stroke() + + const transAngle = Math.atan2(bottomHandle.y - topHandle.y, bottomHandle.x - topHandle.x) + ctx.save() + ctx.fillStyle = colors.label + ctx.font = 'italic 700 17px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + const tOffsetX = topHandle.x > width * 0.82 ? -18 : 12 + ctx.fillText('t', topHandle.x + tOffsetX, topHandle.y) + ctx.restore() + + const upRay = normalizeAngle(transAngle + Math.PI) + const downRay = normalizeAngle(transAngle) + const leftRay = Math.PI + const rightRay = 0 + const wedgeAngles = { + topLeft: [leftRay, upRay], + topRight: [upRay, rightRay], + bottomLeft: [downRay, leftRay], + bottomRight: [rightRay, downRay], + } + + const centers = { upper: upperIntersection, lower: lowerIntersection } + const hasActiveType = angleTypes.some((type) => activeTypes[type.id]) + const activePulse = pulseState && pulseNow - pulseState.startTime <= 400 + + if (!hasActiveType) { + Object.values(centers).forEach((center) => { + Object.values(wedgeAngles).forEach(([firstAngle, secondAngle]) => { + drawGuideArc(ctx, center, firstAngle, secondAngle) + }) + }) + } else { + const activeWedges = {} + angleTypes.forEach((type) => { + if (!activeTypes[type.id]) return + if (expandedPairType === type.id) { + const pairs = allWedgePairs[type.id] + const visiblePairs = pairDemo?.type === type.id + ? (pairDemo.pairIndex === null ? [] : [[pairDemo.pairIndex, pairs[pairDemo.pairIndex]]]) + : pairs.map((pair, pairIndex) => [pairIndex, pair]) + + visiblePairs.forEach(([pairIndex, pair]) => { + pair.forEach(([intersectionId, wedgeId]) => { + const key = `${intersectionId}:${wedgeId}` + const pairColor = pairShadeColors[type.id][pairIndex] ?? secondPairColors[type.id] + activeWedges[key] = [...(activeWedges[key] ?? []), { ...type, color: pairColor }] + }) + }) + return + } + + wedgePairs[type.id].forEach(([intersectionId, wedgeId]) => { + const key = `${intersectionId}:${wedgeId}` + activeWedges[key] = [...(activeWedges[key] ?? []), type] + }) + }) + + Object.entries(activeWedges).forEach(([key, types]) => { + const [intersectionId, wedgeId] = key.split(':') + const [firstAngle, secondAngle] = wedgeAngles[wedgeId] + const baseRadius = 28 + const radiusGap = 8 + types.forEach((type, index) => { + const isPulseType = activePulse && pulseState.type === type.id + const progress = isPulseType ? clamp((pulseNow - pulseState.startTime) / 400, 0, 1) : 0 + const pulseRadius = isPulseType ? 12 * Math.sin(progress * Math.PI) : 0 + const radius = baseRadius + index * radiusGap + pulseRadius + const fillAlpha = isPulseType ? '66' : '33' + drawSector(ctx, centers[intersectionId], firstAngle, secondAngle, type.color, radius, fillAlpha) + }) + }) + } + + const getLabelColor = (intersectionId, wedgeId) => { + const match = angleTypes.find((type) => ( + activeTypes[type.id] + && ( + expandedPairType === type.id + ? ( + pairDemo?.type === type.id + ? (pairDemo.pairIndex === null ? [] : allWedgePairs[type.id][pairDemo.pairIndex]) + : allWedgePairs[type.id].flat() + ) + : wedgePairs[type.id] + ) + .some(([pairIntersection, pairWedge]) => pairIntersection === intersectionId && pairWedge === wedgeId) + )) + if (!match) return colors.angleText + + if (expandedPairType === match.id) { + const pairIndex = allWedgePairs[match.id].findIndex((pair) => ( + pair.some(([pairIntersection, pairWedge]) => pairIntersection === intersectionId && pairWedge === wedgeId) + )) + return pairShadeColors[match.id][pairIndex] ?? match.color + } + + return match.color + } + + ;[ + ['upper', upperIntersection], + ['lower', lowerIntersection], + ].forEach(([intersectionId, center]) => { + labelAngle(ctx, center, normalizeAngle((leftRay + upRay) / 2), acute, getLabelColor(intersectionId, 'topLeft')) + labelAngle(ctx, center, normalizeAngle((upRay + rightRay + Math.PI * 2) / 2), obtuse, getLabelColor(intersectionId, 'topRight')) + labelAngle(ctx, center, normalizeAngle((downRay + leftRay) / 2), obtuse, getLabelColor(intersectionId, 'bottomLeft')) + labelAngle(ctx, center, normalizeAngle((rightRay + downRay) / 2), acute, getLabelColor(intersectionId, 'bottomRight')) + + ctx.fillStyle = colors.corresponding + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.arc(center.x, center.y, 5, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }) + + drawHandle(ctx, topHandle.x, topHandle.y, colors.transversal) + drawHandle(ctx, bottomHandle.x, bottomHandle.y, colors.transversal) + drawHandle(ctx, edgePad, topY, colors.parallel) + drawHandle(ctx, edgePad, bottomY, colors.parallel) + + }, [activeTypes, expandedPairType, getGeometry, pairDemo, pulseNow, pulseState]) + + useEffect(() => { + const wrapper = wrapRef.current + if (!wrapper) return + + const update = () => { + setCanvasWidth(Math.max(280, Math.round(wrapper.clientWidth))) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(wrapper) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + const countTimers = countTimersRef.current + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + if (pulseFrameRef.current) cancelAnimationFrame(pulseFrameRef.current) + Object.values(countTimers).flat().forEach((timerId) => clearTimeout(timerId)) + pairDemoTimersRef.current.forEach((timerId) => clearTimeout(timerId)) + } + }, []) + + useEffect(() => { + if (!pulseState) return undefined + + const animatePulse = (now) => { + setPulseNow(now) + if (now - pulseState.startTime < 400) { + pulseFrameRef.current = requestAnimationFrame(animatePulse) + return + } + pulseFrameRef.current = null + setPulseState(null) + } + + pulseFrameRef.current = requestAnimationFrame(animatePulse) + return () => { + if (pulseFrameRef.current) cancelAnimationFrame(pulseFrameRef.current) + pulseFrameRef.current = null + } + }, [pulseState]) + + const getCanvasPoint = useCallback((event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasWidth / rect.width), + y: (event.clientY - rect.top) * (canvasHeight / rect.height), + } + }, [canvasWidth]) + + const getHitTarget = useCallback((point) => { + const geo = getGeometry() + const handles = [ + { id: 'topTransversal', x: geo.topHandle.x, y: geo.topHandle.y }, + { id: 'bottomTransversal', x: geo.bottomHandle.x, y: geo.bottomHandle.y }, + { id: 'lineP', x: geo.edgePad, y: geo.topY }, + { id: 'lineQ', x: geo.edgePad, y: geo.bottomY }, + ] + const handle = handles.find((item) => Math.hypot(item.x - point.x, item.y - point.y) <= 16) + if (handle) return handle.id + + if (pointToSegmentDistance(point, geo.topHandle, geo.bottomHandle) <= 10) { + return point.y < (geo.topY + geo.bottomY) / 2 ? 'topTransversal' : 'bottomTransversal' + } + + return null + }, [getGeometry]) + + const scheduleDrag = useCallback((target, point) => { + latestPointerRef.current = { target, point } + if (frameRef.current) return + + frameRef.current = requestAnimationFrame(() => { + const latest = latestPointerRef.current + frameRef.current = null + if (!latest) return + + const geo = getGeometry() + if (latest.target === 'lineP') { + const nextY = clamp(latest.point.y, canvasHeight * 0.16, geo.bottomY - 60) + setLineFractions((current) => ({ ...current, p: nextY / canvasHeight })) + } + if (latest.target === 'lineQ') { + const nextY = clamp(latest.point.y, geo.topY + 60, canvasHeight * 0.84) + setLineFractions((current) => ({ ...current, q: nextY / canvasHeight })) + } + if (latest.target === 'topTransversal') { + const nextX = clamp(latest.point.x, geo.edgePad, canvasWidth - geo.edgePad) + const nextY = clamp(latest.point.y, canvasHeight * 0.06, geo.topY - 18) + setTransversalFractions((current) => ({ ...current, top: { x: nextX / canvasWidth, y: nextY / canvasHeight } })) + } + if (latest.target === 'bottomTransversal') { + const nextX = clamp(latest.point.x, geo.edgePad, canvasWidth - geo.edgePad) + const nextY = clamp(latest.point.y, geo.bottomY + 18, canvasHeight * 0.94) + setTransversalFractions((current) => ({ ...current, bottom: { x: nextX / canvasWidth, y: nextY / canvasHeight } })) + } + }) + }, [canvasWidth, getGeometry]) + + const handlePointerDown = (event) => { + const point = getCanvasPoint(event) + const target = getHitTarget(point) + if (!target) return + event.currentTarget.setPointerCapture(event.pointerId) + setDragTarget(target) + scheduleDrag(target, point) + } + + const handlePointerMove = (event) => { + const point = getCanvasPoint(event) + if (dragTarget) { + scheduleDrag(dragTarget, point) + return + } + setHoverTarget(getHitTarget(point)) + } + + const stopDragging = () => { + setDragTarget(null) + latestPointerRef.current = null + } + + const toggleAngleType = (typeId) => { + setActiveTypes((current) => { + const nextChecked = !current[typeId] + const existingTimers = countTimersRef.current[typeId] ?? [] + existingTimers.forEach((timerId) => clearTimeout(timerId)) + + if (nextChecked) { + const now = performance.now() + const pairTotal = getPairTotal(typeId) + setPairCounts((counts) => ({ ...counts, [typeId]: 0 })) + setPulseNow(now) + setPulseState({ type: typeId, startTime: now }) + countTimersRef.current[typeId] = Array.from({ length: pairTotal }, (_, index) => ( + setTimeout(() => { + setPairCounts((counts) => ({ ...counts, [typeId]: index + 1 })) + }, 80 * (index + 1)) + )) + } else { + setExpandedPairType((current) => current === typeId ? null : current) + setPairDemo((current) => current?.type === typeId ? null : current) + countTimersRef.current[typeId] = [ + setTimeout(() => { + setPairCounts((counts) => ({ ...counts, [typeId]: 0 })) + countTimersRef.current[typeId] = [] + }, 150), + ] + } + + return { ...current, [typeId]: nextChecked } + }) + } + + const showAllPairs = (event, typeId) => { + event.stopPropagation() + pairDemoTimersRef.current.forEach((timerId) => clearTimeout(timerId)) + pairDemoTimersRef.current = [] + + requestAnimationFrame(() => { + const now = performance.now() + setPulseNow(now) + setPulseState({ type: typeId, startTime: now }) + }) + setExpandedPairType(typeId) + setPairDemo({ type: typeId, pairIndex: 0 }) + const timers = [] + let delay = 700 + for (let pairIndex = 1; pairIndex < getPairTotal(typeId); pairIndex += 1) { + timers.push(setTimeout(() => { + setPairDemo({ type: typeId, pairIndex: null }) + }, delay)) + delay += 220 + timers.push(setTimeout(() => { + setPairDemo({ type: typeId, pairIndex }) + requestAnimationFrame(() => { + const now = performance.now() + setPulseNow(now) + setPulseState({ type: typeId, startTime: now }) + }) + }, delay)) + delay += 700 + } + pairDemoTimersRef.current = timers + if (!activeTypes[typeId]) toggleAngleType(typeId) + } + + const clearAll = () => { + pairDemoTimersRef.current.forEach((timerId) => clearTimeout(timerId)) + pairDemoTimersRef.current = [] + Object.values(countTimersRef.current).flat().forEach((timerId) => clearTimeout(timerId)) + countTimersRef.current = {} + setActiveTypes({ + corresponding: false, + alternateInterior: false, + alternateExterior: false, + coInterior: false, + }) + setPairCounts({ + corresponding: 0, + alternateInterior: 0, + alternateExterior: 0, + coInterior: 0, + }) + setExpandedPairType(null) + setPairDemo(null) + setPulseState(null) + } + + const checkedType = angleTypes.find((type) => activeTypes[type.id]) + const instructionTitle = checkedType ? "What's the rule?" : 'Try this' + const instructionText = checkedType + ? `${checkedType.hint} Press the pair button to see the pairs.` + : 'Drag the lines, then click an angle row. Press the pair button to see the pairs.' + + return ( + + + + + + { + if (!dragTarget) setHoverTarget(null) + }} + /> + + + + + + + + {instructionTitle}: + {instructionText} + + + + + ) +} diff --git a/src/manipulatives/box-plot-builder.jsx b/src/manipulatives/box-plot-builder.jsx new file mode 100644 index 0000000..7585ff8 --- /dev/null +++ b/src/manipulatives/box-plot-builder.jsx @@ -0,0 +1,470 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + whisker: '#185FA5', + box: '#534AB7', + median: '#D85A30', + iqr: '#0F6E56', + outlier: '#DC2626', + dot: '#AFA9EC', + border: '#E0DDD6', +} + +const canvasHeight = 230 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function makePoint(value) { + return { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + value, + } +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return '-' + const rounded = Math.abs(value - Math.round(value)) < 0.05 ? Math.round(value) : Number(value.toFixed(1)) + return Object.is(rounded, -0) ? '0' : String(rounded) +} + +function quantile(sorted, p) { + if (!sorted.length) return 0 + const index = (sorted.length - 1) * p + const lower = Math.floor(index) + const upper = Math.ceil(index) + if (lower === upper) return sorted[lower] + const weight = index - lower + return sorted[lower] * (1 - weight) + sorted[upper] * weight +} + +function getStats(points) { + const values = points.map((point) => point.value).sort((a, b) => a - b) + if (!values.length) return null + + const min = values[0] + const max = values[values.length - 1] + const q1 = quantile(values, 0.25) + const median = quantile(values, 0.5) + const q3 = quantile(values, 0.75) + const iqr = q3 - q1 + const lowFence = q1 - 1.5 * iqr + const highFence = q3 + 1.5 * iqr + const outlierValues = values.filter((value) => value < lowFence || value > highFence) + const nonOutliers = values.filter((value) => value >= lowFence && value <= highFence) + const whiskerMin = nonOutliers[0] ?? min + const whiskerMax = nonOutliers[nonOutliers.length - 1] ?? max + + return { + values, + min, + max, + q1, + median, + q3, + iqr, + lowFence, + highFence, + outlierValues, + whiskerMin, + whiskerMax, + } +} + +function getScale(values, width) { + const min = Math.min(...values) + const max = Math.max(...values) + const span = Math.max(1, max - min) + const padValue = Math.max(1, span * 0.1) + const domainMin = Math.floor(min - padValue) + const domainMax = Math.ceil(max + padValue) + const left = 42 + const right = width - 34 + const toX = (value) => left + ((value - domainMin) / (domainMax - domainMin || 1)) * (right - left) + const toValue = (x) => domainMin + ((x - left) / (right - left || 1)) * (domainMax - domainMin) + return { domainMin, domainMax, left, right, toX, toValue } +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function getDotPositions(points, stats, scale) { + const stackCounts = new Map() + const dotY = 166 + return points + .map((point, index) => { + const rounded = Math.round(point.value) + const stack = stackCounts.get(rounded) ?? 0 + stackCounts.set(rounded, stack + 1) + const isOutlier = stats ? point.value < stats.lowFence || point.value > stats.highFence : false + return { + ...point, + index, + isOutlier, + x: scale.toX(point.value), + y: dotY + stack * 14, + } + }) +} + +function tickValues(min, max) { + const count = 6 + const span = max - min || 1 + const rawStep = span / (count - 1) + const power = 10 ** Math.floor(Math.log10(rawStep)) + const normalized = rawStep / power + const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10 + const step = nice * power + const start = Math.ceil(min / step) * step + const ticks = [] + for (let value = start; value <= max + step * 0.1; value += step) { + ticks.push(Number(value.toFixed(1))) + if (ticks.length > 7) break + } + return ticks.length ? ticks : [min, max] +} + +export default function BoxPlotBuilder() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const dotHitsRef = useRef([]) + const scaleRef = useRef(null) + const dragRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [points, setPoints] = useState([52, 55, 57, 60, 62, 64, 68, 72, 84].map(makePoint)) + const [inputValue, setInputValue] = useState('66') + const [showDots, setShowDots] = useState(true) + const [showQuarters, setShowQuarters] = useState(true) + const [dragIndex, setDragIndex] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + + const stats = useMemo(() => getStats(points), [points]) + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + if (!points.length) { + ctx.fillStyle = '#5F5E5A' + ctx.font = '700 18px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.fillText('Add at least 4 values to build a box plot.', canvasWidth / 2, canvasHeight / 2) + dotHitsRef.current = [] + scaleRef.current = null + return + } + + const scale = getScale(stats.values, canvasWidth) + scaleRef.current = scale + const axisY = 202 + + ctx.strokeStyle = '#CBD5E1' + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(scale.left, axisY) + ctx.lineTo(scale.right, axisY) + ctx.stroke() + + ctx.fillStyle = '#5F5E5A' + ctx.font = '700 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + tickValues(scale.domainMin, scale.domainMax).forEach((tick) => { + const x = scale.toX(tick) + ctx.strokeStyle = '#E5E7EB' + ctx.beginPath() + ctx.moveTo(x, 30) + ctx.lineTo(x, axisY + 5) + ctx.stroke() + ctx.strokeStyle = '#94A3B8' + ctx.beginPath() + ctx.moveTo(x, axisY - 6) + ctx.lineTo(x, axisY + 6) + ctx.stroke() + ctx.fillText(formatNumber(tick), x, axisY + 22) + }) + + const dotPositions = getDotPositions(points, stats, scale) + + if (points.length < 4) { + ctx.fillStyle = '#5F5E5A' + ctx.font = '800 16px Inter, system-ui, sans-serif' + ctx.fillText('Add at least 4 values to draw the box and whiskers.', canvasWidth / 2, 78) + } else { + const boxY = 74 + const boxH = 42 + const midY = boxY + boxH / 2 + const xMin = scale.toX(stats.whiskerMin) + const xQ1 = scale.toX(stats.q1) + const xMed = scale.toX(stats.median) + const xQ3 = scale.toX(stats.q3) + const xMax = scale.toX(stats.whiskerMax) + + ctx.strokeStyle = colors.whisker + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(xMin, midY) + ctx.lineTo(xQ1, midY) + ctx.moveTo(xQ3, midY) + ctx.lineTo(xMax, midY) + ctx.moveTo(xMin, boxY + 5) + ctx.lineTo(xMin, boxY + boxH - 5) + ctx.moveTo(xMax, boxY + 5) + ctx.lineTo(xMax, boxY + boxH - 5) + ctx.stroke() + + ctx.fillStyle = 'rgba(83, 74, 183, 0.13)' + ctx.strokeStyle = colors.box + ctx.lineWidth = 2.5 + drawRoundRect(ctx, xQ1, boxY, Math.max(2, xQ3 - xQ1), boxH, 8) + ctx.fill() + ctx.stroke() + + ctx.strokeStyle = colors.median + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(xMed, boxY - 4) + ctx.lineTo(xMed, boxY + boxH + 4) + ctx.stroke() + + if (showQuarters) { + const regions = [ + [xMin, xQ1], + [xQ1, xMed], + [xMed, xQ3], + [xQ3, xMax], + ] + ctx.font = '900 12px Inter, system-ui, sans-serif' + ctx.fillStyle = colors.iqr + regions.forEach(([from, to]) => { + if (Math.abs(to - from) > 24) ctx.fillText('25%', (from + to) / 2, boxY + boxH + 20) + }) + } + + const marks = [ + ['Min', stats.whiskerMin, colors.whisker], + ['Q1', stats.q1, colors.box], + ['Median', stats.median, colors.median], + ['Q3', stats.q3, colors.box], + ['Max', stats.whiskerMax, colors.whisker], + ] + ctx.font = '900 11px Inter, system-ui, sans-serif' + marks.forEach(([label, value, color]) => { + const x = scale.toX(value) + ctx.strokeStyle = color + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(x, boxY - 3) + ctx.lineTo(x, boxY - 14) + ctx.stroke() + ctx.fillStyle = color + ctx.fillText(`${label} ${formatNumber(value)}`, x, boxY - 20) + }) + } + + dotHitsRef.current = [] + if (showDots) { + dotPositions.forEach((dot) => { + const isActiveDot = dragIndex === dot.index || hoverIndex === dot.index + const radius = isActiveDot ? 8.5 : 7.5 + ctx.save() + ctx.shadowColor = dot.isOutlier ? 'rgba(220, 38, 38, 0.3)' : 'rgba(83, 74, 183, 0.22)' + ctx.shadowBlur = dot.isOutlier ? 10 : 5 + ctx.fillStyle = dot.isOutlier ? colors.outlier : colors.dot + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(dot.x, dot.y, radius, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.restore() + dotHitsRef.current.push({ index: dot.index, value: dot.value, isOutlier: dot.isOutlier, x: dot.x, y: dot.y, radius: radius + 7 }) + }) + + const activeDot = dotHitsRef.current.find((dot) => dot.index === (dragIndex ?? hoverIndex)) + if (activeDot) { + const label = formatNumber(activeDot.value) + ctx.save() + ctx.font = '900 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + const width = Math.max(34, ctx.measureText(label).width + 18) + const x = clamp(activeDot.x - width / 2, 8, canvasWidth - width - 8) + const y = Math.max(8, activeDot.y - 38) + ctx.fillStyle = activeDot.isOutlier ? colors.outlier : colors.ink + ctx.shadowColor = 'rgba(15, 23, 42, 0.18)' + ctx.shadowBlur = 10 + drawRoundRect(ctx, x, y, width, 25, 12) + ctx.fill() + ctx.shadowBlur = 0 + ctx.fillStyle = '#ffffff' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, x + width / 2, y + 13) + ctx.restore() + } + } + }, [canvasWidth, dragIndex, hoverIndex, points, showDots, showQuarters, stats]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + dragRef.current = null + }, []) + + const addValue = () => { + const value = Number(inputValue) + if (!Number.isFinite(value)) return + setPoints((current) => [...current, makePoint(Math.round(value))]) + setInputValue('') + } + + const removePoint = (id) => { + setPoints((current) => current.filter((point) => point.id !== id)) + } + + const handlePointerDown = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = ((event.clientX - rect.left) / rect.width) * canvasWidth + const y = ((event.clientY - rect.top) / rect.height) * canvasHeight + const hit = dotHitsRef.current.find((dot) => Math.hypot(dot.x - x, dot.y - y) <= dot.radius) + if (!hit) return + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { pointerId: event.pointerId, index: hit.index } + setHoverIndex(hit.index) + setDragIndex(hit.index) + } + + const handlePointerMove = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = ((event.clientX - rect.left) / rect.width) * canvasWidth + const y = ((event.clientY - rect.top) / rect.height) * canvasHeight + const hit = dotHitsRef.current.find((dot) => Math.hypot(dot.x - x, dot.y - y) <= dot.radius) + setHoverIndex(hit?.index ?? null) + if (!dragRef.current || !scaleRef.current) return + const nextValue = Math.round(scaleRef.current.toValue(x)) + const clamped = clamp(nextValue, Math.floor(scaleRef.current.domainMin), Math.ceil(scaleRef.current.domainMax)) + setPoints((current) => current.map((point, index) => ( + index === dragRef.current.index ? { ...point, value: clamped } : point + ))) + } + + const endDrag = (event) => { + if (dragRef.current && event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + dragRef.current = null + setDragIndex(null) + } + + const clearHover = () => { + if (!dragRef.current) setHoverIndex(null) + } + + const summaryCards = stats ? [ + ['Min', stats.whiskerMin, colors.whisker], + ['Q1', stats.q1, colors.box], + ['Median', stats.median, colors.median], + ['Q3', stats.q3, colors.box], + ['Max', stats.whiskerMax, colors.whisker], + ['IQR', stats.iqr, colors.iqr], + ] : [] + + return ( + + + + {points.map((point) => ( + removePoint(point.id)} + className="rounded-full border border-[#D6D3E8] bg-[#F4F2FF] px-2 py-1 text-xs font-black text-[#534AB7]" + > + {formatNumber(point.value)} + + ))} + + + setInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') addValue() + }} + className="h-8 w-[70px] rounded-lg border-[1.5px] border-[#0F6E56] bg-white px-2 text-center text-sm font-black text-[#0F6E56] outline-none focus:ring-2 focus:ring-[#0F6E5633]" + /> + Add value + setPoints([])} className="h-8 rounded-lg border border-[#DC2626] px-3 text-xs font-black text-[#DC2626]">Clear + + + + + + + + + {summaryCards.map(([label, value, color]) => ( + + {label} + {formatNumber(value)} + + ))} + + + + + + setShowDots(event.target.checked)} /> + Show data dots + + + setShowQuarters(event.target.checked)} /> + Show quarter labels + + + + + ) +} diff --git a/src/manipulatives/comparing-two-populations.jsx b/src/manipulatives/comparing-two-populations.jsx new file mode 100644 index 0000000..c0ea017 --- /dev/null +++ b/src/manipulatives/comparing-two-populations.jsx @@ -0,0 +1,446 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + a: '#2660C4', + aTint: '#EAF0FB', + aBorder: '#8AA8DD', + b: '#B25A1E', + bTint: '#FBEEDD', + bBorder: '#E0B579', + purple: '#7B3F9E', + purpleTint: '#F3EEFA', + purpleBorder: '#C99BE0', + different: '#B23050', + differentTint: '#FBE9ED', + differentBorder: '#E8A0B0', + overlap: '#1E7A5E', + overlapTint: '#EAF3DE', + overlapBorder: '#97C459', + correct: '#27500A', + wrong: '#B23050', + choiceDifferent: '#7B3F9E', + choiceDifferentTint: '#F3EEFA', + choiceDifferentBorder: '#C99BE0', + choiceOverlap: '#2660C4', + choiceOverlapTint: '#EAF0FB', + choiceOverlapBorder: '#8AA8DD', + amber: '#B25A1E', + grid: '#DEDAD1', +} + +const scenarios = [ + { + title: 'Homework time', + question: 'Do Year 7 or Year 9 students spend longer on homework each night?', + aName: 'Year 7', + bName: 'Year 9', + units: 'min', + a: [25, 28, 30, 32, 35, 36, 38, 40, 42, 45], + b: [58, 60, 62, 65, 66, 68, 70, 72, 75, 78], + }, + { + title: 'Test scores', + question: 'Does Class B really score higher than Class A?', + aName: 'Class A', + bName: 'Class B', + units: 'pts', + a: [68, 72, 75, 78, 80, 83, 86, 89, 92, 96], + b: [70, 74, 78, 81, 84, 87, 90, 94, 97, 99], + }, + { + title: 'Player height', + question: 'Are basketball players and football players clearly different in height?', + aName: 'Football', + bName: 'Basketball', + units: 'in', + a: [58, 60, 61, 62, 64, 65, 66, 68, 69, 70], + b: [68, 70, 72, 73, 74, 76, 78, 79, 80, 82], + }, + { + title: 'Sleep hours', + question: 'Do cats and dogs in this sample sleep different amounts?', + aName: 'Dogs', + bName: 'Cats', + units: 'hr', + a: [8, 10, 11, 12, 13, 14, 15, 16, 17, 18], + b: [10, 12, 13, 14, 15, 16, 17, 18, 19, 20], + }, + { + title: 'Plant growth', + question: 'Did fertiliser make a real difference in plant growth?', + aName: 'No fertiliser', + bName: 'Fertiliser', + units: 'cm', + a: [4, 5, 5, 6, 6, 7, 7, 8, 8, 9], + b: [14, 15, 16, 16, 17, 18, 18, 19, 20, 21], + }, + { + title: 'Running times', + question: 'Do runners with music have clearly different times?', + aName: 'No music', + bName: 'Music', + units: 'sec', + a: [22, 24, 25, 26, 27, 28, 30, 31, 33, 35], + b: [20, 23, 24, 25, 26, 27, 29, 30, 32, 34], + }, +] + +function mean(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +function mad(values, center) { + return values.reduce((sum, value) => sum + Math.abs(value - center), 0) / values.length +} + +function format(value) { + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} + +function rounded(value) { + return Number(value.toFixed(1)) +} + +function roundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) +} + +function StatCard({ label, value, sub, color, tint, border, hidden }) { + return ( + + + {label} + + + {hidden ? '--' : value} + + + {hidden ? 'judge first' : sub} + + + ) +} + +function JudgeButton({ type, disabled, onClick }) { + const isDifferent = type === 'different' + const color = isDifferent ? colors.choiceDifferent : colors.choiceOverlap + const tint = isDifferent ? colors.choiceDifferentTint : colors.choiceOverlapTint + const border = isDifferent ? colors.choiceDifferentBorder : colors.choiceOverlapBorder + return ( + + {isDifferent ? 'Really different' : 'Too much overlap to tell'} + + ) +} + +export default function ComparingTwoPopulations() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [canvasSize, setCanvasSize] = useState({ width: 500, height: 270 }) + const [scenarioIndex, setScenarioIndex] = useState(0) + const [judgement, setJudgement] = useState(null) + + const scenario = scenarios[scenarioIndex] + const stats = useMemo(() => { + const aMean = mean(scenario.a) + const bMean = mean(scenario.b) + const aMad = mad(scenario.a, aMean) + const bMad = mad(scenario.b, bMean) + const gap = Math.abs(bMean - aMean) + const spread = (aMad + bMad) / 2 + const ratio = spread === 0 ? 0 : gap / spread + return { + aMean: rounded(aMean), + bMean: rounded(bMean), + aMad: rounded(aMad), + bMad: rounded(bMad), + gap: rounded(gap), + spread: rounded(spread), + ratio: rounded(ratio), + reallyDifferent: ratio >= 2, + } + }, [scenario]) + + const revealed = judgement !== null + const correct = revealed && judgement === (stats.reallyDifferent ? 'different' : 'overlap') + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + + ctx.fillStyle = '#ffffff' + roundRect(ctx, 0, 0, canvasSize.width, canvasSize.height, 14) + ctx.fill() + + const allValues = [...scenario.a, ...scenario.b] + const minValue = Math.min(...allValues) + const maxValue = Math.max(...allValues) + const range = Math.max(8, maxValue - minValue) + const padValue = range * 0.12 + const domainMin = Math.floor(minValue - padValue) + const domainMax = Math.ceil(maxValue + padValue) + const pad = 44 + const left = pad + const right = canvasSize.width - pad + const axisY = canvasSize.height - 40 + const aBaseY = 88 + const bBaseY = 174 + const toX = (value) => left + ((value - domainMin) / (domainMax - domainMin)) * (right - left) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + const tickStep = Math.max(1, Math.ceil((domainMax - domainMin) / 6 / 5) * 5) + ctx.font = '700 11px Inter, sans-serif' + ctx.fillStyle = colors.muted + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let tick = Math.ceil(domainMin / tickStep) * tickStep; tick <= domainMax; tick += tickStep) { + const x = toX(tick) + ctx.beginPath() + ctx.moveTo(x, 34) + ctx.lineTo(x, axisY) + ctx.stroke() + ctx.fillText(String(tick), x, axisY + 9) + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(left, axisY) + ctx.lineTo(right, axisY) + ctx.stroke() + + if (revealed) { + const overlapMin = Math.max(Math.min(...scenario.a), Math.min(...scenario.b)) + const overlapMax = Math.min(Math.max(...scenario.a), Math.max(...scenario.b)) + if (overlapMin <= overlapMax) { + const x1 = toX(overlapMin) + const x2 = toX(overlapMax) + ctx.fillStyle = 'rgba(123,63,158,.13)' + roundRect(ctx, x1, 46, Math.max(8, x2 - x1), 156, 12) + ctx.fill() + ctx.fillStyle = colors.purple + ctx.font = '900 12px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('overlap', (x1 + x2) / 2, 58) + } + + ;[ + { meanValue: stats.aMean, madValue: stats.aMad, y: aBaseY + 32, color: colors.a }, + { meanValue: stats.bMean, madValue: stats.bMad, y: bBaseY + 32, color: colors.b }, + ].forEach((item) => { + const x1 = toX(item.meanValue - item.madValue) + const x2 = toX(item.meanValue + item.madValue) + ctx.strokeStyle = item.color + ctx.lineWidth = 6 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(x1, item.y) + ctx.lineTo(x2, item.y) + ctx.stroke() + }) + + const ax = toX(stats.aMean) + const bx = toX(stats.bMean) + const arrowY = 226 + ctx.strokeStyle = colors.purple + ctx.fillStyle = colors.purple + ctx.lineWidth = 3 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(ax, arrowY) + ctx.lineTo(bx, arrowY) + ctx.stroke() + const direction = bx >= ax ? 1 : -1 + ctx.beginPath() + ctx.moveTo(bx, arrowY) + ctx.lineTo(bx - direction * 9, arrowY - 6) + ctx.lineTo(bx - direction * 9, arrowY + 6) + ctx.closePath() + ctx.fill() + ctx.font = '900 12px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.fillText(`gap ${format(stats.gap)}`, (ax + bx) / 2, arrowY - 14) + } + + function drawMeanLine(value, color, yTop, yBottom) { + const x = toX(value) + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.setLineDash([6, 5]) + ctx.beginPath() + ctx.moveTo(x, yTop) + ctx.lineTo(x, yBottom) + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() + } + + function drawDots(values, baseY, color, label) { + const stacks = new Map() + values.forEach((value) => { + const count = stacks.get(value) ?? 0 + stacks.set(value, count + 1) + const x = toX(value) + const y = baseY - count * 15 + ctx.fillStyle = color + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(x, y, 5.7, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }) + ctx.fillStyle = color + ctx.font = '900 13px Inter, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(label, 12, baseY - 2) + } + + drawDots(scenario.a, aBaseY, colors.a, scenario.aName) + drawDots(scenario.b, bBaseY, colors.b, scenario.bName) + if (revealed) { + drawMeanLine(stats.aMean, colors.a, 42, aBaseY + 42) + drawMeanLine(stats.bMean, colors.b, 130, bBaseY + 42) + } + }, [canvasSize, revealed, scenario, stats]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => { + setCanvasSize({ + width: Math.max(360, Math.floor(node.clientWidth)), + height: Math.max(250, Math.floor(node.clientHeight)), + }) + } + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + function nextScenario() { + const next = (scenarioIndex + 1) % scenarios.length + setScenarioIndex(next) + setJudgement(null) + } + + const truthLabel = stats.reallyDifferent ? 'really different' : 'too much overlap to tell' + + const hint = !revealed + ? 'Do not just compare the means. Check how much the groups overlap; if they blend together, the difference may not be real.' + : stats.reallyDifferent + ? `The gap between means is ${format(stats.ratio)}x the spread, so the difference is bigger than the natural variation.` + : `Different means are not enough. The gap is only ${format(stats.ratio)}x the spread, so the dots overlap too much.` + + return ( + + + + + {scenario.title} + + {scenario.question} + + + New comparison + + + + + setJudgement('different')} /> + setJudgement('overlap')} /> + + + + {revealed ? ( + + + {correct ? '✓ Correct' : 'Not quite'} + + — these groups show + {truthLabel} + . The mean gap is {format(stats.ratio)}x the typical spread. + + ) : ( + Make a prediction, then the graph will reveal the overlap and spread. + )} + + + + + + + + + + + + {hint} + + + ) +} diff --git a/src/manipulatives/coordinate-connect-dots.jsx b/src/manipulatives/coordinate-connect-dots.jsx new file mode 100644 index 0000000..b68a858 --- /dev/null +++ b/src/manipulatives/coordinate-connect-dots.jsx @@ -0,0 +1,536 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + ink: '#1A1A2E', + grid: 'rgba(26, 26, 46, 0.15)', + green: '#3B6D11', + red: '#A32D2D', + purple: '#7C3AED', + border: '#E0DDD6', +} + +const minCoord = -12 +const maxCoord = 12 +const gridSpan = maxCoord - minCoord + +const challenges = [ + { + name: 'House', + segments: [ + [[-3, -5], [3, -5], [3, -1], [0, 3], [-3, -1], [-3, -5]], + ], + }, + { + name: 'Boat', + segments: [ + [[-10, 5], [-4, 5], [-5, 3], [-9, 3], [-10, 5]], + [[-7, 5], [-7, 9], [-2, 5], [-7, 5]], + ], + }, + { + name: 'Star', + segments: [ + [[0, 8], [2, 2], [9, 2], [3, -1], [5, -8], [0, -4], [-5, -8], [-3, -1], [-9, 2], [-2, 2], [0, 8]], + ], + }, + { + name: 'Fish', + segments: [ + [[-6, -1], [-1, -3], [2, -1], [-1, 1], [-6, -1]], + [[2, -1], [5, 1], [5, -3], [2, -1]], + ], + }, + { + name: 'Rocket', + segments: [ + [[5, -4], [5, 2], [7, 5], [9, 2], [9, -4], [5, -4]], + [[5, -2], [3, -4], [5, -4]], + [[9, -2], [11, -4], [9, -4]], + ], + }, +] + +function flattenChallenge(challenge) { + const points = [] + challenge.segments.forEach((segment, segmentIndex) => { + segment.forEach(([x, y], pointIndex) => { + points.push({ x, y, segmentIndex, pointIndex }) + }) + }) + return points +} + +function easeOutBack(t) { + const c1 = 1.4 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) +} + +function easeInCubic(t) { + return t * t * t +} + +function getConstants(size) { + const pad = 30 + const cell = (size - pad * 2) / gridSpan + return { + pad, + cell, + originX: pad + Math.abs(minCoord) * cell, + originY: size - pad - Math.abs(minCoord) * cell, + } +} + +function toPx(point, constants) { + return { + x: constants.originX + point.x * constants.cell, + y: constants.originY - point.y * constants.cell, + } +} + +function fireConfetti() { + if (typeof window === 'undefined' || typeof window.confetti !== 'function') return + window.confetti({ + particleCount: 90, + spread: 65, + origin: { y: 0.62 }, + }) +} + +function CoordinateText({ point }) { + return ( + + ( + {point.x} + , + {point.y} + ) + + ) +} + +export default function CoordinateConnectDots() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const launchFrameRef = useRef(null) + const wrongTimerRef = useRef(null) + const [gridSize, setGridSize] = useState(486) + const [challengeIndex, setChallengeIndex] = useState(0) + const [completedChallenges, setCompletedChallenges] = useState(0) + const [placedCount, setPlacedCount] = useState(0) + const [drop, setDrop] = useState(null) + const [wrong, setWrong] = useState(null) + const [complete, setComplete] = useState(false) + const [allDone, setAllDone] = useState(false) + const [showHints, setShowHints] = useState(false) + const [launchProgress, setLaunchProgress] = useState(0) + + const challenge = challenges[challengeIndex] + const points = useMemo(() => flattenChallenge(challenge), [challenge]) + + const clearTimers = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + if (launchFrameRef.current) cancelAnimationFrame(launchFrameRef.current) + if (wrongTimerRef.current) clearTimeout(wrongTimerRef.current) + frameRef.current = null + launchFrameRef.current = null + wrongTimerRef.current = null + }, []) + + const resetChallenge = useCallback(() => { + clearTimers() + setPlacedCount(0) + setDrop(null) + setWrong(null) + setComplete(false) + setLaunchProgress(0) + }, [clearTimers]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = gridSize * dpr + canvas.height = gridSize * dpr + canvas.style.width = `${gridSize}px` + canvas.style.height = `${gridSize}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, gridSize, gridSize) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, gridSize, gridSize) + + const constants = getConstants(gridSize) + const isRocketLaunch = complete && challengeIndex === challenges.length - 1 + const launchDelay = 0.52 + const liftProgress = isRocketLaunch ? Math.max(0, (launchProgress - launchDelay) / (1 - launchDelay)) : 0 + const launchT = easeInCubic(Math.min(1, liftProgress)) + const launchShake = isRocketLaunch && launchProgress < launchDelay ? Math.sin(launchProgress * 120) * 2.8 : 0 + const launchOffset = launchT * gridSize * 0.92 + const launchedPoint = (point) => { + const px = toPx(point, constants) + return { x: px.x + launchShake, y: px.y - launchOffset } + } + const shakeX = wrong ? Math.sin(Date.now() / 28) * 3 : 0 + ctx.save() + ctx.translate(shakeX, 0) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let value = minCoord; value <= maxCoord; value += 1) { + const x = constants.originX + value * constants.cell + const y = constants.originY - value * constants.cell + ctx.beginPath() + ctx.moveTo(x, constants.pad) + ctx.lineTo(x, gridSize - constants.pad) + ctx.moveTo(constants.pad, y) + ctx.lineTo(gridSize - constants.pad, y) + ctx.stroke() + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(constants.pad, constants.originY) + ctx.lineTo(gridSize - constants.pad + 12, constants.originY) + ctx.moveTo(constants.originX, gridSize - constants.pad) + ctx.lineTo(constants.originX, constants.pad - 12) + ctx.stroke() + + ctx.fillStyle = colors.ink + ctx.font = '700 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let value = minCoord; value <= maxCoord; value += 2) { + const x = constants.originX + value * constants.cell + const y = constants.originY - value * constants.cell + ctx.fillText(String(value), x, constants.originY + 7) + if (value !== 0) { + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + ctx.fillText(String(value), constants.originX - 8, y) + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + } + } + ctx.font = '800 16px Inter, system-ui, sans-serif' + ctx.fillText('x', gridSize - constants.pad + 22, constants.originY - 8) + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText('y', constants.originX + 8, constants.pad - 18) + + if (complete) { + ctx.fillStyle = 'rgba(59, 109, 17, 0.12)' + ctx.globalAlpha = Math.max(0.15, 1 - launchT * 0.3) + challenge.segments.forEach((segment) => { + ctx.beginPath() + segment.forEach(([x, y], index) => { + const point = launchedPoint({ x, y }) + if (index === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + }) + ctx.closePath() + ctx.fill() + }) + ctx.globalAlpha = 1 + } + + const visiblePoints = points.slice(0, placedCount) + ctx.strokeStyle = colors.green + ctx.lineWidth = 3 + ctx.lineJoin = 'round' + visiblePoints.forEach((point, index) => { + if (index === 0) return + const previous = visiblePoints[index - 1] + if (previous.segmentIndex !== point.segmentIndex) return + const prevPx = launchedPoint(previous) + const pointPx = launchedPoint(point) + const isDropping = drop?.index === index + const dropProgress = isDropping ? Math.min(1, drop.progress) : 1 + const animatedY = pointPx.y - (1 - dropProgress) * 22 + ctx.beginPath() + ctx.moveTo(prevPx.x, prevPx.y) + ctx.lineTo(pointPx.x, animatedY) + ctx.stroke() + }) + + visiblePoints.forEach((point, index) => { + const pointPx = launchedPoint(point) + const isDropping = drop?.index === index + const dropProgress = isDropping ? easeOutBack(Math.min(1, drop.progress)) : 1 + const y = pointPx.y - (1 - Math.min(1, dropProgress)) * 22 + ctx.fillStyle = colors.green + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(pointPx.x, y, 8, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }) + + if (isRocketLaunch && launchProgress > launchDelay && launchProgress < 1) { + const baseLeft = launchedPoint({ x: 5, y: -4 }) + const baseRight = launchedPoint({ x: 9, y: -4 }) + const flameY = Math.max(baseLeft.y, baseRight.y) + 6 + ctx.fillStyle = 'rgba(216, 90, 48, 0.72)' + ctx.beginPath() + ctx.moveTo((baseLeft.x + baseRight.x) / 2, flameY + 34) + ctx.lineTo(baseLeft.x + 8, flameY) + ctx.lineTo(baseRight.x - 8, flameY) + ctx.closePath() + ctx.fill() + ctx.fillStyle = 'rgba(232, 169, 78, 0.9)' + ctx.beginPath() + ctx.moveTo((baseLeft.x + baseRight.x) / 2, flameY + 22) + ctx.lineTo(baseLeft.x + 15, flameY + 2) + ctx.lineTo(baseRight.x - 15, flameY + 2) + ctx.closePath() + ctx.fill() + } + + if (showHints && !complete && placedCount < points.length) { + const target = toPx(points[placedCount], constants) + ctx.strokeStyle = colors.green + ctx.lineWidth = 2.5 + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.arc(target.x, target.y, 13, 0, Math.PI * 2) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = 'rgba(59, 109, 17, 0.10)' + ctx.beginPath() + ctx.arc(target.x, target.y, 10, 0, Math.PI * 2) + ctx.fill() + } + + if (wrong) { + const wrongPx = toPx(wrong, constants) + ctx.fillStyle = colors.red + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(wrongPx.x, wrongPx.y, 9, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + + ctx.restore() + }, [challenge.segments, challengeIndex, complete, drop, gridSize, launchProgress, placedCount, points, showHints, wrong]) + + useEffect(() => { + if (allDone) return undefined + const node = wrapRef.current + if (!node) return undefined + const update = () => { + const rect = node.getBoundingClientRect() + setGridSize(Math.max(280, Math.min(500, Math.floor(Math.min(rect.width, rect.height) - 2)))) + } + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, [allDone]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => clearTimers(), [clearTimers]) + + const animateDrop = (index) => { + const start = performance.now() + const duration = 200 + const tick = (now) => { + const progress = Math.min(1, (now - start) / duration) + setDrop({ index, progress }) + if (progress < 1) { + frameRef.current = requestAnimationFrame(tick) + return + } + frameRef.current = null + setDrop(null) + } + frameRef.current = requestAnimationFrame(tick) + } + + const startRocketLaunch = () => { + if (launchFrameRef.current) cancelAnimationFrame(launchFrameRef.current) + const start = performance.now() + const duration = 3900 + setLaunchProgress(0) + const tick = (now) => { + const progress = Math.min(1, (now - start) / duration) + setLaunchProgress(progress) + if (progress < 1) { + launchFrameRef.current = requestAnimationFrame(tick) + return + } + launchFrameRef.current = null + } + launchFrameRef.current = requestAnimationFrame(tick) + } + + const handlePointerDown = (event) => { + if (complete || allDone || drop) return + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const px = event.clientX - rect.left + const py = event.clientY - rect.top + const constants = getConstants(gridSize) + const gx = Math.round((px - constants.originX) / constants.cell) + const gy = Math.round((constants.originY - py) / constants.cell) + if (gx < minCoord || gx > maxCoord || gy < minCoord || gy > maxCoord) return + const snap = toPx({ x: gx, y: gy }, constants) + const distance = Math.hypot(px - snap.x, py - snap.y) + if (distance > constants.cell * 0.42) return + + const target = points[placedCount] + if (target.x === gx && target.y === gy) { + const nextCount = placedCount + 1 + setPlacedCount(nextCount) + animateDrop(nextCount - 1) + if (nextCount === points.length) { + setComplete(true) + setCompletedChallenges((current) => Math.max(current, challengeIndex + 1)) + fireConfetti() + if (challengeIndex === challenges.length - 1) startRocketLaunch() + } + return + } + + clearTimers() + setWrong({ x: gx, y: gy }) + wrongTimerRef.current = setTimeout(() => { + setPlacedCount(0) + setWrong(null) + setDrop(null) + setComplete(false) + }, 360) + } + + const nextChallenge = () => { + if (challengeIndex >= challenges.length - 1) { + clearTimers() + setAllDone(true) + return + } + clearTimers() + setChallengeIndex((current) => current + 1) + setPlacedCount(0) + setDrop(null) + setWrong(null) + setComplete(false) + setLaunchProgress(0) + } + + const playAgain = () => { + clearTimers() + setChallengeIndex(0) + setCompletedChallenges(0) + setPlacedCount(0) + setDrop(null) + setWrong(null) + setComplete(false) + setLaunchProgress(0) + setAllDone(false) + } + + if (allDone) { + return ( + + + All pictures revealed! + You plotted every coordinate in order. + + Play again + + + + ) + } + + return ( + + + + + + + + ) +} diff --git a/src/manipulatives/distance-coordinate-plane.jsx b/src/manipulatives/distance-coordinate-plane.jsx new file mode 100644 index 0000000..59272ef --- /dev/null +++ b/src/manipulatives/distance-coordinate-plane.jsx @@ -0,0 +1,535 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + grid: '#D8D5CE', + horizontal: '#2660C4', + horizontalTint: '#EAF0FB', + vertical: '#1E7A5E', + verticalTint: '#E9F5EF', + distance: '#7B3F9E', + distanceTint: '#F3EEFA', + pointA: '#7B3F9E', + pointB: '#B23050', + border: '#E0DDD6', +} + +const minXCoord = -12 +const maxXCoord = 12 +const minYCoord = -9 +const maxYCoord = 9 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeInOut(t) { + return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2 +} + +function formatDistance(value) { + return Number.isInteger(value) ? String(value) : value.toFixed(2) +} + +function samePoint(a, b) { + return a && b && a.x === b.x && a.y === b.y +} + +function roundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) +} + +function drawPointLabel(ctx, point, screen, color, canvasWidth, canvasHeight) { + const text = `(${point.x}, ${point.y})` + ctx.save() + ctx.font = '900 12px Inter, sans-serif' + const width = ctx.measureText(text).width + 16 + const x = clamp(screen.x, width / 2 + 8, canvasWidth - width / 2 - 8) + const y = clamp(screen.y - 28, 18, canvasHeight - 18) + ctx.fillStyle = `${color}1f` + ctx.strokeStyle = color + ctx.lineWidth = 1.5 + roundRect(ctx, x - width / 2, y - 13, width, 26, 13) + ctx.fill() + ctx.stroke() + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(text, x, y) + ctx.restore() +} + +function drawSegment(ctx, from, to, color, progress, dashed = false) { + const x = from.x + (to.x - from.x) * progress + const y = from.y + (to.y - from.y) * progress + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = 3 + ctx.lineCap = 'round' + if (dashed) ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(from.x, from.y) + ctx.lineTo(x, y) + ctx.stroke() + ctx.restore() +} + +function drawSegmentLabel(ctx, text, x, y, color, tint, canvasWidth, canvasHeight) { + ctx.save() + ctx.font = '900 14px Inter, sans-serif' + const width = ctx.measureText(text).width + 18 + const height = 26 + const labelX = clamp(x, width / 2 + 8, canvasWidth - width / 2 - 8) + const labelY = clamp(y, height / 2 + 8, canvasHeight - height / 2 - 8) + ctx.fillStyle = tint + ctx.strokeStyle = color + ctx.lineWidth = 1.5 + roundRect(ctx, labelX - width / 2, labelY - height / 2, width, height, 13) + ctx.fill() + ctx.stroke() + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(text, labelX, labelY) + ctx.restore() +} + +function StatCard({ title, value, color, tint, active }) { + return ( + + {title} + {value} + + ) +} + +function ColorToken({ color, children }) { + return {children} +} + +function WorkingLine({ active, children, large = false }) { + return ( + + {children} + + ) +} + +function Radical({ value }) { + return ( + + √ + {value} + + ) +} + +export default function DistanceCoordinatePlane() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const dragRef = useRef(null) + const labelTimerRef = useRef(null) + const [canvasSize, setCanvasSize] = useState({ width: 590, height: 410 }) + const [points, setPoints] = useState([]) + const [progress, setProgress] = useState({ h: 0, v: 0, d: 0 }) + const [revealed, setRevealed] = useState({ h: false, v: false, d: false }) + const [liveMode, setLiveMode] = useState(false) + const [hoverIndex, setHoverIndex] = useState(null) + const [coordLabelsVisible, setCoordLabelsVisible] = useState(false) + const [showWorking, setShowWorking] = useState(false) + + const pointA = points[0] + const pointB = points[1] + const hasBoth = points.length === 2 + const dx = hasBoth ? Math.abs(pointB.x - pointA.x) : 0 + const dy = hasBoth ? Math.abs(pointB.y - pointA.y) : 0 + const sumSquares = dx * dx + dy * dy + const distance = Math.sqrt(sumSquares) + const corner = useMemo(() => hasBoth ? { x: pointB.x, y: pointA.y } : null, [hasBoth, pointA, pointB]) + + const constants = useMemo(() => { + const pad = Math.max(20, Math.min(28, canvasSize.width * 0.045)) + const cell = Math.min((canvasSize.width - pad * 2) / (maxXCoord - minXCoord), (canvasSize.height - pad * 2) / (maxYCoord - minYCoord)) + const originX = canvasSize.width / 2 + const originY = canvasSize.height / 2 + return { + pad, + cell, + originX, + originY, + left: originX + minXCoord * cell, + right: originX + maxXCoord * cell, + top: originY - maxYCoord * cell, + bottom: originY - minYCoord * cell, + } + }, [canvasSize]) + + const toPx = useCallback((point) => ({ + x: constants.originX + point.x * constants.cell, + y: constants.originY - point.y * constants.cell, + }), [constants]) + + const toGrid = useCallback((x, y) => ({ + x: clamp(Math.round((x - constants.originX) / constants.cell), minXCoord, maxXCoord), + y: clamp(Math.round((constants.originY - y) / constants.cell), minYCoord, maxYCoord), + }), [constants]) + + const cancelAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + }, []) + + const flashCoordinateLabels = useCallback(() => { + if (labelTimerRef.current) clearTimeout(labelTimerRef.current) + setCoordLabelsVisible(true) + labelTimerRef.current = setTimeout(() => { + setCoordLabelsVisible(false) + labelTimerRef.current = null + }, 2000) + }, []) + + const startAnimation = useCallback(() => { + cancelAnimation() + setLiveMode(false) + setProgress({ h: 0, v: 0, d: 0 }) + setRevealed({ h: false, v: false, d: false }) + const startedAt = performance.now() + const legDuration = 520 + const totalDuration = legDuration * 3 + + const tick = (now) => { + const elapsed = now - startedAt + if (elapsed < legDuration) { + setProgress({ h: easeInOut(elapsed / legDuration), v: 0, d: 0 }) + } else if (elapsed < legDuration * 2) { + setRevealed((old) => old.h ? old : { ...old, h: true }) + setProgress({ h: 1, v: easeInOut((elapsed - legDuration) / legDuration), d: 0 }) + } else if (elapsed < totalDuration) { + setRevealed((old) => old.v ? old : { ...old, v: true }) + setProgress({ h: 1, v: 1, d: easeInOut((elapsed - legDuration * 2) / legDuration) }) + } else { + setProgress({ h: 1, v: 1, d: 1 }) + setRevealed({ h: true, v: true, d: true }) + frameRef.current = null + return + } + frameRef.current = requestAnimationFrame(tick) + } + frameRef.current = requestAnimationFrame(tick) + }, [cancelAnimation]) + + const clearPoints = () => { + cancelAnimation() + setPoints([]) + setHoverIndex(null) + setCoordLabelsVisible(false) + if (labelTimerRef.current) clearTimeout(labelTimerRef.current) + labelTimerRef.current = null + setProgress({ h: 0, v: 0, d: 0 }) + setRevealed({ h: false, v: false, d: false }) + setLiveMode(false) + } + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => { + setCanvasSize({ + width: Math.max(340, Math.floor(node.clientWidth)), + height: Math.max(320, Math.floor(node.clientHeight)), + }) + } + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => () => { + cancelAnimation() + if (labelTimerRef.current) clearTimeout(labelTimerRef.current) + }, [cancelAnimation]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + + ctx.fillStyle = '#ffffff' + roundRect(ctx, 0, 0, canvasSize.width, canvasSize.height, 14) + ctx.fill() + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let xValue = minXCoord; xValue <= maxXCoord; xValue += 1) { + const x = toPx({ x: xValue, y: 0 }).x + ctx.beginPath() + ctx.moveTo(x, constants.top) + ctx.lineTo(x, constants.bottom) + ctx.stroke() + } + for (let yValue = minYCoord; yValue <= maxYCoord; yValue += 1) { + const y = toPx({ x: 0, y: yValue }).y + ctx.beginPath() + ctx.moveTo(constants.left, y) + ctx.lineTo(constants.right, y) + ctx.stroke() + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(constants.left, constants.originY) + ctx.lineTo(constants.right, constants.originY) + ctx.moveTo(constants.originX, constants.top) + ctx.lineTo(constants.originX, constants.bottom) + ctx.stroke() + + ctx.fillStyle = '#5F5E5A' + ctx.font = '12px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let x = minXCoord; x <= maxXCoord; x += 1) { + ctx.fillText(String(x), toPx({ x, y: 0 }).x, constants.originY + 5) + } + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (let y = minYCoord; y <= maxYCoord; y += 1) { + if (y !== 0) ctx.fillText(String(y), constants.originX - 6, toPx({ x: 0, y }).y) + } + + if (hasBoth) { + const aPx = toPx(pointA) + const bPx = toPx(pointB) + const cornerPx = toPx(corner) + const hProgress = liveMode ? 1 : progress.h + const vProgress = liveMode ? 1 : progress.v + const dProgress = liveMode ? 1 : progress.d + + if (dx > 0) drawSegment(ctx, aPx, cornerPx, colors.horizontal, hProgress, true) + if (dy > 0) drawSegment(ctx, cornerPx, bPx, colors.vertical, vProgress, true) + if (dProgress > 0) drawSegment(ctx, aPx, bPx, colors.distance, dProgress) + + if ((liveMode || revealed.h || hProgress > 0.98) && dx > 0) { + drawSegmentLabel( + ctx, + `a = ${dx}`, + (aPx.x + cornerPx.x) / 2, + (aPx.y + cornerPx.y) / 2 - 24, + colors.horizontal, + colors.horizontalTint, + canvasSize.width, + canvasSize.height, + ) + } + + if ((liveMode || revealed.v || vProgress > 0.98) && dy > 0) { + const side = pointB.x >= pointA.x ? 28 : -28 + drawSegmentLabel( + ctx, + `b = ${dy}`, + (cornerPx.x + bPx.x) / 2 + side, + (cornerPx.y + bPx.y) / 2, + colors.vertical, + colors.verticalTint, + canvasSize.width, + canvasSize.height, + ) + } + + if ((liveMode || revealed.d || dProgress > 0.98) && dProgress > 0) { + drawSegmentLabel( + ctx, + showWorking ? `c = ${formatDistance(distance)}` : 'c', + (aPx.x + bPx.x) / 2, + (aPx.y + bPx.y) / 2 + 28, + colors.distance, + colors.distanceTint, + canvasSize.width, + canvasSize.height, + ) + } + + if ((liveMode || revealed.v || vProgress > 0.98) && dx > 0 && dy > 0) { + const sx = pointB.x > pointA.x ? 1 : -1 + const sy = pointB.y > pointA.y ? -1 : 1 + const size = Math.min(16, constants.cell * 0.42) + ctx.save() + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(cornerPx.x, cornerPx.y) + ctx.lineTo(cornerPx.x - sx * size, cornerPx.y) + ctx.lineTo(cornerPx.x - sx * size, cornerPx.y + sy * size) + ctx.lineTo(cornerPx.x, cornerPx.y + sy * size) + ctx.stroke() + ctx.restore() + } + } + + points.forEach((point, index) => { + const color = index === 0 ? colors.pointA : colors.pointB + const screen = toPx(point) + ctx.fillStyle = color + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(screen.x, screen.y, 8, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + if (coordLabelsVisible || hoverIndex === index || dragRef.current === index) { + drawPointLabel(ctx, point, screen, color, canvasSize.width, canvasSize.height) + } + }) + }, [canvasSize, constants, coordLabelsVisible, corner, distance, dx, dy, hasBoth, hoverIndex, liveMode, pointA, pointB, points, progress, revealed.d, revealed.h, revealed.v, showWorking, toPx]) + + useEffect(() => { + draw() + }, [draw]) + + const canvasPoint = (event) => { + const rect = canvasRef.current.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasSize.width / rect.width), + y: (event.clientY - rect.top) * (canvasSize.height / rect.height), + } + } + + const handlePointerDown = (event) => { + const pos = canvasPoint(event) + if (hasBoth) { + const hit = points.findIndex((point) => { + const screen = toPx(point) + return Math.hypot(screen.x - pos.x, screen.y - pos.y) <= 18 + }) + if (hit >= 0) { + cancelAnimation() + setLiveMode(true) + setProgress({ h: 1, v: 1, d: 1 }) + setRevealed({ h: true, v: true, d: true }) + dragRef.current = hit + event.currentTarget.setPointerCapture(event.pointerId) + } + return + } + + const grid = toGrid(pos.x, pos.y) + if (points.length === 1 && samePoint(points[0], grid)) return + const nextPoints = [...points, grid] + setPoints(nextPoints) + flashCoordinateLabels() + if (nextPoints.length === 2) { + setLiveMode(false) + startAnimation() + } + } + + const handlePointerMove = (event) => { + const pos = canvasPoint(event) + if (dragRef.current === null) { + const hit = points.findIndex((point) => { + const screen = toPx(point) + return Math.hypot(screen.x - pos.x, screen.y - pos.y) <= 18 + }) + setHoverIndex(hit >= 0 ? hit : null) + return + } + const grid = toGrid(pos.x, pos.y) + setPoints((old) => { + const other = old[dragRef.current === 0 ? 1 : 0] + if (samePoint(other, grid)) return old + return old.map((point, index) => index === dragRef.current ? grid : point) + }) + } + + const stopDragging = () => { + dragRef.current = null + } + + const leaveCanvas = () => { + setHoverIndex(null) + stopDragging() + } + + const activeH = liveMode || revealed.h + const activeV = liveMode || revealed.v + const activeD = liveMode || revealed.d + + return ( + + + + + + + + + + + + + + setShowWorking((current) => !current)} + className="rounded-full border bg-white px-3 py-1 text-sm font-black" + style={{ borderColor: colors.distance, color: colors.distance }} + > + {showWorking ? 'Hide working' : 'Show working'} + + + {showWorking && ( + + Working + + + a2 + b2 = c2 + + + {dx}2 + {dy}2 = c2 + + + {dx * dx} + {dy * dy} = {sumSquares} + + + c = = {formatDistance(distance)} + + + + )} + + + + New points + + + Replay + + + + + + ) +} diff --git a/src/manipulatives/distributive-area-model.jsx b/src/manipulatives/distributive-area-model.jsx new file mode 100644 index 0000000..b3fdc0b --- /dev/null +++ b/src/manipulatives/distributive-area-model.jsx @@ -0,0 +1,361 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const palette = { + page: '#F8F6F0', + active: '#5B2A86', + ruby: '#B23050', + emerald: { fill: '#C8EBDC', stroke: '#22916A', text: '#1B7A54' }, + sapphire: { fill: '#CADCF5', stroke: '#3E6FC4', text: '#2A4F94' }, + amethyst: { fill: '#E7CFF5', stroke: '#8B3FB5', text: '#6B2E92' }, +} + +const canvasHeight = 230 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeInOut(t) { + return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2 +} + +function Stepper({ title, value, color, onChange, min, max, children }) { + return ( + + {title} + {children ?? ( + + onChange(clamp(value - 1, min, max))} + className="flex h-8 w-8 items-center justify-center rounded-full text-lg font-black text-white" + style={{ backgroundColor: color.stroke }} + > + - + + {value} + onChange(clamp(value + 1, min, max))} + className="flex h-8 w-8 items-center justify-center rounded-full text-lg font-black text-white" + style={{ backgroundColor: color.stroke }} + > + + + + + )} + + ) +} + +function ColouredFormula({ mode, a, b, c, distributed = false, total = false }) { + const first = mode === 'variable' ? 'x' : b + return ( + + {distributed ? ( + <> + {a} + {' * '} + {first} + {' + '} + {a} + {' * '} + {c} + {total && mode === 'numbers' ? = {a * (b + c)} : null} + > + ) : ( + <> + {a} + {' * ('} + {first} + {' + '} + {c} + {')'} + > + )} + + ) +} + +function areaLabel(mode, a, part, isVariablePart) { + if (isVariablePart) return `${a}*x` + return `${a}*${part} = ${a * part}` +} + +export default function DistributiveAreaModel() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const gapRef = useRef(0) + const [canvasWidth, setCanvasWidth] = useState(760) + const [mode, setMode] = useState('numbers') + const [a, setA] = useState(4) + const [b, setB] = useState(3) + const [c, setC] = useState(2) + const [apart, setApart] = useState(false) + const [gap, setGap] = useState(0) + + const bVisual = b + const leftArea = mode === 'variable' ? `${a} * x` : a * b + const rightArea = a * c + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + const padX = Math.max(36, canvasWidth * 0.08) + const topSpace = 42 + const bottomSpace = 34 + const availableW = canvasWidth - padX * 2 + const availableH = canvasHeight - topSpace - bottomSpace + const totalUnits = bVisual + c + const unit = Math.min((availableW * 1.15) / totalUnits, availableH / a, 48) + const rectW = totalUnits * unit + const rectH = a * unit + const maxGap = Math.min(56, Math.max(28, unit * 0.9)) + const currentGap = gap * maxGap + const heightLabelText = `a = ${a}` + ctx.font = '900 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + const heightLabelOffset = 58 + const heightLineOffset = 24 + const visualLeftSpace = Math.max(heightLineOffset + 8, heightLabelOffset + ctx.measureText(heightLabelText).width / 2 + 8) + const startX = (canvasWidth - visualLeftSpace - rectW - currentGap) / 2 + visualLeftSpace + const startY = topSpace + (availableH - rectH) / 2 + const leftW = bVisual * unit + const rightW = c * unit + const rightX = startX + leftW + currentGap + + const drawPart = (x, y, width, height, style, label, gridUnits) => { + ctx.save() + ctx.fillStyle = style.fill + ctx.strokeStyle = style.stroke + ctx.lineWidth = 2 + ctx.beginPath() + ctx.rect(x, y, width, height) + ctx.fill() + ctx.stroke() + + if (gridUnits) { + ctx.strokeStyle = `${style.stroke}55` + ctx.lineWidth = 1 + for (let col = 1; col < gridUnits.x; col += 1) { + const gx = x + col * unit + ctx.beginPath() + ctx.moveTo(gx, y) + ctx.lineTo(gx, y + height) + ctx.stroke() + } + for (let row = 1; row < gridUnits.y; row += 1) { + const gy = y + row * unit + ctx.beginPath() + ctx.moveTo(x, gy) + ctx.lineTo(x + width, gy) + ctx.stroke() + } + } + + ctx.fillStyle = style.text + ctx.font = '900 18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const [expression, result] = label.split(' = ') + const shouldSplit = result && ctx.measureText(label).width > width - 14 + if (shouldSplit) { + ctx.fillText(expression, x + width / 2, y + height / 2 - 11) + ctx.fillText(`= ${result}`, x + width / 2, y + height / 2 + 12) + } else { + ctx.fillText(label, x + width / 2, y + height / 2) + } + ctx.restore() + } + + drawPart( + startX, + startY, + leftW, + rectH, + palette.sapphire, + areaLabel(mode, a, b, mode === 'variable'), + mode === 'numbers' ? { x: b, y: a } : null, + ) + drawPart( + rightX, + startY, + rightW, + rectH, + palette.amethyst, + areaLabel(mode, a, c, false), + { x: c, y: a }, + ) + + ctx.font = '900 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillStyle = palette.emerald.text + ctx.fillText(heightLabelText, startX - heightLabelOffset, startY + rectH / 2) + ctx.strokeStyle = palette.emerald.stroke + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(startX - heightLineOffset, startY) + ctx.lineTo(startX - heightLineOffset, startY + rectH) + ctx.stroke() + + ctx.fillStyle = palette.sapphire.text + ctx.fillText(mode === 'variable' ? 'x' : `b = ${b}`, startX + leftW / 2, startY - 18) + ctx.fillStyle = palette.amethyst.text + ctx.fillText(`c = ${c}`, rightX + rightW / 2, startY - 18) + + if (currentGap > 12) { + ctx.fillStyle = '#8A8A8A' + ctx.font = '900 24px Inter, system-ui, sans-serif' + ctx.fillText('+', startX + leftW + currentGap / 2, startY + rectH / 2) + } + }, [a, b, bVisual, c, canvasWidth, gap, mode]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const animateGap = (target) => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + const start = gapRef.current + const startedAt = performance.now() + const duration = 520 + const tick = (now) => { + const t = Math.min(1, (now - startedAt) / duration) + const next = start + (target - start) * easeInOut(t) + gapRef.current = next + setGap(next) + if (t < 1) frameRef.current = requestAnimationFrame(tick) + else frameRef.current = null + } + frameRef.current = requestAnimationFrame(tick) + } + + const toggleApart = () => { + setApart((current) => { + animateGap(current ? 0 : 1) + return !current + }) + } + + const setModeAndReset = (nextMode) => { + setMode(nextMode) + setApart(false) + gapRef.current = 0 + setGap(0) + } + + const hint = mode === 'variable' + ? `The height ${a} multiplies into both parts: ${a} * x and ${a} * ${c}.` + : `The height ${a} multiplies into both parts: ${a} * ${b} = ${leftArea} and ${a} * ${c} = ${rightArea}, which add to ${a * (b + c)}.` + + return ( + + + The distributive property + + = + + + + + + {[ + ['numbers', 'Numbers'], + ['variable', 'With x'], + ].map(([id, label]) => ( + setModeAndReset(id)} + className="rounded-full px-3 py-2 transition-colors" + style={{ + backgroundColor: mode === id ? palette.active : 'transparent', + color: mode === id ? '#ffffff' : '#5F5E5A', + }} + > + {label} + + ))} + + + + + + {mode === 'variable' ? ( + + x + unknown width + + ) : null} + + + + + + + + + + + {apart ? 'Push the parts together' : 'Pull the parts apart'} + + + + + + + = + + + + + + + {hint} + + + ) +} diff --git a/src/manipulatives/elapsed-time-clock.jsx b/src/manipulatives/elapsed-time-clock.jsx new file mode 100644 index 0000000..6bbe5df --- /dev/null +++ b/src/manipulatives/elapsed-time-clock.jsx @@ -0,0 +1,608 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + start: '#2A7DE1', + startTint: '#EAF0FB', + end: '#1E7A5E', + endTint: '#E9F5EF', + elapsed: '#7B3F9E', + elapsedTint: '#F3EEFA', + navy: '#1E2D5A', + border: '#E0DDD6', + muted: '#5F5E5A', +} + +const clockHeight = 282 +const playbackClockHeight = 325 + +function normalizeMinutes(minutes) { + return ((Math.round(minutes) % 1440) + 1440) % 1440 +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function mixHex(from, to, amount) { + const cleanFrom = from.replace('#', '') + const cleanTo = to.replace('#', '') + const fromRgb = [0, 2, 4].map((index) => parseInt(cleanFrom.slice(index, index + 2), 16)) + const toRgb = [0, 2, 4].map((index) => parseInt(cleanTo.slice(index, index + 2), 16)) + const mixed = fromRgb.map((channel, index) => Math.round(channel + (toRgb[index] - channel) * amount)) + return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})` +} + +function snapToFive(minutes) { + return normalizeMinutes(Math.round(minutes / 5) * 5) +} + +function minutesToParts(totalMinutes) { + const normalized = normalizeMinutes(totalMinutes) + const hour24 = Math.floor(normalized / 60) + const minute = normalized % 60 + const period = hour24 >= 12 ? 'PM' : 'AM' + const hour = hour24 % 12 === 0 ? 12 : hour24 % 12 + return { hour, minute, period } +} + +function partsToMinutes({ hour, minute, period }) { + const hour12 = hour % 12 + return normalizeMinutes(hour12 * 60 + minute + (period === 'PM' ? 720 : 0)) +} + +function formatClock(totalMinutes) { + const { hour, minute, period } = minutesToParts(totalMinutes) + return `${hour}:${String(minute).padStart(2, '0')} ${period}` +} + +function getElapsed(start, end) { + const diff = normalizeMinutes(end) - normalizeMinutes(start) + return diff > 0 ? diff : diff + 1440 +} + +function formatDuration(totalMinutes, long = false) { + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + const hourText = long ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${hours} h` + const minuteText = long ? `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` : `${minutes} min` + if (hours === 0) return minuteText + if (minutes === 0) return hourText + return `${hourText} ${minuteText}` +} + +function timeAngles(totalMinutes) { + const normalized = normalizeMinutes(totalMinutes) % 720 + const hourAngle = ((normalized / 720) * Math.PI * 2) - Math.PI / 2 + const minuteAngle = (((normalized % 60) / 60) * Math.PI * 2) - Math.PI / 2 + return { hourAngle, minuteAngle } +} + +function angleFromPoint(point, cx, cy) { + const angle = Math.atan2(point.y - cy, point.x - cx) + Math.PI / 2 + return ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) +} + +function shortestAngleDelta(from, to) { + let delta = to - from + while (delta > Math.PI) delta -= Math.PI * 2 + while (delta < -Math.PI) delta += Math.PI * 2 + return delta +} + +function distanceToSegment(point, a, b) { + const dx = b.x - a.x + const dy = b.y - a.y + const lengthSq = dx * dx + dy * dy || 1 + const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq)) + const x = a.x + dx * t + const y = a.y + dy * t + return Math.hypot(point.x - x, point.y - y) +} + +function clockLayout(width, height) { + const radius = Math.min(width, height) * 0.47 + return { cx: width / 2, cy: height / 2, radius } +} + +function drawClockFace(ctx, width, height, color) { + const { cx, cy, radius } = clockLayout(width, height) + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + + ctx.strokeStyle = colors.navy + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.stroke() + + for (let index = 0; index < 60; index += 1) { + const angle = (index / 60) * Math.PI * 2 - Math.PI / 2 + const major = index % 5 === 0 + const inner = radius - (major ? 13 : 7) + ctx.strokeStyle = major ? '#9AA6B2' : '#D8DEE8' + ctx.lineWidth = major ? 2 : 1 + ctx.beginPath() + ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner) + ctx.lineTo(cx + Math.cos(angle) * (radius - 3), cy + Math.sin(angle) * (radius - 3)) + ctx.stroke() + } + + ctx.fillStyle = colors.navy + ctx.font = '900 18px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + for (let number = 1; number <= 12; number += 1) { + const angle = (number / 12) * Math.PI * 2 - Math.PI / 2 + ctx.fillText(String(number), cx + Math.cos(angle) * (radius - 31), cy + Math.sin(angle) * (radius - 31)) + } + + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(cx, cy, 5, 0, Math.PI * 2) + ctx.fill() + + return { cx, cy, radius } +} + +function drawElapsedArc(ctx, width, height, startTime, endTime, progress = 1) { + const { cx, cy, radius } = clockLayout(width, height) + const elapsed = getElapsed(startTime, endTime) + const startAngle = timeAngles(startTime).minuteAngle + const visibleSweep = (Math.min(elapsed, 720) / 720 * Math.PI * 2) * progress + const arcRadius = radius - 16 + + ctx.save() + ctx.strokeStyle = colors.elapsed + ctx.lineWidth = 5 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.arc(cx, cy, arcRadius, startAngle, startAngle + visibleSweep) + ctx.stroke() + + if (visibleSweep > 0.18) { + const mid = startAngle + visibleSweep / 2 + const label = formatDuration(Math.round(elapsed * progress)) + ctx.font = '900 12px Inter, system-ui, sans-serif' + const labelWidth = ctx.measureText(label).width + 18 + const labelHeight = 25 + const rawX = cx + Math.cos(mid) * (radius - 3) + const rawY = cy + Math.sin(mid) * (radius - 3) + const x = Math.max(labelWidth / 2 + 6, Math.min(width - labelWidth / 2 - 6, rawX)) + const y = Math.max(labelHeight / 2 + 6, Math.min(height - labelHeight / 2 - 6, rawY)) + + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = colors.elapsed + ctx.lineWidth = 1 + ctx.beginPath() + ctx.roundRect(x - labelWidth / 2, y - labelHeight / 2, labelWidth, labelHeight, 13) + ctx.fill() + ctx.stroke() + ctx.fillStyle = colors.elapsed + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, x, y) + } + ctx.restore() +} + +function drawHands(ctx, width, height, time, color) { + const { cx, cy, radius } = clockLayout(width, height) + const { hourAngle, minuteAngle } = timeAngles(time) + const hourTip = { + x: cx + Math.cos(hourAngle) * radius * 0.48, + y: cy + Math.sin(hourAngle) * radius * 0.48, + } + const minuteTip = { + x: cx + Math.cos(minuteAngle) * radius * 0.72, + y: cy + Math.sin(minuteAngle) * radius * 0.72, + } + + ctx.save() + ctx.strokeStyle = color + ctx.lineCap = 'round' + ctx.lineWidth = 8 + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(hourTip.x, hourTip.y) + ctx.stroke() + + ctx.lineWidth = 4 + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.lineTo(minuteTip.x, minuteTip.y) + ctx.stroke() + + ctx.fillStyle = colors.navy + ctx.beginPath() + ctx.arc(cx, cy, 7, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + + return { hourTip, minuteTip } +} + +function easeInOut(t) { + return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2 +} + +function ClockCanvas({ label, time, color, tint, onChange, showElapsedArc, startTime, endTime }) { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const dragRef = useRef(null) + const [size, setSize] = useState({ width: 300, height: clockHeight }) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setSize({ width: Math.max(260, Math.floor(node.clientWidth)), height: clockHeight }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = size.width * dpr + canvas.height = size.height * dpr + canvas.style.width = `${size.width}px` + canvas.style.height = `${size.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + drawClockFace(ctx, size.width, size.height, color) + if (showElapsedArc) drawElapsedArc(ctx, size.width, size.height, startTime, endTime) + drawHands(ctx, size.width, size.height, time, color) + }, [color, endTime, showElapsedArc, size, startTime, time]) + + useEffect(() => { + draw() + }, [draw]) + + const getPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (size.width / rect.width), + y: (event.clientY - rect.top) * (size.height / rect.height), + } + } + + const updateFromDrag = (event) => { + if (!dragRef.current) return + const point = getPoint(event) + const { cx, cy } = clockLayout(size.width, size.height) + const angle = angleFromPoint(point, cx, cy) + let nextTime + + if (dragRef.current.hand === 'hour') { + const halfDayMinutes = snapToFive((angle / (Math.PI * 2)) * 720) + const periodOffset = minutesToParts(dragRef.current.startTime).period === 'PM' ? 720 : 0 + nextTime = periodOffset + (halfDayMinutes % 720) + } else { + const delta = shortestAngleDelta(dragRef.current.lastAngle, angle) + dragRef.current.lastAngle = angle + dragRef.current.unwrappedDelta += delta + nextTime = dragRef.current.startTime + (dragRef.current.unwrappedDelta / (Math.PI * 2)) * 60 + } + + onChange(snapToFive(nextTime)) + } + + const beginDrag = (event) => { + const point = getPoint(event) + const { cx, cy, radius } = clockLayout(size.width, size.height) + const { hourAngle, minuteAngle } = timeAngles(time) + const hourTip = { x: cx + Math.cos(hourAngle) * radius * 0.48, y: cy + Math.sin(hourAngle) * radius * 0.48 } + const minuteTip = { x: cx + Math.cos(minuteAngle) * radius * 0.72, y: cy + Math.sin(minuteAngle) * radius * 0.72 } + const hourDistance = distanceToSegment(point, { x: cx, y: cy }, hourTip) + const minuteDistance = distanceToSegment(point, { x: cx, y: cy }, minuteTip) + dragRef.current = { + hand: hourDistance < minuteDistance ? 'hour' : 'minute', + startTime: time, + lastAngle: angleFromPoint(point, cx, cy), + unwrappedDelta: 0, + } + event.currentTarget.setPointerCapture(event.pointerId) + updateFromDrag(event) + } + + return ( + + + {label} + drag hands or type + + + { dragRef.current = null }} + onPointerCancel={() => { dragRef.current = null }} + aria-label={`${label} clock`} + /> + + + + ) +} + +function PlaybackClock({ startTime, endTime, progress }) { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const [size, setSize] = useState({ width: 440, height: playbackClockHeight }) + const elapsed = useMemo(() => getElapsed(startTime, endTime), [endTime, startTime]) + const eased = easeInOut(progress) + const animatedTime = startTime + elapsed * eased + const handBlend = easeInOut(clamp((progress - 0.08) / 0.84, 0, 1)) + const handColor = mixHex(colors.start, colors.end, handBlend) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setSize({ width: Math.max(300, Math.floor(node.clientWidth)), height: playbackClockHeight }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = size.width * dpr + canvas.height = size.height * dpr + canvas.style.width = `${size.width}px` + canvas.style.height = `${size.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + drawClockFace(ctx, size.width, size.height, colors.elapsed) + drawElapsedArc(ctx, size.width, size.height, startTime, endTime, eased) + drawHands(ctx, size.width, size.height, animatedTime, handColor) + }, [animatedTime, eased, endTime, handColor, size, startTime]) + + useEffect(() => { + draw() + }, [draw]) + + return ( + + + + ) +} + +function TimeEditor({ time, color, tint, onChange }) { + const parts = minutesToParts(time) + const [hourText, setHourText] = useState(String(parts.hour)) + const [minuteText, setMinuteText] = useState(String(parts.minute).padStart(2, '0')) + const editingRef = useRef(null) + + useEffect(() => { + if (editingRef.current) return + setHourText(String(parts.hour)) + setMinuteText(String(parts.minute).padStart(2, '0')) + }, [parts.hour, parts.minute]) + + const commitHour = (raw) => { + const digits = raw.replace(/\D/g, '').slice(0, 2) + const parsed = Number(digits) + if (digits && parsed >= 1 && parsed <= 12) { + onChange(partsToMinutes({ ...parts, hour: parsed })) + } + } + + const commitMinute = (raw) => { + const digits = raw.replace(/\D/g, '').slice(0, 2) + if (!digits) return + const parsed = clamp(Number(digits), 0, 59) + onChange(partsToMinutes({ ...parts, minute: parsed })) + } + + const finishHour = () => { + editingRef.current = null + const digits = hourText.replace(/\D/g, '').slice(0, 2) + const parsed = clamp(Number(digits || parts.hour), 1, 12) + setHourText(String(parsed)) + onChange(partsToMinutes({ ...parts, hour: parsed })) + } + + const finishMinute = () => { + editingRef.current = null + const digits = minuteText.replace(/\D/g, '').slice(0, 2) + const parsed = clamp(Number(digits || 0), 0, 59) + setMinuteText(String(parsed).padStart(2, '0')) + onChange(partsToMinutes({ ...parts, minute: parsed })) + } + + return ( + + { + editingRef.current = 'hour' + event.currentTarget.select() + }} + onBlur={finishHour} + onChange={(event) => { + const next = event.target.value.replace(/\D/g, '').slice(0, 2) + setHourText(next) + commitHour(next) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + className="w-10 rounded-lg border bg-transparent text-center font-mono text-xl font-black outline-none" + style={{ borderColor: tint, color }} + inputMode="numeric" + /> + : + { + editingRef.current = 'minute' + event.currentTarget.select() + }} + onBlur={finishMinute} + onChange={(event) => { + const next = event.target.value.replace(/\D/g, '').slice(0, 2) + setMinuteText(next) + commitMinute(next) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + className="w-12 rounded-lg border bg-transparent text-center font-mono text-xl font-black outline-none" + style={{ borderColor: tint, color }} + inputMode="numeric" + /> + onChange(partsToMinutes({ ...parts, period: parts.period === 'AM' ? 'PM' : 'AM' }))} + className="rounded-full px-3 py-1 text-sm font-black text-white" + style={{ background: color }} + > + {parts.period} + + + ) +} + +export default function ElapsedTimeClock() { + const [startTime, setStartTime] = useState(9 * 60 + 15) + const [endTime, setEndTime] = useState(12 * 60) + const [view, setView] = useState('set') + const [playProgress, setPlayProgress] = useState(0) + const frameRef = useRef(null) + const elapsed = useMemo(() => getElapsed(startTime, endTime), [endTime, startTime]) + + const cancelPlayback = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + }, []) + + const playElapsed = useCallback(() => { + cancelPlayback() + setView('play') + setPlayProgress(0) + const duration = 3900 + const startedAt = performance.now() + + const tick = (now) => { + const next = Math.min(1, (now - startedAt) / duration) + setPlayProgress(next) + if (next < 1) { + frameRef.current = requestAnimationFrame(tick) + } else { + frameRef.current = null + } + } + + frameRef.current = requestAnimationFrame(tick) + }, [cancelPlayback]) + + const editTimes = () => { + cancelPlayback() + setView('set') + setPlayProgress(0) + } + + useEffect(() => () => cancelPlayback(), [cancelPlayback]) + + const crossesPeriod = minutesToParts(startTime).period !== minutesToParts(endTime).period + const hint = `From ${formatClock(startTime)} to ${formatClock(endTime)} is ${formatDuration(elapsed, true)}. Count the hours first, then the extra minutes${crossesPeriod ? ' and watch the AM/PM change.' : '.'}` + + return ( + + + + + Start + -> + End + {view === 'play' && ( + <> + -> + Difference + > + )} + + {view === 'set' ? ( + + + Start {formatClock(startTime)} + + + End {formatClock(endTime)} + + + ) : ( + + Elapsed: {formatDuration(Math.round(elapsed * easeInOut(playProgress)), true)} + + )} + + + {view === 'set' ? ( + <> + + + + + + + Show elapsed time -> + + > + ) : ( + <> + + + + + + + Play again + + + Edit times + + + > + )} + + + {view === 'set' ? 'Set the start and end times first, then show the elapsed-time clock.' : hint} + + + ) +} diff --git a/src/manipulatives/explore-ratios.jsx b/src/manipulatives/explore-ratios.jsx new file mode 100644 index 0000000..3e2f6cb --- /dev/null +++ b/src/manipulatives/explore-ratios.jsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const colors = { + page: '#EAF4F8', + card: '#ffffff', + border: '#E0DDD6', + ink: '#1A1A2E', + muted: '#6B7280', + blue: '#2E6FD4', + blueDark: '#2660C4', + blueSoft: '#DCE8FF', + yellow: '#E8C33C', + yellowDark: '#C79A1E', + yellowSoft: '#FFF4BD', + purple: '#7B3F9E', + purpleSoft: '#F1E8F7', +} + +const canvasHeight = 178 +const maxBase = 9 +const maxMultiplier = 8 +const minReadableUnit = 12 +const barLeft = 92 +const barRightPad = 54 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const next = y + y = x % y + x = next + } + return x || 1 +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function maxGroupsThatFit(width, blue, yellow) { + const availableWidth = width - barLeft - barRightPad + const widestGroup = Math.max(blue, yellow) + return clamp(Math.floor(availableWidth / (widestGroup * minReadableUnit)), 1, maxMultiplier) +} + +function Stepper({ label, value, color, soft, dark, onChange }) { + return ( + + {label} + + onChange(value - 1)} + className="flex h-10 w-10 items-center justify-center rounded-full text-xl font-black text-white" + style={{ background: color }} + > + - + + + {value} + + onChange(value + 1)} + className="flex h-10 w-10 items-center justify-center rounded-full text-xl font-black text-white" + style={{ background: color }} + > + + + + + + ) +} + +export default function ExploreRatios() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [blue, setBlue] = useState(3) + const [yellow, setYellow] = useState(2) + const [multiplier, setMultiplier] = useState(1) + const [animatedGroups, setAnimatedGroups] = useState(1) + + const fitMaxMultiplier = maxGroupsThatFit(canvasWidth, blue, yellow) + const activeMultiplier = clamp(multiplier, 1, fitMaxMultiplier) + const blueTotal = blue * activeMultiplier + const yellowTotal = yellow * activeMultiplier + const divisor = gcd(blue, yellow) + const isSimplified = divisor === 1 + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = colors.card + drawRoundRect(ctx, 0, 0, canvasWidth, canvasHeight, 14) + ctx.fill() + + const labelX = 26 + const barX = barLeft + const rightPad = barRightPad + const availableWidth = canvasWidth - barX - rightPad + const longestCurrent = Math.max(blueTotal, yellowTotal, maxBase) + const unit = Math.min(34, availableWidth / longestCurrent) + const barH = 34 + const rows = [ + { label: 'Blue', base: blue, value: blueTotal, y: 42, color: colors.blue, dark: colors.blueDark, soft: colors.blueSoft }, + { label: 'Yellow', base: yellow, value: yellowTotal, y: 108, color: colors.yellow, dark: colors.yellowDark, soft: colors.yellowSoft }, + ] + const shortestX = barX + Math.min(blueTotal, yellowTotal) * unit + const guideTop = rows[0].y - 8 + const guideBottom = rows[1].y + barH + 8 + + rows.forEach((row) => { + const width = Math.max(1, row.value * unit) + const visibleWidth = Math.max(1, Math.min(row.value, animatedGroups * row.base) * unit) + const trackW = width + Math.max(8, unit * 0.5) + ctx.fillStyle = row.dark + ctx.font = '800 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(row.label, labelX, row.y + barH / 2) + + ctx.fillStyle = row.soft + ctx.globalAlpha = 0.35 + drawRoundRect(ctx, barX, row.y, trackW, barH, 9) + ctx.fill() + ctx.globalAlpha = 1 + + ctx.save() + ctx.beginPath() + drawRoundRect(ctx, barX, row.y, visibleWidth, barH, 9) + ctx.clip() + for (let group = 0; group < activeMultiplier; group += 1) { + const groupStart = barX + group * row.base * unit + const groupWidth = row.base * unit + const groupProgress = clamp(animatedGroups - group, 0, 1) + if (groupProgress <= 0) continue + ctx.globalAlpha = groupProgress + ctx.fillStyle = group % 2 === 0 ? row.color : row.dark + ctx.globalAlpha = group % 2 === 0 ? groupProgress : groupProgress * 0.86 + ctx.fillRect(groupStart, row.y, groupWidth, barH) + ctx.globalAlpha = groupProgress + ctx.strokeStyle = '#ffffff88' + ctx.lineWidth = 1 + for (let i = 1; i < row.base; i += 1) { + const x = groupStart + i * unit + ctx.beginPath() + ctx.moveTo(x, row.y + 4) + ctx.lineTo(x, row.y + barH - 4) + ctx.stroke() + } + } + ctx.globalAlpha = 1 + ctx.restore() + + ctx.strokeStyle = row.dark + ctx.lineWidth = 2 + drawRoundRect(ctx, barX, row.y, width, barH, 9) + ctx.stroke() + + ctx.strokeStyle = row.dark + ctx.lineWidth = 3 + for (let group = 1; group < activeMultiplier; group += 1) { + const x = barX + group * row.base * unit + ctx.beginPath() + ctx.moveTo(x, row.y - 2) + ctx.lineTo(x, row.y + barH + 2) + ctx.stroke() + } + + const textX = Math.min(canvasWidth - 28, barX + width + 12) + ctx.fillStyle = row.dark + ctx.font = '900 18px ui-monospace, SFMono-Regular, Menlo, monospace' + ctx.textAlign = 'left' + ctx.fillText(String(row.value), textX, row.y + barH / 2) + }) + + if (blueTotal !== yellowTotal) { + ctx.save() + ctx.strokeStyle = colors.purple + ctx.globalAlpha = 0.22 + ctx.lineWidth = 2 + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(shortestX, guideTop) + ctx.lineTo(shortestX, guideBottom) + ctx.stroke() + ctx.restore() + } + ctx.fillStyle = colors.purple + ctx.font = '900 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.fillText(`${activeMultiplier} group${activeMultiplier === 1 ? '' : 's'} of ${blue}:${yellow}`, barX + Math.max(blueTotal, yellowTotal) * unit / 2, 24) + }, [activeMultiplier, animatedGroups, blue, blueTotal, canvasWidth, yellow, yellowTotal]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + const startedAt = performance.now() + const duration = 520 + const tick = (now) => { + const t = clamp((now - startedAt) / duration, 0, 1) + const eased = 1 - Math.pow(1 - t, 3) + setAnimatedGroups(activeMultiplier * eased) + if (t < 1) { + frameRef.current = requestAnimationFrame(tick) + return + } + setAnimatedGroups(activeMultiplier) + frameRef.current = null + } + frameRef.current = requestAnimationFrame(tick) + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + }, [activeMultiplier, blue, yellow]) + + return ( + + + setBlue(clamp(value, 1, 9))} /> + : + setYellow(clamp(value, 1, 9))} /> + + + + + + + + + Make copies of the ratio + + ×{activeMultiplier} + + {activeMultiplier} group{activeMultiplier === 1 ? '' : 's'} of {blue}:{yellow} + + setMultiplier(Number(event.target.value))} + className="w-full accent-[#7B3F9E]" + aria-label="Scale both parts by multiplier" + /> + {fitMaxMultiplier < maxMultiplier && ( + + Slider stops at ×{fitMaxMultiplier} so the groups stay readable. + + )} + + + + + {activeMultiplier === 1 ? ( + + {blue} + : + {yellow} + + ) : ( + + + {blue} + : + {yellow} + + = + + {blueTotal} + : + {yellowTotal} + + + )} + {!isSimplified && ( + + = {blue / divisor} + : + {yellow / divisor} simplified + + )} + + {activeMultiplier} group{activeMultiplier === 1 ? '' : 's'} of {blue} + : + {yellow} — both parts grew ×{activeMultiplier}, so the ratio stays the same. + + + + + ) +} diff --git a/src/manipulatives/factor-rainbow.jsx b/src/manipulatives/factor-rainbow.jsx new file mode 100644 index 0000000..74b519b --- /dev/null +++ b/src/manipulatives/factor-rainbow.jsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const rainbow = ['#E23B3B', '#E8822E', '#E8C020', '#4CAF50', '#2AA9E0', '#3F51B5', '#8E44AD'] +const navy = '#1A1A2E' +const cream = '#F8F6F0' +const canvasHeight = 280 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeOutBack(t) { + const c1 = 1.25 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) +} + +function hexToRgb(hex) { + const value = hex.replace('#', '') + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + } +} + +function alpha(hex, amount) { + const { r, g, b } = hexToRgb(hex) + return `rgba(${r}, ${g}, ${b}, ${amount})` +} + +function getFactors(number) { + const result = [] + for (let value = 1; value <= number; value += 1) { + if (number % value === 0) result.push(value) + } + return result +} + +function getPairs(number) { + const result = [] + for (let value = 1; value * value <= number; value += 1) { + if (number % value === 0) result.push([value, number / value]) + } + return result +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function typeInfo(number, factors) { + const root = Math.sqrt(number) + const isSquare = Number.isInteger(root) + const isPrime = factors.length === 2 + return { + isPrime, + isSquare, + root, + label: isPrime ? 'Prime' : 'Composite', + } +} + +export default function FactorRainbow() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [number, setNumber] = useState(36) + const [animation, setAnimation] = useState(1) + + const factors = useMemo(() => getFactors(number), [number]) + const pairs = useMemo(() => getPairs(number), [number]) + const info = useMemo(() => typeInfo(number, factors), [factors, number]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const startArcAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + const startedAt = performance.now() + const duration = 620 + setAnimation(0) + + const tick = (now) => { + const progress = clamp((now - startedAt) / duration, 0, 1) + setAnimation(progress) + if (progress < 1) frameRef.current = requestAnimationFrame(tick) + else frameRef.current = null + } + + frameRef.current = requestAnimationFrame(tick) + }, []) + + useEffect(() => { + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + } + }, []) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const pad = Math.max(30, Math.min(38, canvasWidth * 0.045)) + const baseY = canvasHeight - 45 + const usable = canvasWidth - pad * 2 + const toX = (value) => pad + (value / number) * usable + const leftFactorCount = number % 2 === 0 ? factors.filter((factor) => factor <= number / 2).length : factors.length + const halfSpacing = number % 2 === 0 && leftFactorCount > 1 ? (usable / 2) / (leftFactorCount - 1) : 30 + const dotRadius = clamp(halfSpacing / 2 - 1.5, 10.5, 13) + const fontSize = clamp(dotRadius + 1, 12, 14) + const labelY = baseY + dotRadius + 10 + + ctx.fillStyle = '#FFFFFF' + drawRoundRect(ctx, 0, 0, canvasWidth, canvasHeight, 14) + ctx.fill() + + ctx.strokeStyle = alpha(navy, 0.16) + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(pad - 10, baseY) + ctx.lineTo(canvasWidth - pad + 10, baseY) + ctx.stroke() + + const centerX = pad + usable / 2 + const rightX = canvasWidth - pad + const positionedFactors = factors.map((factor) => { + let x = toX(factor) + if (factor === 1) x = pad + if (factor === number) x = rightX + if (number % 2 === 0 && factor === number / 2) x = centerX + return { factor, x } + }) + const minSpacing = Math.max(20, dotRadius * 2 + 2) + const spreadSegment = (items, startX, endX) => { + if (items.length <= 1) return + items[0].x = startX + items[items.length - 1].x = endX + const availableSpacing = (endX - startX) / (items.length - 1) + const segmentSpacing = Math.min(minSpacing, availableSpacing) + for (let i = 1; i < items.length - 1; i += 1) { + items[i].x = clamp(items[i].x, startX + i * segmentSpacing, endX - (items.length - 1 - i) * segmentSpacing) + if (items[i].x - items[i - 1].x < segmentSpacing) items[i].x = items[i - 1].x + segmentSpacing + } + for (let i = items.length - 2; i > 0; i -= 1) { + if (items[i + 1].x - items[i].x < segmentSpacing) items[i].x = items[i + 1].x - segmentSpacing + } + } + if (number % 2 === 0 && factors.includes(number / 2)) { + spreadSegment(positionedFactors.filter((item) => item.factor <= number / 2), pad, centerX) + spreadSegment(positionedFactors.filter((item) => item.factor >= number / 2), centerX, rightX) + } else { + spreadSegment(positionedFactors, pad, rightX) + } + const positions = new Map() + positionedFactors.forEach(({ factor, x }) => { + positions.set(factor, { x, y: baseY }) + }) + + const arcStroke = 6.25 + const maxArcHeight = Math.min(205, baseY - 32) + const minArcHeight = 34 + const arcBandStep = pairs.length > 1 ? (maxArcHeight - minArcHeight) / (pairs.length - 1) : 0 + + pairs.forEach(([left, right], index) => { + const from = positions.get(left) + const to = positions.get(right) + if (!from || !to) return + const color = rainbow[index % rainbow.length] + const delay = index * 0.085 + const local = clamp((animation - delay) / 0.46, 0, 1) + if (local <= 0) return + const spring = clamp(easeOutBack(local), 0, 1.08) + + ctx.save() + ctx.globalAlpha = clamp(local * 1.15, 0, 1) + ctx.strokeStyle = color + ctx.lineWidth = arcStroke + ctx.lineCap = 'round' + ctx.shadowColor = alpha(color, 0.55) + ctx.shadowBlur = 6 + + if (left === right) { + const loopHeight = 56 * spring + const loopWidth = Math.max(24, dotRadius * 2.1) + ctx.beginPath() + ctx.moveTo(from.x - loopWidth / 2, from.y) + ctx.bezierCurveTo(from.x - loopWidth / 2, from.y - loopHeight, from.x + loopWidth / 2, from.y - loopHeight, from.x + loopWidth / 2, from.y) + ctx.stroke() + } else { + const arcLift = number === 120 && index === 0 ? 18 : 0 + const arcHeight = Math.min(baseY - 14, maxArcHeight + arcLift - index * arcBandStep) * spring + ctx.beginPath() + ctx.moveTo(from.x, from.y) + ctx.bezierCurveTo(from.x, from.y - arcHeight, to.x, from.y - arcHeight, to.x, to.y) + ctx.stroke() + } + ctx.restore() + }) + + factors.forEach((factor) => { + const point = positions.get(factor) + ctx.fillStyle = cream + ctx.strokeStyle = navy + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(point.x, point.y, dotRadius, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = navy + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `900 ${fontSize}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` + ctx.fillText(String(factor), point.x, point.y + 0.5) + }) + + ctx.fillStyle = '#5F5E5A' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.font = '700 11px Inter, system-ui, sans-serif' + ctx.fillText(`${pairs.length} factor ${pairs.length === 1 ? 'pair' : 'pairs'} for ${number}`, canvasWidth / 2, labelY) + }, [animation, canvasWidth, factors, number, pairs]) + + useEffect(() => { + draw() + }, [draw]) + + const setPickedNumber = (next) => { + const picked = clamp(Math.round(next), 2, 120) + setNumber(picked) + startArcAnimation() + } + + const hint = (() => { + if (info.isPrime) return `${number} is prime - only one arc (1 * ${number}). Primes always look this bare.` + if (info.isSquare) return `${number} is a perfect square - sqrt(${number}) = ${info.root} pairs with itself, making the lone middle loop.` + return `${number} has ${pairs.length} factor pairs, so ${pairs.length} arcs. Each arc joins two numbers that multiply to ${number}.` + })() + + return ( + + + + Pick a number + + setPickedNumber(number - 1)} + className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#534AB7] text-xl font-black text-white" + > + - + + + {number} + + setPickedNumber(number + 1)} + className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#534AB7] text-xl font-black text-white" + > + + + + + + + + + + + + + Factors + {factors.join(', ')} + + + How many + {factors.length} factors · {pairs.length} pairs + + + Type + + {info.label} + {info.isSquare ? Square : null} + + + + + + {[7, 12, 17, 36, 48, 100].map((preset) => ( + setPickedNumber(preset)} + className={`rounded-full border px-3 py-1 font-mono text-xs font-black transition ${ + number === preset ? 'border-[#534AB7] bg-[#534AB7] text-white' : 'border-[#E0DDD6] bg-white text-[#1A1A2E]' + }`} + > + {preset} + + ))} + + + + {hint} + + + + ) +} diff --git a/src/manipulatives/fractions-number-line.jsx b/src/manipulatives/fractions-number-line.jsx new file mode 100644 index 0000000..0756357 --- /dev/null +++ b/src/manipulatives/fractions-number-line.jsx @@ -0,0 +1,570 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + card: '#ffffff', + border: '#E0DDD6', + ink: '#1A1A2E', + muted: '#5F5E5A', + blue: '#2A7DE1', + blueTint: '#E8F1FC', + green: '#3B9E4E', + red: '#D64550', + amber: '#E0872E', + purple: '#7B3F9E', + purpleTint: '#EFE7F5', +} + +const compareColors = ['#2A7DE1', '#D64550', '#3B9E4E'] +const modes = ['Place it', 'Compare'] +const maxDenominator = 12 +const canvasHeight = 248 + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const next = y + y = x % y + x = next + } + return x || 1 +} + +function fractionValue(frac) { + return frac.n / frac.d +} + +function fractionText(frac) { + return `${frac.n}/${frac.d}` +} + +function properFractions(maxDen, simplestOnly = false) { + const list = [] + for (let d = 2; d <= maxDen; d += 1) { + for (let n = 1; n < d; n += 1) { + if (!simplestOnly || gcd(n, d) === 1) list.push({ n, d }) + } + } + return list +} + +function pickFraction(maxDen, simplestOnly = false) { + const list = properFractions(maxDen, simplestOnly) + return list[Math.floor(Math.random() * list.length)] ?? { n: 1, d: 2 } +} + +function pickCompareFractions(maxDen) { + const count = maxDen >= 8 ? 3 : 2 + const list = properFractions(maxDen) + const picked = [] + const used = new Set() + while (picked.length < count && list.length) { + const frac = list[Math.floor(Math.random() * list.length)] + const key = fractionValue(frac).toFixed(5) + if (!used.has(key)) { + used.add(key) + picked.push(frac) + } + } + return picked +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function Fraction({ frac, color = colors.ink, size = 'large' }) { + const textSize = size === 'small' ? 'text-base' : size === 'medium' ? 'text-2xl' : 'text-3xl' + return ( + + {frac.n} + + {frac.d} + + ) +} + +function Pill({ active, children, onClick, activeColor = colors.blue }) { + return ( + + {children} + + ) +} + +export default function FractionsNumberLine() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [mode, setMode] = useState('Place it') + const [target, setTarget] = useState(() => pickFraction(maxDenominator)) + const [compare, setCompare] = useState(() => pickCompareFractions(maxDenominator)) + const [comparePositions, setComparePositions] = useState(() => [0, 0, 0]) + const [comparePlaced, setComparePlaced] = useState(() => [false, false, false]) + const [compareChecked, setCompareChecked] = useState(false) + const [compareClose, setCompareClose] = useState([]) + const [compareOrderCorrect, setCompareOrderCorrect] = useState(null) + const [compareMessage, setCompareMessage] = useState('') + const [dotValue, setDotValue] = useState(0.35) + const [dragging, setDragging] = useState(false) + const [compareDragging, setCompareDragging] = useState(null) + const [checked, setChecked] = useState(false) + const [correct, setCorrect] = useState(null) + const [showTicks, setShowTicks] = useState(false) + const [streak, setStreak] = useState(0) + + const resetRound = useCallback((nextMode = mode) => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + if (nextMode === 'Place it') { + const next = pickFraction(maxDenominator) + setTarget(next) + setDotValue(Math.random() * 0.7 + 0.15) + setChecked(false) + setCorrect(null) + } else { + const nextCompare = pickCompareFractions(maxDenominator) + setCompare(nextCompare) + setComparePositions(nextCompare.map(() => 0)) + setComparePlaced(nextCompare.map(() => false)) + setCompareChecked(false) + setCompareClose([]) + setCompareOrderCorrect(null) + setCompareMessage('') + } + }, [mode]) + + const orderedCompare = useMemo(() => [...compare].sort((a, b) => fractionValue(a) - fractionValue(b)), [compare]) + + const drawFractionLabel = useCallback((ctx, frac, x, y, color, align = 'center') => { + ctx.save() + ctx.fillStyle = color + ctx.strokeStyle = color + ctx.textAlign = align + ctx.textBaseline = 'middle' + ctx.font = '900 18px ui-monospace, SFMono-Regular, Menlo, monospace' + ctx.fillText(String(frac.n), x, y - 8) + ctx.lineWidth = 2 + const width = Math.max(20, ctx.measureText(String(Math.max(frac.n, frac.d))).width + 8) + ctx.beginPath() + ctx.moveTo(x - width / 2, y) + ctx.lineTo(x + width / 2, y) + ctx.stroke() + ctx.fillText(String(frac.d), x, y + 12) + ctx.restore() + }, []) + + const drawLine = useCallback((ctx, y, pad, lineW, tickDen = null, muted = false, labelTicks = false) => { + ctx.strokeStyle = muted ? '#1A1A2E55' : colors.ink + ctx.lineWidth = muted ? 1.5 : 2.5 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(pad, y) + ctx.lineTo(pad + lineW, y) + ctx.stroke() + ctx.font = '800 15px Inter, system-ui, sans-serif' + ctx.fillStyle = colors.ink + ctx.textAlign = 'center' + ctx.fillText('0', pad, y + 24) + ctx.fillText('1', pad + lineW, y + 24) + if (tickDen) { + ctx.strokeStyle = '#1A1A2E66' + ctx.lineWidth = 1.2 + for (let i = 1; i < tickDen; i += 1) { + const x = pad + (i / tickDen) * lineW + ctx.beginPath() + ctx.moveTo(x, y - 9) + ctx.lineTo(x, y + 9) + ctx.stroke() + if (labelTicks) { + drawFractionLabel(ctx, { n: i, d: tickDen }, x, y + 51, colors.muted) + } + } + } + }, [drawFractionLabel]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = colors.card + drawRoundRect(ctx, 0, 0, canvasWidth, canvasHeight, 14) + ctx.fill() + + const pad = Math.max(42, Math.min(58, canvasWidth * 0.075)) + const lineW = canvasWidth - pad * 2 + const lineX = (value) => pad + value * lineW + + if (mode === 'Place it') { + const y = 95 + drawLine(ctx, y, pad, lineW, showTicks ? target.d : null, false, checked && showTicks) + const exactX = lineX(fractionValue(target)) + if (checked && !correct) { + const guessX = lineX(dotValue) + ctx.save() + ctx.strokeStyle = colors.red + ctx.globalAlpha = 0.65 + ctx.lineWidth = 2 + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(Math.min(guessX, exactX), y - 31) + ctx.lineTo(Math.max(guessX, exactX), y - 31) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colors.red + ctx.font = '800 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.fillText('gap', (guessX + exactX) / 2, y - 40) + ctx.restore() + ctx.strokeStyle = colors.green + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(exactX, y - 30) + ctx.lineTo(exactX, y + 30) + ctx.stroke() + drawFractionLabel(ctx, target, exactX, y - 48, colors.green) + } + const dotX = lineX(checked && correct ? fractionValue(target) : dotValue) + ctx.fillStyle = checked ? (correct ? colors.green : colors.red) : colors.blue + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(dotX, y, 13, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } else { + const y = 118 + drawLine(ctx, y, pad, lineW) + if (compareChecked) { + const denominators = [...new Set(compare.map((frac) => frac.d))] + ctx.save() + denominators.forEach((denominator) => { + ctx.strokeStyle = '#1A1A2E2e' + ctx.lineWidth = 1 + for (let i = 1; i < denominator; i += 1) { + const x = lineX(i / denominator) + ctx.beginPath() + ctx.moveTo(x, y - 16) + ctx.lineTo(x, y + 16) + ctx.stroke() + } + }) + ctx.restore() + } + compare.forEach((frac, index) => { + const placed = comparePlaced[index] + const trayX = pad + ((index + 1) / (compare.length + 1)) * lineW + const x = placed || compareChecked ? lineX(comparePositions[index] ?? 0) : trayX + const dotY = placed || compareChecked ? y : 206 + const color = compareColors[index % compareColors.length] + const checkedColor = compareChecked ? (compareClose[index] ? colors.green : colors.amber) : color + ctx.fillStyle = checkedColor + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(x, dotY, 14, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + const labelY = placed || compareChecked + ? dotY + (index % 2 === 0 ? -60 : 66) + : dotY - 48 + drawFractionLabel(ctx, frac, x, labelY, color) + }) + } + }, [canvasWidth, compare, compareChecked, compareClose, comparePlaced, comparePositions, correct, dotValue, drawFractionLabel, drawLine, mode, showTicks, target, checked]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const canvasPoint = (event) => { + const rect = canvasRef.current.getBoundingClientRect() + return { + x: ((event.clientX - rect.left) / rect.width) * canvasWidth, + y: ((event.clientY - rect.top) / rect.height) * canvasHeight, + } + } + + const updateDrag = (event) => { + const pad = Math.max(42, Math.min(58, canvasWidth * 0.075)) + const lineW = canvasWidth - pad * 2 + const point = canvasPoint(event) + setDotValue(clamp((point.x - pad) / lineW, 0, 1)) + } + + const handlePointerDown = (event) => { + if (mode === 'Compare') { + if (compareChecked) return + const pad = Math.max(42, Math.min(58, canvasWidth * 0.075)) + const lineW = canvasWidth - pad * 2 + const point = canvasPoint(event) + const lineY = 118 + const hit = compare.findIndex((_, index) => { + const placed = comparePlaced[index] + const x = placed ? pad + (comparePositions[index] ?? 0) * lineW : pad + ((index + 1) / (compare.length + 1)) * lineW + const y = placed ? lineY : 206 + return Math.hypot(point.x - x, point.y - y) <= 23 + }) + if (hit >= 0) { + setCompareDragging(hit) + setComparePlaced((placed) => placed.map((value, index) => index === hit ? true : value)) + setComparePositions((positions) => positions.map((value, index) => index === hit ? clamp((point.x - pad) / lineW, 0, 1) : value)) + } + return + } + if (mode !== 'Place it' || checked) return + setDragging(true) + updateDrag(event) + } + + const handlePointerMove = (event) => { + if (mode === 'Compare' && compareDragging !== null) { + const pad = Math.max(42, Math.min(58, canvasWidth * 0.075)) + const lineW = canvasWidth - pad * 2 + const point = canvasPoint(event) + setComparePositions((positions) => positions.map((value, index) => index === compareDragging ? clamp((point.x - pad) / lineW, 0, 1) : value)) + return + } + if (!dragging) return + updateDrag(event) + } + + const handlePointerUp = () => { + setDragging(false) + setCompareDragging(null) + } + + const checkPlace = () => { + const targetValue = fractionValue(target) + const tolerance = 0.6 * (1 / target.d / 2) + const ok = Math.abs(dotValue - targetValue) <= tolerance + setChecked(true) + setCorrect(ok) + if (!ok) { + setStreak(0) + return + } + setStreak((value) => value + 1) + const from = dotValue + const startedAt = performance.now() + const duration = 520 + if (frameRef.current) cancelAnimationFrame(frameRef.current) + const tick = (now) => { + const t = clamp((now - startedAt) / duration, 0, 1) + const eased = 1 - Math.pow(1 - t, 3) + setDotValue(from + (targetValue - from) * eased) + if (t < 1) { + frameRef.current = requestAnimationFrame(tick) + return + } + setDotValue(targetValue) + frameRef.current = null + } + frameRef.current = requestAnimationFrame(tick) + } + + const changeMode = (next) => { + setMode(next) + resetRound(next) + } + + const checkCompare = () => { + const trueValues = compare.map(fractionValue) + const guessedOrder = comparePositions + .map((position, index) => ({ index, position })) + .sort((a, b) => a.position - b.position) + .map((item) => item.index) + const trueOrder = trueValues + .map((position, index) => ({ index, position })) + .sort((a, b) => a.position - b.position) + .map((item) => item.index) + const orderCorrect = guessedOrder.join(',') === trueOrder.join(',') + const rankCorrect = compare.map((_, index) => guessedOrder.indexOf(index) === trueOrder.indexOf(index)) + let message = 'Correct order — least to greatest!' + if (!orderCorrect) { + for (let rank = 0; rank < guessedOrder.length - 1; rank += 1) { + const left = guessedOrder[rank] + const right = guessedOrder[rank + 1] + if (trueValues[left] > trueValues[right]) { + message = `Not in order yet — ${fractionText(compare[right])} should be to the left of ${fractionText(compare[left])}.` + break + } + } + } + + setCompareChecked(true) + setCompareClose(rankCorrect) + setCompareOrderCorrect(orderCorrect) + setCompareMessage(message) + const from = [...comparePositions] + const startedAt = performance.now() + const duration = 520 + if (frameRef.current) cancelAnimationFrame(frameRef.current) + const tick = (now) => { + const t = clamp((now - startedAt) / duration, 0, 1) + const eased = 1 - Math.pow(1 - t, 3) + setComparePositions(from.map((position, index) => position + (trueValues[index] - position) * eased)) + if (t < 1) { + frameRef.current = requestAnimationFrame(tick) + return + } + setComparePositions(trueValues) + frameRef.current = null + } + frameRef.current = requestAnimationFrame(tick) + } + + const prompt = (() => { + if (mode === 'Place it') { + return <>Drag the dot to place on the line.> + } + return ( + + Drag each fraction to its place, then check. + {compare.map((frac, index) => ( + + + + ))} + + ) + })() + + const hint = (() => { + if (mode === 'Place it') { + if (!checked) return 'Move the blue dot, then check. Tick marks can help you split the line into equal parts.' + if (correct) return 'Correct. The dot slides to the exact fraction location, and the labelled ticks show the equal parts.' + return 'Not quite. The dashed gap shows how far your estimate was from the correct point.' + } + if (!compareChecked) return 'Think about whether each fraction is closer to 0, 1/2, or 1. Drag every dot onto the line before checking.' + return compareOrderCorrect + ? 'Correct order — least to greatest! Further right means bigger, so the line lets you compare without common denominators.' + : `${compareMessage} The dots moved to their true positions so you can see why.` + })() + + const allComparePlaced = comparePlaced.slice(0, compare.length).every(Boolean) + + return ( + + + + {modes.map((item) => ( + changeMode(item)}>{item} + ))} + + + + + {prompt} + + + + + + + + {mode === 'Place it' && ( + <> + + Check + + setShowTicks((value) => !value)} className="rounded-full border bg-white px-4 py-2 text-sm font-black" style={{ borderColor: showTicks ? colors.blue : colors.border, color: showTicks ? colors.blue : colors.ink }}> + Show tick marks + + resetRound('Place it')} className="rounded-full border bg-white px-4 py-2 text-sm font-black" style={{ borderColor: colors.border }}> + New fraction + + 0 ? colors.green : colors.border, color: streak > 0 ? colors.green : colors.muted }}> + {streak} in a row + + > + )} + {mode !== 'Place it' && ( + <> + + Check my order + + resetRound(mode)} className="rounded-full border bg-white px-5 py-2 text-sm font-black" style={{ borderColor: colors.border }}> + New comparison + + > + )} + + + + {mode === 'Compare' && compareChecked && ( + + {orderedCompare.map((frac, index) => ( + + item.n === frac.n && item.d === frac.d) % compareColors.length]}18`, + }} + > + item.n === frac.n && item.d === frac.d) % compareColors.length]} size="small" /> + + {index < orderedCompare.length - 1 && <} + + ))} + + )} + {hint} + + + ) +} diff --git a/src/manipulatives/function-machine-detective.jsx b/src/manipulatives/function-machine-detective.jsx new file mode 100644 index 0000000..9107c70 --- /dev/null +++ b/src/manipulatives/function-machine-detective.jsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + input: '#2660C4', + inputTint: '#EAF0FB', + inputBorder: '#8AA8DD', + output: '#1E7A5E', + outputTint: '#E9F5EF', + outputBorder: '#7FCBAC', + rule: '#7B3F9E', + ruleDark: '#6B2E92', + ruleLight: '#8B54AE', + wrong: '#B23050', + win: '#27500A', + winTint: '#EAF3DE', + amber: '#8A4A12', + amberTint: '#FBEEDD', +} + +const rulePool = [ + { op: 'multiply', value: 2 }, + { op: 'multiply', value: 3 }, + { op: 'multiply', value: 4 }, + { op: 'multiply', value: 5 }, + { op: 'add', value: 3 }, + { op: 'add', value: 5 }, + { op: 'add', value: 7 }, + { op: 'add', value: 10 }, +] + +function applyRule(rule, input) { + const amount = Number(rule.value) + return rule.op === 'multiply' ? input * amount : input + amount +} + +function ruleText(rule) { + return rule.op === 'multiply' ? `out = in × ${rule.value}` : `out = in + ${rule.value}` +} + +function ruleWindowText(rule) { + return rule.op === 'multiply' ? `× ${rule.value}` : `+ ${rule.value}` +} + +function pickRule(previous) { + const choices = previous ? rulePool.filter((rule) => rule.op !== previous.op || rule.value !== previous.value) : rulePool + return choices[Math.floor(Math.random() * choices.length)] +} + +function easeInOut(t) { + return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2 +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function guessLimits(op) { + return op === 'multiply' ? { min: 2, max: 5 } : { min: 1, max: 12 } +} + +function normalizeGuess(guess) { + const { min, max } = guessLimits(guess.op) + const next = Number(guess.value) + return { + ...guess, + value: String(clamp(Number.isFinite(next) ? Math.round(next) : min, min, max)), + } +} + +function MachineScene({ anim, rule, cracked }) { + const process = anim.active ? clamp((anim.t - 0.62) / 0.16, 0, 1) : 0 + const outPhase = anim.active && anim.t > 0.76 + const outputResting = !anim.active && anim.t === 1 + const inPhase = anim.active && anim.t <= 0.72 + const inT = easeInOut(clamp((anim.t - 0.34) / 0.28, 0, 1)) + const outT = easeInOut(clamp((anim.t - 0.76) / 0.17, 0, 1)) + const blueX = 92 + (338 - 92) * inT + const greenX = outputResting ? 708 : 462 + (708 - 462) * outT + const ballY = 94 + + return ( + + + In + + + Out + + + + + + + + + Secret rule + 0 && process < 1 ? 'animate-[rulePulse_420ms_ease-in-out_infinite]' : ''}`} + style={{ color: colors.rule }} + > + {cracked ? ruleWindowText(rule) : '?'} + + + + + + + + + {anim.active && inPhase && ( + + {anim.input} + + )} + {((anim.active && outPhase) || outputResting) && ( + + {anim.output} + + )} + + ) +} + +function CluesTable({ rows, highlightKey }) { + return ( + + + Clues + {rows.length} unique {rows.length === 1 ? 'input' : 'inputs'} + + + In + Out + {rows.length === 0 ? ( + Run a number to collect your first clue. + ) : rows.map((row) => ( + + {row.input} + {row.output} + + ))} + + + ) +} + +export default function FunctionMachineDetective() { + const frameRef = useRef(null) + const [rule, setRule] = useState(() => pickRule()) + const [input, setInput] = useState('3') + const [rows, setRows] = useState([]) + const [highlightKey, setHighlightKey] = useState(null) + const [guess, setGuess] = useState({ op: 'multiply', value: 2 }) + const [message, setMessage] = useState({ kind: 'quiet', text: 'Feed in numbers and gather clues. Look at how each IN becomes its OUT.' }) + const [cracked, setCracked] = useState(false) + const [anim, setAnim] = useState({ active: false, t: 0, input: 0, output: 0 }) + + const distinctInputs = rows.length + const canRun = !anim.active && input !== '' + const quickValues = [1, 2, 3, 5, 10] + const { min: guessMin, max: guessMax } = guessLimits(guess.op) + + const stopAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + }, []) + + const logRow = useCallback((inValue, outValue) => { + setRows((current) => { + if (current.some((row) => row.input === inValue)) { + setMessage({ kind: 'quiet', text: `Same input, same output: ${inValue} always becomes ${outValue}.` }) + return current + } + return [{ input: inValue, output: outValue }, ...current] + }) + setHighlightKey(inValue) + setTimeout(() => setHighlightKey(null), 900) + setCracked(false) + }, []) + + const runInput = useCallback((rawValue = input) => { + if (anim.active) return + const inValue = Number(rawValue) + if (!Number.isFinite(inValue)) return + const roundedInput = Math.round(inValue) + const output = applyRule(rule, roundedInput) + stopAnimation() + setMessage({ kind: 'quiet', text: 'Watch what the machine does, then add the clue to your pattern.' }) + const start = performance.now() + const duration = 3000 + const tick = (now) => { + const t = Math.min(1, (now - start) / duration) + setAnim({ active: true, t, input: roundedInput, output }) + if (t < 1) { + frameRef.current = requestAnimationFrame(tick) + } else { + frameRef.current = null + setAnim({ active: false, t: 1, input: roundedInput, output }) + logRow(roundedInput, output) + setMessage((current) => { + if (current.text.startsWith('Same input')) return current + return { + kind: rows.length === 0 ? 'amber' : 'quiet', + text: rows.length === 0 ? 'Feed one more different number to confirm the pattern holds.' : 'What single operation turns each IN into its OUT?', + } + }) + } + } + frameRef.current = requestAnimationFrame(tick) + }, [anim.active, input, logRow, rows.length, rule, stopAnimation]) + + const setGuessOperation = (op) => { + const { min, max } = guessLimits(op) + setGuess((current) => ({ + op, + value: String(clamp(Number.isFinite(Number(current.value)) ? Math.round(Number(current.value)) : min, min, max)), + })) + } + + const testGuess = () => { + const checkedGuess = normalizeGuess(guess) + setGuess(checkedGuess) + if (rows.length === 0) { + setMessage({ kind: 'amber', text: 'Feed the machine first so your rule has clues to match.' }) + return + } + const broken = rows.find((row) => applyRule(checkedGuess, row.input) !== row.output) + if (broken) { + setCracked(false) + setMessage({ + kind: 'wrong', + text: `Your rule turns ${broken.input} into ${applyRule(checkedGuess, broken.input)}, but the machine made ${broken.output}.`, + }) + return + } + if (distinctInputs < 2) { + setCracked(false) + setMessage({ kind: 'amber', text: 'Fits so far — feed a different number to be sure.' }) + return + } + setCracked(true) + setMessage({ kind: 'win', text: `Rule cracked: ${ruleText(rule)}. Every clue matches.` }) + } + + const newRule = () => { + stopAnimation() + const next = pickRule(rule) + setRule(next) + setRows([]) + setHighlightKey(null) + setCracked(false) + setAnim({ active: false, t: 0, input: 0, output: 0 }) + setMessage({ kind: 'quiet', text: 'New secret rule. Feed in numbers and gather clues.' }) + } + + useEffect(() => () => stopAnimation(), [stopAnimation]) + + const hint = useMemo(() => { + if (message.kind !== 'quiet') return message.text + if (rows.length === 0) return 'Feed in numbers and gather clues. Look at how each IN becomes its OUT.' + if (rows.length === 1) return 'Feed one more different number to confirm the pattern holds.' + return 'What single operation turns each IN into its OUT?' + }, [message, rows.length]) + + const messageStyle = { + quiet: { color: colors.muted, background: '#ffffff', borderColor: colors.border }, + amber: { color: colors.amber, background: colors.amberTint, borderColor: colors.amber }, + wrong: { color: colors.wrong, background: '#FBEAEE', borderColor: colors.wrong }, + win: { color: colors.win, background: colors.winTint, borderColor: colors.win }, + }[message.kind] + + return ( + + + + + + + + + { + event.preventDefault() + if (canRun) runInput() + }} + > + + Input + setInput(event.target.value)} + className="min-w-0 bg-transparent text-center font-mono text-2xl font-black outline-none" + style={{ color: colors.input }} + /> + + + Run + + + + New secret rule + + + + + Quick feed + {quickValues.map((value) => ( + { + setInput(String(value)) + runInput(value) + }} + className="rounded-full border bg-white px-4 py-1.5 font-mono text-sm font-black disabled:opacity-45" + style={{ borderColor: colors.inputBorder, color: colors.input }} + > + {value} + + ))} + + + + + Crack the rule + + out = in + setGuessOperation(event.target.value)} + className="h-10 rounded-xl border bg-white px-3 text-center font-mono text-xl font-black outline-none" + style={{ borderColor: colors.rule, color: colors.rule }} + aria-label="Choose operation" + > + × + + + + { + setGuess((current) => ({ ...current, value: event.target.value })) + }} + onBlur={() => setGuess((current) => normalizeGuess(current))} + className="h-10 w-20 rounded-xl border bg-white px-2 text-center font-mono text-xl font-black outline-none" + style={{ borderColor: colors.rule, color: colors.rule }} + aria-label="Guess number" + /> + + + + Test my rule + + + + + + + + + {hint} + + + ) +} diff --git a/src/manipulatives/hcf-builder.jsx b/src/manipulatives/hcf-builder.jsx new file mode 100644 index 0000000..d9c12b6 --- /dev/null +++ b/src/manipulatives/hcf-builder.jsx @@ -0,0 +1,621 @@ +import { useEffect, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + a: '#2660C4', + aTint: '#EAF0FB', + b: '#1E7A5E', + bTint: '#E9F5EF', + shared: '#7B3F9E', + sharedTint: '#F3EEFA', + result: '#1E5F74', + resultTint: '#E4F3F7', + resultBorder: '#7FC5D6', + amber: '#8A4A12', + amberTint: '#FBEEDD', + win: '#27500A', + winTint: '#EAF3DE', +} + +const pairs = [ + [60, 90], + [12, 18], + [8, 12], + [20, 30], + [16, 24], + [18, 24], + [10, 15], + [9, 12], + [24, 36], + [8, 9], +] + +function primeFactors(value) { + const factors = [] + let n = value + let divisor = 2 + while (divisor * divisor <= n) { + while (n % divisor === 0) { + factors.push(divisor) + n /= divisor + } + divisor += divisor === 2 ? 1 : 2 + } + if (n > 1) factors.push(n) + return factors +} + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const next = y + y = x % y + x = next + } + return x +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function makeTokens(row, factors) { + return factors.map((value, index) => ({ + id: `${row}-${index}-${value}`, + row, + value, + status: 'open', + })) +} + +function rowColor(row) { + return row === 'a' ? colors.a : colors.b +} + +function rowTint(row) { + return row === 'a' ? colors.aTint : colors.bTint +} + +function tokenTheme(token) { + if (token.origin === 'shared') return { color: colors.shared, tint: colors.sharedTint } + return { color: rowColor(token.row ?? token.origin), tint: rowTint(token.row ?? token.origin) } +} + +function PrimeToken({ token, selected, disabled, register, onClick, built }) { + const { color, tint } = tokenTheme(token) + const isUsed = token.status === 'used' + const isMerging = token.status === 'merging' + const isLeftover = token.status === 'leftover' + + return ( + register(token.id, node)} + type="button" + disabled={disabled || isUsed || isMerging || isLeftover} + onClick={onClick} + className={`relative flex h-[46px] w-[46px] items-center justify-center rounded-xl border-2 font-mono text-xl font-black transition duration-300 ${ + selected ? 'scale-110 shadow-[0_0_0_7px_rgba(123,63,158,.18)]' : '' + } ${isUsed ? 'opacity-20 grayscale' : ''} ${isMerging ? 'opacity-0' : ''} ${ + isLeftover ? 'shadow-[0_0_0_4px_rgba(138,74,18,.16)]' : '' + } ${built && isLeftover ? 'translate-y-4 opacity-15' : ''} ${token.shake ? 'animate-[hcfShake_260ms_ease-in-out]' : ''}`} + style={{ + color, + background: isLeftover ? '#fffaf3' : tint, + borderColor: isLeftover ? colors.amber : color, + cursor: disabled || isUsed || isMerging || isLeftover ? 'default' : 'pointer', + }} + > + {token.value} + {isLeftover ? ( + + ! + + ) : null} + + ) +} + +function SharedToken({ item, built }) { + return ( + + {item.value} + + ) +} + +function NumberLane({ value, children, color, ariaLabel, onValueChange, onValueCommit }) { + return ( + + onValueChange(event.target.value)} + onBlur={onValueCommit} + onKeyDown={(event) => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + aria-label={ariaLabel} + className="h-[48px] rounded-xl border px-3 text-center font-mono text-2xl font-black outline-none transition focus:shadow-[0_0_0_5px_rgba(123,63,158,.16)]" + style={{ color, background: color === colors.a ? colors.aTint : colors.bTint, borderColor: color }} + /> + {children} + + ) +} + +function HcfEquation({ factors, hcf, built }) { + if (!built) { + return ( + + Build to keep only the shared primes. + + ) + } + if (!factors.length) { + return ( + + HCF + = + + 1 + + + ) + } + return ( + + HCF + = + {factors.map((factor, index) => ( + + {factor.value} + {index < factors.length - 1 ? x : null} + + ))} + = + + {hcf} + + + ) +} + +function GroupingRow({ value, hcf, row }) { + const count = value / hcf + const color = rowColor(row) + const tint = rowTint(row) + return ( + + + {value} + + + {Array.from({ length: count }, (_, index) => ( + + {hcf} + + ))} + + + {count} x {hcf} = {value} + + + ) +} + +export default function HcfBuilder() { + const [pairIndex, setPairIndex] = useState(0) + const [[aValue, bValue], setValues] = useState(pairs[0]) + const [aText, setAText] = useState(String(pairs[0][0])) + const [bText, setBText] = useState(String(pairs[0][1])) + const hcf = gcd(aValue, bValue) + const [aTokens, setATokens] = useState(() => makeTokens('a', primeFactors(aValue))) + const [bTokens, setBTokens] = useState(() => makeTokens('b', primeFactors(bValue))) + const [selected, setSelected] = useState(null) + const [shared, setShared] = useState([]) + const [mergeFx, setMergeFx] = useState(null) + const [buildStarted, setBuildStarted] = useState(false) + const [buildDone, setBuildDone] = useState(false) + const stageRef = useRef(null) + const tokenRefs = useRef({}) + const sharedLaneRef = useRef(null) + const timersRef = useRef([]) + + const allTokens = [...aTokens, ...bTokens] + const openTokens = allTokens.filter((token) => token.status === 'open') + const canBuild = !mergeFx && !buildStarted && openTokens.every((token) => !hasPartner(token)) + + function clearTimers() { + timersRef.current.forEach((id) => window.clearTimeout(id)) + timersRef.current = [] + } + + useEffect(() => () => clearTimers(), []) + + function resetToValues(nextA, nextB, nextIndex = pairIndex) { + clearTimers() + setPairIndex(nextIndex) + setValues([nextA, nextB]) + setAText(String(nextA)) + setBText(String(nextB)) + setATokens(makeTokens('a', primeFactors(nextA))) + setBTokens(makeTokens('b', primeFactors(nextB))) + setSelected(null) + setShared([]) + setMergeFx(null) + setBuildStarted(false) + setBuildDone(false) + } + + function reset(nextIndex = pairIndex) { + const [nextA, nextB] = pairs[nextIndex] + resetToValues(nextA, nextB, nextIndex) + } + + function normalizeValue(raw, fallback) { + const next = Number(raw) + if (!Number.isFinite(next)) return fallback + return clamp(Math.round(next), 2, 120) + } + + function changeNumber(row, raw) { + if (row === 'a') { + setAText(raw) + const nextA = Number(raw) + if (Number.isFinite(nextA) && nextA >= 2 && nextA <= 120) { + resetToValues(Math.round(nextA), bValue) + } + return + } + setBText(raw) + const nextB = Number(raw) + if (Number.isFinite(nextB) && nextB >= 2 && nextB <= 120) { + resetToValues(aValue, Math.round(nextB)) + } + } + + function commitNumber(row) { + if (row === 'a') { + resetToValues(normalizeValue(aText, aValue), bValue) + return + } + resetToValues(aValue, normalizeValue(bText, bValue)) + } + + function updateToken(row, id, patch) { + const setter = row === 'a' ? setATokens : setBTokens + setter((tokens) => tokens.map((token) => (token.id === id ? { ...token, ...patch } : token))) + } + + function tokenCenter(id) { + const stage = stageRef.current + const node = tokenRefs.current[id] + if (!stage || !node) return null + const stageBox = stage.getBoundingClientRect() + const box = node.getBoundingClientRect() + return { + x: box.left - stageBox.left + box.width / 2, + y: box.top - stageBox.top + box.height / 2, + } + } + + function sharedTarget() { + const stage = stageRef.current + const lane = sharedLaneRef.current + if (!stage || !lane) return { x: 420, y: 255 } + const stageBox = stage.getBoundingClientRect() + const box = lane.getBoundingClientRect() + return { + x: box.left - stageBox.left + 38 + shared.length * 60, + y: box.top - stageBox.top + box.height / 2, + } + } + + function mergePosition(point) { + if (!mergeFx || mergeFx.phase === 'start') return point + if (mergeFx.phase === 'lift') { + return { + x: (point.x + mergeFx.target.x) / 2, + y: Math.min(point.y - 38, mergeFx.target.y - 70), + } + } + return mergeFx.target + } + + function hasPartner(token) { + const otherTokens = token.row === 'a' ? bTokens : aTokens + return otherTokens.some((other) => other.status === 'open' && other.value === token.value) + } + + function markLeftover(token) { + updateToken(token.row, token.id, { status: 'leftover' }) + setSelected(null) + } + + function markRemainingLeftovers() { + setATokens((tokens) => tokens.map((token) => (token.status === 'open' ? { ...token, status: 'leftover' } : token))) + setBTokens((tokens) => tokens.map((token) => (token.status === 'open' ? { ...token, status: 'leftover' } : token))) + setSelected(null) + } + + function markShake(token) { + updateToken(token.row, token.id, { shake: true }) + const timer = window.setTimeout(() => updateToken(token.row, token.id, { shake: false }), 280) + timersRef.current.push(timer) + } + + function mergeTokens(first, second) { + const fromA = first.row === 'a' ? first : second + const fromB = first.row === 'b' ? first : second + const startA = tokenCenter(fromA.id) + const startB = tokenCenter(fromB.id) + const target = sharedTarget() + if (!startA || !startB) return + + updateToken(fromA.row, fromA.id, { status: 'merging' }) + updateToken(fromB.row, fromB.id, { status: 'merging' }) + setSelected(null) + setMergeFx({ value: first.value, startA, startB, target, phase: 'start' }) + const liftTimer = window.setTimeout(() => setMergeFx((fx) => (fx ? { ...fx, phase: 'lift' } : fx)), 30) + const dropTimer = window.setTimeout(() => setMergeFx((fx) => (fx ? { ...fx, phase: 'land' } : fx)), 410) + const landTimer = window.setTimeout(() => { + updateToken(fromA.row, fromA.id, { status: 'used' }) + updateToken(fromB.row, fromB.id, { status: 'used' }) + setShared((items) => [ + ...items, + { id: `shared-${fromA.id}-${fromB.id}`, value: first.value, origin: 'shared', fresh: true }, + ]) + setMergeFx(null) + }, 760) + const settleTimer = window.setTimeout(() => { + setShared((items) => items.map((item) => ({ ...item, fresh: false }))) + }, 820) + timersRef.current.push(liftTimer, dropTimer, landTimer, settleTimer) + } + + function handleToken(token) { + if (buildStarted || mergeFx || token.status !== 'open') return + + if (!hasPartner(token)) { + markLeftover(token) + return + } + + if (!selected) { + setSelected(token) + return + } + + if (selected.id === token.id) { + setSelected(null) + return + } + + if (selected.row !== token.row && selected.value === token.value) { + mergeTokens(selected, token) + return + } + + markShake(token) + setSelected(token) + } + + function buildHcf() { + if (!canBuild) return + markRemainingLeftovers() + setBuildStarted(true) + const done = window.setTimeout(() => setBuildDone(true), 900) + timersRef.current.push(done) + } + + function nextPair() { + reset((pairIndex + 1) % pairs.length) + } + + const hint = (() => { + if (buildDone && hcf === 1) return 'These share no primes at all. Their only common factor is 1, so they are coprime.' + if (buildDone) return 'The leftovers fell away because they belong to only one number. Only the shared primes survive.' + if (canBuild) return 'All shared pairs are merged. Build the HCF; any remaining primes will fall away as leftovers.' + if (selected) return `You picked a ${selected.value}. Tap a ${selected.value} in the other number to merge them.` + return 'Find primes that appear in both numbers. A prime with no partner is a leftover; tap it on its own.' + })() + + return ( + + + + + + Merge shared primes. On build, leftovers fall away and only the HCF survives. + + + New pair + + + + + {mergeFx ? ( + <> + {[mergeFx.startA, mergeFx.startB].map((point, index) => ( + + {mergeFx.value} + + ))} + > + ) : null} + + + changeNumber('a', value)} + onValueCommit={() => commitNumber('a')} + > + {aTokens.map((token) => ( + { + if (node) tokenRefs.current[id] = node + }} + onClick={() => handleToken(token)} + /> + ))} + + + changeNumber('b', value)} + onValueCommit={() => commitNumber('b')} + > + {bTokens.map((token) => ( + { + if (node) tokenRefs.current[id] = node + }} + onClick={() => handleToken(token)} + /> + ))} + + + + + + + Shared + + + {shared.length ? ( + shared.map((item) => ) + ) : ( + + Shared primes will merge here. + + )} + + + + + + + + + + + + {hint} + + + Build the HCF + + + + + {buildDone ? ( + + + {hcf} is the biggest equal group that fits into both. + + + + + ) : ( + + After building, the shared primes will become the HCF and show equal groups for both numbers. + + )} + + + ) +} diff --git a/src/manipulatives/index.js b/src/manipulatives/index.js index fec55c1..a26c858 100644 --- a/src/manipulatives/index.js +++ b/src/manipulatives/index.js @@ -1,26 +1,208 @@ +import AngleRelationships from './angle-relationships.jsx' +import AddingUnlikeFractions from './adding-unlike-fractions.jsx' +import BoxPlotBuilder from './box-plot-builder.jsx' +import ComparingTwoPopulations from './comparing-two-populations.jsx' +import CoordinateConnectDots from './coordinate-connect-dots.jsx' import CoordinateTreasureMap from './coordinate-treasure-map.jsx' +import DistributiveAreaModel from './distributive-area-model.jsx' +import DistanceCoordinatePlane from './distance-coordinate-plane.jsx' +import ElapsedTimeClock from './elapsed-time-clock.jsx' +import ExploreRatios from './explore-ratios.jsx' +import FactorRainbow from './factor-rainbow.jsx' import FactorTree from './factor-tree.jsx' +import FractionsNumberLine from './fractions-number-line.jsx' +import FunctionMachineDetective from './function-machine-detective.jsx' +import HcfBuilder from './hcf-builder.jsx' +import IntegerMultiplyDivide from './integer-multiply-divide.jsx' +import LcmCirclingPairs from './lcm-circling-pairs.jsx' +import LinearEquationGrapher from './linear-equation-grapher.jsx' import MeanBalancePoint from './mean-balance-point.jsx' +import NetsSurfaceArea from './nets-surface-area.jsx' +import NumberLineAddSubtract from './number-line-add-subtract.jsx' import NumberLineExplorer from './number-line-explorer.jsx' import ParallelogramArea from './parallelogram-area.jsx' +import PolygonInteriorAngles from './polygon-interior-angles.jsx' +import ProbabilitySpinner from './probability-spinner.jsx' +import RatioBalanceScale from './ratio-balance-scale.jsx' +import ScatterLineFit from './scatter-line-fit.jsx' +import SlopeExplorer from './slope-explorer.jsx' +import SubstitutionMachine from './substitution-machine.jsx' +import SystemsOfEquations from './systems-of-equations.jsx' +import TwoWayTables from './two-way-tables.jsx' import TwoFactorTrees from './two-factor-trees.jsx' +import TwoStepEquationSolver from './two-step-equation-solver.jsx' +import UnitRateExplorer from './unit-rate-better-buy.jsx' +import VolumePrisms from './volume-prisms.jsx' +import LinearVsNonlinear from './linear-vs-nonlinear.jsx' +import RateOfChangeExplorer from './rate-of-change-explorer.jsx' +import PercentParkDesigner from './percent-park-designer.jsx' export const manipulatives = [ + { + id: 'percent-park-designer', + name: 'Percent Park Designer', + component: PercentParkDesigner, + }, + { + id: 'rate-of-change-explorer', + name: 'Rate of Change Explorer', + component: RateOfChangeExplorer, + }, + { + id: 'linear-vs-nonlinear', + name: 'Linear vs Non-Linear', + component: LinearVsNonlinear, + }, + { + id: 'adding-unlike-fractions', + name: 'Adding Unlike Fractions', + component: AddingUnlikeFractions, + }, + { + id: 'angle-relationships', + name: 'Angle Relationships', + component: AngleRelationships, + }, + { + id: 'box-plot-builder', + name: 'Box Plot Builder', + component: BoxPlotBuilder, + }, + { + id: 'comparing-two-populations', + name: 'Comparing Two Populations', + component: ComparingTwoPopulations, + }, + { + id: 'coordinate-connect-dots', + name: 'Coordinate Connect-the-Dots', + component: CoordinateConnectDots, + }, { id: 'coordinate-treasure-map', name: 'Coordinate Treasure Map', component: CoordinateTreasureMap, }, + { + id: 'distributive-area-model', + name: 'Distributive Area Model', + component: DistributiveAreaModel, + }, + { + id: 'distance-coordinate-plane', + name: 'Distance on a Coordinate Plane', + component: DistanceCoordinatePlane, + }, + { + id: 'elapsed-time-clock', + name: 'Elapsed Time Clock', + component: ElapsedTimeClock, + }, + { + id: 'explore-ratios', + name: 'Explore Ratios', + component: ExploreRatios, + }, + { + id: 'factor-rainbow', + name: 'Factor Rainbow', + component: FactorRainbow, + }, + { + id: 'fractions-number-line', + name: 'Fractions on a Number Line', + component: FractionsNumberLine, + }, + { + id: 'function-machine-detective', + name: 'Function Machine Detective', + component: FunctionMachineDetective, + }, + { + id: 'hcf-builder', + name: 'HCF / GCF Builder', + component: HcfBuilder, + }, + { + id: 'integer-multiply-divide', + name: 'Integer Multiply/Divide', + component: IntegerMultiplyDivide, + }, + { + id: 'lcm-circling-pairs', + name: 'LCM Builder', + component: LcmCirclingPairs, + }, + { + id: 'linear-equation-grapher', + name: 'Linear Equation Grapher', + component: LinearEquationGrapher, + }, { id: 'number-line-explorer', name: 'Number Line Explorer', component: NumberLineExplorer, }, + { + id: 'number-line-add-subtract', + name: 'Number Line Add/Subtract', + component: NumberLineAddSubtract, + }, { id: 'mean-balance-point', name: 'Mean Balance Point', component: MeanBalancePoint, }, + { + id: 'nets-surface-area', + name: 'Nets & Surface Area', + component: NetsSurfaceArea, + }, + { + id: 'polygon-interior-angles', + name: 'Polygon Interior Angles', + component: PolygonInteriorAngles, + }, + { + id: 'probability-spinner', + name: 'Probability Spinner', + component: ProbabilitySpinner, + }, + { + id: 'ratio-balance-scale', + name: 'Ratio Balance Scale', + component: RatioBalanceScale, + }, + { + id: 'scatter-line-fit', + name: 'Scatter Plot & Line of Best Fit', + component: ScatterLineFit, + }, + { + id: 'substitution-machine', + name: 'Substitution Machine', + component: SubstitutionMachine, + }, + { + id: 'systems-of-equations', + name: 'Systems of Equations', + component: SystemsOfEquations, + }, + { + id: 'two-way-tables', + name: 'Two-Way Tables', + component: TwoWayTables, + }, + { + id: 'two-step-equation-solver', + name: 'Two-Step Equation Solver', + component: TwoStepEquationSolver, + }, + { + id: 'slope-explorer', + name: 'Slope Explorer', + component: SlopeExplorer, + }, { id: 'parallelogram-area', name: 'Parallelogram Area', @@ -36,4 +218,14 @@ export const manipulatives = [ name: 'Two Factor Trees', component: TwoFactorTrees, }, + { + id: 'unit-rate-explorer', + name: 'Unit Rate Explorer', + component: UnitRateExplorer, + }, + { + id: 'volume-prisms', + name: 'Volume of Prisms', + component: VolumePrisms, + }, ] diff --git a/src/manipulatives/integer-multiply-divide.jsx b/src/manipulatives/integer-multiply-divide.jsx new file mode 100644 index 0000000..7aae925 --- /dev/null +++ b/src/manipulatives/integer-multiply-divide.jsx @@ -0,0 +1,434 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + ruby: '#B23050', + rubyFill: '#FBEAEE', + teal: '#1E5F74', + tealFill: '#E4F3F7', + purple: '#5B2A86', + purpleFill: '#EFEAF7', + border: '#E0DDD6', +} + +const canvasHeight = 225 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeInOut(t) { + return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2 +} + +function safeB(value) { + if (value === 0) return 1 + return value +} + +function snapDividend(a, b) { + const divisor = safeB(b) + return clamp(Math.round(a / divisor) * divisor, -81, 81) +} + +function ChipStepper({ label, value, color, fill, onChange, min = -9, max = 9, forbidZero = false, step = 1 }) { + const change = (delta) => { + let next = clamp(value + delta * step, min, max) + if (forbidZero && next === 0) next = delta > 0 ? 1 : -1 + onChange(next) + } + + return ( + + change(-1)} className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-black text-white" style={{ backgroundColor: color }}>▼ + + {label} + {value} + + change(1)} className="flex h-7 w-7 items-center justify-center rounded-full text-sm font-black text-white" style={{ backgroundColor: color }}>▲ + + ) +} + +function getScale(values, width) { + const rawMin = Math.min(...values, 0) + const rawMax = Math.max(...values, 0) + const span = Math.max(8, rawMax - rawMin) + const min = Math.floor(rawMin - span * 0.15) + const max = Math.ceil(rawMax + span * 0.15) + const left = 38 + const right = width - 30 + const toX = (value) => left + ((value - min) / (max - min || 1)) * (right - left) + return { min, max, left, right, toX } +} + +function tickStep(min, max) { + return max - min > 24 ? 5 : 1 +} + +function quadPoint(fromX, toX, baseY, arcHeight, t) { + const controlX = (fromX + toX) / 2 + return { + x: ((1 - t) ** 2) * fromX + 2 * (1 - t) * t * controlX + (t ** 2) * toX, + y: ((1 - t) ** 2) * baseY + 2 * (1 - t) * t * (baseY - arcHeight) + (t ** 2) * baseY, + } +} + +function drawJumpArc(ctx, fromX, toX, baseY, arcHeight, progress, color) { + ctx.strokeStyle = color + ctx.lineWidth = 3 + ctx.beginPath() + const steps = Math.max(3, Math.ceil(24 * progress)) + for (let step = 0; step <= steps; step += 1) { + const point = quadPoint(fromX, toX, baseY, arcHeight, (step / steps) * progress) + if (step === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + } + ctx.stroke() +} + +function getMultiplicationJumps(a, b) { + const count = Math.abs(b) + const direction = b < 0 ? -1 : 1 + const jumps = [] + let current = 0 + for (let index = 0; index < count; index += 1) { + const next = current + a * direction + jumps.push({ from: current, to: next, size: a, color: a < 0 ? colors.ruby : colors.teal }) + current = next + } + return jumps +} + +function getDivisionJumps(a, b) { + const divisor = safeB(b) + const quotient = a / divisor + const count = Math.abs(quotient) + const step = quotient < 0 ? -1 : 1 + const jumps = [] + let current = 0 + for (let index = 0; index < count; index += 1) { + const next = current + step + jumps.push({ from: current, to: next, size: step, color: quotient < 0 ? colors.ruby : colors.teal }) + current = next + } + return jumps +} + +function arrowHead(ctx, x, y, direction, color) { + ctx.save() + ctx.fillStyle = color + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x - direction * 8, y - 4) + ctx.lineTo(x - direction * 8, y + 4) + ctx.closePath() + ctx.fill() + ctx.restore() +} + +function signHint(mode, a, b, result) { + if (mode === 'divide') { + return Math.sign(a) === Math.sign(b) + ? 'Same signs divide to a positive answer.' + : 'Different signs divide to a negative answer.' + } + if (a < 0 && b < 0) return 'A negative second number flips the negative jumps back, so the answer is positive. The pattern table keeps climbing past zero.' + if (a < 0 && b > 0) return 'Positive groups of a negative number move left, so the answer is negative.' + if (a > 0 && b < 0) return 'A negative second number means take the jumps the opposite way.' + if (result === 0) return 'Any multiplication with zero lands at zero.' + return 'Positive groups of a positive number move right, so the answer is positive.' +} + +function readingLine(mode, a, b) { + if (mode === 'divide') return `${a} ÷ ${b}: count the jumps to find the answer.` + if (b < 0) return `${b} means jump ${Math.abs(b)} groups of ${a} the opposite way.` + return `${b} groups of ${a}.` +} + +export default function IntegerMultiplyDivide() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [mode, setMode] = useState('multiply') + const [a, setA] = useState(-3) + const [b, setB] = useState(-2) + const [animation, setAnimation] = useState({ playing: false, jumpIndex: 0, progress: 0 }) + const [resultVisible, setResultVisible] = useState(false) + const [patternVisible, setPatternVisible] = useState(false) + + const cleanB = mode === 'divide' ? safeB(b) : b + const result = mode === 'multiply' ? a * b : a / cleanB + const jumps = useMemo(() => ( + mode === 'multiply' ? getMultiplicationJumps(a, b) : getDivisionJumps(a, cleanB) + ), [a, b, cleanB, mode]) + + const resetAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + setAnimation({ playing: false, jumpIndex: 0, progress: 0 }) + setResultVisible(false) + setPatternVisible(false) + }, []) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + const values = [a, b, result, ...jumps.flatMap((jump) => [jump.from, jump.to])] + const scale = getScale(values, canvasWidth) + const baseY = 150 + + ctx.strokeStyle = '#1A1A2E' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(scale.left, baseY) + ctx.lineTo(scale.right, baseY) + ctx.stroke() + + const step = tickStep(scale.min, scale.max) + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let value = Math.ceil(scale.min / step) * step; value <= scale.max; value += step) { + const x = scale.toX(value) + const isZero = value === 0 + ctx.strokeStyle = isZero ? '#1A1A2E' : '#9CA3AF' + ctx.lineWidth = isZero ? 3 : 1.2 + ctx.beginPath() + ctx.moveTo(x, baseY - (isZero ? 12 : 8)) + ctx.lineTo(x, baseY + (isZero ? 12 : 8)) + ctx.stroke() + ctx.fillStyle = isZero ? '#1A1A2E' : '#4B5563' + ctx.font = '600 14px Inter, system-ui, sans-serif' + ctx.fillText(String(value), x, baseY + 16) + } + + jumps.forEach((jump, index) => { + if (index > animation.jumpIndex || (index === animation.jumpIndex && animation.progress <= 0)) return + const fromX = scale.toX(jump.from) + const toX = scale.toX(jump.to) + const visibleProgress = index < animation.jumpIndex ? 1 : easeInOut(animation.progress) + const arcHeight = Math.min(52, Math.max(24, Math.abs(toX - fromX) * 0.28)) + drawJumpArc(ctx, fromX, toX, baseY, arcHeight, visibleProgress, jump.color) + if (visibleProgress === 1) arrowHead(ctx, toX, baseY, Math.sign(toX - fromX) || 1, jump.color) + }) + + const currentJump = jumps[animation.jumpIndex] + let markerX = scale.toX(0) + let markerY = baseY + if (animation.playing && currentJump) { + const t = easeInOut(animation.progress) + const fromX = scale.toX(currentJump.from) + const toX = scale.toX(currentJump.to) + const arcHeight = Math.min(52, Math.max(24, Math.abs(toX - fromX) * 0.28)) + const marker = quadPoint(fromX, toX, baseY, arcHeight, t) + markerX = marker.x + markerY = marker.y + } else if (animation.jumpIndex >= jumps.length && jumps.length) { + markerX = scale.toX(jumps[jumps.length - 1].to) + } + + ctx.fillStyle = colors.purple + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(markerX, markerY, 10, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }, [a, animation, b, canvasWidth, jumps, result]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const showJumps = () => { + resetAnimation() + if (!jumps.length) { + setResultVisible(true) + setPatternVisible(true) + return + } + let jumpIndex = 0 + let startedAt = performance.now() + const duration = 700 + const tick = (now) => { + const progress = Math.min(1, (now - startedAt) / duration) + setAnimation({ playing: true, jumpIndex, progress }) + if (progress < 1) { + frameRef.current = requestAnimationFrame(tick) + } else if (jumpIndex < jumps.length - 1) { + jumpIndex += 1 + startedAt = performance.now() + frameRef.current = requestAnimationFrame(tick) + } else { + frameRef.current = null + setAnimation({ playing: false, jumpIndex: jumps.length, progress: 1 }) + setResultVisible(true) + setPatternVisible(true) + } + } + frameRef.current = requestAnimationFrame(tick) + } + + const setModeSafe = (nextMode) => { + resetAnimation() + setMode(nextMode) + if (nextMode === 'divide') { + const divisor = safeB(b) + setB(divisor) + setA((current) => snapDividend(current, divisor)) + } + } + + const setASafe = (next) => { + resetAnimation() + setA(mode === 'divide' ? snapDividend(next, cleanB) : next) + } + + const setBSafe = (next) => { + resetAnimation() + const nextB = mode === 'divide' ? safeB(next) : next + setB(nextB) + if (mode === 'divide') setA((current) => snapDividend(current, nextB)) + } + + const tableRows = useMemo(() => { + if (mode === 'multiply') { + const direction = b < 0 ? -1 : 1 + return Array.from({ length: Math.abs(b) }, (_, index) => { + const group = (index + 1) * direction + return { + key: group, + first: a, + operator: '×', + second: group, + answer: a * group, + active: index === Math.abs(b) - 1, + } + }) + } + const divisor = cleanB + const direction = result < 0 ? -1 : 1 + return Array.from({ length: Math.abs(result) }, (_, index) => { + const quotient = (index + 1) * direction + return { + key: quotient, + first: divisor * quotient, + operator: '÷', + second: divisor, + answer: quotient, + active: index === Math.abs(result) - 1, + } + }) + }, [a, b, cleanB, mode, result]) + + return ( + + + + {[ + ['multiply', 'Multiply'], + ['divide', 'Divide'], + ].map(([id, label]) => ( + setModeSafe(id)} + className="rounded-full px-4 py-2" + style={{ backgroundColor: mode === id ? colors.teal : 'transparent', color: mode === id ? '#ffffff' : '#5F5E5A' }} + > + {label} + + ))} + + + {mode === 'multiply' ? '×' : '÷'} + + = + + Result + {resultVisible ? result : '?'} + + + + + + + + + + {readingLine(mode, a, cleanB)} + + Show the jumps + Reset + + + + + {patternVisible ? ( + <> + Watch the pattern: same step each row + + {[tableRows.filter((_, index) => index % 2 === 0), tableRows.filter((_, index) => index % 2 === 1)].map((column, columnIndex) => ( + + {column.map((row) => ( + + {row.first} + {row.operator} + {row.second} + = + {row.answer} + + ))} + + ))} + + > + ) : ( + + Press Show the jumps to reveal the pattern. + + )} + + + + + ) +} diff --git a/src/manipulatives/lcm-circling-pairs.jsx b/src/manipulatives/lcm-circling-pairs.jsx new file mode 100644 index 0000000..2a55bf2 --- /dev/null +++ b/src/manipulatives/lcm-circling-pairs.jsx @@ -0,0 +1,568 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + a: '#2660C4', + aTint: '#EAF0FB', + b: '#1E7A5E', + bTint: '#E9F5EF', + shared: '#7B3F9E', + sharedTint: '#F3EEFA', + result: '#B25A1E', + resultTint: '#FBEEDD', + resultBorder: '#E0B579', + win: '#27500A', + winTint: '#EAF3DE', +} + +const pairs = [ + [60, 90], + [12, 18], + [8, 12], + [10, 15], + [9, 12], + [16, 24], + [6, 8], + [20, 30], + [15, 20], + [8, 9], +] + +function primeFactors(value) { + const factors = [] + let n = value + let divisor = 2 + while (divisor * divisor <= n) { + while (n % divisor === 0) { + factors.push(divisor) + n /= divisor + } + divisor += divisor === 2 ? 1 : 2 + } + if (n > 1) factors.push(n) + return factors +} + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const temp = y + y = x % y + x = temp + } + return x +} + +function lcm(a, b) { + return (a * b) / gcd(a, b) +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function rowColor(row) { + return row === 'a' ? colors.a : colors.b +} + +function rowTint(row) { + return row === 'a' ? colors.aTint : colors.bTint +} + +function makeTokens(row, factors) { + return factors.map((value, index) => ({ + id: `${row}-${index}-${value}`, + row, + value, + status: 'open', + })) +} + +function tokenTheme(token) { + if (token.origin === 'shared') { + return { color: colors.shared, tint: colors.sharedTint } + } + return { color: rowColor(token.row ?? token.origin), tint: rowTint(token.row ?? token.origin) } +} + +function PrimeToken({ token, selected, disabled, ghost, register, onClick }) { + const { color, tint } = tokenTheme(token) + const isUsed = token.status === 'used' + const isMerging = token.status === 'merging' + + return ( + register(token.id, node)} + type="button" + onClick={onClick} + disabled={disabled || isUsed || isMerging} + className={`relative flex h-[52px] w-[52px] items-center justify-center rounded-xl border-2 font-mono text-2xl font-black transition duration-200 ${ + selected ? 'scale-110 shadow-[0_0_0_7px_rgba(123,63,158,.2)]' : '' + } ${ghost || isUsed ? 'opacity-25 grayscale' : ''} ${isMerging ? 'opacity-0' : ''} ${ + !ghost && !isUsed && !isMerging ? 'opacity-100' : '' + } ${token.shake ? 'animate-[lcmShake_260ms_ease-in-out]' : ''}`} + style={{ + color, + background: tint, + borderColor: color, + cursor: disabled || isUsed || isMerging ? 'default' : 'pointer', + }} + > + {token.value} + + ) +} + +function SortedToken({ item, visible = true }) { + const { color, tint } = tokenTheme(item) + return ( + + {item.value} + + ) +} + +function FactorList({ items, visibleCount = items.length, showAnswer = false, answer }) { + const visibleItems = items.slice(0, visibleCount) + if (!visibleItems.length) return null + return ( + + {visibleItems.map((item, index) => ( + + + {index < visibleItems.length - 1 ? ( + + x + + ) : null} + + ))} + {showAnswer ? ( + + = + + {answer} + + + ) : null} + + ) +} + +function Lane({ value, onValueChange, onValueCommit, children, color, ariaLabel }) { + return ( + + onValueChange(event.target.value)} + onBlur={onValueCommit} + onKeyDown={(event) => { + if (event.key === 'Enter') event.currentTarget.blur() + }} + aria-label={ariaLabel} + className="h-[52px] rounded-xl border px-3 text-center font-mono text-2xl font-black outline-none transition focus:shadow-[0_0_0_5px_rgba(123,63,158,.16)]" + style={{ color, background: color === colors.a ? colors.aTint : color === colors.b ? colors.bTint : colors.resultTint, borderColor: color }} + /> + {children} + + ) +} + +export default function LcmCirclingPairs() { + const [pairIndex, setPairIndex] = useState(0) + const [[aValue, bValue], setValues] = useState(pairs[0]) + const [aText, setAText] = useState(String(pairs[0][0])) + const [bText, setBText] = useState(String(pairs[0][1])) + const finalLcm = lcm(aValue, bValue) + const [aTokens, setATokens] = useState(() => makeTokens('a', primeFactors(aValue))) + const [bTokens, setBTokens] = useState(() => makeTokens('b', primeFactors(bValue))) + const [selected, setSelected] = useState(null) + const [sorted, setSorted] = useState([]) + const [mergeFx, setMergeFx] = useState(null) + const [buildStarted, setBuildStarted] = useState(false) + const [building, setBuilding] = useState(false) + const stageRef = useRef(null) + const tokenRefs = useRef({}) + const lcmLaneRef = useRef(null) + const timersRef = useRef([]) + + const allTokens = [...aTokens, ...bTokens] + const openTokens = allTokens.filter((token) => token.status === 'open') + const buildReady = openTokens.length === 0 && !mergeFx && !building && !buildStarted + const buildItems = useMemo(() => sorted, [sorted]) + + function clearTimers() { + timersRef.current.forEach((id) => window.clearTimeout(id)) + timersRef.current = [] + } + + useEffect(() => () => clearTimers(), []) + + function resetToValues(nextA, nextB, nextIndex = pairIndex) { + clearTimers() + setPairIndex(nextIndex) + setValues([nextA, nextB]) + setAText(String(nextA)) + setBText(String(nextB)) + setATokens(makeTokens('a', primeFactors(nextA))) + setBTokens(makeTokens('b', primeFactors(nextB))) + setSelected(null) + setSorted([]) + setMergeFx(null) + setBuildStarted(false) + setBuilding(false) + } + + function reset(nextIndex = pairIndex) { + const [nextA, nextB] = pairs[nextIndex] + resetToValues(nextA, nextB, nextIndex) + } + + function normalizeValue(raw, fallback) { + const next = Number(raw) + if (!Number.isFinite(next)) return fallback + return clamp(Math.round(next), 2, 120) + } + + function changeNumber(row, raw) { + if (row === 'a') { + setAText(raw) + const nextA = Number(raw) + if (Number.isFinite(nextA) && nextA >= 2 && nextA <= 120) { + resetToValues(Math.round(nextA), bValue) + } + return + } + setBText(raw) + const nextB = Number(raw) + if (Number.isFinite(nextB) && nextB >= 2 && nextB <= 120) { + resetToValues(aValue, Math.round(nextB)) + } + } + + function commitNumber(row) { + if (row === 'a') { + resetToValues(normalizeValue(aText, aValue), bValue) + return + } + resetToValues(aValue, normalizeValue(bText, bValue)) + } + + function updateToken(row, id, patch) { + const setter = row === 'a' ? setATokens : setBTokens + setter((tokens) => tokens.map((token) => (token.id === id ? { ...token, ...patch } : token))) + } + + function tokenCenter(id) { + const stage = stageRef.current + const node = tokenRefs.current[id] + if (!stage || !node) return null + const stageBox = stage.getBoundingClientRect() + const box = node.getBoundingClientRect() + return { + x: box.left - stageBox.left + box.width / 2, + y: box.top - stageBox.top + box.height / 2, + } + } + + function lcmTarget(newItem) { + const stage = stageRef.current + const lane = lcmLaneRef.current + if (!stage || !lane) return { x: 480, y: 250 } + const stageBox = stage.getBoundingClientRect() + const box = lane.getBoundingClientRect() + const index = newItem ? sorted.length : sorted.length + const offset = Math.min(index, 7) * 86 + return { + x: box.left - stageBox.left + 46 + offset, + y: box.top - stageBox.top + box.height / 2, + } + } + + function mergePosition(point) { + if (!mergeFx || mergeFx.phase === 'start') return point + if (mergeFx.phase === 'lift') { + return { + x: (point.x + mergeFx.target.x) / 2, + y: Math.min(mergeFx.target.y - 86, point.y - 38), + } + } + return mergeFx.target + } + + function hasPartner(token) { + const otherTokens = token.row === 'a' ? bTokens : aTokens + return otherTokens.some((other) => other.status === 'open' && other.value === token.value) + } + + function markLeftover(token) { + updateToken(token.row, token.id, { status: 'used' }) + setSelected(null) + setSorted((items) => [ + ...items, + { id: `left-${token.id}`, value: token.value, origin: token.row, row: token.row, fresh: true }, + ]) + const timer = window.setTimeout(() => { + setSorted((items) => items.map((item) => ({ ...item, fresh: false }))) + }, 450) + timersRef.current.push(timer) + } + + function markShake(token) { + updateToken(token.row, token.id, { shake: true }) + const timer = window.setTimeout(() => updateToken(token.row, token.id, { shake: false }), 280) + timersRef.current.push(timer) + } + + function mergeTokens(first, second) { + const fromA = first.row === 'a' ? first : second + const fromB = first.row === 'b' ? first : second + const startA = tokenCenter(fromA.id) + const startB = tokenCenter(fromB.id) + const mergedItem = { id: `shared-${fromA.id}-${fromB.id}`, value: first.value, origin: 'shared', fresh: true } + const target = lcmTarget(mergedItem) + if (!startA || !startB) return + + updateToken(fromA.row, fromA.id, { status: 'merging' }) + updateToken(fromB.row, fromB.id, { status: 'merging' }) + setSelected(null) + setMergeFx({ value: first.value, startA, startB, target, phase: 'start' }) + const liftTimer = window.setTimeout(() => setMergeFx((fx) => (fx ? { ...fx, phase: 'lift' } : fx)), 30) + const dropTimer = window.setTimeout(() => setMergeFx((fx) => (fx ? { ...fx, phase: 'land' } : fx)), 470) + const landTimer = window.setTimeout(() => { + updateToken(fromA.row, fromA.id, { status: 'used' }) + updateToken(fromB.row, fromB.id, { status: 'used' }) + setSorted((items) => [...items, mergedItem]) + setMergeFx(null) + }, 840) + const settleTimer = window.setTimeout(() => { + setSorted((items) => items.map((item) => ({ ...item, fresh: false }))) + }, 880) + timersRef.current.push(liftTimer, dropTimer, landTimer, settleTimer) + } + + function handleToken(token) { + if (buildStarted || building || mergeFx || token.status !== 'open') return + + if (!hasPartner(token)) { + markLeftover(token) + return + } + + if (!selected) { + setSelected(token) + return + } + + if (selected.id === token.id) { + setSelected(null) + return + } + + if (selected.row !== token.row && selected.value === token.value) { + mergeTokens(selected, token) + return + } + + markShake(token) + setSelected(token) + } + + function startBuild() { + if (!buildReady) return + setBuildStarted(true) + setBuilding(true) + const done = window.setTimeout(() => setBuilding(false), 520) + timersRef.current.push(done) + } + + function nextPair() { + reset((pairIndex + 1) % pairs.length) + } + + const hint = (() => { + if (buildStarted && !building) { + return `Each shared pair became one purple prime, so the LCM is ${finalLcm}, not ${aValue} x ${bValue}.` + } + if (buildReady) return 'Every prime is sorted. Build the LCM and watch them combine.' + if (selected) return `You picked a ${selected.value}. Tap a ${selected.value} in the other number to merge them.` + return 'Tap a prime, then tap its match in the other number. If no match is left, it becomes a leftover.' + })() + + return ( + + + + + + Shared primes merge into one purple token. Leftovers keep their original colour. + + + New pair + + + + + {mergeFx ? ( + <> + {[mergeFx.startA, mergeFx.startB].map((point, index) => ( + + {mergeFx.value} + + ))} + > + ) : null} + + + changeNumber('a', value)} + onValueCommit={() => commitNumber('a')} + > + {aTokens.map((token) => ( + { + if (node) tokenRefs.current[id] = node + }} + onClick={() => handleToken(token)} + /> + ))} + + + changeNumber('b', value)} + onValueCommit={() => commitNumber('b')} + > + {bTokens.map((token) => ( + { + if (node) tokenRefs.current[id] = node + }} + onClick={() => handleToken(token)} + /> + ))} + + + + + + + LCM + + + {sorted.length ? ( + + ) : ( + + Shared primes and leftovers will build the LCM here. + + )} + + + + + + + + {hint} + + + Show LCM + + + + + {buildStarted && !building ? ( + + Check: {finalLcm} / {aValue} = {finalLcm / aValue} and {finalLcm} / {bValue} = {finalLcm / bValue}. Shared pairs merged once, so {finalLcm} is smaller than {aValue} x {bValue}. + + ) : ( + + Sort every prime first: shared factors become purple, unshared factors stay blue or green. + + )} + + + ) +} diff --git a/src/manipulatives/linear-equation-grapher.jsx b/src/manipulatives/linear-equation-grapher.jsx new file mode 100644 index 0000000..b26a3ab --- /dev/null +++ b/src/manipulatives/linear-equation-grapher.jsx @@ -0,0 +1,546 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + grid: '#DEDAD1', + slope: '#2660C4', + intercept: '#1E7A5E', + line: '#7B3F9E', + target: '#D4879E', + highlight: '#7B3F9E', + border: '#E0DDD6', +} + +const xRows = [-2, -1, 0, 1, 2] + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function snapSlope(value) { + return clamp(Math.round(value * 2) / 2, -4, 4) +} + +function snapIntercept(value) { + return clamp(Math.round(value), -6, 6) +} + +function formatNumber(value) { + if (Object.is(value, -0)) return '0' + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} + +function signed(value) { + if (value === 0) return '' + return value > 0 ? `+ ${formatNumber(value)}` : `- ${formatNumber(Math.abs(value))}` +} + +function equationParts(m, b) { + let mx + if (m === 0) mx = '' + else if (m === 1) mx = 'x' + else if (m === -1) mx = '-x' + else mx = `${formatNumber(m)}x` + const bPart = signed(b) + return { mx, bPart, constantOnly: m === 0 } +} + +function makeTarget() { + const slopes = [-4, -3.5, -3, -2.5, -2, -1.5, -1, -0.5, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4] + const m = slopes[Math.floor(Math.random() * slopes.length)] + const b = Math.floor(Math.random() * 13) - 6 + return { m, b } +} + +function roundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) +} + +function ToggleButton({ active, children, onClick }) { + return ( + + {children} + + ) +} + +function SliderCard({ label, value, color, min, max, step, onChange }) { + return ( + + + {label} + + {formatNumber(value)} + + + onChange(Number(event.target.value))} + className="w-full" + style={{ accentColor: color }} + /> + + ) +} + +function EquationBar({ m, b, activeLink, setActiveLink }) { + const { mx, bPart, constantOnly } = equationParts(m, b) + return ( + + y = + {constantOnly ? ( + setActiveLink('intercept')} + onPointerLeave={() => setActiveLink(null)} + onClick={() => setActiveLink(activeLink === 'intercept' ? null : 'intercept')} + className="rounded-lg px-2 py-1" + style={{ color: colors.intercept, background: activeLink === 'intercept' ? '#E9F5EF' : 'transparent' }} + > + {formatNumber(b)} + + ) : ( + <> + setActiveLink('slope')} + onPointerLeave={() => setActiveLink(null)} + onClick={() => setActiveLink(activeLink === 'slope' ? null : 'slope')} + className="rounded-lg px-2 py-1" + style={{ color: colors.slope, background: activeLink === 'slope' ? '#E8F1FC' : 'transparent' }} + > + {mx} + + {b !== 0 && ( + setActiveLink('intercept')} + onPointerLeave={() => setActiveLink(null)} + onClick={() => setActiveLink(activeLink === 'intercept' ? null : 'intercept')} + className="rounded-lg px-2 py-1" + style={{ color: colors.intercept, background: activeLink === 'intercept' ? '#E9F5EF' : 'transparent' }} + > + {bPart} + + )} + > + )} + + ) +} + +export default function LinearEquationGrapher() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const dragRef = useRef(null) + const frameRef = useRef(null) + const [canvasSize, setCanvasSize] = useState({ width: 540, height: 468 }) + const [mode, setMode] = useState('Explore') + const [m, setM] = useState(1) + const [b, setB] = useState(0) + const [target, setTarget] = useState(() => makeTarget()) + const [showTriangle, setShowTriangle] = useState(false) + const [showPoints] = useState(false) + const [activeLink, setActiveLink] = useState(null) + + const constants = useMemo(() => { + const pad = 46 + const plotW = canvasSize.width - pad * 2 + const plotH = canvasSize.height - pad * 2 + const cell = Math.min(plotW, plotH) / 16 + const originX = canvasSize.width / 2 + const originY = canvasSize.height / 2 + return { pad, cell, originX, originY, left: originX - cell * 8, right: originX + cell * 8, top: originY - cell * 8, bottom: originY + cell * 8 } + }, [canvasSize]) + + const toPx = useCallback((x, y) => ({ + x: constants.originX + x * constants.cell, + y: constants.originY - y * constants.cell, + }), [constants]) + + const toGrid = useCallback((x, y) => ({ + x: clamp((x - constants.originX) / constants.cell, -8, 8), + y: clamp((constants.originY - y) / constants.cell, -8, 8), + }), [constants]) + + const yFor = useCallback((x, slope = m, intercept = b) => slope * x + intercept, [b, m]) + const tableRows = xRows.map((x) => ({ x, y: yFor(x) })) + const matched = mode === 'Match' && m === target.m && b === target.b + + const slopeHandleX = useMemo(() => { + const options = [2, -2, 1, -1, 3, -3, 4, -4] + return options.find((x) => yFor(x) >= -8 && yFor(x) <= 8) ?? 2 + }, [yFor]) + + const drawLine = useCallback((ctx, slope, intercept, color, dashed = false, width = 2.5) => { + const intersections = [] + ;[-8, 8].forEach((x) => { + const y = slope * x + intercept + if (y >= -8 && y <= 8) intersections.push({ x, y }) + }) + if (slope !== 0) { + ;[-8, 8].forEach((y) => { + const x = (y - intercept) / slope + if (x >= -8 && x <= 8) intersections.push({ x, y }) + }) + } + const unique = intersections.filter((point, index) => intersections.findIndex((item) => Math.abs(item.x - point.x) < 0.001 && Math.abs(item.y - point.y) < 0.001) === index) + if (unique.length < 2) return + const p1 = toPx(unique[0].x, unique[0].y) + const p2 = toPx(unique[1].x, unique[1].y) + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = width + ctx.lineCap = 'round' + if (dashed) ctx.setLineDash([8, 7]) + ctx.beginPath() + ctx.moveTo(p1.x, p1.y) + ctx.lineTo(p2.x, p2.y) + ctx.stroke() + ctx.restore() + }, [toPx]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + + ctx.fillStyle = '#ffffff' + roundRect(ctx, 0, 0, canvasSize.width, canvasSize.height, 14) + ctx.fill() + + ctx.save() + ctx.beginPath() + ctx.rect(constants.left, constants.top, constants.right - constants.left, constants.bottom - constants.top) + ctx.clip() + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let i = -8; i <= 8; i += 1) { + const v = toPx(i, 0).x + const h = toPx(0, i).y + ctx.beginPath() + ctx.moveTo(v, constants.top) + ctx.lineTo(v, constants.bottom) + ctx.moveTo(constants.left, h) + ctx.lineTo(constants.right, h) + ctx.stroke() + } + + if (mode === 'Match') drawLine(ctx, target.m, target.b, colors.target, true, 3) + drawLine(ctx, m, b, colors.line, false, 3) + + if (showTriangle) { + const p0 = toPx(0, b) + const p1 = toPx(1, b) + const p2 = toPx(1, b + m) + ctx.strokeStyle = colors.intercept + ctx.lineWidth = 3 + ctx.setLineDash([5, 4]) + ctx.beginPath() + ctx.moveTo(p0.x, p0.y) + ctx.lineTo(p1.x, p1.y) + ctx.stroke() + ctx.strokeStyle = colors.slope + ctx.beginPath() + ctx.moveTo(p1.x, p1.y) + ctx.lineTo(p2.x, p2.y) + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = colors.intercept + ctx.font = '900 12px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.fillText('run 1', (p0.x + p1.x) / 2, p0.y + 18) + ctx.fillStyle = colors.slope + ctx.fillText(`rise ${formatNumber(m)}`, p2.x + 32, (p1.y + p2.y) / 2) + } + + if (showPoints || activeLink?.startsWith('row-')) { + tableRows.forEach((row) => { + if (row.y < -8 || row.y > 8) return + const p = toPx(row.x, row.y) + const active = activeLink === `row-${row.x}` + if (active) return + if (!showPoints && !active) return + ctx.fillStyle = active ? colors.highlight : colors.slope + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = active ? 4 : 2.5 + ctx.beginPath() + ctx.arc(p.x, p.y, active ? 8.5 : 5.5, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }) + } + + ctx.restore() + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(constants.left, constants.originY) + ctx.lineTo(constants.right, constants.originY) + ctx.moveTo(constants.originX, constants.top) + ctx.lineTo(constants.originX, constants.bottom) + ctx.stroke() + + ctx.fillStyle = '#5F5E5A' + ctx.font = '11px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let x = -8; x <= 8; x += 2) { + const px = toPx(x, 0) + if (x === -8) ctx.textAlign = 'left' + else if (x === 8) ctx.textAlign = 'right' + else ctx.textAlign = 'center' + ctx.fillText(String(x), px.x, constants.originY + 5) + } + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (let y = -8; y <= 8; y += 2) { + if (y === 0) continue + const py = toPx(0, y) + const labelY = clamp(py.y, constants.top + 8, constants.bottom - 8) + ctx.fillText(String(y), constants.originX - 6, labelY) + } + + const interceptPoint = toPx(0, b) + const slopePoint = toPx(slopeHandleX, yFor(slopeHandleX)) + ;[ + { point: interceptPoint, color: colors.intercept, active: activeLink === 'intercept', label: 'b' }, + { point: slopePoint, color: colors.slope, active: activeLink === 'slope', label: 'm' }, + ].forEach((item) => { + ctx.fillStyle = item.color + ctx.strokeStyle = item.active ? colors.highlight : '#ffffff' + ctx.lineWidth = item.active ? 5 : 3 + ctx.beginPath() + ctx.arc(item.point.x, item.point.y, item.active ? 11 : 8, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.fillStyle = '#ffffff' + ctx.font = '900 11px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(item.label, item.point.x, item.point.y) + }) + + if (activeLink?.startsWith('row-')) { + const activeRow = tableRows.find((row) => activeLink === `row-${row.x}`) + if (activeRow && activeRow.y >= -8 && activeRow.y <= 8) { + const point = toPx(activeRow.x, activeRow.y) + const touchesHandle = [interceptPoint, slopePoint].some((handle) => Math.hypot(handle.x - point.x, handle.y - point.y) < 16) + ctx.save() + ctx.strokeStyle = colors.highlight + ctx.lineWidth = 4 + ctx.beginPath() + ctx.arc(point.x, point.y, touchesHandle ? 15 : 10, 0, Math.PI * 2) + if (!touchesHandle) { + ctx.fillStyle = colors.highlight + ctx.fill() + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + } + ctx.stroke() + ctx.restore() + } + } + + if (matched) { + const badgeW = 114 + const badgeH = 34 + const badgeX = constants.originX - badgeW / 2 + const badgeY = constants.top + 12 + ctx.save() + ctx.shadowColor = 'rgba(123, 63, 158, 0.22)' + ctx.shadowBlur = 10 + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = colors.line + ctx.lineWidth = 2 + roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 17) + ctx.fill() + ctx.stroke() + ctx.shadowBlur = 0 + ctx.fillStyle = colors.line + ctx.font = '900 15px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('Matched!', constants.originX, badgeY + badgeH / 2) + ctx.restore() + } + }, [activeLink, b, canvasSize, constants, drawLine, m, matched, mode, showPoints, showTriangle, slopeHandleX, tableRows, target, toPx, yFor]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasSize({ + width: Math.max(420, Math.floor(node.clientWidth)), + height: Math.max(420, Math.floor(node.clientHeight)), + }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const canvasPoint = (event) => { + const rect = canvasRef.current.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasSize.width / rect.width), + y: (event.clientY - rect.top) * (canvasSize.height / rect.height), + } + } + + const handlePointerDown = (event) => { + const point = canvasPoint(event) + const interceptPoint = toPx(0, b) + const slopePoint = toPx(slopeHandleX, yFor(slopeHandleX)) + if (Math.hypot(point.x - interceptPoint.x, point.y - interceptPoint.y) <= 18) { + dragRef.current = 'intercept' + setActiveLink('intercept') + event.currentTarget.setPointerCapture(event.pointerId) + return + } + if (Math.hypot(point.x - slopePoint.x, point.y - slopePoint.y) <= 18) { + dragRef.current = 'slope' + setActiveLink('slope') + event.currentTarget.setPointerCapture(event.pointerId) + } + } + + const handlePointerMove = (event) => { + if (!dragRef.current) return + const point = canvasPoint(event) + const grid = toGrid(point.x, point.y) + if (dragRef.current === 'intercept') { + setB(snapIntercept(grid.y)) + return + } + const divisor = Math.abs(grid.x) < 0.35 ? slopeHandleX : grid.x + setM(snapSlope((grid.y - b) / divisor)) + } + + const stopDragging = () => { + dragRef.current = null + } + + const setModeSafely = (nextMode) => { + setMode(nextMode) + setActiveLink(null) + if (nextMode === 'Match') setTarget(makeTarget()) + } + + const hint = mode === 'Explore' + ? `m = ${formatNumber(m)} controls steepness. b = ${formatNumber(b)} is where the line crosses the y-axis. Hover the equation or table to see the links.` + : matched + ? 'Matched. The slope and y-intercept are exactly the same as the target line.' + : `Match the pink line: adjust steepness with m and move the crossing point with b.` + + return ( + + + + + + + + ) +} diff --git a/src/manipulatives/linear-vs-nonlinear.jsx b/src/manipulatives/linear-vs-nonlinear.jsx new file mode 100644 index 0000000..ea3ec16 --- /dev/null +++ b/src/manipulatives/linear-vs-nonlinear.jsx @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const C={page:'#F8F6F0',ink:'#20201D',muted:'#66635D',border:'#E0DDD6',teal:'#1E5F74',purple:'#7B3F9E',purpleTint:'#F3EEFA',orange:'#B25A1E',green:'#27500A',greenTint:'#EAF3DE',rose:'#8A2540',roseTint:'#FBE9ED'} +const rounds=[ +['🕯️','A candle burns 3 cm each hour','How tall is the candle after each hour?','hours','height (cm)',[24,21,18,15,12,9],1], +['🦠','A bacteria colony doubles','How many groups of bacteria are there?','time steps','groups',[1,2,4,8,16,32],0], +['💰','Saving $5 every week','You start with $10, then save the same amount weekly.','weeks','savings ($)',[15,20,25,30,35,40],1], +['🌱','A square garden grows','Each side grows by 1 m. What happens to its area?','side length (m)','area (m²)',[1,4,9,16,25,36],0], +['🚕','A taxi costs $4 plus $2 per mile','Predict the total fare as the trip gets longer.','miles','fare ($)',[6,8,10,12,14,16],1], +['⚽','A ball falls farther each second','Gravity makes the distance grow faster and faster.','seconds','distance (m)',[5,20,45,80,125,180],0], +].map(([icon,title,context,x,y,values,linear])=>({icon,title,context,x,y,values,linear:!!linear})) +const P={l:54,r:22,t:24,b:44},clamp=(n,a,b)=>Math.max(a,Math.min(b,n)),ease=t=>t<.5?2*t*t:1-Math.pow(-2*t+2,2)/2 + +export default function LinearVsNonlinear(){ + const canvas=useRef(),wrap=useRef(),raf=useRef(),timer=useRef(),map=useRef({}) + const [round,setRound]=useState(0),[width,setWidth]=useState(720),[shown,setShown]=useState(3),[phase,setPhase]=useState('guess'),[guess,setGuess]=useState(null),[moving,setMoving]=useState(null),[feedback,setFeedback]=useState('Tap the purple column to place your prediction.') + const s=rounds[round],done=shown===6 + const maxY=useMemo(()=>{const m=Math.max(...s.values),step=m<=40?5:m<=100?10:25;return Math.ceil(m*1.08/step)*step},[s]) + useEffect(()=>{const ro=new ResizeObserver(([e])=>setWidth(Math.max(300,Math.floor(e.contentRect.width))));ro.observe(wrap.current);return()=>ro.disconnect()},[]) + useEffect(()=>()=>{cancelAnimationFrame(raf.current);clearTimeout(timer.current)},[]) + const reset=useCallback(i=>{cancelAnimationFrame(raf.current);clearTimeout(timer.current);setRound(i);setShown(3);setPhase('guess');setGuess(null);setMoving(null);setFeedback('Tap the purple column to place your prediction.')},[]) + const draw=useCallback(()=>{const el=canvas.current;if(!el)return;const h=210,dpr=devicePixelRatio||1;el.width=width*dpr;el.height=h*dpr;el.style.width=width+'px';el.style.height=h+'px';const c=el.getContext('2d');c.setTransform(dpr,0,0,dpr,0,0);c.clearRect(0,0,width,h);const pw=width-P.l-P.r,ph=h-P.t-P.b,x=i=>P.l+i/5*pw,y=v=>P.t+(1-v/maxY)*ph;map.current={x,ph};c.fillStyle='#fff';c.fillRect(0,0,width,h) + if(shown<6){const tx=x(shown),col=pw/5;c.fillStyle=C.purpleTint;c.fillRect(tx-col*.43,P.t,col*.86,ph);c.strokeStyle=C.purple;c.setLineDash([6,5]);c.beginPath();c.moveTo(tx,P.t);c.lineTo(tx,h-P.b);c.stroke();c.setLineDash([])} + c.font='600 11px Inter,system-ui';c.fillStyle=C.muted;c.textAlign='right';c.textBaseline='middle';for(let i=0;i<=4;i++){const v=maxY*i/4,yy=y(v);c.strokeStyle='#E7E3DC';c.lineWidth=1;c.beginPath();c.moveTo(P.l,yy);c.lineTo(width-P.r,yy);c.stroke();c.fillText(String(v),P.l-8,yy)}c.strokeStyle='#817E78';c.beginPath();c.moveTo(P.l,P.t);c.lineTo(P.l,h-P.b);c.lineTo(width-P.r,h-P.b);c.stroke();c.textAlign='center';c.textBaseline='top';for(let i=0;i<6;i++)c.fillText(String(i+1),x(i),h-P.b+8);c.fillText(s.x,width/2,h-16);c.save();c.translate(13,h/2);c.rotate(-Math.PI/2);c.fillText(s.y,0,0);c.restore() + const pts=[];for(let i=0;i{c.beginPath();c.arc(p[0],p[1],6,0,Math.PI*2);c.fillStyle=i<3?C.teal:C.orange;c.fill();c.strokeStyle='#fff';c.lineWidth=2;c.stroke()}) + if(guess!==null&&shown<6){const xx=x(shown),yy=y(guess);c.strokeStyle=C.purple;c.lineWidth=2;c.setLineDash([4,3]);c.beginPath();c.arc(xx,yy,8,0,Math.PI*2);c.stroke();c.setLineDash([]);c.fillStyle=C.purple;c.font='700 12px Inter,system-ui';c.textAlign=xx>width-100?'right':'left';c.textBaseline='bottom';c.fillText(`your guess: ${guess}`,xx+(xx>width-100?-12:12),yy-7)} + },[width,maxY,s,shown,phase,moving,guess]);useEffect(draw,[draw]) + const reveal=useCallback(g=>{const actual=s.values[shown],start=performance.now();setPhase('animating');setMoving(g);const tick=now=>{const t=clamp((now-start)/700,0,1);setMoving(g+(actual-g)*ease(t));if(t<1)raf.current=requestAnimationFrame(tick);else{const close=Math.abs(actual-g)<=maxY*.06;setShown(n=>n+1);setMoving(null);setGuess(null);setFeedback(close?`🎯 Great guess — actual value: ${actual}`:`The point landed at ${actual} — ${actual>g?'higher':'lower'} than your guess of ${g}!`);setPhase(shown+1===6?'done':'guess')}};raf.current=requestAnimationFrame(tick)},[s,shown,maxY]) + const tap=e=>{if(phase!=='guess'||done)return;e.preventDefault();const r=canvas.current.getBoundingClientRect(),lx=(e.clientX-r.left)*width/r.width,ly=(e.clientY-r.top)*210/r.height,{x,ph}=map.current,col=(width-P.l-P.r)/5;if(Math.abs(lx-x(shown))>col*.48||ly210-P.b)return;const snap=maxY<=40?1:5,g=clamp(Math.round(((1-(ly-P.t)/ph)*maxY)/snap)*snap,0,maxY);setGuess(g);setFeedback('Prediction locked in… watch where the real point lands!');clearTimeout(timer.current);timer.current=setTimeout(()=>reveal(g),600)} + const first=s.values[1]-s.values[0],changes=s.values.slice(1,shown).map((v,i)=>v-s.values[i]);const hint=done?(s.linear?'Equal steps in x, same change in y every time → linear. Just keep adding the same amount.':'The change in y is different each step → non-linear. Curves defy straight-line prediction — the change itself keeps changing.'):(shown===3?'Look at the change between the first points. If it stays the same, the pattern continues in a straight line — use that to predict!':'Check the chips: green = same change as before, rose = the change is changing. Let them guide your next guess.') + return + {s.icon}{s.title}{s.context}Round {round+1} of 6Predict point {shown+1}: tap inside the purple column. + e.preventDefault()} role="img" aria-label={`Graph of ${s.title}. ${shown} of 6 points revealed.`}/>{feedback} + Changes:{changes.map((d,i)=>{const same=d===first;return {d>=0?'+':''}{d} {same?'• same':'• changed'}})} + {done&&{s.linear?<>Linear — totally predictable. Every step changed by the same amount ({first>=0?'+':''}{first}), so the line stayed straight and your guesses could follow the pattern.>:<>Non-linear — it kept surprising you. The change grew every step ({changes.map(d=>`${d>=0?'+':''}${d}`).join(', ')}) — a changing rate bends the graph into a curve that outruns straight-line guessing.>}} + 💡 {hint}reset((round+1)%rounds.length)}>{done?'New relationship':'Skip relationship'} → +} diff --git a/src/manipulatives/nets-surface-area.jsx b/src/manipulatives/nets-surface-area.jsx new file mode 100644 index 0000000..58e459c --- /dev/null +++ b/src/manipulatives/nets-surface-area.jsx @@ -0,0 +1,386 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +const faceStyles = { + top: { fill: '#CECBF6', stroke: '#534AB7', text: '#3C3489', dims: 'l x w' }, + bottom: { fill: '#CECBF6', stroke: '#534AB7', text: '#3C3489', dims: 'l x w' }, + front: { fill: '#9FE1CB', stroke: '#0F6E56', text: '#085041', dims: 'l x h' }, + back: { fill: '#9FE1CB', stroke: '#0F6E56', text: '#085041', dims: 'l x h' }, + left: { fill: '#F5C4B3', stroke: '#993C1D', text: '#712B13', dims: 'w x h' }, + right: { fill: '#F5C4B3', stroke: '#993C1D', text: '#712B13', dims: 'w x h' }, +} + +const dimConfig = [ + { key: 'length', label: 'Length', color: '#534AB7' }, + { key: 'width', label: 'Width', color: '#0F6E56' }, + { key: 'height', label: 'Height', color: '#993C1D' }, +] + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function pointInPolygon(point, polygon) { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) { + const a = polygon[index] + const b = polygon[previous] + const crosses = (a.y > point.y) !== (b.y > point.y) + && point.x < ((b.x - a.x) * (point.y - a.y)) / ((b.y - a.y) || 1e-6) + a.x + if (crosses) inside = !inside + } + return inside +} + +function rotateAroundAxis(point, axisPoint, axisDir, angle) { + const length = Math.hypot(axisDir.x, axisDir.y, axisDir.z) || 1 + const u = { x: axisDir.x / length, y: axisDir.y / length, z: axisDir.z / length } + const p = { + x: point.x - axisPoint.x, + y: point.y - axisPoint.y, + z: point.z - axisPoint.z, + } + const cos = Math.cos(angle) + const sin = Math.sin(angle) + const dot = u.x * p.x + u.y * p.y + u.z * p.z + const cross = { + x: u.y * p.z - u.z * p.y, + y: u.z * p.x - u.x * p.z, + z: u.x * p.y - u.y * p.x, + } + + return { + x: axisPoint.x + p.x * cos + cross.x * sin + u.x * dot * (1 - cos), + y: axisPoint.y + p.y * cos + cross.y * sin + u.y * dot * (1 - cos), + z: axisPoint.z + p.z * cos + cross.z * sin + u.z * dot * (1 - cos), + } +} + +function rotateScene(point, viewX, viewY) { + const cosX = Math.cos(viewX) + const sinX = Math.sin(viewX) + const y1 = point.y * cosX - point.z * sinX + const z1 = point.y * sinX + point.z * cosX + + const cosY = Math.cos(viewY) + const sinY = Math.sin(viewY) + return { + x: point.x * cosY + z1 * sinY, + y: y1, + z: -point.x * sinY + z1 * cosY, + } +} + +function buildFaces({ length, width, height }, angle) { + const l = length + const w = width + const h = height + const topZ = h / 2 + const bottomZ = -h / 2 + const x0 = -l / 2 + const x1 = l / 2 + const y0 = -w / 2 + const y1 = w / 2 + + const a = { x: x0, y: y0, z: topZ } + const b = { x: x1, y: y0, z: topZ } + const c = { x: x1, y: y1, z: topZ } + const d = { x: x0, y: y1, z: topZ } + const a0 = { x: x0, y: y0, z: bottomZ } + const b0 = { x: x1, y: y0, z: bottomZ } + const c0 = { x: x1, y: y1, z: bottomZ } + const d0 = { x: x0, y: y1, z: bottomZ } + + const rotateFront = (point) => rotateAroundAxis(point, a, { x: 1, y: 0, z: 0 }, -angle) + const front = [a, b, b0, a0].map(rotateFront) + const back = [d, c, c0, d0].map((point) => rotateAroundAxis(point, d, { x: 1, y: 0, z: 0 }, angle)) + const left = [d, a, a0, d0].map((point) => rotateAroundAxis(point, a, { x: 0, y: 1, z: 0 }, angle)) + const right = [b, c, c0, b0].map((point) => rotateAroundAxis(point, b, { x: 0, y: 1, z: 0 }, -angle)) + + const bottomAfterFront = [a0, b0, c0, d0].map(rotateFront) + const hingeA = bottomAfterFront[0] + const hingeB = bottomAfterFront[1] + const bottomAxis = { + x: hingeB.x - hingeA.x, + y: hingeB.y - hingeA.y, + z: hingeB.z - hingeA.z, + } + const bottom = bottomAfterFront.map((point) => rotateAroundAxis(point, hingeA, bottomAxis, -angle)) + + return [ + { name: 'top', points: [a, b, c, d] }, + { name: 'front', points: front }, + { name: 'back', points: back }, + { name: 'left', points: left }, + { name: 'right', points: right }, + { name: 'bottom', points: bottom }, + ] +} + +function projectFaces(faces, unfoldPercent) { + const flatten = unfoldPercent / 100 + const viewX = (Math.PI / 6) * (1 - flatten) + const viewY = (-25 * Math.PI / 180) * (1 - flatten) + + return faces.map((face) => { + const projected = face.points.map((point) => { + const rotated = rotateScene(point, viewX, viewY) + return { x: rotated.x, y: -rotated.y, depth: rotated.z } + }) + return { + ...face, + projected, + avgDepth: projected.reduce((sum, point) => sum + point.depth, 0) / projected.length, + } + }) +} + +function boundsFor(projectedFaces) { + const all = projectedFaces.flatMap((face) => face.projected) + return all.reduce((box, point) => ({ + minX: Math.min(box.minX, point.x), + maxX: Math.max(box.maxX, point.x), + minY: Math.min(box.minY, point.y), + maxY: Math.max(box.maxY, point.y), + }), { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }) +} + +function faceArea(name, dims) { + if (name === 'top' || name === 'bottom') return dims.length * dims.width + if (name === 'front' || name === 'back') return dims.length * dims.height + return dims.width * dims.height +} + +function Stepper({ label, value, color, onChange }) { + return ( + + {label} + + onChange(clamp(value - 1, 1, 6))} className="h-6 w-6 rounded-full text-sm font-black text-white" style={{ backgroundColor: color }}>- + {value} + onChange(clamp(value + 1, 1, 6))} className="h-6 w-6 rounded-full text-sm font-black text-white" style={{ backgroundColor: color }}>+ + + + ) +} + +export default function NetsSurfaceArea() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const hitFacesRef = useRef([]) + const [canvasSize, setCanvasSize] = useState({ width: 500, height: 360 }) + const [dims, setDims] = useState({ length: 4, width: 3, height: 2 }) + const [unfold, setUnfold] = useState(0) + const [activeFace, setActiveFace] = useState(null) + + const angle = (unfold / 100) * (Math.PI / 2) + const surfaceArea = 2 * (dims.length * dims.width) + 2 * (dims.length * dims.height) + 2 * (dims.width * dims.height) + const stateText = unfold === 0 ? 'folded' : unfold === 100 ? 'flat net' : 'unfolding' + + const faceData = useMemo(() => buildFaces(dims, angle), [angle, dims]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return + const update = () => setCanvasSize({ + width: Math.max(320, Math.floor(node.clientWidth)), + height: Math.max(260, Math.floor(node.clientHeight)), + }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height) + + const projected = projectFaces(faceData, unfold) + const fitSamples = [ + projectFaces(buildFaces(dims, 0), 0), + projectFaces(buildFaces(dims, Math.PI / 2), 100), + projected, + ].map(boundsFor) + const maxWidth = Math.max(...fitSamples.map((box) => box.maxX - box.minX), Math.hypot(dims.length, dims.width)) + const maxHeight = Math.max(...fitSamples.map((box) => box.maxY - box.minY), Math.hypot(dims.width, dims.height)) + const scale = Math.min(62, (canvasSize.width - 42) / maxWidth, (canvasSize.height - 42) / maxHeight) + const currentBounds = boundsFor(projected) + const centerX = (currentBounds.minX + currentBounds.maxX) / 2 + const centerY = (currentBounds.minY + currentBounds.maxY) / 2 + const toScreen = (point) => ({ + x: canvasSize.width / 2 + (point.x - centerX) * scale, + y: canvasSize.height / 2 + (point.y - centerY) * scale, + }) + + const hitFaces = [] + + projected + .slice() + .sort((a, b) => a.avgDepth - b.avgDepth) + .forEach((face) => { + const style = faceStyles[face.name] + const screenPoints = face.projected.map(toScreen) + const isHighlighted = activeFace === face.name + hitFaces.push({ name: face.name, points: screenPoints, depth: face.avgDepth }) + ctx.save() + ctx.beginPath() + screenPoints.forEach((point, index) => { + if (index === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + }) + ctx.closePath() + ctx.fillStyle = style.fill + ctx.strokeStyle = style.stroke + ctx.lineWidth = 2 + ctx.lineJoin = 'round' + ctx.fill() + if (isHighlighted) { + ctx.save() + ctx.shadowColor = 'rgba(255, 255, 255, 0.95)' + ctx.shadowBlur = 14 + ctx.strokeStyle = 'rgba(255, 255, 255, 0.98)' + ctx.lineWidth = 7 + ctx.lineJoin = 'round' + ctx.stroke() + ctx.shadowBlur = 0 + ctx.strokeStyle = 'rgba(255, 255, 255, 1)' + ctx.lineWidth = 3 + ctx.stroke() + ctx.restore() + } + ctx.stroke() + + const center = screenPoints.reduce((total, point) => ({ + x: total.x + point.x / screenPoints.length, + y: total.y + point.y / screenPoints.length, + }), { x: 0, y: 0 }) + const calc = style.dims + .replace('l', dims.length) + .replace('w', dims.width) + .replace('h', dims.height) + ctx.font = '900 11px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.lineWidth = 3 + ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)' + ctx.strokeText(calc, center.x, center.y) + ctx.fillStyle = style.text + ctx.fillText(calc, center.x, center.y) + ctx.restore() + }) + hitFacesRef.current = hitFaces.sort((a, b) => b.depth - a.depth) + }, [activeFace, canvasSize, dims, faceData, unfold]) + + const updateDim = (key, value) => setDims((current) => ({ ...current, [key]: value })) + + const toggleFace = (face) => { + setActiveFace((current) => current === face ? null : face) + } + + const handleCanvasPointerDown = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const point = { + x: ((event.clientX - rect.left) / rect.width) * canvasSize.width, + y: ((event.clientY - rect.top) / rect.height) * canvasSize.height, + } + const hit = hitFacesRef.current.find((face) => pointInPolygon(point, face.points)) + if (!hit) { + setActiveFace(null) + return + } + toggleFace(hit.name) + } + + return ( + + + + + + + + + + + {dimConfig.map((item) => ( + updateDim(item.key, value)} + /> + ))} + + + + + Box + {stateText} + Net + + setUnfold(Number(event.target.value))} + className="mt-1 w-full cursor-pointer" + style={{ accentColor: '#534AB7' }} + /> + + + + Tap a face to highlight it. + + + + {['top', 'bottom', 'front', 'back', 'left', 'right'].map((name) => { + const style = faceStyles[name] + const area = faceArea(name, dims) + const calc = style.dims + .replace('l', dims.length) + .replace('w', dims.width) + .replace('h', dims.height) + .replaceAll(' x ', ' x ') + return ( + toggleFace(name)} + className="h-[58px] rounded-xl border-[1.5px] bg-white px-2 py-1 text-left transition-shadow duration-150" + style={{ + borderColor: style.stroke, + boxShadow: activeFace === name ? `inset 0 0 0 3px rgba(255,255,255,0.96), 0 0 0 2px ${style.stroke}, 0 8px 20px ${style.stroke}33` : 'none', + }} + > + {name} + {calc} + = {area} + + ) + })} + + + + Matching faces come in 3 pairs: top-bottom, front-back, left-right. + + + + + + 2({dims.length}x{dims.width}) + 2({dims.length}x{dims.height}) + 2({dims.width}x{dims.height}) = {surfaceArea} sq units + + + ) +} diff --git a/src/manipulatives/number-line-add-subtract.jsx b/src/manipulatives/number-line-add-subtract.jsx new file mode 100644 index 0000000..9467215 --- /dev/null +++ b/src/manipulatives/number-line-add-subtract.jsx @@ -0,0 +1,428 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const axisColor = '#1A1A2E' +const purple = '#534AB7' +const teal = '#1D9E75' +const orange = '#D85A30' + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function normalizeNumber(value, fallback = 0) { + if (value === '' || value === '-' || Number.isNaN(Number(value))) return fallback + return clamp(Math.round(Number(value)), -10, 10) +} + +function formatNumber(value) { + return Object.is(value, -0) ? 0 : value +} + +function easeInOut(t) { + return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2 +} + +function drawArrowHead(ctx, x, y, angle, size = 9) { + ctx.save() + ctx.translate(x, y) + ctx.rotate(angle) + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(-size, -size * 0.55) + ctx.lineTo(-size, size * 0.55) + ctx.closePath() + ctx.fill() + ctx.restore() +} + +function drawAxis(ctx, width, height) { + const PAD = 40 + const CELL = (width - PAD * 2) / 20 + const ORIGIN_X = PAD + CELL * 10 + const AXIS_Y = height * 0.7 + const toX = (value) => ORIGIN_X + value * CELL + + ctx.strokeStyle = axisColor + ctx.fillStyle = axisColor + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.moveTo(PAD, AXIS_Y) + ctx.lineTo(width - PAD, AXIS_Y) + ctx.stroke() + + drawArrowHead(ctx, PAD, AXIS_Y, Math.PI) + drawArrowHead(ctx, width - PAD, AXIS_Y, 0) + + ctx.font = '12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + ctx.fillStyle = '#5F5E5A' + ctx.strokeStyle = axisColor + ctx.lineWidth = 1.4 + + for (let n = -10; n <= 10; n += 1) { + const x = toX(n) + const isLabeled = n % 2 === 0 + ctx.beginPath() + ctx.moveTo(x, AXIS_Y - 13) + ctx.lineTo(x, AXIS_Y + 13) + ctx.stroke() + if (isLabeled) ctx.fillText(String(n), x, AXIS_Y + 17) + } + + return { PAD, CELL, ORIGIN_X, AXIS_Y, toX } +} + +function drawDot(ctx, x, y, value, color) { + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(x, y, 18, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.font = '800 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(formatNumber(value)), x, y) +} + +function getNumberColor(value) { + return value < 0 ? orange : purple +} + +function drawEquation(ctx, width, y, a, operator, b, phase, rawResult) { + const parts = [ + { text: String(formatNumber(a)), color: getNumberColor(a) }, + { text: ` ${operator} `, color: operator === '+' ? purple : orange }, + { text: String(formatNumber(b)), color: getNumberColor(b) }, + { text: ' = ', color: axisColor }, + { text: phase === 'done' ? String(formatNumber(rawResult)) : '?', color: phase === 'done' ? getNumberColor(rawResult) : teal }, + ] + + ctx.save() + ctx.font = '900 25px Inter, system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + const totalWidth = parts.reduce((sum, part) => sum + ctx.measureText(part.text).width, 0) + let x = width / 2 - totalWidth / 2 + parts.forEach((part) => { + ctx.fillStyle = part.color + ctx.fillText(part.text, x, y) + x += ctx.measureText(part.text).width + }) + ctx.restore() +} + +function drawCar(ctx, x, wheelY, operator, movementDirection, progress) { + const facing = operator === '+' ? -1 : 1 + const bodyColor = facing > 0 ? purple : orange + const wheelSpin = progress * Math.PI * 8 * (movementDirection >= 0 ? 1 : -1) + + ctx.save() + ctx.translate(x, wheelY) + ctx.scale(facing, 1) + ctx.lineJoin = 'round' + ctx.lineCap = 'round' + + ctx.fillStyle = 'rgba(26, 26, 46, 0.12)' + ctx.beginPath() + ctx.ellipse(0, 7, 36, 7, 0, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = bodyColor + ctx.strokeStyle = axisColor + ctx.lineWidth = 2 + ctx.beginPath() + ctx.roundRect(-31, -31, 64, 23, 7) + ctx.fill() + ctx.stroke() + + ctx.beginPath() + ctx.moveTo(-18, -31) + ctx.lineTo(-7, -46) + ctx.lineTo(17, -46) + ctx.lineTo(29, -31) + ctx.closePath() + ctx.fill() + ctx.stroke() + + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.moveTo(-4, -42) + ctx.lineTo(14, -42) + ctx.lineTo(22, -31) + ctx.lineTo(-4, -31) + ctx.closePath() + ctx.fill() + + ctx.fillStyle = '#F8F6F0' + ctx.beginPath() + ctx.roundRect(27, -26, 8, 7, 3) + ctx.fill() + ctx.fillStyle = '#7C2D12' + ctx.beginPath() + ctx.roundRect(-34, -25, 6, 8, 3) + ctx.fill() + + ;[-18, 20].forEach((wheelX) => { + ctx.save() + ctx.translate(wheelX, -7) + ctx.rotate(wheelSpin) + ctx.fillStyle = axisColor + ctx.beginPath() + ctx.arc(0, 0, 8, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(-5, 0) + ctx.lineTo(5, 0) + ctx.moveTo(0, -5) + ctx.lineTo(0, 5) + ctx.stroke() + ctx.restore() + }) + + ctx.restore() +} + +function drawStepPath(ctx, toX, axisY, startValue, stepAmount, progress) { + const totalSteps = Math.abs(stepAmount) + if (totalSteps === 0 || progress <= 0) return + + const direction = stepAmount >= 0 ? 1 : -1 + const visibleSteps = totalSteps * progress + + ctx.save() + ctx.strokeStyle = teal + ctx.fillStyle = teal + ctx.lineCap = 'round' + ctx.lineWidth = 7 + + for (let index = 0; index < totalSteps; index += 1) { + const segmentProgress = clamp(visibleSteps - index, 0, 1) + if (segmentProgress <= 0) continue + + const fromValue = clamp(startValue + direction * index, -10, 10) + const toValue = clamp(startValue + direction * (index + segmentProgress), -10, 10) + if (fromValue === toValue) continue + + ctx.globalAlpha = index + 1 <= Math.floor(visibleSteps) ? 0.95 : 0.55 + ctx.beginPath() + ctx.moveTo(toX(fromValue), axisY) + ctx.lineTo(toX(toValue), axisY) + ctx.stroke() + } + + ctx.globalAlpha = 1 + const reachedWholeSteps = Math.min(totalSteps, Math.floor(visibleSteps)) + for (let index = 0; index <= reachedWholeSteps; index += 1) { + const value = startValue + direction * index + if (value < -10 || value > 10) continue + ctx.beginPath() + ctx.arc(toX(value), axisY, 5, 0, Math.PI * 2) + ctx.fill() + } + ctx.restore() +} + +export default function NumberLineAddSubtract() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(720) + const [aInput, setAInput] = useState('3') + const [bInput, setBInput] = useState('5') + const [operator, setOperator] = useState('+') + const [phase, setPhase] = useState('idle') + const [progress, setProgress] = useState(0) + + const canvasHeight = 220 + const a = normalizeNumber(aInput, 0) + const b = normalizeNumber(bInput, 0) + const result = clamp(operator === '+' ? a + b : a - b, -10, 10) + const rawResult = operator === '+' ? a + b : a - b + const stepAmount = operator === '+' ? b : -b + const movementDirection = stepAmount >= 0 ? 1 : -1 + + const resetAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + setPhase('idle') + setProgress(0) + }, []) + + useEffect(() => { + const wrapper = wrapRef.current + if (!wrapper) return + + const update = () => { + const rect = wrapper.getBoundingClientRect() + setCanvasWidth(Math.max(320, Math.round(rect.width))) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(wrapper) + return () => observer.disconnect() + }, []) + + useEffect(() => resetAnimation, [resetAnimation]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!ctx) return + + const width = canvasWidth + const height = canvasHeight + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + + drawEquation(ctx, width, 30, a, operator, b, phase, rawResult) + + const { AXIS_Y, toX } = drawAxis(ctx, width, height) + const startX = toX(a) + const endX = toX(result) + const startY = AXIS_Y - 34 + const carWheelY = AXIS_Y - 62 + + drawDot(ctx, startX, startY, a, getNumberColor(a)) + + if (phase !== 'idle') { + const visibleProgress = phase === 'showStart' ? 0 : progress + drawStepPath(ctx, toX, AXIS_Y, a, stepAmount, visibleProgress) + + const carX = startX + (endX - startX) * visibleProgress + drawCar(ctx, carX, carWheelY, operator, movementDirection, visibleProgress) + } + + if (phase === 'done') { + drawDot(ctx, endX, AXIS_Y - 34, rawResult, getNumberColor(rawResult)) + } + }, [a, b, canvasWidth, movementDirection, operator, phase, progress, rawResult, result, stepAmount]) + + useEffect(() => { + draw() + }, [draw]) + + const finishNumberInput = (setter, fallback) => { + setter((current) => String(normalizeNumber(current, fallback))) + resetAnimation() + } + + const animate = () => { + resetAnimation() + setPhase('showStart') + + const startDelay = 450 + const duration = 3024 + const startedAt = performance.now() + + const tick = (now) => { + const elapsed = now - startedAt + if (elapsed < startDelay) { + frameRef.current = requestAnimationFrame(tick) + return + } + + const nextProgress = Math.min(1, (elapsed - startDelay) / duration) + setPhase('walking') + setProgress(easeInOut(nextProgress)) + + if (nextProgress < 1) { + frameRef.current = requestAnimationFrame(tick) + } else { + frameRef.current = null + setProgress(1) + setPhase('done') + } + } + + frameRef.current = requestAnimationFrame(tick) + } + + const handleKeyDown = (event) => { + if (event.key === 'Enter') animate() + } + + return ( + + + + + + + + { + setAInput(event.target.value.replace(/[^\d-]/g, '').slice(0, 3)) + resetAnimation() + }} + onBlur={() => finishNumberInput(setAInput, a)} + onKeyDown={handleKeyDown} + aria-label="Starting number" + className="h-16 w-16 rounded-xl border border-[#E0DDD6] bg-[#F8F6F0] text-center text-2xl font-black" + style={{ color: getNumberColor(a) }} + /> + + + { + setOperator('+') + resetAnimation() + }} + className={`h-8 w-14 text-xl font-black ${operator === '+' ? 'bg-[#534AB7] text-white' : 'text-[#534AB7]'}`} + aria-label="Add" + > + + + + { + setOperator('-') + resetAnimation() + }} + className={`h-8 w-14 text-xl font-black ${operator === '-' ? 'bg-[#D85A30] text-white' : 'text-[#D85A30]'}`} + aria-label="Subtract" + > + - + + + + { + setBInput(event.target.value.replace(/[^\d-]/g, '').slice(0, 3)) + resetAnimation() + }} + onBlur={() => finishNumberInput(setBInput, b)} + onKeyDown={handleKeyDown} + aria-label="Change amount" + className="h-16 w-16 rounded-xl border border-[#E0DDD6] bg-[#F8F6F0] text-center text-2xl font-black" + style={{ color: getNumberColor(b) }} + /> + + + Animate + + + Reset + + + + + The sign chooses the direction the car faces. The car may drive forward or backward to show the result. + + + + ) +} diff --git a/src/manipulatives/parallelogram-area.jsx b/src/manipulatives/parallelogram-area.jsx index b2d22cc..03b7ff6 100644 --- a/src/manipulatives/parallelogram-area.jsx +++ b/src/manipulatives/parallelogram-area.jsx @@ -6,8 +6,8 @@ const minBase = 80 const maxBase = 220 const minHeight = 40 const maxHeight = 130 -const minSkew = 10 -const maxSkew = 70 +const minSlantExtra = 5 +const maxSlant = 170 const animationMs = 1150 const drawingScale = 1.2 @@ -66,7 +66,7 @@ export default function ParallelogramArea() { const progressRef = useRef(0) const [base, setBase] = useState(150) const [height, setHeight] = useState(85) - const [skew, setSkew] = useState(40) + const [slantLength, setSlantLength] = useState(95) const [progress, setProgress] = useState(0) const [phase, setPhase] = useState('ready') @@ -92,17 +92,19 @@ export default function ParallelogramArea() { const updateHeight = (nextHeight) => { resetAnimation() setHeight(nextHeight) + setSlantLength((current) => Math.max(current, nextHeight + minSlantExtra)) } - const updateSkew = (nextSkew) => { + const updateSlantLength = (nextSlantLength) => { resetAnimation() - setSkew(nextSkew) + setSlantLength(nextSlantLength) } const geometry = useMemo(() => { const visualBase = base * drawingScale const visualHeight = height * drawingScale - const visualSkew = skew * drawingScale + const horizontalSlant = Math.sqrt(Math.max(slantLength * slantLength - height * height, 0)) + const visualSkew = horizontalSlant * drawingScale const left = Math.round((canvasWidth - visualBase - visualSkew) / 2) const top = 64 const bottom = top + visualHeight @@ -126,7 +128,7 @@ export default function ParallelogramArea() { e: { x: left + visualSkew, y: bottom }, }, } - }, [base, height, skew]) + }, [base, height, slantLength]) const getCanvasPoint = (event) => { const canvas = canvasRef.current @@ -304,7 +306,7 @@ export default function ParallelogramArea() { ctx.font = '700 17px Inter, system-ui, sans-serif' ctx.textAlign = 'left' ctx.textBaseline = 'middle' - ctx.fillText(`slant = ${skew}`, labelX, labelY) + ctx.fillText(`slant = ${slantLength}`, labelX, labelY) } const centerX = (points.a.x + points.c.x) / 2 @@ -326,7 +328,7 @@ export default function ParallelogramArea() { ctx.fillText(part.text, formulaX, formulaY) formulaX += ctx.measureText(part.text).width }) - }, [area, base, geometry, height, phase, progress, skew]) + }, [area, base, geometry, height, phase, progress, slantLength]) useEffect(() => { return () => { @@ -363,7 +365,7 @@ export default function ParallelogramArea() { const resetValues = () => { setBase(150) setHeight(85) - setSkew(40) + setSlantLength(95) resetAnimation() } @@ -457,11 +459,11 @@ export default function ParallelogramArea() { /> {phase === 'done' ? ( diff --git a/src/manipulatives/percent-park-designer.jsx b/src/manipulatives/percent-park-designer.jsx new file mode 100644 index 0000000..258738e --- /dev/null +++ b/src/manipulatives/percent-park-designer.jsx @@ -0,0 +1,111 @@ +import { useMemo, useRef, useState } from 'react' + +const terrains = [ + { id: 'grass', name: 'Grass', color: '#3E9B4F' }, + { id: 'woodland', name: 'Woodland', color: '#1E6B3C' }, + { id: 'flowers', name: 'Flower beds', color: '#F0D9A8' }, + { id: 'playground', name: 'Playground', color: '#E8923A' }, + { id: 'water', name: 'Water', color: '#3D8FD1' }, +] +const challenges = [ + { grass: 40, woodland: 25, flowers: 15, playground: 15, water: 5 }, + { grass: 50, woodland: 20, flowers: 10, playground: 10, water: 10 }, + { grass: 30, woodland: 30, flowers: 20, playground: 15, water: 5 }, + { grass: 60, woodland: 15, flowers: 10, playground: 10, water: 5 }, + null, +] + +function TerrainIcon({ type }) { + if (type === 'grass') return + if (type === 'water') return + if (type === 'woodland') return + if (type === 'flowers') return + return +} + +export default function PercentParkDesigner() { + const [cells, setCells] = useState(() => Array(100).fill(null)) + const [active, setActive] = useState('grass') + const [challenge, setChallenge] = useState(0) + const [feedback, setFeedback] = useState('') + const [celebrate, setCelebrate] = useState(false) + const drag = useRef(null) + const target = challenges[challenge] + const counts = useMemo(() => Object.fromEntries(terrains.map(t => [t.id, cells.filter(v => v === t.id).length])), [cells]) + const empty = cells.filter(v => v === null).length + + const paint = (index, mode) => { + if (index < 0 || index > 99 || drag.current?.seen.has(index)) return + drag.current?.seen.add(index) + setCells(old => { + const next = [...old] + next[index] = mode === 'erase' ? null : active + return next + }) + setFeedback('') + setCelebrate(false) + } + const pointerDown = (event, index) => { + event.preventDefault() + const mode = cells[index] === active ? 'erase' : 'paint' + drag.current = { mode, seen: new Set() } + event.currentTarget.setPointerCapture?.(event.pointerId) + paint(index, mode) + } + const pointerMove = event => { + if (!drag.current) return + event.preventDefault() + const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-cell]') + if (hit) paint(Number(hit.dataset.cell), drag.current.mode) + } + const stopDrag = () => { drag.current = null } + + const check = () => { + if (!target) { + const used = 100 - empty + setFeedback(`Your park uses ${used}% of the grid: ${terrains.map(t => `${counts[t.id]}% ${t.name.toLowerCase()}`).join(', ')}.`) + return + } + const wrong = terrains.filter(t => counts[t.id] !== target[t.id]) + if (!wrong.length) { + setFeedback(`Perfect park! ${terrains.map(t => target[t.id]).join(' + ')} = 100 — every tile accounted for.`) + setCelebrate(true) + } else { + setFeedback(wrong.map(t => `${t.name}: ${counts[t.id]}% — needs ${target[t.id]}%.`).join(' ')) + } + } + const clear = () => { setCells(Array(100).fill(null)); setFeedback(''); setCelebrate(false) } + const nextPark = () => { setChallenge(i => (i + 1) % challenges.length); clear() } + + const banner = target + ? `Lay out the park: ${terrains.map(t => `${target[t.id]}% ${t.name.toLowerCase()}`).join(', ')}.` + : 'Design any park you like — then read off your percentages.' + + return + + {banner} + + { if (event.buttons === 0) stopDrag() }} onTouchMove={event => event.preventDefault()}> + {cells.map((terrain, i) => t.id === terrain).color : '#FFF', '--i':i}} aria-label={`Tile ${i + 1}: ${terrain || 'empty'}`} onPointerDown={event => pointerDown(event, i)}>{terrain && })} + + + {terrains.map(t => { + const exact = target && counts[t.id] === target[t.id] + const over = target && counts[t.id] > target[t.id] + return setActive(t.id)}> + + {t.name}{counts[t.id]}%{counts[t.id]} tiles{target ? over ? 'Too many' : exact ? '✓ Right' : '' : 'free design'} + + })} + Empty tiles{empty} · {empty}% + + + + ✓ Done + Clear + New park → + {feedback || 'Paint by tapping or dragging. One tile = one percent.'} + + {celebrate && Spot on!Well done — every part of your park is exactly right. setCelebrate(false)}>Continue painting} + +} diff --git a/src/manipulatives/polygon-interior-angles.jsx b/src/manipulatives/polygon-interior-angles.jsx new file mode 100644 index 0000000..9bafc7b --- /dev/null +++ b/src/manipulatives/polygon-interior-angles.jsx @@ -0,0 +1,575 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + fill: '#EEEDFE', + stroke: '#1E2D5A', + purple: '#534AB7', + orange: '#D85A30', + teal: '#1D9E75', + blue: '#185FA5', + brown: '#BA7517', + border: '#E0DDD6', + muted: '#6B7280', +} + +const triangleColors = [colors.purple, colors.orange, colors.teal, colors.blue, colors.brown] +const sideOptions = [ + { sides: 3, label: 'Triangle' }, + { sides: 4, label: 'Quad' }, + { sides: 5, label: 'Pentagon' }, + { sides: 6, label: 'Hexagon' }, + { sides: 7, label: 'Heptagon' }, + { sides: 8, label: 'Octagon' }, +] + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeInOut(value) { + return value < 0.5 ? 2 * value * value : 1 - ((-2 * value + 2) ** 2) / 2 +} + +function modAngle(angle) { + let next = angle + while (next < -Math.PI) next += Math.PI * 2 + while (next > Math.PI) next -= Math.PI * 2 + return next +} + +function regularPolygon(sides, width, height) { + const radius = Math.min(width, height) * 0.36 + return Array.from({ length: sides }, (_, index) => { + const angle = -Math.PI / 2 + (Math.PI * 2 * index) / sides + return { + x: (width / 2 + Math.cos(angle) * radius) / width, + y: (height / 2 + Math.sin(angle) * radius) / height, + } + }) +} + +function formatAngleValue(value) { + return Number.isInteger(value) ? `${value}` : value.toFixed(1) +} + +function toCanvasPoints(points, width, height) { + return points.map((point) => ({ x: point.x * width, y: point.y * height })) +} + +function interiorAngle(prev, current, next) { + const a1 = Math.atan2(prev.y - current.y, prev.x - current.x) + const a2 = Math.atan2(next.y - current.y, next.x - current.x) + const sweep = modAngle(a2 - a1) + const degrees = Math.round(Math.abs(sweep) * 180 / Math.PI) + return { start: a1, sweep, degrees: clamp(degrees, 1, 179) } +} + +function drawPolygon(ctx, points) { + ctx.beginPath() + points.forEach((point, index) => { + if (index === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + }) + ctx.closePath() + ctx.fillStyle = `${colors.fill}80` + ctx.strokeStyle = colors.stroke + ctx.lineWidth = 2.5 + ctx.fill() + ctx.stroke() +} + +function drawTriangles(ctx, points) { + if (points.length < 3) return + + for (let index = 1; index < points.length - 1; index += 1) { + const triangle = [points[0], points[index], points[index + 1]] + const color = triangleColors[(index - 1) % triangleColors.length] + ctx.save() + ctx.beginPath() + ctx.moveTo(triangle[0].x, triangle[0].y) + ctx.lineTo(triangle[1].x, triangle[1].y) + ctx.lineTo(triangle[2].x, triangle[2].y) + ctx.closePath() + ctx.fillStyle = `${color}33` + ctx.strokeStyle = color + ctx.lineWidth = 1.2 + ctx.setLineDash([7, 5]) + ctx.fill() + ctx.stroke() + ctx.setLineDash([]) + + const centroid = { + x: (triangle[0].x + triangle[1].x + triangle[2].x) / 3, + y: (triangle[0].y + triangle[1].y + triangle[2].y) / 3, + } + ctx.fillStyle = color + ctx.font = '800 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('180°', centroid.x, centroid.y) + ctx.restore() + } +} + +function drawAngles(ctx, points, answerGlow = false) { + points.forEach((point, index) => { + const prev = points[(index - 1 + points.length) % points.length] + const next = points[(index + 1) % points.length] + const angle = interiorAngle(prev, point, next) + const radius = 22 + + ctx.save() + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.arc(point.x, point.y, radius, angle.start, angle.start + angle.sweep, angle.sweep < 0) + ctx.closePath() + ctx.fillStyle = `${colors.purple}2e` + ctx.strokeStyle = colors.purple + ctx.lineWidth = 1.5 + ctx.fill() + ctx.stroke() + + const labelAngle = angle.start + angle.sweep / 2 + ctx.fillStyle = answerGlow ? colors.orange : colors.purple + ctx.shadowColor = answerGlow ? `${colors.orange}aa` : 'transparent' + ctx.shadowBlur = answerGlow ? 12 : 0 + ctx.font = answerGlow ? '900 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' : '800 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${angle.degrees}°`, point.x + Math.cos(labelAngle) * 38, point.y + Math.sin(labelAngle) * 38) + ctx.restore() + }) +} + +function drawExterior(ctx, points, progress = 1) { + const lineProgress = clamp(progress / 0.34, 0, 1) + const calcProgress = clamp((progress - 0.26) / 0.46, 0, 1) + const arcProgress = clamp((progress - 0.48) / 0.42, 0, 1) + const finalProgress = clamp((progress - 0.86) / 0.12, 0, 1) + + points.forEach((point, index) => { + const prev = points[(index - 1 + points.length) % points.length] + const next = points[(index + 1) % points.length] + const angle = interiorAngle(prev, point, next) + const exterior = 180 - angle.degrees + const dx = point.x - prev.x + const dy = point.y - prev.y + const length = Math.hypot(dx, dy) || 1 + const ux = dx / length + const uy = dy / length + const extensionAngle = Math.atan2(uy, ux) + const nextAngle = Math.atan2(next.y - point.y, next.x - point.x) + let sweep = modAngle(extensionAngle - nextAngle) + if (sweep < 0) sweep += Math.PI * 2 + if (sweep > Math.PI) sweep -= Math.PI * 2 + + ctx.save() + ctx.strokeStyle = colors.orange + ctx.fillStyle = colors.orange + ctx.lineCap = 'round' + ctx.lineWidth = 2 + ctx.globalAlpha = 0.95 + ctx.beginPath() + ctx.moveTo(point.x - ux * 36 * lineProgress, point.y - uy * 36 * lineProgress) + ctx.lineTo(point.x + ux * 48 * lineProgress, point.y + uy * 48 * lineProgress) + ctx.stroke() + + const labelAngle = nextAngle + sweep / 2 + const labelX = point.x + Math.cos(labelAngle) * 46 + const labelY = point.y + Math.sin(labelAngle) * 46 + + if (calcProgress > 0) { + ctx.globalAlpha = calcProgress * (1 - finalProgress) + ctx.fillStyle = colors.orange + const grow = 1 + 0.62 * easeInOut(calcProgress) + ctx.font = '900 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.save() + ctx.translate(labelX, labelY) + ctx.scale(grow, grow) + ctx.shadowColor = `${colors.orange}88` + ctx.shadowBlur = 8 * calcProgress + ctx.fillText(`180° - ${angle.degrees}°`, 0, 0) + ctx.restore() + } + + ctx.beginPath() + ctx.moveTo(point.x, point.y) + ctx.arc(point.x, point.y, 24, nextAngle, nextAngle + sweep * arcProgress, sweep < 0) + ctx.closePath() + ctx.globalAlpha = arcProgress + ctx.fillStyle = `${colors.orange}24` + ctx.strokeStyle = colors.orange + ctx.lineWidth = 1.5 + ctx.fill() + ctx.stroke() + + if (finalProgress > 0) { + ctx.globalAlpha = finalProgress + ctx.fillStyle = colors.orange + ctx.font = '900 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(`${Math.round(exterior)}°`, labelX, labelY) + } + + ctx.globalAlpha = 1 + ctx.fillStyle = colors.orange + ctx.restore() + }) +} + +function drawHandles(ctx, points) { + points.forEach((point) => { + ctx.beginPath() + ctx.arc(point.x, point.y, 8, 0, Math.PI * 2) + ctx.fillStyle = colors.stroke + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2 + ctx.fill() + ctx.stroke() + }) +} + +export default function PolygonInteriorAngles() { + const rootRef = useRef(null) + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const exteriorFrameRef = useRef(null) + const regularTimersRef = useRef([]) + const [canvasWidth, setCanvasWidth] = useState(720) + const canvasHeight = 270 + const [sides, setSides] = useState(4) + const [points, setPoints] = useState(() => regularPolygon(4, 720, canvasHeight)) + const [dragIndex, setDragIndex] = useState(null) + const [toggles, setToggles] = useState({ + angles: true, + triangles: false, + exterior: false, + }) + const [regularMode, setRegularMode] = useState(false) + const [regularPhase, setRegularPhase] = useState('idle') + const [exteriorProgress, setExteriorProgress] = useState(1) + + const triangleCount = sides - 2 + const sum = triangleCount * 180 + const regularAngle = sum / sides + + const canvasPoints = useMemo(() => toCanvasPoints(points, canvasWidth, canvasHeight), [canvasWidth, points]) + + const clearRegularTimers = useCallback(() => { + regularTimersRef.current.forEach((timer) => clearTimeout(timer)) + regularTimersRef.current = [] + }, []) + + const playRegularGlow = useCallback(() => { + clearRegularTimers() + setRegularPhase('sum') + regularTimersRef.current = [ + setTimeout(() => setRegularPhase('sides'), 2000), + setTimeout(() => setRegularPhase('answer'), 4000), + setTimeout(() => setRegularPhase('done'), 6000), + ] + }, [clearRegularTimers]) + + const resetPolygon = useCallback((nextSides) => { + setSides(nextSides) + setPoints(regularPolygon(nextSides, canvasWidth, canvasHeight)) + setDragIndex(null) + if (regularMode) playRegularGlow() + }, [canvasWidth, playRegularGlow, regularMode]) + + const toggleRegularPolygon = () => { + if (!regularMode) { + setPoints(regularPolygon(sides, canvasWidth, canvasHeight)) + setDragIndex(null) + playRegularGlow() + } else { + clearRegularTimers() + setRegularPhase('idle') + } + setRegularMode((current) => !current) + } + + const cancelExteriorAnimation = useCallback(() => { + if (exteriorFrameRef.current) cancelAnimationFrame(exteriorFrameRef.current) + exteriorFrameRef.current = null + }, []) + + const playExteriorAnimation = useCallback(() => { + cancelExteriorAnimation() + setExteriorProgress(0) + const start = performance.now() + const duration = 4600 + + const tick = (now) => { + const elapsed = Math.min(1, (now - start) / duration) + const eased = elapsed < 0.5 ? 2 * elapsed * elapsed : 1 - ((-2 * elapsed + 2) ** 2) / 2 + setExteriorProgress(eased) + if (elapsed < 1) { + exteriorFrameRef.current = requestAnimationFrame(tick) + } else { + exteriorFrameRef.current = null + } + } + + exteriorFrameRef.current = requestAnimationFrame(tick) + }, [cancelExteriorAnimation]) + + const toggleOption = (id) => { + if (id === 'exterior') { + setToggles((current) => { + const next = !current.exterior + if (next) playExteriorAnimation() + else { + cancelExteriorAnimation() + setExteriorProgress(0) + } + return { ...current, exterior: next } + }) + return + } + setToggles((current) => ({ ...current, [id]: !current[id] })) + } + + useEffect(() => { + const wrapper = wrapRef.current + if (!wrapper) return + + const update = () => setCanvasWidth(Math.max(320, Math.round(wrapper.clientWidth))) + update() + const observer = new ResizeObserver(update) + observer.observe(wrapper) + return () => observer.disconnect() + }, []) + + useEffect(() => () => { + cancelExteriorAnimation() + clearRegularTimers() + }, [cancelExteriorAnimation, clearRegularTimers]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!ctx) return + + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + if (toggles.triangles) drawTriangles(ctx, canvasPoints) + drawPolygon(ctx, canvasPoints) + if (toggles.exterior) drawExterior(ctx, canvasPoints, exteriorProgress) + if (toggles.angles) drawAngles(ctx, canvasPoints, regularMode && regularPhase === 'answer') + drawHandles(ctx, canvasPoints) + }, [canvasPoints, canvasWidth, exteriorProgress, regularMode, regularPhase, toggles]) + + useEffect(() => { + draw() + }, [draw]) + + const getCanvasPoint = (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 point = getCanvasPoint(event) + const hitIndex = canvasPoints.findIndex((vertex) => Math.hypot(vertex.x - point.x, vertex.y - point.y) <= 20) + if (hitIndex === -1) return + event.currentTarget.setPointerCapture(event.pointerId) + setDragIndex(hitIndex) + } + + const handlePointerMove = (event) => { + if (dragIndex === null) return + const point = getCanvasPoint(event) + setRegularMode(false) + clearRegularTimers() + setRegularPhase('idle') + setPoints((current) => current.map((vertex, index) => ( + index === dragIndex + ? { + x: clamp(point.x / canvasWidth, 0.04, 0.96), + y: clamp(point.y / canvasHeight, 0.06, 0.94), + } + : vertex + ))) + } + + const stopDragging = () => setDragIndex(null) + + const insight = (() => { + if (toggles.exterior) return 'Exterior angles of any convex polygon always sum to exactly 360° — one full rotation.' + if (toggles.triangles && toggles.angles) return `No matter how you drag, ${triangleCount} triangles × 180° always equals ${sum}°.` + if (toggles.triangles) return `Diagonal lines split the polygon into ${triangleCount} triangles from one vertex. Each has 180°, so the total is ${triangleCount} × 180° = ${sum}°.` + return `Drag any vertex — the angles change but the total stays ${sum}°. Turn on Show triangles to see why.` + })() + + const sumGlow = regularMode && regularPhase === 'sum' + const sidesGlow = regularMode && regularPhase === 'sides' + const answerGlow = regularMode && regularPhase === 'answer' + const showSidesStep = regularMode && ['sides', 'answer', 'done'].includes(regularPhase) + const showAnswerStep = regularMode && ['answer', 'done'].includes(regularPhase) + + return ( + + + + Sides: + {sideOptions.map((option) => ( + resetPolygon(option.sides)} + className={`rounded-lg border px-4 py-2 text-sm font-semibold ${ + sides === option.sides + ? 'border-[#534AB7] bg-[#EEEDFE] text-[#534AB7]' + : 'border-[#E0DDD6] bg-white text-[#1A1A2E]' + }`} + > + {option.label} + + ))} + + + + + + Regular Polygon + + + Each interior angle + + + {sum}° + + ÷ + + {sides} + + = + + {formatAngleValue(regularAngle)}° + + + + + + + + + + + Sides (N) + + {sides} + + + Triangles + {triangleCount} + + + Formula + + (N−2)×180° + + ({sides}−2)×180° = {sum}° + + + Angle sum + {sum}° + + + + + {[ + ['angles', 'Show angles', colors.purple], + ['triangles', 'Show triangles', colors.teal], + ['exterior', 'Exterior angles', colors.orange], + ].map(([id, label, color]) => ( + toggleOption(id)} + className={`rounded-full border px-3 py-2 text-sm font-semibold ${toggles[id] ? 'bg-white' : 'bg-white text-[#5F5E5A]'}`} + style={{ borderColor: toggles[id] ? color : colors.border, color: toggles[id] ? color : undefined }} + > + + {label} + + ))} + + + + {insight} + + + ) +} diff --git a/src/manipulatives/probability-spinner.jsx b/src/manipulatives/probability-spinner.jsx new file mode 100644 index 0000000..a0f2f46 --- /dev/null +++ b/src/manipulatives/probability-spinner.jsx @@ -0,0 +1,384 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + accent: '#1E7A8C', + border: '#E0DDD6', + muted: '#5F5E5A', +} + +const choices = [ + { id: 'red', name: 'Red', color: '#E23B4E' }, + { id: 'blue', name: 'Blue', color: '#2E6FD4' }, + { id: 'green', name: 'Green', color: '#1FA05E' }, + { id: 'gold', name: 'Gold', color: '#F0A722' }, +] + +const presets = { + equal4: { label: '4 equal', parts: { red: 1, blue: 1, green: 1, gold: 1 } }, + equal3: { label: '3 equal', parts: { red: 1, blue: 1, green: 1, gold: 0 } }, + halfQuarters: { label: 'Half & quarters', parts: { red: 2, blue: 1, green: 1, gold: 0 } }, + custom: { label: 'Custom', parts: null }, +} + +const canvasSize = 268 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function easeOutCubic(t) { + return 1 - (1 - t) ** 3 +} + +function normalizeAngle(angle) { + const full = Math.PI * 2 + return ((angle % full) + full) % full +} + +function percent(value) { + return `${Math.round(value * 100)}%` +} + +function activeSlices(parts) { + const total = choices.reduce((sum, item) => sum + (parts[item.id] || 0), 0) + if (total < 1) return [] + let start = 0 + return choices + .filter((item) => parts[item.id] > 0) + .map((item) => { + const share = parts[item.id] / total + const slice = { + ...item, + parts: parts[item.id], + share, + start, + end: start + share * Math.PI * 2, + } + start = slice.end + return slice + }) +} + +function landedSlice(parts, rotation) { + const slices = activeSlices(parts) + const angleUnderPointer = normalizeAngle(-rotation) + return slices.find((slice) => angleUnderPointer >= slice.start && angleUnderPointer < slice.end) ?? slices[slices.length - 1] +} + +function drawSpinner(ctx, width, height, parts, rotation) { + const slices = activeSlices(parts) + const cx = width / 2 + const cy = height / 2 + 3 + const radius = Math.min(width, height) * 0.37 + + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + + if (!slices.length) { + ctx.fillStyle = colors.muted + ctx.font = '800 17px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.fillText('Add at least 1 part.', cx, cy) + return + } + + slices.forEach((slice) => { + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.arc(cx, cy, radius, rotation + slice.start, rotation + slice.end) + ctx.closePath() + ctx.fillStyle = slice.color + ctx.fill() + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.stroke() + }) + + ctx.beginPath() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.strokeStyle = colors.ink + ctx.lineWidth = 4 + ctx.stroke() + + ctx.beginPath() + ctx.arc(cx, cy, 17, 0, Math.PI * 2) + ctx.fillStyle = colors.ink + ctx.fill() + ctx.beginPath() + ctx.arc(cx, cy, 5, 0, Math.PI * 2) + ctx.fillStyle = '#ffffff' + ctx.fill() + + const tipX = cx + radius - 9 + ctx.beginPath() + ctx.moveTo(tipX, cy) + ctx.lineTo(cx + radius + 29, cy - 15) + ctx.lineTo(cx + radius + 29, cy + 15) + ctx.closePath() + ctx.fillStyle = colors.ink + ctx.fill() +} + +export default function ProbabilitySpinner() { + const canvasRef = useRef(null) + const animationRef = useRef(null) + const mountedRef = useRef(true) + const [mode, setMode] = useState('equal4') + const [parts, setParts] = useState(presets.equal4.parts) + const [rotation, setRotation] = useState(0) + const [counts, setCounts] = useState({}) + const [spinning, setSpinning] = useState(false) + const [lastLand, setLastLand] = useState(null) + + const slices = useMemo(() => activeSlices(parts), [parts]) + const totalParts = useMemo(() => choices.reduce((sum, item) => sum + (parts[item.id] || 0), 0), [parts]) + const totalSpins = useMemo(() => Object.values(counts).reduce((sum, count) => sum + count, 0), [counts]) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize * dpr + canvas.height = canvasSize * dpr + canvas.style.width = `${canvasSize}px` + canvas.style.height = `${canvasSize}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + drawSpinner(ctx, canvasSize, canvasSize, parts, rotation) + }, [parts, rotation]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (animationRef.current) cancelAnimationFrame(animationRef.current) + } + }, []) + + const resetCounts = () => { + setCounts({}) + setLastLand(null) + } + + const choosePreset = (key) => { + setMode(key) + if (key !== 'custom') { + setParts(presets[key].parts) + resetCounts() + } + } + + const setPart = (id, delta) => { + setMode('custom') + setParts((current) => { + const nextValue = clamp((current[id] || 0) + delta, 0, 8) + const totalWithout = choices.reduce((sum, item) => sum + (item.id === id ? 0 : current[item.id] || 0), 0) + if (nextValue === 0 && totalWithout === 0) return current + return { ...current, [id]: nextValue } + }) + resetCounts() + } + + const spinOnce = useCallback((duration = 520) => { + if (totalParts < 1) return Promise.resolve(null) + if (animationRef.current) cancelAnimationFrame(animationRef.current) + const startRotation = rotation + const finalRotation = startRotation + Math.PI * 2 * (2 + Math.random() * 2) + Math.random() * Math.PI * 2 + const startTime = performance.now() + + return new Promise((resolve) => { + const tick = (now) => { + const t = Math.min(1, (now - startTime) / duration) + const eased = easeOutCubic(t) + const currentRotation = startRotation + (finalRotation - startRotation) * eased + setRotation(currentRotation) + if (t < 1) { + animationRef.current = requestAnimationFrame(tick) + return + } + const landed = landedSlice(parts, finalRotation) + setRotation(finalRotation) + if (landed) { + setCounts((current) => ({ ...current, [landed.id]: (current[landed.id] || 0) + 1 })) + setLastLand(landed.id) + } + resolve(landed) + } + animationRef.current = requestAnimationFrame(tick) + }) + }, [parts, rotation, totalParts]) + + const handleSpinOnce = async () => { + if (spinning) return + setSpinning(true) + await spinOnce(600) + if (mountedRef.current) setSpinning(false) + } + + const handleSpinMany = async () => { + if (spinning) return + setSpinning(true) + for (let i = 0; i < 50 && mountedRef.current; i += 1) { + await spinOnce(138) + } + if (mountedRef.current) setSpinning(false) + } + + const hint = + totalSpins < 10 + ? 'Small samples are jumpy, so the bars may miss the black theory lines.' + : totalSpins < 50 + ? 'The bars are starting to settle toward the true probability for each colour.' + : 'Law of large numbers: after many spins, experimental frequency gets closer to the true probability.' + + return ( + + + + {Object.entries(presets).map(([key, preset]) => ( + choosePreset(key)} + className="rounded-full border px-4 py-1.5 text-[15px] font-black transition" + style={{ + borderColor: mode === key ? colors.accent : colors.border, + background: mode === key ? colors.accent : '#ffffff', + color: mode === key ? '#ffffff' : colors.ink, + }} + > + {preset.label} + + ))} + + + + {mode === 'custom' && ( + + {choices.map((item) => ( + + + + {item.name} + + + setPart(item.id, -1)} + disabled={spinning} + className="grid h-5 w-5 place-items-center rounded-full text-[13px] font-black text-white disabled:opacity-40" + style={{ background: item.color }} + > + - + + {parts[item.id] || 0} + setPart(item.id, 1)} + disabled={spinning} + className="grid h-5 w-5 place-items-center rounded-full text-[13px] font-black text-white disabled:opacity-40" + style={{ background: item.color }} + > + + + + + + ))} + + )} + + + + + + + + + Spin once + + + Spin x50 + + + + Reset counts + + + + + + Results + + {totalSpins} spins so far + + + + {slices.map((slice) => { + const count = counts[slice.id] || 0 + const experimental = totalSpins ? count / totalSpins : 0 + return ( + + + {slice.name} + + {count} / {totalSpins || 0} · true: {percent(slice.share)} + + + + + {experimental > 0.16 && ( + + {percent(experimental)} + + )} + + {lastLand === slice.id && ( + + landed + + )} + + + ) + })} + + + + + + {hint} + + + ) +} diff --git a/src/manipulatives/rate-of-change-explorer.jsx b/src/manipulatives/rate-of-change-explorer.jsx new file mode 100644 index 0000000..abc97a4 --- /dev/null +++ b/src/manipulatives/rate-of-change-explorer.jsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const C={page:'#F8F6F0',ink:'#22211E',muted:'#65615B',border:'#E0DDD6',blue:'#2660C4',blueTint:'#EAF0FB',blueBorder:'#8AA8DD',purple:'#7B3F9E',purpleTint:'#F3EEFA',purpleBorder:'#C99BE0',green:'#1E7A5E'} +const scenarios={ + car:{tab:'🚗 Car — speed',icon:'🚗',xUnit:'hours',xOne:'1 hour',xMax:4,yUnit:'miles',yMax:240,min:10,max:60,step:10,initial:40,noun:'car',verb:'travels',rateUnit:'miles per hour'}, + tank:{tab:'💧 Tank — filling',icon:'💧',xUnit:'minutes',xOne:'1 minute',xMax:10,yUnit:'liters',yMax:100,min:2,max:10,step:2,initial:6,noun:'tank',verb:'gains',rateUnit:'liters per minute'}, +} +const P={l:58,r:24,t:24,b:48},clamp=(v,a,b)=>Math.max(a,Math.min(b,v)) + +export default function RateOfChangeExplorer(){ + const canvasRef=useRef(),wrapRef=useRef(),rafRef=useRef(),runningRef=useRef(false) + const [kind,setKind]=useState('car'),[rates,setRates]=useState({car:40,tank:6}),[width,setWidth]=useState(720),[progress,setProgress]=useState(0),[running,setRunning]=useState(false),[complete,setComplete]=useState(false),[ghosts,setGhosts]=useState({car:[],tank:[]}) + const s=scenarios[kind],rate=rates[kind],value=rate*s.xMax*progress + const stop=useCallback(()=>{cancelAnimationFrame(rafRef.current);runningRef.current=false;setRunning(false)},[]) + useEffect(()=>()=>stop(),[stop]) + useEffect(()=>{const ro=new ResizeObserver(([e])=>setWidth(Math.max(300,Math.floor(e.contentRect.width))));ro.observe(wrapRef.current);return()=>ro.disconnect()},[]) + const reset=useCallback(()=>{stop();setProgress(0);setComplete(false)},[stop]) + const switchScenario=key=>{if(key===kind)return;stop();setKind(key);setProgress(0);setComplete(false)} + const changeRate=e=>{const next=Number(e.target.value);setRates(old=>({...old,[kind]:next}));setProgress(0);setComplete(false)} + const go=()=>{if(runningRef.current)return;cancelAnimationFrame(rafRef.current);runningRef.current=true;setRunning(true);setComplete(false);setProgress(0);const start=performance.now(),duration=3200;const tick=now=>{const p=clamp((now-start)/duration,0,1);setProgress(p);if(p<1)rafRef.current=requestAnimationFrame(tick);else{runningRef.current=false;setRunning(false);setComplete(true);setGhosts(old=>({...old,[kind]:[...old[kind].filter(v=>v!==rate),rate].slice(-3)}))}};rafRef.current=requestAnimationFrame(tick)} + + const draw=useCallback(()=>{const el=canvasRef.current;if(!el)return;const h=181,dpr=devicePixelRatio||1;el.width=width*dpr;el.height=h*dpr;el.style.width=width+'px';el.style.height=h+'px';const c=el.getContext('2d');c.setTransform(dpr,0,0,dpr,0,0);c.clearRect(0,0,width,h);const pw=width-P.l-P.r,ph=h-P.t-P.b,x=v=>P.l+v/s.xMax*pw,y=v=>P.t+(1-v/s.yMax)*ph;c.fillStyle='#fff';c.fillRect(0,0,width,h) + c.font='600 11px Inter,system-ui';c.fillStyle=C.muted;c.textAlign='right';c.textBaseline='middle';for(let i=0;i<=4;i++){const val=s.yMax*i/4,yy=y(val);c.strokeStyle='#E8E4DD';c.lineWidth=1;c.beginPath();c.moveTo(P.l,yy);c.lineTo(width-P.r,yy);c.stroke();c.fillText(String(val),P.l-8,yy)}c.textAlign='center';c.textBaseline='top';for(let i=0;i<=4;i++){const val=s.xMax*i/4;c.fillText(String(val),x(val),h-P.b+8)}c.strokeStyle='#77736D';c.lineWidth=1.2;c.beginPath();c.moveTo(P.l,P.t);c.lineTo(P.l,h-P.b);c.lineTo(width-P.r,h-P.b);c.stroke();c.fillStyle=C.ink;c.font='700 11px Inter,system-ui';c.fillText(s.xUnit,width/2,h-16);c.save();c.translate(14,h/2);c.rotate(-Math.PI/2);c.fillText(s.yUnit,0,0);c.restore() + ghosts[kind].forEach(g=>{c.strokeStyle='rgba(123,63,158,.18)';c.lineWidth=3;c.beginPath();c.moveTo(x(0),y(0));c.lineTo(x(s.xMax),y(g*s.xMax));c.stroke()}) + const endX=s.xMax*progress,endY=rate*endX;if(progress>0){c.strokeStyle=C.purple;c.lineWidth=4;c.lineCap='round';c.beginPath();c.moveTo(x(0),y(0));c.lineTo(x(endX),y(endY));c.stroke();c.beginPath();c.arc(x(endX),y(endY),6,0,Math.PI*2);c.fillStyle=C.purple;c.fill();c.strokeStyle='#fff';c.lineWidth=2;c.stroke()} + if(complete){const x0=x(0),x1=x(1),y0=y(0),y1=y(rate);c.lineCap='butt';c.lineWidth=4;c.strokeStyle=C.blue;c.beginPath();c.moveTo(x0,y0);c.lineTo(x1,y0);c.stroke();c.fillStyle=C.blue;c.font='800 11px Inter,system-ui';c.textAlign='center';c.textBaseline='top';c.fillText(s.xOne,(x0+x1)/2,y0+8);c.strokeStyle=C.green;c.beginPath();c.moveTo(x1,y0);c.lineTo(x1,y1);c.stroke();c.fillStyle=C.green;c.textAlign='left';c.textBaseline='middle';c.fillText(`+${rate} ${s.yUnit}`,x1+8,(y0+y1)/2)} + },[width,s,kind,ghosts,progress,rate,complete]);useEffect(draw,[draw]) + const shownValue=Math.round(value),pct=progress*100 + const hint=useMemo(()=>`Rate of change is how much one thing changes for every 1 unit of another. Here it’s ${s.rateUnit} — and it’s exactly the steepness of the line.${complete?` The triangle shows the rise for a run of just ${s.xOne}.`:''}`,[s,complete]) + return + switchScenario('car')}>🚗 Car — speedswitchScenario('tank')}>💧 Tank — filling + Set the {kind==='car'?'speed':'fill rate'}, press Go, and watch the graph draw itself. Steeper line = faster rate. + {kind==='car'?<>{shownValue} mi🚗>:<>{shownValue} L>} + + current run{ghosts[kind].length>0&&past runs}{complete&&blue = 1-unit run · green = rise} + {rate} {s.rateUnit}Every 1 {kind==='car'?'hour':'minute'}, the {s.noun} {s.verb} {rate} more {s.yUnit}. + {kind==='car'?'Speed':'Fill rate'}{rate} {kind==='car'?'mph':'L/min'}▶ {running?'Running…':'Go'}↺ Reset + 💡 Rate of change {hint.slice('Rate of change'.length)} + +} diff --git a/src/manipulatives/ratio-balance-scale.jsx b/src/manipulatives/ratio-balance-scale.jsx new file mode 100644 index 0000000..b14d839 --- /dev/null +++ b/src/manipulatives/ratio-balance-scale.jsx @@ -0,0 +1,587 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const fruits = [ + { id: 'strawberry', name: 'Strawberry', color: '#E23B4E', dark: '#A3132A', weight: 1 }, + { id: 'orange', name: 'Orange', color: '#E88A2E', dark: '#B25A1E', weight: 2 }, + { id: 'apple', name: 'Apple', color: '#D63A3A', dark: '#A01F1F', weight: 3 }, + { id: 'pear', name: 'Pear', color: '#9BC13C', dark: '#5B7A22', weight: 4 }, +] + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + border: '#E0DDD6', + left: '#B25A1E', + right: '#1E7A5E', + balanced: '#3B6D11', + ratio: '#7B3F9E', + muted: '#5F5E5A', +} + +const canvasHeight = 330 +const challenges = [ + { left: 'orange', right: 'apple', count: 3 }, + { left: 'apple', right: 'pear', count: 4 }, + { left: 'pear', right: 'orange', count: 2 }, + { left: 'strawberry', right: 'orange', count: 4 }, +] + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function gcd(a, b) { + let x = Math.abs(a) + let y = Math.abs(b) + while (y) { + const next = y + y = x % y + x = next + } + return x || 1 +} + +function fruitById(id) { + return fruits.find((fruit) => fruit.id === id) ?? fruits[0] +} + +function plural(name, count) { + return `${name}${count === 1 ? '' : 's'}` +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function drawFruit(ctx, fruit, x, y, size) { + ctx.save() + ctx.translate(x, y) + const s = size + + if (fruit.id === 'orange') { + ctx.fillStyle = fruit.color + ctx.beginPath() + ctx.arc(0, 0, s * 0.48, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#ffffff66' + ctx.beginPath() + ctx.arc(-s * 0.16, -s * 0.16, s * 0.11, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#4E8D35' + ctx.beginPath() + ctx.ellipse(s * 0.07, -s * 0.48, s * 0.16, s * 0.08, -0.5, 0, Math.PI * 2) + ctx.fill() + } else if (fruit.id === 'apple') { + ctx.fillStyle = fruit.color + ctx.beginPath() + ctx.moveTo(0, -s * 0.36) + ctx.bezierCurveTo(-s * 0.38, -s * 0.58, -s * 0.66, -s * 0.1, -s * 0.38, s * 0.37) + ctx.bezierCurveTo(-s * 0.17, s * 0.58, s * 0.17, s * 0.58, s * 0.38, s * 0.37) + ctx.bezierCurveTo(s * 0.66, -s * 0.1, s * 0.38, -s * 0.58, 0, -s * 0.36) + ctx.fill() + ctx.strokeStyle = fruit.dark + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.strokeStyle = '#6B3A16' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(0, -s * 0.36) + ctx.lineTo(s * 0.05, -s * 0.58) + ctx.stroke() + ctx.fillStyle = '#5B9A38' + ctx.beginPath() + ctx.ellipse(s * 0.2, -s * 0.49, s * 0.16, s * 0.08, -0.45, 0, Math.PI * 2) + ctx.fill() + } else if (fruit.id === 'pear') { + ctx.fillStyle = fruit.color + ctx.beginPath() + ctx.moveTo(0, -s * 0.52) + ctx.bezierCurveTo(-s * 0.26, -s * 0.52, -s * 0.3, -s * 0.2, -s * 0.18, -s * 0.02) + ctx.bezierCurveTo(-s * 0.55, s * 0.07, -s * 0.55, s * 0.52, 0, s * 0.52) + ctx.bezierCurveTo(s * 0.55, s * 0.52, s * 0.55, s * 0.07, s * 0.18, -s * 0.02) + ctx.bezierCurveTo(s * 0.3, -s * 0.2, s * 0.26, -s * 0.52, 0, -s * 0.52) + ctx.fill() + ctx.strokeStyle = fruit.dark + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.strokeStyle = '#6B3A16' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(0, -s * 0.52) + ctx.lineTo(s * 0.06, -s * 0.7) + ctx.stroke() + } else { + ctx.fillStyle = fruit.color + ctx.beginPath() + ctx.moveTo(0, s * 0.55) + ctx.bezierCurveTo(-s * 0.54, s * 0.2, -s * 0.5, -s * 0.45, 0, -s * 0.48) + ctx.bezierCurveTo(s * 0.5, -s * 0.45, s * 0.54, s * 0.2, 0, s * 0.55) + ctx.fill() + ctx.strokeStyle = fruit.dark + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.fillStyle = '#FBEAEE' + for (let row = 0; row < 3; row += 1) { + for (let col = 0; col < 3; col += 1) { + ctx.beginPath() + ctx.arc((col - 1) * s * 0.17 + (row % 2) * s * 0.07, -s * 0.12 + row * s * 0.18, 1.3, 0, Math.PI * 2) + ctx.fill() + } + } + ctx.fillStyle = '#2F8B3B' + for (let i = -2; i <= 2; i += 1) { + ctx.beginPath() + ctx.moveTo(0, -s * 0.42) + ctx.lineTo(i * s * 0.11, -s * 0.62) + ctx.lineTo(i * s * 0.06, -s * 0.38) + ctx.closePath() + ctx.fill() + } + } + ctx.restore() +} + +function drawCheck(ctx, cx, cy, progress) { + ctx.save() + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 4 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.beginPath() + ctx.moveTo(cx - 11, cy) + if (progress <= 0.5) { + const t = progress / 0.5 + ctx.lineTo(cx - 11 + 8 * t, cy + 8 * t) + } else { + ctx.lineTo(cx - 3, cy + 8) + const t = (progress - 0.5) / 0.5 + ctx.lineTo(cx - 3 + 17 * t, cy + 8 - 20 * t) + } + ctx.stroke() + ctx.restore() +} + +function makePanPath(ctx, cx, cy, width, height) { + ctx.beginPath() + ctx.ellipse(cx, cy, width / 2, height / 2, 0, 0, Math.PI * 2) +} + +export default function RatioBalanceScale() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const fruitHitsRef = useRef([]) + const panHitsRef = useRef({}) + const [canvasWidth, setCanvasWidth] = useState(510) + const [leftFruitId, setLeftFruitId] = useState('orange') + const [rightFruitId, setRightFruitId] = useState('apple') + const [leftCount, setLeftCount] = useState(0) + const [rightCount, setRightCount] = useState(0) + const [dragging, setDragging] = useState(null) + const [balanceProgress, setBalanceProgress] = useState(0) + const [ratioRows, setRatioRows] = useState([]) + const [recentKey, setRecentKey] = useState(null) + const [challenge, setChallenge] = useState(null) + + const leftFruit = fruitById(leftFruitId) + const rightFruit = fruitById(rightFruitId) + const leftWeight = leftCount * leftFruit.weight + const rightWeight = rightCount * rightFruit.weight + const isBalanced = leftCount > 0 && rightCount > 0 && leftWeight === rightWeight + const countGcd = gcd(leftCount, rightCount) + const simplest = leftCount && rightCount ? `${leftCount / countGcd}:${rightCount / countGcd}` : '-' + const liveRatio = `${leftCount}:${rightCount}` + const challengeSolved = challenge && isBalanced && leftCount === challenge.count && leftFruitId === challenge.left && rightFruitId === challenge.right + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + drawRoundRect(ctx, 0, 0, canvasWidth, canvasHeight, 14) + ctx.fill() + + const cx = canvasWidth / 2 + const baseY = canvasHeight - 30 + const hubY = 122 + const arm = Math.min(canvasWidth * 0.34, 190) + const diff = leftWeight - rightWeight + const angle = clamp(-diff * 0.035, -0.18, 0.18) + const leftEnd = { x: cx - Math.cos(angle) * arm, y: hubY - Math.sin(angle) * arm } + const rightEnd = { x: cx + Math.cos(angle) * arm, y: hubY + Math.sin(angle) * arm } + const panDrop = 72 + const leftPan = { x: leftEnd.x, y: leftEnd.y + panDrop, w: 148, h: 38 } + const rightPan = { x: rightEnd.x, y: rightEnd.y + panDrop, w: 148, h: 38 } + panHitsRef.current = { + left: { x: leftPan.x - leftPan.w / 2, y: leftPan.y - leftPan.h / 2 - 22, w: leftPan.w, h: leftPan.h + 34 }, + right: { x: rightPan.x - rightPan.w / 2, y: rightPan.y - rightPan.h / 2 - 22, w: rightPan.w, h: rightPan.h + 34 }, + } + + const baseTopY = baseY - 16 + ctx.save() + ctx.fillStyle = '#1A1A2E18' + ctx.beginPath() + ctx.ellipse(cx, baseY + 9, 86, 10, 0, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = colors.ink + ctx.lineWidth = 4 + drawRoundRect(ctx, cx - 78, baseTopY, 156, 28, 14) + ctx.fill() + ctx.stroke() + ctx.fillStyle = colors.ink + drawRoundRect(ctx, cx - 34, baseTopY - 12, 68, 18, 9) + ctx.fill() + ctx.restore() + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 5 + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(cx, baseTopY - 6) + ctx.lineTo(cx, hubY + 6) + ctx.stroke() + + ctx.strokeStyle = isBalanced ? colors.balanced : colors.ink + ctx.lineWidth = 7 + ctx.beginPath() + ctx.moveTo(leftEnd.x, leftEnd.y) + ctx.lineTo(rightEnd.x, rightEnd.y) + ctx.stroke() + + ctx.lineWidth = 2.5 + ;[ + [leftEnd, leftPan], + [rightEnd, rightPan], + ].forEach(([end, pan]) => { + const rimLeft = pan.x - pan.w * 0.38 + const rimRight = pan.x + pan.w * 0.38 + const rimY = pan.y - pan.h * 0.18 + ctx.strokeStyle = colors.ink + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.beginPath() + ctx.moveTo(end.x, end.y + 4) + ctx.lineTo(rimLeft, rimY) + ctx.moveTo(end.x, end.y + 4) + ctx.lineTo(rimRight, rimY) + ctx.moveTo(rimLeft, rimY) + ctx.lineTo(rimRight, rimY) + ctx.stroke() + ctx.fillStyle = '#F8F6F0' + ctx.strokeStyle = colors.ink + ctx.lineWidth = 3 + makePanPath(ctx, pan.x, pan.y, pan.w, pan.h) + ctx.fill() + ctx.stroke() + ctx.strokeStyle = '#59606E' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.ellipse(pan.x, pan.y - 1, pan.w / 2 - 8, pan.h / 2 - 5, 0, 0, Math.PI) + ctx.stroke() + }) + + const hubRadius = 22 + ctx.beginPath() + ctx.arc(cx, hubY, hubRadius, 0, Math.PI * 2) + ctx.fillStyle = isBalanced ? colors.balanced : colors.ink + ctx.shadowColor = isBalanced ? '#3B6D1166' : 'transparent' + ctx.shadowBlur = isBalanced ? 14 : 0 + ctx.fill() + ctx.shadowBlur = 0 + if (isBalanced) drawCheck(ctx, cx, hubY, balanceProgress) + + fruitHitsRef.current = [] + const drawStack = (side, fruit, count, pan) => { + const size = 27 + const perRow = 5 + for (let index = 0; index < count; index += 1) { + if (dragging?.side === side && dragging.index === index) continue + const row = Math.floor(index / perRow) + const col = index % perRow + const rowCount = Math.min(perRow, count - row * perRow) + const startX = pan.x - ((rowCount - 1) * 26) / 2 + const x = startX + col * 26 + const y = pan.y - 23 - row * 25 + drawFruit(ctx, fruit, x, y, size) + fruitHitsRef.current.push({ side, index, x, y, radius: 17 }) + } + } + drawStack('left', leftFruit, leftCount, leftPan) + drawStack('right', rightFruit, rightCount, rightPan) + if (dragging) drawFruit(ctx, fruitById(dragging.fruitId), dragging.x, dragging.y, 28) + + ctx.fillStyle = '#FBEEDD' + drawRoundRect(ctx, cx - 142, 12, 284, 25, 13) + ctx.fill() + ctx.fillStyle = colors.ink + ctx.font = '800 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('tap a pan to add · drag a fruit off to remove', cx, 25) + }, [balanceProgress, canvasWidth, dragging, isBalanced, leftCount, leftFruit, leftWeight, rightCount, rightFruit, rightWeight]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return undefined + const observer = new ResizeObserver(([entry]) => setCanvasWidth(Math.max(320, Math.floor(entry.contentRect.width)))) + observer.observe(wrap) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + if (!isBalanced) { + frameRef.current = requestAnimationFrame(() => setBalanceProgress(0)) + return undefined + } + let start = null + const tick = (now) => { + if (start === null) start = now + const progress = clamp((now - start) / 300, 0, 1) + setBalanceProgress(progress) + if (progress < 1) frameRef.current = requestAnimationFrame(tick) + } + frameRef.current = requestAnimationFrame(tick) + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + } + }, [isBalanced, leftCount, rightCount, leftFruitId, rightFruitId]) + + useEffect(() => { + if (!isBalanced) return + const key = `${leftFruitId}-${rightFruitId}-${leftCount}-${rightCount}` + const logTimer = setTimeout(() => { + setRatioRows((rows) => { + if (rows.some((row) => row.key === key)) return rows + const next = [{ key, left: leftCount, right: rightCount, simplest }, ...rows] + return next.slice(0, 6) + }) + setRecentKey(key) + }, 0) + const clearTimer = setTimeout(() => setRecentKey(null), 900) + return () => { + clearTimeout(logTimer) + clearTimeout(clearTimer) + } + }, [isBalanced, leftCount, leftFruitId, rightCount, rightFruitId, simplest]) + + const clearScale = () => { + setLeftCount(0) + setRightCount(0) + setChallenge(null) + } + + const makeChallenge = () => { + const item = challenges[Math.floor(Math.random() * challenges.length)] + setLeftFruitId(item.left) + setRightFruitId(item.right) + setLeftCount(item.count) + setRightCount(0) + setChallenge(item) + } + + const handleFruitChoice = (side, id) => { + if (side === 'left') { + setLeftFruitId(id) + setLeftCount(0) + } else { + setRightFruitId(id) + setRightCount(0) + } + setChallenge(null) + } + + const canvasPoint = (event) => { + const rect = canvasRef.current.getBoundingClientRect() + return { + x: ((event.clientX - rect.left) / rect.width) * canvasWidth, + y: ((event.clientY - rect.top) / rect.height) * canvasHeight, + } + } + + const handlePointerDown = (event) => { + const canvas = canvasRef.current + if (!canvas) return + const point = canvasPoint(event) + canvas.setPointerCapture(event.pointerId) + const fruitHit = fruitHitsRef.current.find((hit) => Math.hypot(hit.x - point.x, hit.y - point.y) <= hit.radius) + if (fruitHit) { + setDragging({ + ...fruitHit, + fruitId: fruitHit.side === 'left' ? leftFruitId : rightFruitId, + x: point.x, + y: point.y, + moved: false, + }) + return + } + const panSide = Object.entries(panHitsRef.current).find(([, pan]) => point.x >= pan.x && point.x <= pan.x + pan.w && point.y >= pan.y && point.y <= pan.y + pan.h)?.[0] + if (panSide === 'left') setLeftCount((count) => count + 1) + if (panSide === 'right') setRightCount((count) => count + 1) + } + + const handlePointerMove = (event) => { + if (!dragging) return + const point = canvasPoint(event) + setDragging((current) => current ? { ...current, x: point.x, y: point.y, moved: true } : current) + } + + const handlePointerUp = (event) => { + if (!dragging) return + const point = canvasPoint(event) + const pan = panHitsRef.current[dragging.side] + const outside = point.x < pan.x || point.x > pan.x + pan.w || point.y < pan.y || point.y > pan.y + pan.h + if (dragging.moved && outside) { + if (dragging.side === 'left') setLeftCount((count) => Math.max(0, count - 1)) + else setRightCount((count) => Math.max(0, count - 1)) + } + setDragging(null) + } + + const status = isBalanced ? '✓ balanced' : leftCount + rightCount === 0 ? 'empty' : leftWeight > rightWeight ? 'tips left' : rightWeight > leftWeight ? 'tips right' : 'empty' + const hint = (() => { + if (challengeSolved) { + return `Challenge solved: ${leftCount} ${plural(leftFruit.name, leftCount)} balance ${rightCount} ${plural(rightFruit.name, rightCount)}.` + } + if (challenge) { + return `Challenge: balance exactly ${challenge.count} ${plural(fruitById(challenge.left).name, challenge.count)} using ${plural(fruitById(challenge.right).name, 2)}.` + } + if (leftCount + rightCount === 0) return 'Tap a pan to add fruit. Drag a fruit away from a pan to remove it.' + if (!isBalanced) return `${leftWeight > rightWeight ? 'Left' : 'Right'} is heavier. Add fruit to the lighter side or remove from the heavier side.` + return `${leftCount}:${rightCount} balances, and the simplest ratio is ${simplest}. Multiplying both sides keeps an equivalent ratio.` + })() + const visibleRatioRows = ratioRows.slice(-5) + + return ( + + + + + handleFruitChoice('left', id)} /> + handleFruitChoice('right', id)} /> + + + + + + + + + + + {hint} + + + ) +} + +function FruitPicker({ title, active, accent, onPick }) { + return ( + + + {title} + tap fruit + + + {fruits.map((fruit) => ( + onPick(fruit.id)} + className="flex min-w-0 items-center gap-1 rounded-lg border px-1.5 py-1 text-[11px] font-black transition" + style={{ + borderColor: active === fruit.id ? fruit.color : colors.border, + background: active === fruit.id ? `${fruit.color}22` : '#ffffff', + color: active === fruit.id ? fruit.dark : colors.ink, + }} + > + + {fruit.name} + {fruit.weight} + + ))} + + + ) +} + +function Stat({ label, value, color, wide = false }) { + return ( + + {label} + {value} + + ) +} diff --git a/src/manipulatives/scatter-line-fit.jsx b/src/manipulatives/scatter-line-fit.jsx new file mode 100644 index 0000000..070d0f7 --- /dev/null +++ b/src/manipulatives/scatter-line-fit.jsx @@ -0,0 +1,555 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + grid: 'rgba(26, 26, 46, 0.14)', + point: '#B5651D', + line: '#5B6BD6', + lineDark: '#3A47A8', + lineTint: '#EEF0FB', + best: '#1E9E6E', + bestDark: '#1E7D56', + bestTint: '#E9F5EF', + residual: '#E39D7C', + border: '#E0DDD6', + muted: '#5F5E5A', +} + +const canvasHeight = 300 +const PAD = 36 + +const presets = { + positive: [ + [1, 1.5], + [2, 2], + [3, 3.5], + [4, 4], + [5, 5.5], + [6, 6], + [7, 7], + [8, 7.5], + [9, 8.5], + ], + negative: [ + [1, 8.5], + [2, 8], + [3, 7], + [4, 6], + [5, 5.5], + [6, 4], + [7, 3.5], + [8, 2], + [9, 1.5], + ], + none: [ + [1, 6], + [2, 2], + [3, 8], + [4, 4], + [5, 7], + [6, 3], + [7, 6], + [8, 2.5], + [9, 7.5], + ], + strong: [ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5.5], + [6, 6], + [7, 7], + [8, 8], + [9, 9], + ], +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function snapHalf(value) { + return clamp(Math.round(value * 2) / 2, 0, 10) +} + +function makePoint(x, y) { + return { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + x, + y, + } +} + +function pointList(raw) { + return raw.map(([x, y]) => makePoint(x, y)) +} + +function getStats(points) { + if (points.length < 2) { + return { + r: null, + direction: 'none', + strength: 'add points', + bestFit: null, + } + } + + const n = points.length + const xMean = points.reduce((sum, point) => sum + point.x, 0) / n + const yMean = points.reduce((sum, point) => sum + point.y, 0) / n + let sxx = 0 + let syy = 0 + let sxy = 0 + + points.forEach((point) => { + const dx = point.x - xMean + const dy = point.y - yMean + sxx += dx * dx + syy += dy * dy + sxy += dx * dy + }) + + const r = sxx === 0 || syy === 0 ? null : sxy / Math.sqrt(sxx * syy) + const absR = Math.abs(r ?? 0) + const direction = r === null || absR < 0.3 ? 'none' : r > 0 ? 'positive' : 'negative' + const strength = r === null || absR < 0.3 ? 'none' : absR < 0.7 ? 'moderate' : 'strong' + const bestFit = sxx === 0 ? { vertical: true, x: xMean } : { vertical: false, m: sxy / sxx, b: yMean - (sxy / sxx) * xMean } + + return { r, direction, strength, bestFit } +} + +function lineFromHandles(line) { + const dx = line.x2 - line.x1 + if (Math.abs(dx) < 0.001) return { vertical: true, x: line.x1 } + const m = (line.y2 - line.y1) / dx + return { vertical: false, m, b: line.y1 - m * line.x1 } +} + +function yOnLine(line, x) { + if (line.vertical) return null + return line.m * x + line.b +} + +function getRmse(points, line) { + if (points.length < 2 || line.vertical) return null + const mse = points.reduce((sum, point) => { + const expected = yOnLine(line, point.x) + return sum + (point.y - expected) ** 2 + }, 0) / points.length + return Math.sqrt(mse) +} + +function fitRating(rmse, bestRmse) { + if (rmse === null || bestRmse === null) return 'move line' + const extraError = rmse - bestRmse + if (extraError <= 0.35) return 'excellent' + if (extraError <= 0.9) return 'good' + if (extraError <= 1.7) return 'okay' + return 'poor' +} + +function getCanvasPoint(event, canvas) { + const rect = canvas.getBoundingClientRect() + return { + x: ((event.clientX - rect.left) / rect.width) * canvas.clientWidth, + y: ((event.clientY - rect.top) / rect.height) * canvas.clientHeight, + } +} + +function drawCircle(ctx, x, y, radius, fill, stroke = '#ffffff', lineWidth = 2) { + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fillStyle = fill + ctx.fill() + ctx.strokeStyle = stroke + ctx.lineWidth = lineWidth + ctx.stroke() +} + +export default function ScatterLineFit() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const pointHitsRef = useRef([]) + const handleHitsRef = useRef([]) + const transformRef = useRef(null) + const dragRef = useRef(null) + const lastTapRef = useRef({ id: null, time: 0 }) + const [canvasWidth, setCanvasWidth] = useState(760) + const [points, setPoints] = useState(() => pointList(presets.positive)) + const [trendLine, setTrendLine] = useState({ x1: 1, y1: 2, x2: 9, y2: 8 }) + const [showMyLine, setShowMyLine] = useState(true) + const [showBestFit, setShowBestFit] = useState(false) + const [showDistances, setShowDistances] = useState(false) + const [hoverKind, setHoverKind] = useState('grid') + + const stats = useMemo(() => getStats(points), [points]) + const userLine = useMemo(() => lineFromHandles(trendLine), [trendLine]) + const rmse = useMemo(() => getRmse(points, userLine), [points, userLine]) + const bestRmse = useMemo(() => getRmse(points, stats.bestFit), [points, stats.bestFit]) + const rating = fitRating(rmse, bestRmse) + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + + const width = canvasWidth + const height = canvasHeight + const gridW = width - PAD * 2 + const gridH = height - PAD * 2 + const toPx = (gx, gy) => ({ + x: PAD + (gx / 10) * gridW, + y: height - PAD - (gy / 10) * gridH, + }) + const toGrid = (px, py) => ({ + x: snapHalf(((px - PAD) / gridW) * 10), + y: snapHalf(((height - PAD - py) / gridH) * 10), + }) + transformRef.current = { toPx, toGrid, width, height } + + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, width, height) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let tick = 0; tick <= 10; tick += 1) { + const x = toPx(tick, 0).x + const y = toPx(0, tick).y + ctx.beginPath() + ctx.moveTo(x, PAD) + ctx.lineTo(x, height - PAD) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(PAD, y) + ctx.lineTo(width - PAD, y) + ctx.stroke() + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(PAD, height - PAD) + ctx.lineTo(width - PAD + 6, height - PAD) + ctx.moveTo(PAD, height - PAD) + ctx.lineTo(PAD, PAD - 6) + ctx.stroke() + + ctx.fillStyle = colors.muted + ctx.font = '700 12px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + for (let tick = 0; tick <= 10; tick += 1) { + const x = toPx(tick, 0).x + const y = toPx(0, tick).y + ctx.fillText(String(tick), x, height - PAD - 7) + if (tick > 0 && tick % 2 === 0) { + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + ctx.fillText(String(tick), PAD - 9, y) + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + } + } + ctx.fillStyle = colors.ink + ctx.font = '800 13px Inter, system-ui, sans-serif' + ctx.textBaseline = 'middle' + ctx.fillText('x', width - PAD + 16, height - PAD) + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + ctx.fillText('y', PAD - 7, PAD - 13) + + const drawLineAcross = (line, color, dash = []) => { + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = color === colors.line ? 3 : 2.5 + ctx.setLineDash(dash) + ctx.beginPath() + if (line.vertical) { + const start = toPx(line.x, 0) + const end = toPx(line.x, 10) + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + } else { + const yLeft = line.m * 0 + line.b + const yRight = line.m * 10 + line.b + const start = toPx(0, yLeft) + const end = toPx(10, yRight) + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + } + ctx.stroke() + ctx.restore() + } + + if (showDistances && showMyLine && !userLine.vertical) { + ctx.save() + ctx.strokeStyle = colors.residual + ctx.lineWidth = 1.6 + ctx.setLineDash([5, 5]) + points.forEach((point) => { + const expected = clamp(yOnLine(userLine, point.x), -2, 12) + const pointPx = toPx(point.x, point.y) + const linePx = toPx(point.x, expected) + ctx.beginPath() + ctx.moveTo(pointPx.x, pointPx.y) + ctx.lineTo(linePx.x, linePx.y) + ctx.stroke() + }) + ctx.restore() + } + + if (showBestFit && stats.bestFit) { + drawLineAcross(stats.bestFit, colors.best, [9, 7]) + } + + if (showMyLine) { + drawLineAcross(userLine, colors.line) + const a = toPx(trendLine.x1, trendLine.y1) + const b = toPx(trendLine.x2, trendLine.y2) + handleHitsRef.current = [ + { id: 'start', x: a.x, y: a.y }, + { id: 'end', x: b.x, y: b.y }, + ] + drawCircle(ctx, a.x, a.y, 10, colors.lineDark, '#ffffff', 2) + drawCircle(ctx, b.x, b.y, 10, colors.lineDark, '#ffffff', 2) + } else { + handleHitsRef.current = [] + } + + pointHitsRef.current = points.map((point) => { + const px = toPx(point.x, point.y) + drawCircle(ctx, px.x, px.y, 8.5, colors.point, '#ffffff', 2.25) + return { id: point.id, x: px.x, y: px.y } + }) + }, [canvasWidth, points, showBestFit, showDistances, showMyLine, stats.bestFit, trendLine, userLine]) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => { + const wrap = wrapRef.current + if (!wrap) return undefined + const observer = new ResizeObserver(([entry]) => { + setCanvasWidth(Math.max(320, Math.floor(entry.contentRect.width))) + }) + observer.observe(wrap) + return () => observer.disconnect() + }, []) + + const hitPoint = (x, y) => + pointHitsRef.current.find((point) => Math.hypot(point.x - x, point.y - y) <= 14) + + const hitHandle = (x, y) => + handleHitsRef.current.find((handle) => Math.hypot(handle.x - x, handle.y - y) <= 17) + + const updateHover = (event) => { + const canvas = canvasRef.current + if (!canvas) return + const { x, y } = getCanvasPoint(event, canvas) + if (hitPoint(x, y) || hitHandle(x, y)) { + setHoverKind('grab') + return + } + setHoverKind('grid') + } + + const handlePointerDown = (event) => { + const canvas = canvasRef.current + const transform = transformRef.current + if (!canvas || !transform) return + const { x, y } = getCanvasPoint(event, canvas) + const pointHit = hitPoint(x, y) + const handleHit = hitHandle(x, y) + canvas.setPointerCapture(event.pointerId) + if (pointHit) { + dragRef.current = { type: 'point', id: pointHit.id, startX: x, startY: y, moved: false } + setHoverKind('grabbing') + return + } + if (handleHit) { + dragRef.current = { type: 'handle', id: handleHit.id, startX: x, startY: y, moved: false } + setHoverKind('grabbing') + return + } + if (x >= PAD && x <= transform.width - PAD && y >= PAD && y <= transform.height - PAD) { + const grid = transform.toGrid(x, y) + setPoints((current) => [...current, makePoint(grid.x, grid.y)]) + } + } + + const handlePointerMove = (event) => { + const canvas = canvasRef.current + const transform = transformRef.current + if (!canvas || !transform) return + const { x, y } = getCanvasPoint(event, canvas) + const drag = dragRef.current + if (!drag) { + updateHover(event) + return + } + if (Math.hypot(x - drag.startX, y - drag.startY) > 3) drag.moved = true + const grid = transform.toGrid(x, y) + if (drag.type === 'point') { + setPoints((current) => + current.map((point) => (point.id === drag.id ? { ...point, x: grid.x, y: grid.y } : point)), + ) + } else { + setTrendLine((current) => { + if (drag.id === 'start') return { ...current, x1: grid.x, y1: grid.y } + return { ...current, x2: grid.x, y2: grid.y } + }) + } + } + + const handlePointerUp = (event) => { + const drag = dragRef.current + if (!drag) return + if (drag.type === 'point' && !drag.moved) { + const now = performance.now() + if (lastTapRef.current.id === drag.id && now - lastTapRef.current.time < 360) { + setPoints((current) => current.filter((point) => point.id !== drag.id)) + lastTapRef.current = { id: null, time: 0 } + } else { + lastTapRef.current = { id: drag.id, time: now } + } + } + dragRef.current = null + setHoverKind('grid') + updateHover(event) + } + + const loadPreset = (key) => { + setPoints(pointList(presets[key])) + if (key === 'negative') setTrendLine({ x1: 1, y1: 8, x2: 9, y2: 2 }) + else if (key === 'none') setTrendLine({ x1: 1, y1: 5, x2: 9, y2: 5 }) + else setTrendLine({ x1: 1, y1: 2, x2: 9, y2: 8 }) + } + + const fitText = rmse === null ? 'Move the line' : rating + const correlationText = stats.r === null ? 'Need 2 points' : `${stats.strength} ${stats.direction}` + + const hint = + stats.r === null + ? 'Add at least two points, then drag the indigo line handles to fit the pattern.' + : `This data has ${stats.strength} ${stats.direction} correlation. The green best-fit line minimises the total vertical distances from all points.` + + return ( + + + + + + + + + { + const canvas = canvasRef.current + if (!canvas) return + const { x, y } = getCanvasPoint(event, canvas) + const pointHit = hitPoint(x, y) + if (pointHit) setPoints((current) => current.filter((point) => point.id !== pointHit.id)) + }} + aria-label="Scatter plot grid" + /> + + + + + setShowMyLine((value) => !value)} color={colors.line}> + My trend line + + setShowBestFit((value) => !value)} color={colors.best}> + Show best-fit line + + setShowDistances((value) => !value)} color={colors.residual}> + Show distances + + + setPoints([])} + className="rounded-full border bg-white px-4 py-2 text-sm font-black" + style={{ borderColor: colors.border, color: colors.ink }} + > + Clear points + + + + + Presets + {[ + ['positive', 'Positive trend'], + ['negative', 'Negative trend'], + ['none', 'No trend'], + ['strong', 'Strong trend'], + ].map(([key, label]) => ( + loadPreset(key)} + className="rounded-full border bg-white px-3 py-1.5 text-xs font-black text-neutral-700" + style={{ borderColor: colors.border }} + > + {label} + + ))} + + Click to add. Drag points or handles. Double-click a point to remove. + + + + + {hint} + + + ) +} + +function StatCard({ label, value, color }) { + return ( + + {label} + + {value} + + + ) +} + +function Toggle({ active, onClick, color, children }) { + return ( + + {children} + + ) +} diff --git a/src/manipulatives/slope-explorer.jsx b/src/manipulatives/slope-explorer.jsx new file mode 100644 index 0000000..edc4a40 --- /dev/null +++ b/src/manipulatives/slope-explorer.jsx @@ -0,0 +1,470 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + rise: '#0F6E56', + run: '#185FA5', + slope: '#534AB7', + pointA: '#534AB7', + pointB: '#993556', + grid: '#DEDAD1', + cardBorder: '#E0DDD6', +} + +const presets = [ + { label: 'Positive', a: { x: -4, y: -2 }, b: { x: 3, y: 3 } }, + { label: 'Negative', a: { x: -4, y: 4 }, b: { x: 4, y: -2 } }, + { label: 'Zero', a: { x: -5, y: 2 }, b: { x: 5, y: 2 } }, + { label: 'Undefined', a: { x: 2, y: -4 }, b: { x: 2, y: 4 } }, + { label: 'Steep', a: { x: -2, y: -5 }, b: { x: 0, y: 5 } }, + { label: 'Gentle', a: { x: -6, y: -1 }, b: { x: 6, y: 2 } }, +] + +function easeInOut(t) { + return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2 +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function signed(value) { + if (value === 0) return '0' + return value > 0 ? `+${value}` : `${value}` +} + +function slopeText(rise, run) { + if (run === 0) return 'undefined' + const value = rise / run + return Number.isInteger(value) ? String(value) : value.toFixed(2) +} + +function roundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) +} + +function drawPointLabel(ctx, point, screen, label, color, canvasWidth) { + const text = `${label} (${point.x}, ${point.y})` + ctx.save() + ctx.font = '800 13px Inter, sans-serif' + const width = ctx.measureText(text).width + 18 + const x = clamp(screen.x, width / 2 + 8, canvasWidth - width / 2 - 8) + const y = Math.max(18, screen.y - 30) + ctx.fillStyle = `${color}22` + ctx.strokeStyle = color + ctx.lineWidth = 1.5 + roundRect(ctx, x - width / 2, y - 15, width, 30, 15) + ctx.fill() + ctx.stroke() + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(text, x, y) + ctx.restore() +} + +function drawArrowLine(ctx, from, to, color, label, progress = 1) { + const x = from.x + (to.x - from.x) * progress + const y = from.y + (to.y - from.y) * progress + + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = 2.25 + ctx.lineCap = 'round' + ctx.setLineDash([1, 7]) + ctx.beginPath() + ctx.moveTo(from.x, from.y) + ctx.lineTo(x, y) + ctx.stroke() + ctx.setLineDash([]) + + if (progress > 0.92) { + ctx.fillStyle = color + ctx.font = '900 14px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const lx = (from.x + to.x) / 2 + const ly = (from.y + to.y) / 2 + const vertical = Math.abs(to.y - from.y) > Math.abs(to.x - from.x) + ctx.fillText(label, lx + (vertical ? 22 : 0), ly + (vertical ? 0 : -22)) + } + ctx.restore() +} + +function StatCard({ title, value, color, active, hideValue = false }) { + return ( + + + {title} + + + {value} + + + ) +} + +export default function SlopeExplorer() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const dragRef = useRef(null) + const [canvasSize, setCanvasSize] = useState({ width: 760, height: 280 }) + const [points, setPoints] = useState([]) + const [riseT, setRiseT] = useState(1) + const [runT, setRunT] = useState(1) + const [revealed, setRevealed] = useState({ rise: false, run: false, slope: false }) + const [liveMode, setLiveMode] = useState(false) + const [hideMeasurements, setHideMeasurements] = useState(true) + + const hasBoth = points.length === 2 + const a = points[0] + const b = points[1] + const rise = hasBoth ? b.y - a.y : 0 + const run = hasBoth ? b.x - a.x : 0 + const slope = slopeText(rise, run) + const showRise = hasBoth && (liveMode || revealed.rise) + const showRun = hasBoth && (liveMode || revealed.run) + const showSlope = hasBoth && (liveMode || revealed.slope) + + const constants = useMemo(() => { + const cell = (canvasSize.height - 56) / 14 + const originX = canvasSize.width / 2 + const originY = canvasSize.height / 2 + const minX = Math.floor((0 - originX) / cell) + const maxX = Math.ceil((canvasSize.width - originX) / cell) + return { cell, originX, originY, minX, maxX, minY: -7, maxY: 7 } + }, [canvasSize]) + + const toPx = useCallback((point) => ({ + x: constants.originX + point.x * constants.cell, + y: constants.originY - point.y * constants.cell, + }), [constants]) + + const toGrid = useCallback((x, y) => ({ + x: clamp(Math.round((x - constants.originX) / constants.cell), constants.minX, constants.maxX), + y: clamp(Math.round((constants.originY - y) / constants.cell), constants.minY, constants.maxY), + }), [constants]) + + const cancelAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + }, []) + + const startAnimation = useCallback(() => { + cancelAnimation() + setLiveMode(false) + setRiseT(0) + setRunT(0) + setRevealed({ rise: false, run: false, slope: false }) + + const start = performance.now() + const riseDuration = 780 + const runDuration = 780 + const slopeDuration = 630 + + const tick = (now) => { + const elapsed = now - start + if (elapsed < riseDuration) { + setRiseT(easeInOut(elapsed / riseDuration)) + setRunT(0) + } else if (elapsed < riseDuration + runDuration) { + setRiseT(1) + setRunT(easeInOut((elapsed - riseDuration) / runDuration)) + setRevealed((current) => current.rise ? current : { ...current, rise: true }) + } else if (elapsed < riseDuration + runDuration + slopeDuration) { + setRiseT(1) + setRunT(1) + setRevealed((current) => current.run ? current : { ...current, rise: true, run: true }) + } else { + frameRef.current = null + setRiseT(1) + setRunT(1) + setRevealed({ rise: true, run: true, slope: true }) + return + } + frameRef.current = requestAnimationFrame(tick) + } + + frameRef.current = requestAnimationFrame(tick) + }, [cancelAnimation]) + + const switchToLive = useCallback(() => { + cancelAnimation() + setLiveMode(true) + setRiseT(1) + setRunT(1) + setRevealed({ rise: true, run: true, slope: true }) + }, [cancelAnimation]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return + const update = () => setCanvasSize({ width: Math.max(340, Math.floor(node.clientWidth)), height: 280 }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => () => cancelAnimation(), [cancelAnimation]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const draw = (rProgress = 1, runProgress = 1) => { + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let x = constants.minX; x <= constants.maxX; x += 1) { + const px = constants.originX + x * constants.cell + ctx.beginPath() + ctx.moveTo(px, 0) + ctx.lineTo(px, canvasSize.height) + ctx.stroke() + } + for (let y = constants.minY; y <= constants.maxY; y += 1) { + const py = constants.originY - y * constants.cell + ctx.beginPath() + ctx.moveTo(0, py) + ctx.lineTo(canvasSize.width, py) + ctx.stroke() + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(0, constants.originY) + ctx.lineTo(canvasSize.width, constants.originY) + ctx.moveTo(constants.originX, 0) + ctx.lineTo(constants.originX, canvasSize.height) + ctx.stroke() + + ctx.fillStyle = '#5F5E5A' + ctx.font = '11px Inter, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let x = constants.minX; x <= constants.maxX; x += 2) { + const px = constants.originX + x * constants.cell + if (px > 12 && px < canvasSize.width - 12) ctx.fillText(String(x), px, constants.originY + 5) + } + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (let y = constants.minY; y <= constants.maxY; y += 2) { + if (y !== 0) ctx.fillText(String(y), constants.originX - 6, constants.originY - y * constants.cell) + } + + if (hasBoth) { + const pa = toPx(a) + const pb = toPx(b) + const corner = { x: pb.x, y: pb.y } + + if (!hideMeasurements) { + const dx = pb.x - pa.x + const dy = pb.y - pa.y + const leftEdge = { x: 0, y: pa.y - (dy / (dx || 1)) * pa.x } + const rightEdge = { x: canvasSize.width, y: pa.y + (dy / (dx || 1)) * (canvasSize.width - pa.x) } + if (run === 0) { + ctx.strokeStyle = colors.slope + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(pa.x, 0) + ctx.lineTo(pa.x, canvasSize.height) + ctx.stroke() + } else { + ctx.strokeStyle = colors.slope + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(leftEdge.x, leftEdge.y) + ctx.lineTo(rightEdge.x, rightEdge.y) + ctx.stroke() + } + } + + if (showRise || rProgress > 0) { + drawArrowLine(ctx, pa, { x: pa.x, y: pb.y }, colors.rise, hideMeasurements ? '' : signed(rise), rProgress) + } + if (showRun || runProgress > 0) { + drawArrowLine(ctx, { x: pa.x, y: pb.y }, corner, colors.run, hideMeasurements ? '' : signed(run), runProgress) + } + } + + points.forEach((point, index) => { + const screen = toPx(point) + const color = index === 0 ? colors.pointA : colors.pointB + ctx.save() + ctx.fillStyle = color + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(screen.x, screen.y, 9, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + drawPointLabel(ctx, point, screen, index === 0 ? 'A' : 'B', color, canvasSize.width) + ctx.restore() + }) + } + + draw(riseT, runT) + }, [a, b, canvasSize, constants, hasBoth, hideMeasurements, points, rise, riseT, run, runT, showRise, showRun, toPx]) + + const canvasPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasSize.width / rect.width), + y: (event.clientY - rect.top) * (canvasSize.height / rect.height), + } + } + + const handlePointerDown = (event) => { + const point = canvasPoint(event) + const hitIndex = points.findIndex((gridPoint) => { + const screen = toPx(gridPoint) + return Math.hypot(screen.x - point.x, screen.y - point.y) <= 16 + }) + + if (hitIndex !== -1) { + switchToLive() + dragRef.current = hitIndex + event.currentTarget.setPointerCapture(event.pointerId) + return + } + + if (points.length < 2) { + const next = [...points, toGrid(point.x, point.y)] + setPoints(next) + setHideMeasurements(true) + if (next.length === 2) setTimeout(startAnimation, 0) + } + } + + const handlePointerMove = (event) => { + if (dragRef.current === null) return + const point = canvasPoint(event) + const gridPoint = toGrid(point.x, point.y) + setPoints((current) => current.map((item, index) => (index === dragRef.current ? gridPoint : item))) + } + + const stopDragging = () => { + dragRef.current = null + } + + const clearPoints = () => { + cancelAnimation() + setPoints([]) + setLiveMode(false) + setHideMeasurements(true) + setRiseT(1) + setRunT(1) + setRevealed({ rise: false, run: false, slope: false }) + } + + const applyPreset = (preset) => { + cancelAnimation() + setHideMeasurements(false) + setPoints([preset.a, preset.b]) + setTimeout(startAnimation, 0) + } + + const hint = (() => { + if (!hasBoth) return points.length === 0 ? 'Click the grid to place point A, then click again for point B.' : 'Now click a second point to make a line.' + if (run === 0) return "Undefined slope means the run is 0, so you can't divide by zero." + if (rise === 0) return 'Zero slope means there is no rise, so the line is horizontal.' + if (rise / run > 0) return 'Positive slope rises as you move from left to right.' + return 'Negative slope falls as you move from left to right.' + })() + + return ( + + + + + + + + + setHideMeasurements((current) => !current)} + className={`absolute right-2 top-2 z-10 rounded-full border px-3 py-1.5 text-xs font-black shadow-sm ${hideMeasurements ? 'bg-[#1A1A2E] text-white' : 'bg-white text-[#1A1A2E]'}`} + style={{ borderColor: colors.ink }} + > + {hideMeasurements ? 'Show values' : 'Hide values'} + + + + + + {!hideMeasurements ? ( + + slope + = + + rise + / + run + + + {' = '} + {hasBoth ? rise : '?'} + / + {hasBoth ? run : '?'} + + + {' = '} + {hasBoth ? slope : '?'} + + + ) : ( + + Work out the slope yourself, then show the values to check. + + )} + + + + + Place new points + + + Replay + + {presets.map((preset) => ( + applyPreset(preset)} + className="rounded-full border border-[#E0DDD6] bg-white px-3 py-1.5 text-xs font-black text-[#1A1A2E]" + > + {preset.label} + + ))} + + + {hint} + + ) +} diff --git a/src/manipulatives/substitution-machine.jsx b/src/manipulatives/substitution-machine.jsx new file mode 100644 index 0000000..cdc72f9 --- /dev/null +++ b/src/manipulatives/substitution-machine.jsx @@ -0,0 +1,656 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const xOrange = '#D85A30' +const answerGreen = '#3B6D11' + +function easeInOut(t) { + return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2 +} + +function formatNumber(value) { + if (Number.isInteger(value)) return String(value) + return value.toFixed(1).replace(/\.0$/, '') +} + +function randomX() { + return Math.floor(Math.random() * 15) - 6 +} + +const variableLetters = ['a', 'b', 'c', 'x', 'y', 'z'] + +function randomVariable() { + return variableLetters[Math.floor(Math.random() * variableLetters.length)] +} + +function withVariable(text, variable) { + return text.replace(/x/g, variable) +} + +function Sup2() { + return 2 +} + +function MathText({ children }) { + if (typeof children !== 'string') return children + const parts = children.split('^2') + return parts.map((part, index) => ( + + {part} + {index < parts.length - 1 ? : null} + + )) +} + +const expressions = [ + { + id: '3x-plus-5', + label: '3x + 5', + title: '3x + 5', + color: '#534AB7', + substitution: (x) => <>3 * {formatNumber(x)} + 5>, + expressionMath: (variable) => <>3{variable} + 5>, + firstStepMath: (x, substituted, variable) => <>3 * {substituted ? formatNumber(x) : variable}>, + buildSteps: (x) => { + const multiply = 3 * x + const answer = multiply + 5 + return [ + { + operation: 'Multiply', + symbol: 'x3', + before: formatNumber(x), + after: formatNumber(multiply), + equation: `3 * ${formatNumber(x)} = ${formatNumber(multiply)}`, + note: 'Replace x, then multiply by 3.', + }, + { + operation: 'Add', + symbol: '+5', + before: formatNumber(multiply), + after: formatNumber(answer), + equation: `${formatNumber(multiply)} + 5 = ${formatNumber(answer)}`, + note: 'Add 5 to the value inside the machine.', + }, + ] + }, + }, + { + id: '2x-squared-minus-4', + label: '2x^2 - 4', + title: '2x^2 - 4', + color: '#185FA5', + substitution: (x) => <>2({formatNumber(x)}) - 4>, + expressionMath: (variable) => <>2{variable} - 4>, + firstStepMath: (x, substituted, variable) => <>{substituted ? formatNumber(x) : variable}>, + buildSteps: (x) => { + const square = x * x + const double = 2 * square + const answer = double - 4 + return [ + { + operation: 'Square', + symbol: '^2', + before: formatNumber(x), + after: formatNumber(square), + equation: `${formatNumber(x)}^2 = ${formatNumber(square)}`, + note: 'Square the substituted value first.', + }, + { + operation: 'Multiply', + symbol: 'x2', + before: formatNumber(square), + after: formatNumber(double), + equation: `2 * ${formatNumber(square)} = ${formatNumber(double)}`, + note: 'Multiply the squared value by 2.', + }, + { + operation: 'Subtract', + symbol: '-4', + before: formatNumber(double), + after: formatNumber(answer), + equation: `${formatNumber(double)} - 4 = ${formatNumber(answer)}`, + note: 'Finish by subtracting 4.', + }, + ] + }, + }, + { + id: 'four-brackets', + label: '4(x + 3)', + title: '4(x + 3)', + color: '#0F6E56', + substitution: (x) => <>4({formatNumber(x)} + 3)>, + expressionMath: (variable) => <>4({variable} + 3)>, + firstStepMath: (x, substituted, variable) => <>{substituted ? formatNumber(x) : variable} + 3>, + buildSteps: (x) => { + const inside = x + 3 + const answer = 4 * inside + return [ + { + operation: 'Brackets', + symbol: '+3', + before: formatNumber(x), + after: formatNumber(inside), + equation: `${formatNumber(x)} + 3 = ${formatNumber(inside)}`, + note: 'Work inside the brackets first.', + }, + { + operation: 'Multiply', + symbol: 'x4', + before: formatNumber(inside), + after: formatNumber(answer), + equation: `4 * ${formatNumber(inside)} = ${formatNumber(answer)}`, + note: 'Multiply the bracket value by 4.', + }, + ] + }, + }, + { + id: 'quadratic', + label: 'x^2 + 2x + 1', + title: 'x^2 + 2x + 1', + color: '#7C3AED', + substitution: (x) => <>{formatNumber(x)} + 2({formatNumber(x)}) + 1>, + expressionMath: (variable) => <>{variable} + 2{variable} + 1>, + firstStepMath: (x, substituted, variable) => <>{substituted ? formatNumber(x) : variable}>, + buildSteps: (x) => { + const square = x * x + const twoX = 2 * x + const partial = square + twoX + const answer = partial + 1 + return [ + { + operation: 'Square', + symbol: '^2', + before: formatNumber(x), + after: formatNumber(square), + equation: `${formatNumber(x)}^2 = ${formatNumber(square)}`, + note: 'Find the x^2 part first.', + }, + { + operation: 'Add 2x', + symbol: '+2x', + before: formatNumber(square), + after: formatNumber(partial), + equation: `${formatNumber(square)} + 2(${formatNumber(x)}) = ${formatNumber(partial)}`, + note: 'Add the 2x part.', + }, + { + operation: 'Add', + symbol: '+1', + before: formatNumber(partial), + after: formatNumber(answer), + equation: `${formatNumber(partial)} + 1 = ${formatNumber(answer)}`, + note: 'Add the final 1.', + }, + ] + }, + }, + { + id: 'ten-minus-2x', + label: '10 - 2x', + title: '10 - 2x', + color: '#BA7517', + substitution: (x) => <>10 - 2({formatNumber(x)})>, + expressionMath: (variable) => <>10 - 2{variable}>, + firstStepMath: (x, substituted, variable) => <>2 * {substituted ? formatNumber(x) : variable}>, + buildSteps: (x) => { + const twoX = 2 * x + const answer = 10 - twoX + return [ + { + operation: 'Multiply', + symbol: 'x2', + before: formatNumber(x), + after: formatNumber(twoX), + equation: `2 * ${formatNumber(x)} = ${formatNumber(twoX)}`, + note: 'First find 2x.', + }, + { + operation: 'Subtract', + symbol: '10-', + before: formatNumber(twoX), + after: formatNumber(answer), + equation: `10 - ${formatNumber(twoX)} = ${formatNumber(answer)}`, + note: 'Subtract that amount from 10.', + }, + ] + }, + }, + { + id: 'two-brackets', + label: '(x+2)(x-1)', + title: '(x + 2)(x - 1)', + color: '#993556', + substitution: (x) => <>({formatNumber(x)} + 2)({formatNumber(x)} - 1)>, + expressionMath: (variable) => <>({variable} + 2)({variable} - 1)>, + firstStepMath: (x, substituted, variable) => <>{substituted ? formatNumber(x) : variable} + 2>, + buildSteps: (x) => { + const left = x + 2 + const right = x - 1 + const answer = left * right + return [ + { + operation: 'First bracket', + symbol: '+2', + before: formatNumber(x), + after: formatNumber(left), + equation: `${formatNumber(x)} + 2 = ${formatNumber(left)}`, + note: 'Evaluate the first bracket.', + }, + { + operation: 'Second bracket', + symbol: '-1', + before: formatNumber(left), + after: `${formatNumber(left)} & ${formatNumber(right)}`, + equation: `${formatNumber(x)} - 1 = ${formatNumber(right)}`, + note: 'Evaluate the second bracket too.', + }, + { + operation: 'Multiply', + symbol: 'x', + before: `${formatNumber(left)} & ${formatNumber(right)}`, + after: formatNumber(answer), + equation: `${formatNumber(left)} * ${formatNumber(right)} = ${formatNumber(answer)}`, + note: 'Multiply the two bracket values.', + }, + ] + }, + }, +] + +function withAlpha(hex, alpha) { + const value = hex.replace('#', '') + const r = parseInt(value.slice(0, 2), 16) + const g = parseInt(value.slice(2, 4), 16) + const b = parseInt(value.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +function Token({ value, progress, phase, isFinalStep, isWaitingAtTray }) { + const y = (() => { + if (phase === 'done') return 224 + if (phase !== 'animating') return isWaitingAtTray ? 218 : 28 + if (progress < 0.24) return 28 + (progress / 0.24) * 38 + if (progress < 0.62) return 66 + ((progress - 0.24) / 0.38) * 44 + if (progress < 0.8) return 110 + ((progress - 0.62) / 0.18) * 42 + return 152 + ((progress - 0.8) / 0.2) * (isFinalStep ? 72 : 66) + })() + const scale = phase === 'animating' && progress > 0.36 && progress < 0.72 ? 0.88 : 1 + const isAnswer = phase === 'done' || (isFinalStep && phase === 'animating' && progress > 0.66) + const isInsideMachine = phase === 'animating' && progress > 0.22 && progress < 0.88 + const isPairedValue = String(value).includes('&') + + return ( + + {value} + + ) +} + +function StepCard({ step, index, status, color }) { + const isActive = status === 'active' + const isDone = status === 'done' + + return ( + + + + {isDone ? '✓' : index + 1} + + + {step.operation} {step.equation} + + + + {step.note} + + + ) +} + +function WindowMath({ expression, xValue, variable, completedSteps, phase, progress, currentStep, lastStep, isComplete, answer }) { + const isFirstAnimation = completedSteps === 0 && phase === 'animating' + const isLaterAnimation = completedSteps > 0 && phase === 'animating' + const showGlow = completedSteps === 0 && (!isFirstAnimation || progress < 0.58) + const isWorking = phase === 'animating' && progress > 0.22 && progress < 0.82 + const shake = isWorking + ? Math.sin(progress * Math.PI * 44) * 1.7 + : 0 + const scale = isFirstAnimation && progress > 0.5 && progress < 0.68 ? 1.04 : 1 + const firstOperation = currentStep?.equation.split(' = ')[0] + const multiplySymbol = currentStep?.symbol?.match(/^x(\d+)$/) + const operationDisplay = completedSteps === 0 ? firstOperation : currentStep?.symbol + const twoXValue = formatNumber(2 * xValue) + const pairedValues = currentStep?.before?.includes(' & ') ? currentStep.before.split(' & ') : null + const operationMath = multiplySymbol && isLaterAnimation ? ( + <> + {currentStep.before} * {multiplySymbol[1]} + > + ) : currentStep?.symbol === '10-' && isLaterAnimation ? ( + <> + 10 - {currentStep.before} + > + ) : currentStep?.operation === 'Second bracket' ? ( + !isLaterAnimation || progress < 0.34 ? ( + <> + {variable} - 1 + > + ) : ( + <> + {formatNumber(xValue)} - 1 + > + ) + ) : currentStep?.symbol === '+2x' && isLaterAnimation ? ( + progress < 0.12 ? ( + <> + + 2{variable} + > + ) : progress < 0.28 ? ( + <> + + 2 * {formatNumber(xValue)} + > + ) : progress < 0.52 ? ( + <> + + {twoXValue} + > + ) : ( + <> + {currentStep.before} + {twoXValue} = {currentStep.after} + > + ) + ) : pairedValues && currentStep?.symbol === 'x' && isLaterAnimation ? ( + <> + {pairedValues[0]} * {pairedValues[1]} + > + ) : ( + {operationDisplay} + ) + const xHasDroppedIn = isFirstAnimation && progress > 0.36 + + return ( + 0.5 && progress < 0.68 ? 0.9 : 1, + transform: `translateX(${shake}px) scale(${scale})`, + }} + > + {isComplete ? ( + {answer} + ) : isFirstAnimation ? ( + {expression.firstStepMath(xValue, xHasDroppedIn, variable)} + ) : phase === 'idle' && completedSteps > 0 && lastStep ? ( + {lastStep.after} + ) : completedSteps > 0 && currentStep ? ( + operationMath + ) : ( + + {expression.expressionMath(variable)} + + )} + + ) +} + +export default function SubstitutionMachine() { + const frameRef = useRef(null) + const [expressionIndex, setExpressionIndex] = useState(() => Math.floor(Math.random() * expressions.length)) + const [variable] = useState(() => randomVariable()) + const [xValue, setXValue] = useState(4) + const [completedSteps, setCompletedSteps] = useState(0) + const [phase, setPhase] = useState('idle') + const [progress, setProgress] = useState(0) + const [tokenValue, setTokenValue] = useState('4') + + const expression = expressions[expressionIndex] + const steps = useMemo(() => expression.buildSteps(xValue), [expression, xValue]) + const currentStep = steps[completedSteps] + const lastStep = completedSteps > 0 ? steps[completedSteps - 1] : null + const isComplete = completedSteps >= steps.length + const isFinalStep = completedSteps === steps.length - 1 + const answer = steps[steps.length - 1]?.after ?? formatNumber(xValue) + const color = expression.color + const hasStarted = phase === 'animating' || completedSteps > 0 || isComplete + + const resetRun = useCallback((nextX) => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + setCompletedSteps(0) + setPhase('idle') + setProgress(0) + setTokenValue(formatNumber(nextX)) + }, []) + + useEffect(() => { + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + } + }, []) + + const animateStep = () => { + if (!currentStep || frameRef.current) return + const duration = 3024 + const topPause = 1000 + const startedAt = performance.now() + setPhase('animating') + setProgress(0) + setTokenValue(completedSteps === 0 ? formatNumber(xValue) : currentStep.before) + + const tick = (now) => { + const elapsed = now - startedAt + if (elapsed < topPause) { + setProgress(0) + frameRef.current = requestAnimationFrame(tick) + return + } + + const raw = Math.min(1, (elapsed - topPause) / duration) + const eased = easeInOut(raw) + setProgress(eased) + if (raw > 0.52) setTokenValue(currentStep.after) + + if (raw < 1) { + frameRef.current = requestAnimationFrame(tick) + } else { + frameRef.current = null + setProgress(1) + setTokenValue(currentStep.after) + setCompletedSteps((value) => value + 1) + setPhase(completedSteps + 1 >= steps.length ? 'done' : 'idle') + } + } + + frameRef.current = requestAnimationFrame(tick) + } + + const tryAnother = () => { + const next = randomX() + setXValue(next) + resetRun(next) + } + + const changeX = (amount) => { + const next = Math.max(-10, Math.min(10, xValue + amount)) + setXValue(next) + resetRun(next) + } + + const cycleExpression = (amount) => { + setExpressionIndex((index) => (index + amount + expressions.length) % expressions.length) + resetRun(xValue) + } + + const chooseExpression = (index) => { + setExpressionIndex(index) + resetRun(xValue) + } + + return ( + + + + + cycleExpression(-1)} + className="hidden h-8 w-8 items-center justify-center rounded-full border border-[#E0DDD6] bg-white text-2xl font-black text-[#1A1A2E]" + aria-label="Previous expression" + > + ‹ + + + {withVariable(expression.label, variable)} + + cycleExpression(1)} + className="hidden h-8 w-8 items-center justify-center rounded-full border border-[#E0DDD6] bg-white text-2xl font-black text-[#1A1A2E]" + aria-label="Next expression" + > + › + + + + {expressions.map((item, index) => ( + chooseExpression(index)} + className="h-2 w-2 rounded-full transition-all" + style={{ + backgroundColor: index === expressionIndex ? color : '#D9D6CF', + transform: index === expressionIndex ? 'scale(1.25)' : 'scale(1)', + }} + aria-label={`Choose expression ${index + 1}`} + /> + ))} + + + + + Set the value of {variable} + + {variable} = + changeX(-1)} + className="flex h-8 w-8 items-center justify-center rounded-lg text-xl font-black text-white" + style={{ backgroundColor: color }} + > + - + + + {xValue} + + changeX(1)} + className="flex h-8 w-8 items-center justify-center rounded-lg text-xl font-black text-white" + style={{ backgroundColor: color }} + > + + + + + + + + {hasStarted ? ( + 0 && !isComplete} + /> + ) : null} + + + + + + + + + + + + + + {[0, 1, 2].map((dot) => ( + + ))} + + + + + + + {steps.map((step, index) => ( + + ))} + + + + + {steps.map((step, index) => ( + + ))} + + + + + {`${withVariable(expression.title, variable)} when ${variable} = ${xValue}${isComplete ? ` -> ${answer}` : ''}`} + + + {isComplete ? 'Try another ->' : completedSteps === 0 ? 'Start ->' : 'Next step ->'} + + resetRun(xValue)} + className="h-10 rounded-full border border-[#D9D6CF] bg-white text-sm font-black text-[#1A1A2E]" + > + Start over + + + + + ) +} diff --git a/src/manipulatives/systems-of-equations.jsx b/src/manipulatives/systems-of-equations.jsx new file mode 100644 index 0000000..2aa5c53 --- /dev/null +++ b/src/manipulatives/systems-of-equations.jsx @@ -0,0 +1,471 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + grid: '#DEDAD1', + line1: '#2660C4', + line1Tint: '#EAF0FB', + line1Border: '#8AA8DD', + line2: '#1E7A5E', + line2Tint: '#E9F5EF', + line2Border: '#7FCBAC', + solution: '#7B3F9E', + solutionTint: '#F3EEFA', + none: '#8A4A12', + noneTint: '#FBEEDD', + infinite: '#134858', + infiniteTint: '#E4F3F7', + border: '#E0DDD6', + muted: '#5F5E5A', +} + +const presets = [ + { label: 'One solution', l1: { m: 1, b: 1 }, l2: { m: -1, b: 5 } }, + { label: 'Parallel', l1: { m: 1, b: -2 }, l2: { m: 1, b: 3 } }, + { label: 'Same line', l1: { m: -0.5, b: 4 }, l2: { m: -0.5, b: 4 } }, + { label: 'Steep vs shallow', l1: { m: 3, b: -5 }, l2: { m: 0.5, b: 2 } }, +] + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function snapSlope(value) { + return clamp(Math.round(value * 2) / 2, -4, 4) +} + +function snapIntercept(value) { + return clamp(Math.round(value), -8, 8) +} + +function formatNumber(value) { + const rounded = Math.round(value * 100) / 100 + if (Object.is(rounded, -0)) return '0' + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function signed(value) { + if (value === 0) return '' + return value > 0 ? ` + ${formatNumber(value)}` : ` - ${formatNumber(Math.abs(value))}` +} + +function equationText({ m, b }) { + let mx + if (m === 0) return `y = ${formatNumber(b)}` + if (m === 1) mx = 'x' + else if (m === -1) mx = '-x' + else mx = `${formatNumber(m)}x` + return `y = ${mx}${signed(b)}` +} + +function lineY(line, x) { + return line.m * x + line.b +} + +function solveSystem(line1, line2) { + if (line1.m === line2.m && line1.b === line2.b) return { type: 'infinite' } + if (line1.m === line2.m) return { type: 'none' } + const x = (line2.b - line1.b) / (line1.m - line2.m) + const y = lineY(line1, x) + return { type: 'one', x, y } +} + +function SliderRow({ label, value, color, min, max, step, onChange }) { + return ( + + {label} + onChange(Number(event.target.value))} + style={{ accentColor: color }} + /> + + {formatNumber(value)} + + + ) +} + +function EquationCard({ title, line, color, tint, border, onSlope, onIntercept }) { + return ( + + + {title} + + {equationText(line)} + + + + onSlope(snapSlope(value))} /> + onIntercept(snapIntercept(value))} /> + + + ) +} + +function SolutionChip({ solution, showSolution }) { + if (!showSolution) { + return ( + + Solution hidden + + ) + } + if (solution.type === 'none') { + return ( + + No solution + + ) + } + if (solution.type === 'infinite') { + return ( + + Infinitely many solutions + + ) + } + return ( + + Solution: ({formatNumber(solution.x)}, {formatNumber(solution.y)}) + + ) +} + +function Verification({ line1, line2, solution, showSolution }) { + if (solution.type !== 'one') { + return ( + + ) + } + const x = formatNumber(solution.x) + const y1 = formatNumber(lineY(line1, solution.x)) + const y2 = formatNumber(lineY(line2, solution.x)) + return ( + + {showSolution ? ( + <> + Verify in both equations + + + Line 1: {formatNumber(line1.m)}({x}){signed(line1.b)} = {y1} + + + Line 2: {formatNumber(line2.m)}({x}){signed(line2.b)} = {y2} + + ✓ Both give y = {y1} + + > + ) : ( + + Press Show solution to reveal the check. + + )} + + ) +} + +export default function SystemsOfEquations() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const dragRef = useRef(null) + const frameRef = useRef(null) + const [canvasSize, setCanvasSize] = useState({ width: 560, height: 442 }) + const [line1, setLine1] = useState({ m: 1, b: 1 }) + const [line2, setLine2] = useState({ m: -1, b: 5 }) + const [pulse, setPulse] = useState(0) + const [showSolution, setShowSolution] = useState(false) + + const solution = useMemo(() => solveSystem(line1, line2), [line1, line2]) + + const constants = useMemo(() => { + const padX = 22 + const padY = 24 + const xScale = (canvasSize.width - padX * 2) / 20 + const yScale = (canvasSize.height - padY * 2) / 20 + const originX = canvasSize.width / 2 + const originY = canvasSize.height / 2 + return { + padX, + padY, + xScale, + yScale, + originX, + originY, + left: padX, + right: canvasSize.width - padX, + top: padY, + bottom: canvasSize.height - padY, + } + }, [canvasSize]) + + const toPx = useCallback((x, y) => ({ + x: constants.originX + x * constants.xScale, + y: constants.originY - y * constants.yScale, + }), [constants]) + + const toGrid = useCallback((x, y) => ({ + x: clamp((x - constants.originX) / constants.xScale, -10, 10), + y: clamp((constants.originY - y) / constants.yScale, -10, 10), + }), [constants]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasSize({ + width: Math.max(330, Math.floor(node.clientWidth)), + height: Math.max(330, Math.floor(node.clientHeight)), + }) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const tick = (now) => { + setPulse((Math.sin(now / 360) + 1) / 2) + frameRef.current = requestAnimationFrame(tick) + } + frameRef.current = requestAnimationFrame(tick) + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + } + }, []) + + const drawLine = useCallback((ctx, line, color, width = 2.5, dashed = false) => { + ctx.save() + ctx.beginPath() + ctx.rect(constants.left, constants.top, constants.right - constants.left, constants.bottom - constants.top) + ctx.clip() + ctx.strokeStyle = color + ctx.lineWidth = width + ctx.lineCap = 'round' + if (dashed) ctx.setLineDash([8, 7]) + const p1 = toPx(-10, lineY(line, -10)) + const p2 = toPx(10, lineY(line, 10)) + ctx.beginPath() + ctx.moveTo(p1.x, p1.y) + ctx.lineTo(p2.x, p2.y) + ctx.stroke() + ctx.restore() + }, [constants, toPx]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasSize.width * dpr + canvas.height = canvasSize.height * dpr + canvas.style.width = `${canvasSize.width}px` + canvas.style.height = `${canvasSize.height}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasSize.width, canvasSize.height) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasSize.width, canvasSize.height) + + ctx.strokeStyle = colors.grid + ctx.lineWidth = 1 + for (let x = -10; x <= 10; x += 1) { + const px = toPx(x, 0).x + ctx.beginPath() + ctx.moveTo(px, constants.top) + ctx.lineTo(px, constants.bottom) + ctx.stroke() + } + for (let y = -10; y <= 10; y += 1) { + const py = toPx(0, y).y + ctx.beginPath() + ctx.moveTo(constants.left, py) + ctx.lineTo(constants.right, py) + ctx.stroke() + } + + ctx.strokeStyle = colors.ink + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(constants.left, constants.originY) + ctx.lineTo(constants.right, constants.originY) + ctx.moveTo(constants.originX, constants.top) + ctx.lineTo(constants.originX, constants.bottom) + ctx.stroke() + + ctx.fillStyle = colors.muted + ctx.font = '700 13px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let x = -10; x <= 10; x += 2) { + const px = toPx(x, 0).x + ctx.fillText(String(x), px, constants.originY + 5) + } + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (let y = -10; y <= 10; y += 2) { + if (y !== 0) ctx.fillText(String(y), constants.originX - 6, toPx(0, y).y) + } + + if (solution.type === 'infinite') { + drawLine(ctx, line1, colors.solution, 3.5) + } else { + drawLine(ctx, line1, colors.line1) + drawLine(ctx, line2, colors.line2) + } + + const intercepts = [ + { line: line1, color: colors.line1, point: toPx(0, line1.b), id: 'line1' }, + { line: line2, color: colors.line2, point: toPx(0, line2.b), id: 'line2' }, + ] + intercepts.forEach(({ color, point }) => { + ctx.fillStyle = color + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 2.5 + ctx.beginPath() + ctx.arc(point.x, point.y, 5.5, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + }) + + if (solution.type === 'one' && solution.x >= -10 && solution.x <= 10 && solution.y >= -10 && solution.y <= 10) { + const point = toPx(solution.x, solution.y) + const halo = 13 + pulse * 8 + ctx.fillStyle = `rgba(123,63,158,${0.16 + pulse * 0.12})` + ctx.beginPath() + ctx.arc(point.x, point.y, halo, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = colors.solution + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(point.x, point.y, 8, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + + if (showSolution) { + const label = `(${formatNumber(solution.x)}, ${formatNumber(solution.y)})` + ctx.font = '900 15px Inter, system-ui, sans-serif' + const width = ctx.measureText(label).width + 18 + const lx = clamp(point.x, constants.left + width / 2 + 4, constants.right - width / 2 - 4) + const ly = clamp(point.y - 30, constants.top + 15, constants.bottom - 15) + ctx.fillStyle = colors.solutionTint + ctx.strokeStyle = colors.solution + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.roundRect(lx - width / 2, ly - 15, width, 30, 15) + ctx.fill() + ctx.stroke() + ctx.fillStyle = colors.solution + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, lx, ly) + } + } + }, [canvasSize, constants, drawLine, line1, line2, pulse, showSolution, solution, toPx]) + + const canvasPoint = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + return { + x: (event.clientX - rect.left) * (canvasSize.width / rect.width), + y: (event.clientY - rect.top) * (canvasSize.height / rect.height), + } + } + + const handlePointerDown = (event) => { + const point = canvasPoint(event) + const h1 = toPx(0, line1.b) + const h2 = toPx(0, line2.b) + if (Math.hypot(point.x - h1.x, point.y - h1.y) <= 18) dragRef.current = 'line1' + else if (Math.hypot(point.x - h2.x, point.y - h2.y) <= 18) dragRef.current = 'line2' + else return + event.currentTarget.setPointerCapture(event.pointerId) + } + + const handlePointerMove = (event) => { + if (!dragRef.current) return + const point = canvasPoint(event) + const grid = toGrid(point.x, point.y) + const b = snapIntercept(grid.y) + if (dragRef.current === 'line1') setLine1((current) => ({ ...current, b })) + if (dragRef.current === 'line2') setLine2((current) => ({ ...current, b })) + } + + const stopDrag = () => { + dragRef.current = null + } + + const applyPreset = (preset) => { + setLine1(preset.l1) + setLine2(preset.l2) + } + + return ( + + + setShowSolution((current) => !current)} + className="absolute left-3 top-3 z-10 rounded-full border px-3 py-1.5 text-sm font-black shadow-sm transition" + style={{ + borderColor: colors.solution, + color: showSolution ? '#ffffff' : colors.solution, + background: showSolution ? colors.solution : 'rgba(255,255,255,0.94)', + }} + > + {showSolution ? 'Hide solution' : 'Show solution'} + + + + + + + ) +} diff --git a/src/manipulatives/two-step-equation-solver.jsx b/src/manipulatives/two-step-equation-solver.jsx new file mode 100644 index 0000000..6326ac3 --- /dev/null +++ b/src/manipulatives/two-step-equation-solver.jsx @@ -0,0 +1,364 @@ +import { useMemo, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + left: '#2660C4', + leftTint: '#EAF0FB', + leftBorder: '#8AA8DD', + right: '#B25A1E', + rightTint: '#FBEEDD', + rightBorder: '#E0B579', + x: '#7B3F9E', + xTint: '#F3EEFA', + xBorder: '#C99BE0', + solved: '#27500A', + solvedTint: '#EAF3DE', + solvedBorder: '#97C459', + invalid: '#B23050', + invalidTint: '#FBE9ED', + teal: '#1E5F74', + tealTint: '#E4F3F7', + tealBorder: '#7FC5D6', +} + +const equations = [ + { a: 3, b: 5, x: 5 }, + { a: 2, b: 7, x: 6 }, + { a: 4, b: -3, x: 4 }, + { a: 5, b: 2, x: 3 }, + { a: 2, b: -8, x: 9 }, + { a: 6, b: 4, x: 2 }, + { a: 3, b: -6, x: 7 }, + { a: 4, b: 9, x: 5 }, +] + +const ops = ['+', '-', '\u00d7', '\u00f7'] + +function formatNumber(value) { + const rounded = Math.round(value * 100) / 100 + if (Object.is(rounded, -0)) return '0' + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function formatSigned(value) { + if (Math.abs(value) < 0.0001) return '' + return value > 0 ? ` + ${formatNumber(value)}` : ` - ${formatNumber(Math.abs(value))}` +} + +function formatMove(op, value) { + return `${op} ${formatNumber(value)}` +} + +function expressionText(coeff, constant) { + const coeffPart = Math.abs(coeff - 1) < 0.0001 + ? 'x' + : Math.abs(coeff + 1) < 0.0001 + ? '-x' + : `${formatNumber(coeff)}x` + return `${coeffPart}${formatSigned(constant)}` +} + +function makeState(index) { + const item = equations[index] + return { + coeff: item.a, + constant: item.b, + right: item.a * item.x + item.b, + targetX: item.x, + start: item, + } +} + +function isSolved(state) { + return Math.abs(state.coeff - 1) < 0.0001 && Math.abs(state.constant) < 0.0001 +} + +function applyOperation(state, op, value) { + if (op === '+') return { ...state, constant: state.constant + value, right: state.right + value } + if (op === '-') return { ...state, constant: state.constant - value, right: state.right - value } + if (op === '\u00d7') return { ...state, coeff: state.coeff * value, constant: state.constant * value, right: state.right * value } + return { ...state, coeff: state.coeff / value, constant: state.constant / value, right: state.right / value } +} + +function moveProgress(before, after) { + const constantImproved = Math.abs(after.constant) < Math.abs(before.constant) - 0.0001 + const coeffImproved = Math.abs(after.coeff - 1) < Math.abs(before.coeff - 1) - 0.0001 + return constantImproved || coeffImproved +} + +function TabButton({ active, children, onClick }) { + return ( + + {children} + + ) +} + +function EquationPanel({ label, children, color, tint, border, flash }) { + return ( + + + {label} + + + {children} + + + ) +} + +function MoveButton({ op, value, disabled, onClick }) { + return ( + onClick(op, value)} + className="rounded-[14px] border-2 px-5 py-3 font-mono text-xl font-black transition disabled:cursor-not-allowed disabled:opacity-45" + style={{ color: colors.x, background: colors.xTint, borderColor: colors.xBorder }} + > + {formatMove(op, value)} + + ) +} + +function HistoryRow({ item, index }) { + return ( + + + {index + 1} + + + {expressionText(item.after.coeff, item.after.constant)} = {formatNumber(item.after.right)} + + + {formatMove(item.op, item.value)} + + + ) +} + +export default function TwoStepEquationSolver() { + const [tab, setTab] = useState('explorer') + const [equationIndex, setEquationIndex] = useState(0) + const [state, setState] = useState(() => makeState(0)) + const [history, setHistory] = useState([]) + const [moveText, setMoveText] = useState('') + const [moveOp, setMoveOp] = useState('-') + const [feedback, setFeedback] = useState('') + const [flash, setFlash] = useState(0) + const solved = isSolved(state) + + const explorerMove = useMemo(() => { + if (Math.abs(state.constant) > 0.0001) { + return { op: state.constant > 0 ? '-' : '+', value: Math.abs(state.constant) } + } + if (Math.abs(state.coeff - 1) > 0.0001 && Math.abs(state.coeff) > 0.0001) { + return { op: '\u00f7', value: state.coeff } + } + return null + }, [state]) + + function reset(index) { + setEquationIndex(index) + setState(makeState(index)) + setHistory([]) + setMoveText('') + setMoveOp('-') + setFeedback('') + setFlash(0) + } + + function newEquation() { + reset((equationIndex + 1) % equations.length) + } + + function switchTab(nextTab) { + if (nextTab === tab) return + setTab(nextTab) + reset((equationIndex + 1) % equations.length) + } + + function cycleOperation() { + setMoveOp((current) => ops[(ops.indexOf(current) + 1) % ops.length]) + } + + function applyMove(op, rawValue, source = 'explorer') { + const value = Number(rawValue) + if (!Number.isFinite(value) || rawValue === '' || (op === '\u00f7' && Math.abs(value) < 0.0001)) { + setFeedback('Enter a valid number. Division by 0 is not allowed.') + return + } + + const before = state + const after = applyOperation(before, op, value) + const progress = moveProgress(before, after) + const nextSolved = isSolved(after) + setState(after) + setHistory((items) => [...items, { op, value, before, after }]) + setFeedback(() => { + if (nextSolved && history.length + 1 === 2) return 'Solved in reverse order: constant off first, then the coefficient.' + if (nextSolved) return `Solved in ${history.length + 1} steps. Try dividing last for the cleanest path.` + if (source === 'solver' && !progress) return "Legal move - still balanced - but x is not closer to being alone. What's stuck to x?" + if (op === '\u00f7' && Math.abs(before.constant) > 0.0001) return 'Notice the fractions? Dividing first works, but it is messier.' + if (Math.abs(after.constant) < 0.0001 && Math.abs(after.coeff - 1) > 0.0001) return `Constant gone. Now divide both sides by ${formatNumber(after.coeff)}.` + return `Applied ${formatMove(op, value)} to both sides.` + }) + setFlash((count) => count + 1) + setMoveText('') + } + + const taskText = solved + ? `Solved: x = ${formatNumber(state.right)}` + : `Solve ${expressionText(state.start.a, state.start.b)} = ${formatNumber(state.start.a * state.start.x + state.start.b)}` + + const moveLine = history.length ? `\u2193 ${formatMove(history.at(-1).op, history.at(-1).value)} to both sides` : 'Every move applies to both sides.' + + const hint = (() => { + if (solved && history.length === 2) return 'You undid it in reverse order - constant off first, then the coefficient.' + if (solved) return `Solved, but it took ${history.length} steps. Try peeling off the constant before dividing.` + if (Math.abs(state.constant) < 0.0001) return `Constant gone. Divide both sides by ${formatNumber(state.coeff)} to leave x alone.` + if (feedback) return feedback + return 'To get x alone, undo operations in reverse: peel off the added number first, then divide by the coefficient.' + })() + + return ( + + + + + + switchTab('explorer')}>Explorer + switchTab('solver')}>Solver + + + New equation + + + + + {taskText} + + + + + 0}> + + {Math.abs(state.coeff - 1) < 0.0001 ? '' : Math.abs(state.coeff + 1) < 0.0001 ? '-' : formatNumber(state.coeff)} + + x + {formatSigned(state.constant)} + + = + 0}> + {formatNumber(state.right)} + + + + {moveLine} + + + + + + + {tab === 'explorer' ? ( + + Tap the next undo move: + {explorerMove ? ( + applyMove(op, value, 'explorer')} /> + ) : ( + x is isolated. + )} + + ) : ( + + Apply to both sides + + {moveOp} + + setMoveText(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') applyMove(moveOp, moveText, 'solver') + }} + className="h-14 w-32 rounded-2xl border-2 px-4 text-center font-mono text-xl font-black outline-none transition placeholder:font-sans placeholder:text-sm focus:shadow-[0_0_0_5px_rgba(123,63,158,.14)]" + style={{ borderColor: colors.xBorder, color: colors.x, background: '#ffffff' }} + /> + applyMove(moveOp, moveText, 'solver')} + className="h-14 rounded-2xl px-6 text-base font-black text-white disabled:cursor-not-allowed disabled:opacity-45" + style={{ background: colors.x }} + > + Apply + + + )} + + + + {hint} + + + {solved ? ( + + Check: {state.start.a}({formatNumber(state.right)}) {state.start.b >= 0 ? '+' : '-'} {Math.abs(state.start.b)} = {state.start.a * state.targetX + state.start.b} ✓ + + ) : null} + + + + Step history + + {history.length ? history.map((item, index) => ) : ( + + Moves will appear here as equivalent equations. + + )} + + + + + ) +} diff --git a/src/manipulatives/two-way-tables.jsx b/src/manipulatives/two-way-tables.jsx new file mode 100644 index 0000000..bfc48b4 --- /dev/null +++ b/src/manipulatives/two-way-tables.jsx @@ -0,0 +1,358 @@ +import { useMemo, useState } from 'react' + +const colors = { + page: '#F8F6F0', + row: '#1E5F74', + rowTint: '#E4F3F7', + col: '#7B3F9E', + colTint: '#F3EEFA', + totals: '#5B2A86', + association: '#8A2540', + associationTint: '#FBE9ED', + noAssociation: '#27500A', + noAssociationTint: '#EAF3DE', + border: '#E0DDD6', + ink: '#1A1A2E', +} + +const scenarios = [ + { + name: 'Pets & Sport', + rowLabel: 'Pet ownership', + colLabel: 'Sport', + rows: ['Owns a pet', 'No pet'], + cols: ['Plays a sport', 'No sport'], + counts: [[4, 1], [1, 4]], + avatar: 'P', + }, + { + name: 'Homework & Grades', + rowLabel: 'Homework', + colLabel: 'Grade', + rows: ['Does homework', 'Skips homework'], + cols: ['Good grade', 'Poor grade'], + counts: [[5, 1], [1, 3]], + avatar: 'H', + }, + { + name: 'Breakfast & Focus', + rowLabel: 'Breakfast', + colLabel: 'Focus', + rows: ['Eats breakfast', 'Skips breakfast'], + cols: ['Focused in class', 'Not focused'], + counts: [[3, 2], [3, 2]], + avatar: 'B', + }, + { + name: 'Music & Study', + rowLabel: 'Study sound', + colLabel: 'Time', + rows: ['Studies with music', 'Studies in silence'], + cols: ['Finishes on time', 'Runs late'], + counts: [[2, 3], [4, 1]], + avatar: 'M', + }, +] + +function percent(part, whole) { + if (!whole) return 0 + return Math.round((part / whole) * 100) +} + +function emptyCounts() { + return [[0, 0], [0, 0]] +} + +function buildRespondents(scenario) { + const people = [] + scenario.counts.forEach((row, rowIndex) => { + row.forEach((count, colIndex) => { + for (let i = 0; i < count; i += 1) { + people.push({ + rowIndex, + colIndex, + rowTrait: scenario.rows[rowIndex], + colTrait: scenario.cols[colIndex], + }) + } + }) + }) + return people.sort((a, b) => (a.rowIndex * 3 + b.colIndex) - (b.rowIndex * 2 + a.colIndex)) +} + +function total(matrix) { + return matrix.flat().reduce((sum, value) => sum + value, 0) +} + +function rowTotal(matrix, rowIndex) { + return matrix[rowIndex][0] + matrix[rowIndex][1] +} + +function colTotal(matrix, colIndex) { + return matrix[0][colIndex] + matrix[1][colIndex] +} + +function resetSurvey(index) { + return { + scenarioIndex: index, + counts: emptyCounts(), + currentIndex: 0, + feedback: 'Find where their row and column meet.', + phase: 'build', + showPercents: false, + showHints: true, + } +} + +function CellButton({ children, highlighted, onClick }) { + return ( + + {children} + + ) +} + +function TraitPill({ children, color, tint }) { + return ( + + {children} + + ) +} + +function AnalysisBar({ label, part, whole, pct, delay }) { + return ( + + + {label} + {pct}% ({part}/{whole}) + + + 0 ? 32 : 0, + background: colors.row, + transitionDelay: `${delay}ms`, + }} + > + {pct}% + + + + ) +} + +export default function TwoWayTables() { + const [state, setState] = useState(() => resetSurvey(0)) + const scenario = scenarios[state.scenarioIndex] + const respondents = useMemo(() => buildRespondents(scenario), [scenario]) + const current = respondents[state.currentIndex] + const placed = total(state.counts) + const left = respondents.length - placed + const complete = placed === respondents.length + const firstRowTotal = rowTotal(state.counts, 0) + const secondRowTotal = rowTotal(state.counts, 1) + const firstPct = percent(state.counts[0][0], firstRowTotal) + const secondPct = percent(state.counts[1][0], secondRowTotal) + const gap = Math.abs(firstPct - secondPct) + const hasAssociation = gap >= 15 + + const pickCell = (rowIndex, colIndex) => { + if (!current || state.phase !== 'build') return + if (current.rowIndex !== rowIndex || current.colIndex !== colIndex) { + setState((old) => ({ + ...old, + feedback: `${current.rowTrait} and ${current.colTrait} belongs in the ${scenario.rows[current.rowIndex]} row and ${scenario.cols[current.colIndex]} column.`, + })) + return + } + setState((old) => { + const nextCounts = old.counts.map((row) => [...row]) + nextCounts[rowIndex][colIndex] += 1 + const nextPlaced = total(nextCounts) + return { + ...old, + counts: nextCounts, + currentIndex: old.currentIndex + 1, + feedback: nextPlaced === respondents.length ? 'All 10 people are sorted. Now compare row percentages.' : 'Correct. Pick the next cell.', + phase: nextPlaced === respondents.length ? 'analyse' : 'build', + } + }) + } + + const newSurvey = () => { + setState((old) => resetSurvey((old.scenarioIndex + 1) % scenarios.length)) + } + + const hint = state.phase === 'build' + ? 'Find where their row and column meet - that is their cell.' + : 'Do not compare raw counts. Compare the percentage within each row. Far apart = associated. Close = no link.' + + return ( + + + + + + Two-Way Tables + {scenario.name} · {placed}/10 sorted · {left} left + + + setState((old) => ({ ...old, showHints: !old.showHints }))} + className="rounded-full border px-3 py-1.5 text-xs font-black shadow-sm" + style={{ borderColor: colors.row, color: state.showHints ? '#ffffff' : colors.row, background: state.showHints ? colors.row : '#ffffff' }} + > + {state.showHints ? 'Hints on' : 'Hints off'} + + + New survey + + + + + + + + Current respondent + {current && state.phase === 'build' ? ( + + + {scenario.avatar} + Person {state.currentIndex + 1} + + {current.rowTrait} + {current.colTrait} + + ) : ( + + Table complete + + )} + + + + {state.feedback} + + + {state.showHints && ( + + {hint} + + )} + + + + + + + + {scenario.rowLabel} + + {scenario.cols.map((col) => ( + + {col} + + ))} + + Total + + + {scenario.rows.map((row, rowIndex) => ( + + + {row} + + {scenario.cols.map((col, colIndex) => { + const rowSum = rowTotal(state.counts, rowIndex) + const cellPct = percent(state.counts[rowIndex][colIndex], rowSum) + return ( + pickCell(rowIndex, colIndex)} + > + {state.counts[rowIndex][colIndex]} + {state.showPercents && state.phase === 'analyse' && {cellPct}% of row} + + ) + })} + + {rowTotal(state.counts, rowIndex)} + + + ))} + + Total + {[0, 1].map((colIndex) => ( + + {colTotal(state.counts, colIndex)} + + ))} + + {placed} + + + + + + {complete ? ( + + + + % of each row that {scenario.cols[0]} + setState((old) => ({ ...old, showPercents: !old.showPercents }))} + className="rounded-full border px-3 py-1 text-[11px] font-black" + style={{ borderColor: colors.col, color: colors.col, background: state.showPercents ? colors.colTint : '#ffffff' }} + > + Show % in table + + + + + + + + + + Verdict + {hasAssociation ? 'There IS an association' : 'No real association'} + + {hasAssociation + ? `The row percentages differ by ${gap} points. Knowing one variable helps predict the other.` + : `The row percentages differ by only ${gap} points. Knowing one does not tell you much about the other.`} + + + + ) : ( + + Build the table first. Every person belongs in exactly one cell. + + )} + + + + ) +} diff --git a/src/manipulatives/unit-rate-better-buy.jsx b/src/manipulatives/unit-rate-better-buy.jsx new file mode 100644 index 0000000..9f8fca7 --- /dev/null +++ b/src/manipulatives/unit-rate-better-buy.jsx @@ -0,0 +1,299 @@ +import { useMemo, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + muted: '#5F5E5A', + border: '#E0DDD6', + packA: '#2660C4', + packATint: '#EAF0FB', + packABorder: '#8AA8DD', + packB: '#1E7A5E', + packBTint: '#E9F5EF', + packBBorder: '#7FCBAC', + rate: '#7B3F9E', + rateTint: '#F3EEFA', + rateBorder: '#C99BE0', + wrong: '#B23050', + win: '#27500A', + winTint: '#EAF3DE', +} + +const rounds = [ + { item: 'Granola bar', icon: '🍫', a: { qty: 6, total: 3 }, b: { qty: 10, total: 4.5 } }, + { item: 'Juice box', icon: '🧃', a: { qty: 4, total: 3 }, b: { qty: 8, total: 6.4 } }, + { item: 'Pencil', icon: '✏️', a: { qty: 5, total: 2.5 }, b: { qty: 12, total: 5.4 } }, + { item: 'Notebook', icon: '📓', a: { qty: 3, total: 3.3 }, b: { qty: 7, total: 7 } }, + { item: 'Snack pack', icon: '🥨', a: { qty: 8, total: 4.8 }, b: { qty: 12, total: 7.8 } }, + { item: 'Water bottle', icon: '💧', a: { qty: 6, total: 4.2 }, b: { qty: 9, total: 6.75 } }, +] + +function money(value) { + return `$${value.toFixed(2)}` +} + +function unitRate(pack) { + return pack.total / pack.qty +} + +function cleanInput(raw) { + return raw.replace(/[$,\s]/g, '') +} + +function PackIcons({ count, icon, color, lifted }) { + return ( + + {Array.from({ length: count }).map((_, index) => ( + + {icon} + + ))} + + ) +} + +function RateInput({ packKey, color, value, onChange, onCheck }) { + return ( + { + event.preventDefault() + onCheck() + }} + > + $ + onChange(event.target.value)} + inputMode="decimal" + placeholder="0.00" + aria-label={`One costs for ${packKey}`} + className="min-w-0 rounded-xl border px-3 py-2 text-center font-mono text-xl font-black outline-none" + style={{ borderColor: colors.rateBorder, color: colors.rate }} + /> + + Check + + + ) +} + +function PackCard({ label, pack, item, icon, color, tint, border, input, revealed, status, chosen, winner, onInput, onCheck }) { + const rate = unitRate(pack) + const isWinner = chosen && winner + const isWrongChoice = chosen && !winner + + return ( + + + + + {label} + {item}s + + + {pack.qty} for {money(pack.total)} + + + + + + + {!revealed ? ( + <> + + One costs + + + + {status === 'wrong' ? ( + + {money(pack.total)} shared by {pack.qty} — try {money(pack.total)} ÷ {pack.qty}. + + ) : ( + Divide total price by quantity. + )} + + > + ) : ( + + + {money(rate)} each + + + {money(pack.total)} ÷ {pack.qty} = {money(rate)} + + + )} + + + {isWrongChoice && ( + + This pack costs more per item. + + )} + + ) +} + +export default function UnitRateExplorer() { + const [roundIndex, setRoundIndex] = useState(0) + const [inputs, setInputs] = useState(['', '']) + const [revealed, setRevealed] = useState([false, false]) + const [statuses, setStatuses] = useState([null, null]) + const [choice, setChoice] = useState(null) + + const round = rounds[roundIndex] + const packs = useMemo(() => [round.a, round.b], [round]) + const betterIndex = unitRate(packs[0]) <= unitRate(packs[1]) ? 0 : 1 + const bothRevealed = revealed.every(Boolean) + const hasChoice = choice !== null + + const resetForRound = (nextIndex) => { + setRoundIndex(nextIndex) + setInputs(['', '']) + setRevealed([false, false]) + setStatuses([null, null]) + setChoice(null) + } + + const nextRound = () => { + resetForRound((roundIndex + 1) % rounds.length) + } + + const checkRate = (index) => { + const guess = Number(cleanInput(inputs[index])) + const correct = unitRate(packs[index]) + if (Number.isFinite(guess) && Math.abs(guess - correct) <= 0.005) { + setRevealed((current) => current.map((value, itemIndex) => (itemIndex === index ? true : value))) + setStatuses((current) => current.map((value, itemIndex) => (itemIndex === index ? 'correct' : value))) + } else { + setStatuses((current) => current.map((value, itemIndex) => (itemIndex === index ? 'wrong' : value))) + } + } + + const choosePack = (index) => { + if (!bothRevealed) return + setChoice(index) + } + + const hint = (() => { + const aRate = unitRate(packs[0]) + const bRate = unitRate(packs[1]) + const winner = betterIndex === 0 ? 'Pack A' : 'Pack B' + if (hasChoice) { + return `${money(packs[0].total)} ÷ ${packs[0].qty} = ${money(aRate)} and ${money(packs[1].total)} ÷ ${packs[1].qty} = ${money(bRate)}. ${winner} is cheaper per one.` + } + if (bothRevealed) return 'Both packs are now priced per one. Lower per-one wins.' + return `You can't compare ${packs[0].qty} for ${money(packs[0].total)} against ${packs[1].qty} for ${money(packs[1].total)} directly. Divide each total by its quantity.` + })() + + return ( + + + + + Unit Rate — Better Buy + Find the price for one item in each pack, then choose the lower price per item. + + + + setInputs((current) => current.map((item, index) => (index === 0 ? value : item)))} + onCheck={() => checkRate(0)} + /> + setInputs((current) => current.map((item, index) => (index === 1 ? value : item)))} + onCheck={() => checkRate(1)} + /> + + + + {!bothRevealed && ( + Find both unit prices to unlock the better-buy choice. + )} + + {bothRevealed && !hasChoice && ( + + choosePack(0)} className="rounded-xl px-3 py-2 text-base font-black text-white" style={{ background: colors.packA }}> + Pack A is cheaper + + choosePack(1)} className="rounded-xl px-3 py-2 text-base font-black text-white" style={{ background: colors.packB }}> + Pack B is cheaper + + + )} + + {hasChoice && ( + + {choice === betterIndex + ? `${betterIndex === 0 ? 'Pack A' : 'Pack B'} wins: fewer dollars per item.` + : `${betterIndex === 0 ? 'Pack A' : 'Pack B'} is actually cheaper per item.`} + + )} + + + + {hint} + + New packs + + + + ) +} diff --git a/src/manipulatives/volume-prisms.jsx b/src/manipulatives/volume-prisms.jsx new file mode 100644 index 0000000..9c71c7e --- /dev/null +++ b/src/manipulatives/volume-prisms.jsx @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +const colors = { + page: '#F8F6F0', + ink: '#1A1A2E', + lengthFill: '#FBEEDD', + lengthBorder: '#D99A4E', + lengthAccent: '#B5651D', + lengthText: '#8A4A12', + widthFill: '#E9F2E4', + widthBorder: '#7AAE5C', + widthAccent: '#5E8C3E', + widthText: '#456B2E', + heightFill: '#E6EEF6', + heightBorder: '#5B84B8', + heightAccent: '#3E6BA8', + heightText: '#33547E', + volume: '#7B3F9E', + cubeTop: '#E8A94E', + cubeLeft: '#C97B2E', + cubeRight: '#A85F1E', + cubeEdge: '#6D3B12', + border: '#E0DDD6', +} + +const canvasHeight = 300 + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} + +function easeOutBack(t) { + const c1 = 1.15 + const c3 = c1 + 1 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) +} + +function StepperCard({ label, value, min, max, fill, border, accent, text, onChange }) { + const change = (delta) => onChange(clamp(value + delta, min, max)) + return ( + + {label} + + change(-1)} className="flex h-9 w-9 items-center justify-center rounded-full text-xl font-black text-white" style={{ backgroundColor: accent }}>− + {value} + change(1)} className="flex h-9 w-9 items-center justify-center rounded-full text-xl font-black text-white" style={{ backgroundColor: accent }}>+ + + + ) +} + +function projectPoint(x, y, z, originX, originY, unit) { + const xVec = { x: unit * 0.86, y: unit * 0.48 } + const yVec = { x: -unit * 0.86, y: unit * 0.48 } + const zVec = { x: 0, y: -unit } + return { + x: originX + x * xVec.x + y * yVec.x + z * zVec.x, + y: originY + x * xVec.y + y * yVec.y + z * zVec.y, + } +} + +function cubePoints(x, y, z, originX, originY, unit) { + const p = (px, py, pz) => projectPoint(px, py, pz, originX, originY, unit) + return { + top: [p(x, y, z + 1), p(x + 1, y, z + 1), p(x + 1, y + 1, z + 1), p(x, y + 1, z + 1)], + left: [p(x, y + 1, z), p(x, y + 1, z + 1), p(x + 1, y + 1, z + 1), p(x + 1, y + 1, z)], + right: [p(x + 1, y, z), p(x + 1, y, z + 1), p(x + 1, y + 1, z + 1), p(x + 1, y + 1, z)], + } +} + +function drawFace(ctx, points, fill, alpha = 1, dashed = false) { + ctx.save() + ctx.globalAlpha *= alpha + ctx.fillStyle = fill + ctx.strokeStyle = colors.cubeEdge + ctx.lineWidth = 1 + if (dashed) ctx.setLineDash([4, 5]) + ctx.beginPath() + points.forEach((point, index) => { + if (index === 0) ctx.moveTo(point.x, point.y) + else ctx.lineTo(point.x, point.y) + }) + ctx.closePath() + if (!dashed) ctx.fill() + ctx.stroke() + ctx.restore() +} + +function getUnit(width, length, prismWidth, height) { + const xSpan = (length + prismWidth) * 0.86 + const ySpan = (length + prismWidth) * 0.48 + height + return Math.min(34, (width - 70) / Math.max(1, xSpan), (canvasHeight - 58) / Math.max(1, ySpan)) +} + +function getOrigin(width, length, prismWidth, height, unit) { + const corners = [ + [0, 0, 0], + [length, 0, 0], + [0, prismWidth, 0], + [length, prismWidth, 0], + [0, 0, height], + [length, 0, height], + [0, prismWidth, height], + [length, prismWidth, height], + ].map(([x, y, z]) => projectPoint(x, y, z, 0, 0, unit)) + const minX = Math.min(...corners.map((point) => point.x)) + const maxX = Math.max(...corners.map((point) => point.x)) + const minY = Math.min(...corners.map((point) => point.y)) + const maxY = Math.max(...corners.map((point) => point.y)) + return { + x: width / 2 - (minX + maxX) / 2, + y: canvasHeight / 2 + 12 - (minY + maxY) / 2, + } +} + +function drawCube(ctx, cube, origin, unit, alpha = 1, dashed = false) { + const points = cubePoints(cube.x, cube.y, cube.z, origin.x, origin.y, unit) + drawFace(ctx, points.left, colors.cubeLeft, alpha, dashed) + drawFace(ctx, points.right, colors.cubeRight, alpha, dashed) + drawFace(ctx, points.top, colors.cubeTop, alpha, dashed) +} + +function drawDimensionGuide(ctx, start, end, color, label, labelOffset = { x: 0, y: 0 }) { + const mid = { + x: (start.x + end.x) / 2 + labelOffset.x, + y: (start.y + end.y) / 2 + labelOffset.y, + } + + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 8 + ctx.beginPath() + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + ctx.stroke() + + ctx.strokeStyle = color + ctx.lineWidth = 4 + ctx.beginPath() + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + ctx.stroke() + + ctx.fillStyle = color + ;[start, end].forEach((point) => { + ctx.beginPath() + ctx.arc(point.x, point.y, 4.5, 0, Math.PI * 2) + ctx.fill() + }) + + ctx.font = '800 12px Inter, system-ui, sans-serif' + const metrics = ctx.measureText(label) + const pillW = metrics.width + 16 + const pillH = 22 + drawRoundRect(ctx, mid.x - pillW / 2, mid.y - pillH / 2, pillW, pillH, 11) + ctx.fillStyle = '#ffffffee' + ctx.fill() + ctx.strokeStyle = color + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.fillStyle = color + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, mid.x, mid.y + 0.5) + ctx.restore() +} + +function drawDimensionGuides(ctx, origin, unit, length, prismWidth, height) { + const frontLeftBottom = projectPoint(0, prismWidth, 0, origin.x, origin.y, unit) + const frontRightBottom = projectPoint(length, prismWidth, 0, origin.x, origin.y, unit) + const backRightBottom = projectPoint(length, 0, 0, origin.x, origin.y, unit) + const frontLeftTop = projectPoint(0, prismWidth, height, origin.x, origin.y, unit) + + drawDimensionGuide(ctx, frontLeftBottom, frontRightBottom, colors.lengthAccent, `length ${length}`, { x: 0, y: 18 }) + drawDimensionGuide(ctx, backRightBottom, frontRightBottom, colors.widthAccent, `width ${prismWidth}`, { x: 18, y: 14 }) + drawDimensionGuide(ctx, frontLeftBottom, frontLeftTop, colors.heightAccent, `height ${height}`, { x: -42, y: -2 }) +} + +function formatCount(layer, baseArea, volume) { + if (layer <= 0) return '0 layers × 0 cubes = 0 cubes so far' + if (layer >= volume.layers) return `${volume.layers} layers × ${baseArea} cubes = ${volume.total} cubes total` + return `${layer} layers × ${baseArea} cubes = ${layer * baseArea} cubes so far` +} + +export default function VolumePrisms() { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const frameRef = useRef(null) + const [canvasWidth, setCanvasWidth] = useState(760) + const [length, setLength] = useState(4) + const [width, setWidth] = useState(3) + const [height, setHeight] = useState(3) + const [filledLayers, setFilledLayers] = useState(0) + const [currentLayer, setCurrentLayer] = useState(null) + const [layerProgress, setLayerProgress] = useState(0) + const [playing, setPlaying] = useState(false) + + const baseArea = length * width + const totalVolume = baseArea * height + const isComplete = filledLayers >= height + + const cubes = useMemo(() => { + const result = [] + for (let z = 0; z < height; z += 1) { + for (let y = width - 1; y >= 0; y -= 1) { + for (let x = 0; x < length; x += 1) { + result.push({ x, y, z, depth: x + y + z * 2 }) + } + } + } + return result.sort((a, b) => a.depth - b.depth) + }, [height, length, width]) + + const stopAnimation = useCallback(() => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + frameRef.current = null + setPlaying(false) + setCurrentLayer(null) + setLayerProgress(0) + }, []) + + const resetFill = useCallback(() => { + stopAnimation() + setFilledLayers(0) + }, [stopAnimation]) + + const updateLength = (next) => { + resetFill() + setLength(next) + } + + const updateWidth = (next) => { + resetFill() + setWidth(next) + } + + const updateHeight = (next) => { + resetFill() + setHeight(next) + } + + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const dpr = window.devicePixelRatio || 1 + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + canvas.style.width = `${canvasWidth}px` + canvas.style.height = `${canvasHeight}px` + const ctx = canvas.getContext('2d') + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, canvasWidth, canvasHeight) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + const unit = getUnit(canvasWidth, length, width, height) + const origin = getOrigin(canvasWidth, length, width, height, unit) + const activeLayer = currentLayer ?? filledLayers + const pop = currentLayer === null ? 1 : easeOutBack(layerProgress) + + cubes.forEach((cube) => { + if (cube.z < filledLayers) { + drawCube(ctx, cube, origin, unit, 1) + } else if (cube.z === activeLayer && currentLayer !== null) { + const center = projectPoint(cube.x + 0.5, cube.y + 0.5, cube.z + 0.5, origin.x, origin.y, unit) + ctx.save() + ctx.translate(center.x, center.y) + ctx.scale(0.82 + Math.min(1, pop) * 0.18, 0.82 + Math.min(1, pop) * 0.18) + ctx.translate(-center.x, -center.y) + drawCube(ctx, cube, origin, unit, Math.min(1, layerProgress + 0.15)) + ctx.restore() + } else { + drawCube(ctx, cube, origin, unit, 0.18, true) + } + }) + + if (filledLayers >= height && currentLayer === null) { + drawDimensionGuides(ctx, origin, unit, length, width, height) + } + + if (filledLayers === 0 && currentLayer === null) { + ctx.fillStyle = '#6B7280' + ctx.font = '700 14px Inter, system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.fillText('press Fill layer by layer to count the cubes', canvasWidth / 2, 28) + } + }, [canvasWidth, cubes, currentLayer, filledLayers, height, layerProgress, length, width]) + + useEffect(() => { + const node = wrapRef.current + if (!node) return undefined + const update = () => setCanvasWidth(Math.max(320, Math.round(node.getBoundingClientRect().width))) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + draw() + }, [draw]) + + useEffect(() => () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current) + }, []) + + const fillLayers = () => { + stopAnimation() + setFilledLayers(0) + setPlaying(true) + let layer = 0 + let startedAt = performance.now() + const duration = 350 + const tick = (now) => { + const progress = Math.min(1, (now - startedAt) / duration) + setCurrentLayer(layer) + setLayerProgress(progress) + if (progress < 1) { + frameRef.current = requestAnimationFrame(tick) + return + } + setFilledLayers(layer + 1) + if (layer + 1 >= height) { + setCurrentLayer(null) + setLayerProgress(0) + setPlaying(false) + frameRef.current = null + return + } + layer += 1 + startedAt = performance.now() + frameRef.current = requestAnimationFrame(tick) + } + frameRef.current = requestAnimationFrame(tick) + } + + return ( + + + + + + + + + + + {formatCount(currentLayer === null ? filledLayers : currentLayer + 1, baseArea, { layers: height, total: totalVolume })} + + + + + + + V = {length} × {width} × {height} = {totalVolume} + + + base area ({length}×{width} = {baseArea}) × height ({height}) + + + + Fill layer by layer + + + Reset + + + + + Each layer is the base — {length} × {width} = {baseArea} cubes. Stack {height} identical layers to get {totalVolume} cubes. + + + ) +} diff --git a/vite.config.js b/vite.config.js index 0616e59..630137b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,5 +3,6 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ + base: '/manipulativeSandbox/', plugins: [react(), tailwindcss()], })
Manipulatives
+ {instructionTitle}: + {instructionText} +
{label}
{formatNumber(value)}
All pictures revealed!
You plotted every coordinate in order.
{title}
{value}
Working
The distributive property
+ = +
x
unknown width
+ {hint} +
+ {view === 'set' ? 'Set the start and end times first, then show the elapsed-time clock.' : hint} +
Pick a number
Factors
{factors.join(', ')}
How many
{factors.length} factors · {pairs.length} pairs
Type
Secret rule
Clues
{rows.length} unique {rows.length === 1 ? 'input' : 'inputs'}
Crack the rule
Result
{resultVisible ? result : '?'}
+ {readingLine(mode, a, cleanB)} +
Watch the pattern: same step each row
+ Tap a face to highlight it. +
{name}
{calc}
= {area}
+ Matching faces come in 3 pairs: top-bottom, front-back, left-right. +
+ The sign chooses the direction the car faces. The car may drive forward or backward to show the result. +
Well done — every part of your park is exactly right.
Each interior angle
+ + {sum}° + + ÷ + + {sides} + + = + + {formatAngleValue(regularAngle)}° + +
+ Sides (N) +
{sides}
Triangles
{triangleCount}
Formula
+ (N−2)×180° +
({sides}−2)×180° = {sum}°
Angle sum
{sum}°
+ {title} +
+ {value} +
{hint}
+ {step.operation} {step.equation} +
+ {step.note} +
Set the value of {variable}
+ {equationText(line)} +
Verify in both equations
+ Line 1: {formatNumber(line1.m)}({x}){signed(line1.b)} = {y1} +
+ Line 2: {formatNumber(line2.m)}({x}){signed(line2.b)} = {y2} +
✓ Both give y = {y1}
{scenario.name} · {placed}/10 sorted · {left} left
Current respondent
Verdict
+ {hasAssociation + ? `The row percentages differ by ${gap} points. Knowing one variable helps predict the other.` + : `The row percentages differ by only ${gap} points. Knowing one does not tell you much about the other.`} +
+ {pack.qty} for {money(pack.total)} +
+ {money(pack.total)} ÷ {pack.qty} = {money(rate)} +
+ This pack costs more per item. +
Unit Rate — Better Buy
Find the price for one item in each pack, then choose the lower price per item.
+ V = {length} × {width} × {height} = {totalVolume} +
+ base area ({length}×{width} = {baseArea}) × height ({height}) +
+ Each layer is the base — {length} × {width} = {baseArea} cubes. Stack {height} identical layers to get {totalVolume} cubes. +