Files
Learn_System/frontend/js/labs/_util.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

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 };
})();