import { renderLatex } from "@ofb/katex"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import { computeRoots, discriminant, formatNum } from "../../../../frontend/src/lib/math-graph"; const HORIZONTAL_SPEED = 12; function SliderRow(props: { id: string; label: string; value: number; min: number; max: number; step: number; unit?: string; disabled?: boolean; onInput: (v: number) => void; }) { const display = () => props.unit ? `${formatNum(props.value)} ${props.unit}` : formatNum(props.value); return (
props.onInput(Number(e.currentTarget.value))} />
); } export function ProjectileDemo() { const [v0y, setV0y] = createSignal(20); const [g, setG] = createSignal(10); const [h0, setH0] = createSignal(0); const [t, setT] = createSignal(0); const [playing, setPlaying] = createSignal(false); let canvasRef: HTMLCanvasElement | undefined; let rafId: number | undefined; let playStart = 0; const heightAt = (time: number) => h0() + v0y() * time - 0.5 * g() * time * time; const distanceAt = (time: number) => HORIZONTAL_SPEED * time; const landingTime = createMemo(() => { const a = -0.5 * g(); const d = discriminant(a, v0y(), h0()); const roots = computeRoots(a, v0y(), h0(), d); const positive = roots.filter((r) => r > 0.01); if (positive.length > 0) return Math.max(...positive); return Math.max(2, (2 * v0y()) / g()); }); const maxX = createMemo(() => distanceAt(landingTime()) * 1.1 + 5); const maxY = createMemo(() => { const peak = h0() + (v0y() * v0y()) / (2 * g()); return Math.max(peak * 1.15, h0() + 2, 5); }); const drawScene = (time: number) => { const canvas = canvasRef; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const w = 400; const h = 240; const pad = { l: 36, r: 12, t: 12, b: 28 }; const plotW = w - pad.l - pad.r; const plotH = h - pad.t - pad.b; const dpr = window.devicePixelRatio || 1; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const mx = maxX(); const my = maxY(); const toX = (x: number) => pad.l + (x / mx) * plotW; const toY = (y: number) => pad.t + plotH - (y / my) * plotH; ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff"; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5"; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = (my * i) / 4; const py = toY(y); ctx.beginPath(); ctx.moveTo(pad.l, py); ctx.lineTo(w - pad.r, py); ctx.stroke(); } ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e"; ctx.lineWidth = 2; const groundY = toY(0); ctx.beginPath(); ctx.moveTo(pad.l, groundY); ctx.lineTo(w - pad.r, groundY); ctx.stroke(); ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e"; ctx.lineWidth = 2.5; ctx.beginPath(); const steps = 80; const tEnd = landingTime(); for (let i = 0; i <= steps; i++) { const ti = (tEnd * i) / steps; const px = toX(distanceAt(ti)); const py = toY(Math.max(0, heightAt(ti))); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); const bx = distanceAt(time); const by = heightAt(time); const cx = toX(bx); const cy = toY(Math.max(0, by)); ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e"; ctx.beginPath(); ctx.arc(cx, cy, 8, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676"; ctx.font = "11px IBM Plex Mono, monospace"; ctx.fillText("x (m)", w / 2 - 12, h - 6); ctx.save(); ctx.translate(10, h / 2); ctx.rotate(-Math.PI / 2); ctx.fillText("h (m)", 0, 0); ctx.restore(); }; const stopPlayback = () => { if (rafId !== undefined) { cancelAnimationFrame(rafId); rafId = undefined; } setPlaying(false); }; const startPlayback = () => { stopPlayback(); setT(0); setPlaying(true); playStart = performance.now(); const tick = (now: number) => { const elapsed = (now - playStart) / 1000; const tEnd = landingTime(); if (elapsed >= tEnd) { setT(tEnd); drawScene(tEnd); stopPlayback(); return; } setT(elapsed); drawScene(elapsed); rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); }; createEffect(() => { v0y(); g(); h0(); t(); if (!playing()) drawScene(t()); }); onCleanup(() => stopPlayback()); return (
{ canvasRef = el; drawScene(0); }} class="projectile-scenario__canvas" width={400} height={240} role="img" aria-label="Side view of projectile path with horizontal and vertical position" />

t = {formatNum(t())} s · x = {formatNum(distanceAt(t()))} m · h ={" "} {formatNum(Math.max(0, heightAt(t())))} m

