Files
Learn_System/frontend/js/labs/graph.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

494 lines
16 KiB
JavaScript

'use strict';
/* ═══════════════════════════════════════════════
GraphSim — interactive function plotter
Usage:
const sim = new GraphSim(canvasElement);
sim.setFn(0, 'sin(x)', '#9B5DE5');
sim.onHover = (mx, yVals) => { ... };
═══════════════════════════════════════════════ */
class GraphSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.ox = 0; // viewport centre x (math units)
this.oy = 0; // viewport centre y (math units)
this.scl = 50; // px per unit
this.fns = []; // [{ color, fn } | null]
this.hx = null; // hovered x (math) or null
this._dg = null; // drag state
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); })
.observe(canvas.parentElement);
}
/* ── public ────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const r = this.c.parentElement.getBoundingClientRect();
const w = r.width || 600, h = r.height || 400;
this.c.width = w * dpr;
this.c.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._cw = w; this._ch = h;
}
/** idx 0-2, expr string, color hex. Returns error string or null. */
setFn(idx, expr, color) {
if (!expr || !expr.trim()) {
this.fns[idx] = null;
this.draw();
return null;
}
try {
const fn = this._compile(expr);
fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test
this.fns[idx] = { color, fn };
this.draw();
return null;
} catch {
this.fns[idx] = null;
this.draw();
return 'Синтаксическая ошибка';
}
}
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
/* ── formula compiler (CSP-safe: no eval / new Function) ── */
_compile(raw) {
const tokens = this._tokenize(raw.trim());
const expanded = this._insertImplicit(tokens);
return this._parseExpr(expanded);
}
_tokenize(src) {
const out = [];
let i = 0;
while (i < src.length) {
const ch = src[i];
if (/\s/.test(ch)) { i++; continue; }
/* number */
if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(src[i + 1] || ''))) {
let j = i;
while (j < src.length && /[0-9]/.test(src[j])) j++;
if (j < src.length && src[j] === '.') {
j++;
while (j < src.length && /[0-9]/.test(src[j])) j++;
}
if (j < src.length && /[eE]/.test(src[j])) {
j++;
if (j < src.length && /[+\-]/.test(src[j])) j++;
while (j < src.length && /[0-9]/.test(src[j])) j++;
}
out.push({ type: 'num', val: parseFloat(src.slice(i, j)) });
i = j;
continue;
}
/* identifier */
if (/[a-zA-Z_]/.test(ch)) {
let j = i;
while (j < src.length && /[a-zA-Z_0-9]/.test(src[j])) j++;
out.push({ type: 'id', val: src.slice(i, j) });
i = j;
continue;
}
/* operator / bracket */
if ('+-*/^()'.includes(ch)) {
out.push({ type: 'op', val: ch });
i++;
continue;
}
throw new Error('Unknown character: ' + ch);
}
return out;
}
/* Insert implicit '*' where adjacent tokens imply multiplication.
Covers: 2x <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> 2*x, 2( <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> 2*(, )( <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> )*(, )x <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> )*x */
_insertImplicit(tokens) {
const FUNS = new Set([
'sin','cos','tan','tg','ctg',
'asin','acos','atan','arcsin','arccos','arctan','arctg',
'sqrt','abs','exp','ln','log','log2','log10',
'ceil','floor','round','sign',
]);
const out = [];
for (let i = 0; i < tokens.length; i++) {
out.push(tokens[i]);
const cur = tokens[i], nxt = tokens[i + 1];
if (!nxt) continue;
const curEnds = cur.type === 'num' ||
(cur.type === 'id' && !FUNS.has(cur.val)) ||
(cur.type === 'op' && cur.val === ')');
const nxtStarts = nxt.type === 'num' ||
nxt.type === 'id' ||
(nxt.type === 'op' && nxt.val === '(');
if (curEnds && nxtStarts) out.push({ type: 'op', val: '*' });
}
return out;
}
/* Recursive-descent parser <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> returns a closure x => number */
_parseExpr(tokens) {
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
const eat = v => {
if (!peek() || peek().val !== v) throw new Error('Expected ' + v);
pos++;
};
/* All supported functions including Russian aliases */
const FN = {
sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan,
asin: Math.asin, acos: Math.acos, atan: Math.atan,
arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
ln: Math.log, log: Math.log10, log2: Math.log2, log10: Math.log10,
ceil: Math.ceil, floor: Math.floor, round: Math.round, sign: Math.sign,
ctg: t => 1 / Math.tan(t),
};
/* additive: left-associative +/- */
const addSub = () => {
let l = mulDiv();
while (peek() && (peek().val === '+' || peek().val === '-')) {
const op = next().val;
const r = mulDiv();
const ll = l;
l = op === '+' ? x => ll(x) + r(x) : x => ll(x) - r(x);
}
return l;
};
/* multiplicative: left-associative */
const mulDiv = () => {
let l = power();
while (peek() && (peek().val === '*' || peek().val === '/')) {
const op = next().val;
const r = power();
const ll = l;
l = op === '*' ? x => ll(x) * r(x) : x => ll(x) / r(x);
}
return l;
};
/* power: right-associative ^ */
const power = () => {
const base = unary();
if (peek() && peek().val === '^') {
next();
const exp = power(); // right-recursive <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> right-associative
return x => Math.pow(base(x), exp(x));
}
return base;
};
/* unary minus / plus */
const unary = () => {
if (peek()?.val === '-') { next(); const v = unary(); return x => -v(x); }
if (peek()?.val === '+') { next(); return unary(); }
return primary();
};
/* primary: number | variable | constant | fn(…) | (…) */
const primary = () => {
const t = peek();
if (!t) throw new Error('Unexpected end of expression');
if (t.type === 'num') {
next();
const v = t.val;
return () => v;
}
if (t.type === 'id') {
next();
if (t.val === 'x') return x => x;
if (t.val === 'pi' || t.val === 'PI') return () => Math.PI;
if (t.val === 'e') return () => Math.E;
if (FN[t.val]) {
eat('(');
const arg = addSub();
eat(')');
const f = FN[t.val];
return x => f(arg(x));
}
throw new Error('Unknown identifier: ' + t.val);
}
if (t.type === 'op' && t.val === '(') {
next();
const v = addSub();
eat(')');
return v;
}
throw new Error('Unexpected token: ' + t.val);
};
const fn = addSub();
if (pos !== tokens.length) throw new Error('Unexpected tokens after expression');
return fn;
}
/* ── coordinate transforms ─────────────────── */
_toPx(mx, my) {
const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
return [cx + (mx - this.ox) * this.scl,
cy - (my - this.oy) * this.scl];
}
_toMx(px, py) {
const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
return [(px - cx) / this.scl + this.ox,
-(py - cy) / this.scl + this.oy];
}
/* ── main render ───────────────────────────── */
draw() {
const c = this.ctx, W = this._cw || this.c.width, H = this._ch || this.c.height;
if (!W || !H) return;
c.fillStyle = '#0D0D1A';
c.fillRect(0, 0, W, H);
this._drawGrid(c, W, H);
this._drawAxes(c, W, H);
for (const f of this.fns) if (f) this._drawCurve(c, W, H, f);
if (this.hx !== null) this._drawHover(c, W, H);
}
/* ── grid ──────────────────────────────────── */
_niceStep() {
const raw = (this._cw || this.c.width) / this.scl / 8;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const n of [1, 2, 5, 10]) if (n * p >= raw) return n * p;
return p;
}
_drawGrid(c, W, H) {
const step = this._niceStep();
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const [, y0] = this._toMx(0, H), [, y1] = this._toMx(0, 0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
c.strokeStyle = 'rgba(255,255,255,0.065)';
c.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = this._toPx(x, 0);
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = this._toPx(0, y);
c.beginPath(); c.moveTo(0, py); c.lineTo(W, py); c.stroke();
}
/* number labels */
c.font = '11px Manrope, system-ui, sans-serif';
c.fillStyle = 'rgba(255,255,255,0.3)';
const [axX, axY] = this._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));
c.textAlign = 'center'; c.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = this._toPx(x, 0);
if (px < 18 || px > W - 18) continue;
c.fillText(this._fmtN(x, step), px, lblY);
}
c.textAlign = 'right'; c.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = this._toPx(0, y);
if (py < 12 || py > H - 12) continue;
c.fillText(this._fmtN(y, step), lblX, py);
}
}
_fmtN(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);
}
/* ── axes ──────────────────────────────────── */
_drawAxes(c, W, H) {
const [ax, ay] = this._toPx(0, 0);
c.strokeStyle = 'rgba(255,255,255,0.4)';
c.lineWidth = 1.5;
c.beginPath(); c.moveTo(0, ay); c.lineTo(W - 10, ay); c.stroke();
c.beginPath(); c.moveTo(ax, H); c.lineTo(ax, 8); c.stroke();
c.fillStyle = 'rgba(255,255,255,0.4)';
this._arrowHead(c, W - 8, ay, 0);
this._arrowHead(c, ax, 6, -Math.PI / 2);
c.fillStyle = 'rgba(255,255,255,0.55)';
c.font = 'bold 12px Manrope, sans-serif';
c.textBaseline = 'middle'; c.textAlign = 'left';
c.fillText('x', W - 10, ay - 13);
c.textBaseline = 'top'; c.textAlign = 'left';
c.fillText('y', ax + 7, 4);
}
_arrowHead(c, x, y, angle) {
const s = 5;
c.save(); c.translate(x, y); c.rotate(angle);
c.beginPath();
c.moveTo(0, 0); c.lineTo(-s * 1.6, -s * 0.6); c.lineTo(-s * 1.6, s * 0.6);
c.closePath(); c.fill();
c.restore();
}
/* ── curve ─────────────────────────────────── */
_drawCurve(c, W, H, { fn, color }) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const dx = (x1 - x0) / steps;
const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units)
c.strokeStyle = color;
c.lineWidth = 2.5;
c.lineJoin = 'round';
c.beginPath();
let pen = false, pyPrev = null;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
let my;
try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
// discontinuity guard
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
pen = false;
}
const [px, py] = this._toPx(mx, my);
pen ? c.lineTo(px, py) : c.moveTo(px, py);
pen = true; pyPrev = my;
}
c.stroke();
}
/* ── hover crosshair ───────────────────────── */
_drawHover(c, W, H) {
const [px] = this._toPx(this.hx, 0);
c.strokeStyle = 'rgba(255,255,255,0.15)';
c.lineWidth = 1;
c.setLineDash([5, 5]);
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
c.setLineDash([]);
for (const f of this.fns) {
if (!f) continue;
let my;
try { my = f.fn(this.hx); } catch { continue; }
if (!isFinite(my) || isNaN(my)) continue;
const [, py] = this._toPx(this.hx, my);
if (py < -20 || py > H + 20) continue;
c.fillStyle = f.color;
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
c.strokeStyle = 'rgba(255,255,255,0.8)';
c.lineWidth = 1.5; c.stroke();
}
}
/* ── events ─────────────────────────────────── */
_bind() {
const cv = this.c;
/* wheel zoom — zoom toward cursor */
cv.addEventListener('wheel', e => {
e.preventDefault();
const [mx, my] = this._toMx(e.offsetX, e.offsetY);
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
const [nx, ny] = this._toMx(e.offsetX, e.offsetY);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
}, { passive: false });
/* mouse drag */
cv.addEventListener('mousedown', e => {
this._dg = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
cv.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (this._dg) {
this.ox = this._dg.ox - (e.clientX - this._dg.x) / this.scl;
this.oy = this._dg.oy + (e.clientY - this._dg.y) / this.scl;
this.draw();
} else {
const r = cv.getBoundingClientRect();
const lx = e.clientX - r.left, ly = e.clientY - r.top;
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
this.hx = this._toMx(lx, ly)[0];
this.draw();
this._emitHover();
}
}
});
window.addEventListener('mouseup', () => {
this._dg = null;
cv.style.cursor = 'crosshair';
});
cv.addEventListener('mouseleave', () => {
this.hx = null; this.draw();
if (this.onHover) this.onHover(null, null);
});
cv.style.cursor = 'crosshair';
/* touch drag */
let t0 = null;
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
}
_emitHover() {
if (!this.onHover) return;
const vals = this.fns.map(f => {
if (!f) return null;
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
});
this.onHover(this.hx, vals);
}
}