'use strict'; /* ══════════════════════════════════════════════════════════════ SimUtil — shared utility functions for lab simulations. Loaded once before all sim scripts. ══════════════════════════════════════════════════════════════ */ const SimUtil = (() => { /** * DPR-aware canvas resize. Sets canvas pixel size and applies * `setTransform` so subsequent drawing is in CSS-pixel coords. * Returns { W, H, dpr }. */ function fitCanvas(canvas, ctx) { const dpr = window.devicePixelRatio || 1; const w = canvas.offsetWidth || canvas.parentElement?.offsetWidth || 600; const h = canvas.offsetHeight || canvas.parentElement?.offsetHeight || 400; canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { W: w, H: h, dpr }; } /** * Compute a "nice" grid step for the given pixel range and target ~n divisions. */ function niceStep(rangePx, scale, n) { if (!n) n = 8; const raw = rangePx / scale / n; const p = Math.pow(10, Math.floor(Math.log10(raw))); for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; return p; } /** * Format number for grid/axis labels. */ function fmt(n, step) { if (n === 0) return '0'; if (step >= 1 && Number.isInteger(n)) return String(n); if (step < 0.001) return n.toExponential(1); const dec = Math.max(0, -Math.floor(Math.log10(step))); return n.toFixed(dec); } /** * Draw an arrow from (x1,y1) to (x2,y2). */ function arrow(ctx, x1, y1, x2, y2, color, lw) { const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); if (len < 1) return; const ux = dx / len, uy = dy / len; const hs = Math.min(10, len * 0.3); // head size ctx.save(); ctx.strokeStyle = color || '#fff'; ctx.fillStyle = color || '#fff'; ctx.lineWidth = lw || 2; ctx.lineCap = 'round'; // shaft ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - ux * hs * 0.5, y2 - uy * hs * 0.5); ctx.stroke(); // head ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - ux * hs - uy * hs * 0.4, y2 - uy * hs + ux * hs * 0.4); ctx.lineTo(x2 - ux * hs + uy * hs * 0.4, y2 - uy * hs - ux * hs * 0.4); ctx.closePath(); ctx.fill(); ctx.restore(); } /** * Draw a coordinate grid with labels. * @param {object} opts — { ox, oy, scl, font, gridColor, labelColor } */ function drawGrid(ctx, W, H, opts) { const { ox = 0, oy = 0, scl = 50, font, gridColor, labelColor } = opts || {}; const step = niceStep(W, scl); // math pixel helpers const toPx = (mx, my) => [W / 2 + (mx - ox) * scl, H / 2 - (my - oy) * scl]; const toMx = (px) => (px - W / 2) / scl + ox; const toMy = (py) => -(py - H / 2) / scl + oy; const x0 = toMx(0), x1 = toMx(W); const y0 = toMy(H), y1 = toMy(0); const gx = Math.floor(x0 / step) * step; const gy = Math.floor(y0 / step) * step; // grid lines ctx.strokeStyle = gridColor || 'rgba(255,255,255,0.065)'; ctx.lineWidth = 1; for (let x = gx; x <= x1 + step; x += step) { const [px] = toPx(x, 0); ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); } for (let y = gy; y <= y1 + step; y += step) { const [, py] = toPx(0, y); ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); } // labels ctx.font = font || '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = labelColor || 'rgba(255,255,255,0.3)'; const [axX, axY] = toPx(0, 0); const lblY = Math.max(4, Math.min(H - 18, axY + 5)); const lblX = Math.max(28, Math.min(W - 6, axX - 5)); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let x = gx; x <= x1; x += step) { if (Math.abs(x) < step * 0.01) continue; const [px] = toPx(x, 0); if (px < 18 || px > W - 18) continue; ctx.fillText(fmt(x, step), px, lblY); } ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let y = gy; y <= y1; y += step) { if (Math.abs(y) < step * 0.01) continue; const [, py] = toPx(0, y); if (py < 12 || py > H - 12) continue; ctx.fillText(fmt(y, step), lblX, py); } // axes ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, axY); ctx.lineTo(W - 10, axY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(axX, H); ctx.lineTo(axX, 8); ctx.stroke(); // arrowheads ctx.fillStyle = 'rgba(255,255,255,0.4)'; _arrowHead(ctx, W - 8, axY, 0); _arrowHead(ctx, axX, 6, -Math.PI / 2); // axis labels ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = 'bold 12px Manrope, sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; ctx.fillText('x', W - 10, axY - 13); ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText('y', axX + 7, 4); } function _arrowHead(ctx, x, y, angle) { const s = 5; ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore(); } /** * Draw a tooltip panel at (x, y). * @param {string[]} rows — lines of text * @param {object} opts — { bg, fg, font, padding, radius } */ function tooltip(ctx, x, y, rows, opts) { const { bg = 'rgba(22,22,38,0.92)', fg = '#ddd', font = '12px Manrope, sans-serif', padding = 8, radius = 8 } = opts || {}; ctx.save(); ctx.font = font; let maxW = 0; for (const r of rows) maxW = Math.max(maxW, ctx.measureText(r).width); const w = maxW + padding * 2; const lineH = 17; const h = rows.length * lineH + padding * 2; const tx = x + 14, ty = y - h / 2; ctx.fillStyle = bg; ctx.beginPath(); ctx.roundRect(tx, ty, w, h, radius); ctx.fill(); ctx.fillStyle = fg; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; for (let i = 0; i < rows.length; i++) { ctx.fillText(rows[i], tx + padding, ty + padding + i * lineH); } ctx.restore(); } return { fitCanvas, niceStep, fmt, arrow, drawGrid, tooltip }; })();