Horizontal speed is fixed at {HORIZONTAL_SPEED} m/s. Height follows \(h = h_0 + v_0 t - \frac{1}{2}gt^2\).

); } const PROFIT_PRICE_MIN = 5; const PROFIT_PRICE_MAX = 20; /** Π(p) = -2p² + 40p - 150 with Q(p) = 40 - 2p and fixed cost C = $150 */ const PROFIT_OPTIMAL_PRICE = 10; const PROFIT_FIXED_COST = 150; const demandQuantity = (price: number) => Math.max(0, 40 - 2 * price); const demandPrice = (quantity: number) => Math.max(0, (40 - quantity) / 2); const profitAtPrice = (price: number) => -2 * price * price + 40 * price - 150; const revenueAtQ = (q: number) => q * demandPrice(q); const costAtQ = (_q: number) => PROFIT_FIXED_COST; const profitAtQ = (q: number) => revenueAtQ(q) - costAtQ(q); function drawProfitChart( canvas: HTMLCanvasElement, price: number, quantity: number, revenue: number, cost: number, profit: number ) { const ctx = canvas.getContext("2d"); if (!ctx) return; const w = 400; const h = 240; const dpr = window.devicePixelRatio || 1; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const bg = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff"; ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); const pad = { l: 44, r: 44, t: 20, b: 40 }; const plotW = w - pad.l - pad.r; const plotH = h - pad.t - pad.b; const qMax = 42; const pMax = 22; let dollarMin = -120; let dollarMax = 420; for (let q = 0; q <= 40; q += 1) { dollarMin = Math.min(dollarMin, profitAtQ(q), revenueAtQ(q)); dollarMax = Math.max(dollarMax, profitAtQ(q), revenueAtQ(q), costAtQ(q)); } const dollarSpan = dollarMax - dollarMin || 1; const toX = (q: number) => pad.l + (q / qMax) * plotW; const toPriceY = (p: number) => pad.t + plotH - (p / pMax) * plotH; const toDollarY = (d: number) => pad.t + plotH - ((d - dollarMin) / dollarSpan) * plotH; const grid = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5"; const axis = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e"; const ink = getComputedStyle(canvas).getPropertyValue("--ink").trim() || "#1a1a1a"; const muted = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676"; ctx.strokeStyle = grid; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const py = pad.t + (plotH * i) / 4; ctx.beginPath(); ctx.moveTo(pad.l, py); ctx.lineTo(w - pad.r, py); ctx.stroke(); } ctx.strokeStyle = axis; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, h - pad.b); ctx.lineTo(w - pad.r, h - pad.b); ctx.stroke(); const drawCurve = ( fn: (q: number) => number, toY: (v: number) => number, color: string, width = 2, dash: number[] = [] ) => { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash); ctx.beginPath(); for (let i = 0; i <= 80; i++) { const q = (40 * i) / 80; const px = toX(q); const py = toY(fn(q)); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); ctx.setLineDash([]); }; drawCurve(demandPrice, toPriceY, getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", 2.5); drawCurve(revenueAtQ, toDollarY, "#4a6b4a", 2); drawCurve(costAtQ, toDollarY, "#a8a29e", 2); drawCurve( profitAtQ, toDollarY, getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e", 2.5 ); const qOpt = demandQuantity(PROFIT_OPTIMAL_PRICE); const pxOpt = toX(qOpt); const pyOptP = toPriceY(PROFIT_OPTIMAL_PRICE); const pyOptPi = toDollarY(profitAtQ(qOpt)); ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--success").trim() || "#2a7d52"; ctx.beginPath(); ctx.arc(pxOpt, pyOptP, 5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(pxOpt, pyOptPi, 5, 0, Math.PI * 2); ctx.fill(); const qx = toX(quantity); const py = toPriceY(price); const pRev = toDollarY(revenue); const pCost = toDollarY(cost); const pProfit = toDollarY(profit); ctx.strokeStyle = muted; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(qx, pad.t); ctx.lineTo(qx, h - pad.b); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb"; ctx.beginPath(); ctx.arc(qx, py, 7, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#4a6b4a"; ctx.beginPath(); ctx.arc(qx, pRev, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#a8a29e"; ctx.beginPath(); ctx.arc(qx, pCost, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e"; ctx.beginPath(); ctx.arc(qx, pProfit, 7, 0, Math.PI * 2); ctx.fill(); ctx.font = "10px IBM Plex Mono, monospace"; ctx.fillStyle = ink; ctx.textAlign = "center"; ctx.fillText("Quantity Q (books)", w / 2, h - 6); ctx.save(); ctx.translate(12, h / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillText("Price P ($)", 0, 0); ctx.restore(); ctx.save(); ctx.translate(w - 10, h / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillStyle = muted; ctx.fillText("$ (R, C, Π)", 0, 0); ctx.restore(); ctx.font = "9px IBM Plex Mono, monospace"; ctx.textAlign = "left"; const legendX = pad.l + 6; let legendY = pad.t + 6; const legend = [ { color: getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", label: "Demand" }, { color: "#4a6b4a", label: "Revenue" }, { color: "#a8a29e", label: "Cost" }, { color: getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e", label: "Profit Π" }, ]; for (const item of legend) { ctx.fillStyle = item.color; ctx.fillRect(legendX, legendY, 10, 3); ctx.fillStyle = ink; ctx.fillText(item.label, legendX + 14, legendY + 4); legendY += 14; } } export function ProfitDemo() { const [price, setPrice] = createSignal(10); let canvasRef: HTMLCanvasElement | undefined; const quantity = createMemo(() => demandQuantity(price())); const revenue = createMemo(() => price() * quantity()); const cost = createMemo(() => costAtQ(quantity())); const profit = createMemo(() => revenue() - cost()); const atOptimum = createMemo(() => Math.abs(price() - PROFIT_OPTIMAL_PRICE) < 0.26); const formulaHtml = createMemo(() => renderLatex(`\\Pi = -2p^2 + 40p - 150`)); const redraw = () => { if (!canvasRef) return; drawProfitChart( canvasRef, price(), quantity(), revenue(), cost(), profit() ); }; createEffect(() => { price(); quantity(); revenue(); cost(); profit(); redraw(); }); return (

A textbook shop sets price ${formatNum(price())}. Higher price means lower demand: {formatNum(quantity())} books sold (demand curve). Revenue, cost, and profit Π are read off the curves at that quantity.

{ canvasRef = el; redraw(); }} class="profit-scenario__canvas" width={400} height={240} role="img" aria-label="Demand, revenue, cost, and profit curves with quantity on the horizontal axis" />

