'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 2*x, 2( 2*(, )( )*(, )x )*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 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 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); } }