be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
6.5 KiB
JavaScript
192 lines
6.5 KiB
JavaScript
'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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 };
|
|
})();
|