Profit model

Books sold {formatNum(quantity())}
Revenue ${formatNum(revenue())}
Cost ${formatNum(cost())}
Profit Π ${formatNum(profit())}

{atOptimum() ? `Maximum profit at P = $${formatNum(PROFIT_OPTIMAL_PRICE)} (Q = ${formatNum(demandQuantity(PROFIT_OPTIMAL_PRICE))}, Π = $${formatNum(profitAtPrice(PROFIT_OPTIMAL_PRICE))}).` : "Profit is positive between break-even prices P = $5 and P = $15. Drag toward the peak of the gold curve."}

Blue: demand (P vs Q). Green, gray, gold: revenue, cost, and profit in dollars (right scale).

); } export function AreaDemo() { const [perimeter, setPerimeter] = createSignal(20); const [widthUnits, setWidthUnits] = createSignal(5); const [dragging, setDragging] = createSignal(false); let gridRef: HTMLDivElement | undefined; const halfP = createMemo(() => Math.round(perimeter() / 2)); const lengthUnits = createMemo(() => halfP() - widthUnits()); const area = createMemo(() => widthUnits() * lengthUnits()); const maxArea = createMemo(() => { const h = halfP(); let best = 0; for (let w = 1; w < h; w += 1) { best = Math.max(best, w * (h - w)); } return best; }); const isMax = createMemo(() => area() === maxArea()); const clampWidth = (w: number, h: number) => Math.max(1, Math.min(h - 1, Math.round(w))); const widthFromPointer = (clientX: number) => { const grid = gridRef; if (!grid) return widthUnits(); const h = halfP(); const bounds = grid.getBoundingClientRect(); const cell = bounds.width / h; const col = Math.floor((clientX - bounds.left) / cell) + 1; return clampWidth(col, h); }; const onCornerPointerDown = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); setDragging(true); setWidthUnits(widthFromPointer(e.clientX)); const onMove = (ev: PointerEvent) => setWidthUnits(widthFromPointer(ev.clientX)); const onUp = () => { setDragging(false); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; const changePerimeter = (p: number) => { const newHalf = Math.round(p / 2); const oldHalf = halfP(); const ratio = oldHalf > 0 ? widthUnits() / oldHalf : 0.5; setPerimeter(p); setWidthUnits(clampWidth(Math.round(ratio * newHalf), newHalf)); }; createEffect(() => { const h = halfP(); const clamped = clampWidth(widthUnits(), h); if (widthUnits() !== clamped) setWidthUnits(clamped); }); return (

Each square is 1 m. Perimeter P = {perimeter()} m (fixed while resizing). Drag the corner — snaps to whole metres.

w = {widthUnits()} m ℓ = {lengthUnits()} m

2w + 2ℓ = {perimeter()} m · w + ℓ = {halfP()} m

Area model

Area A {area()} m²
Largest possible {maxArea()} m²

{isMax() ? `Maximum area on this grid — ${widthUnits()} m × ${lengthUnits()} m (A = ${area()} m²).` : "\u00a0"}

); }