cd0ce17a60
«График функции», большой апгрейд UX: - у каждой функции кнопки «глаз» (скрыть/показать, не удаляя) и «очистить»; скрытая — приглушена и зачёркнута, исключается из графика/hover/значений - плавающие контролы вида поверх canvas: зум +/−, сброс вида, тумблер «Особые точки» - ОСОБЫЕ ТОЧКИ: нули функций, y-перехваты и пересечения кривых — ringed-точки с подписью координат (бисекция по смене знака; правка: точные нули на узлах сетки больше не теряются; дедуп; подписи скрываются при «частоколе» >22 точек) - пинч-зум двумя пальцами к центру жеста (к 1-пальцевой панораме) Движок: setHidden/setShowPoints/_drawPoints/_findZeros/_visible; hover и инфобар уважают скрытие. Только фронт. node --check OK; zero-finder 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
844 lines
33 KiB
JavaScript
844 lines
33 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._hidden = [false, false, false]; // показ/скрытие функции
|
|
this.showPts = false; // особые точки (нули/пересечения/y-перехват)
|
|
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
|
|
const wasNull = !this.fns[idx];
|
|
this.fns[idx] = { color, fn };
|
|
this.draw();
|
|
if (wasNull && window.LabFX) LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
|
|
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(); }
|
|
setHidden(idx, v) { this._hidden[idx] = !!v; this.draw(); }
|
|
setShowPoints(v) { this.showPts = !!v; 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);
|
|
this.fns.forEach((f, i) => { if (f && !this._hidden[i]) this._drawCurve(c, W, H, f); });
|
|
if (this.showPts) this._drawPoints(c, W, H);
|
|
if (this.hx !== null) this._drawHover(c, W, H);
|
|
}
|
|
|
|
/* видимые функции (для hover/особых точек) */
|
|
_visible() { return this.fns.map((f, i) => (f && !this._hidden[i]) ? f : null); }
|
|
|
|
/* ── 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)
|
|
|
|
const drawPath = () => {
|
|
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();
|
|
};
|
|
|
|
if (window.LabFX) {
|
|
LabFX.glow.drawGlow(c, drawPath, { color, intensity: 4 });
|
|
} else {
|
|
drawPath();
|
|
}
|
|
}
|
|
|
|
/* ── 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([]);
|
|
|
|
this.fns.forEach((f, i) => {
|
|
if (!f || this._hidden[i]) return;
|
|
let my;
|
|
try { my = f.fn(this.hx); } catch { return; }
|
|
if (!isFinite(my) || isNaN(my)) return;
|
|
|
|
const [, py] = this._toPx(this.hx, my);
|
|
if (py < -20 || py > H + 20) return;
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
/* ── особые точки: нули, y-перехват, пересечения ─── */
|
|
_findZeros(g, a, b, samples) {
|
|
const zeros = []; const dx = (b - a) / samples; const eps = Math.abs(dx) * 0.25;
|
|
const push = (r) => { if (!zeros.length || Math.abs(r - zeros[zeros.length - 1]) > eps) zeros.push(r); };
|
|
let pmx = a, pv; try { pv = g(a); } catch { pv = NaN; }
|
|
if (isFinite(pv) && pv === 0) push(a);
|
|
for (let i = 1; i <= samples && zeros.length < 60; i++) {
|
|
const mx = a + i * dx;
|
|
let v; try { v = g(mx); } catch { v = NaN; }
|
|
if (isFinite(pv) && isFinite(v)) {
|
|
if (v === 0) { push(mx); } // точный ноль на узле сетки
|
|
else if (pv !== 0 && pv * v < 0) { // смена знака — бисекция
|
|
let lo = pmx, hi = mx, flo = pv;
|
|
for (let k = 0; k < 50; k++) {
|
|
const mid = (lo + hi) / 2; let fm; try { fm = g(mid); } catch { fm = NaN; }
|
|
if (!isFinite(fm)) { lo = hi = mid; break; }
|
|
if (flo * fm <= 0) hi = mid; else { lo = mid; flo = fm; }
|
|
}
|
|
push((lo + hi) / 2);
|
|
}
|
|
}
|
|
pmx = mx; pv = v;
|
|
}
|
|
return zeros;
|
|
}
|
|
|
|
_drawPoints(c, W, H) {
|
|
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
|
|
const samples = Math.min(Math.round(W), 800);
|
|
const vis = this._visible();
|
|
const pts = []; // { mx, my, color, kind }
|
|
vis.forEach(f => {
|
|
if (!f) return;
|
|
// нули функции
|
|
this._findZeros(f.fn, x0, x1, samples).forEach(rx => pts.push({ mx: rx, my: 0, color: f.color, kind: 'root' }));
|
|
// y-перехват
|
|
if (x0 <= 0 && x1 >= 0) { let v; try { v = f.fn(0); } catch { v = NaN; } if (isFinite(v)) pts.push({ mx: 0, my: v, color: f.color, kind: 'yint' }); }
|
|
});
|
|
// пересечения пар
|
|
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) {
|
|
if (!vis[i] || !vis[j]) continue;
|
|
const fi = vis[i].fn, fj = vis[j].fn;
|
|
this._findZeros(x => fi(x) - fj(x), x0, x1, samples).forEach(ix => {
|
|
let v; try { v = fi(ix); } catch { v = NaN; }
|
|
if (isFinite(v)) pts.push({ mx: ix, my: v, color: '#ffffff', kind: 'cross' });
|
|
});
|
|
}
|
|
const labels = pts.length <= 22; // не подписываем при «частоколе» (sin на широком диапазоне)
|
|
c.font = '600 10.5px Manrope, system-ui, sans-serif';
|
|
for (const p of pts) {
|
|
const [px, py] = this._toPx(p.mx, p.my);
|
|
if (px < -8 || px > W + 8 || py < -8 || py > H + 8) continue;
|
|
c.beginPath(); c.arc(px, py, 4.5, 0, 2 * Math.PI);
|
|
c.fillStyle = p.color; c.fill();
|
|
c.lineWidth = 1.5; c.strokeStyle = '#0D0D1A'; c.stroke();
|
|
if (labels) {
|
|
const tx = '(' + this._fmtP(p.mx) + '; ' + this._fmtP(p.my) + ')';
|
|
c.textAlign = 'left'; c.textBaseline = 'bottom';
|
|
const lx = Math.min(px + 7, W - c.measureText(tx).width - 4), ly = Math.max(12, py - 6);
|
|
c.fillStyle = 'rgba(13,13,26,0.78)';
|
|
const tw = c.measureText(tx).width;
|
|
c.fillRect(lx - 3, ly - 12, tw + 6, 14);
|
|
c.fillStyle = p.kind === 'cross' ? 'rgba(255,255,255,0.92)' : p.color;
|
|
c.fillText(tx, lx, ly);
|
|
}
|
|
}
|
|
}
|
|
_fmtP(n) { if (Math.abs(n) < 1e-9) return '0'; const r = Math.round(n * 100) / 100; return Number.isInteger(r) ? String(r) : r.toFixed(2); }
|
|
|
|
/* ── 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: 1 палец — панорама, 2 пальца — пинч-зум к центру жеста */
|
|
let t0 = null, pinch = null;
|
|
const tDist = ts => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
|
|
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 }; pinch = null;
|
|
} else if (e.touches.length === 2) {
|
|
const r = cv.getBoundingClientRect();
|
|
pinch = { d: tDist(e.touches), scl: this.scl,
|
|
cx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
|
|
cy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top };
|
|
t0 = null;
|
|
}
|
|
}, { passive: true });
|
|
cv.addEventListener('touchmove', e => {
|
|
e.preventDefault();
|
|
if (e.touches.length === 2 && pinch) {
|
|
const [mx, my] = this._toMx(pinch.cx, pinch.cy);
|
|
this.scl = Math.max(4, Math.min(800, pinch.scl * (tDist(e.touches) / (pinch.d || 1))));
|
|
const [nx, ny] = this._toMx(pinch.cx, pinch.cy);
|
|
this.ox -= nx - mx; this.oy -= ny - my;
|
|
this.draw();
|
|
} else 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', e => { if (e.touches.length === 0) { t0 = null; pinch = null; } });
|
|
}
|
|
|
|
_emitHover() {
|
|
if (!this.onHover) return;
|
|
const vals = this.fns.map((f, i) => {
|
|
if (!f || this._hidden[i]) return null;
|
|
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
|
|
});
|
|
this.onHover(this.hx, vals);
|
|
}
|
|
}
|
|
|
|
/* ─── lab UI init ─────────────────────────────────── */
|
|
function _openGraph() {
|
|
document.getElementById('sim-topbar-title').textContent = 'График функции';
|
|
_simShow('sim-graph');
|
|
_simShow('ctrl-graph');
|
|
_initGraphPanel();
|
|
|
|
_registerSimState('graph',
|
|
() => ({
|
|
fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
|
|
}),
|
|
(st) => {
|
|
if (!Array.isArray(st.fns)) return;
|
|
st.fns.forEach((fn, i) => {
|
|
const el = document.getElementById(`fn${i}`);
|
|
if (el) { el.value = fn.expr; }
|
|
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
|
|
_fnDisplay(i);
|
|
});
|
|
}
|
|
);
|
|
if (_embedMode) _startStateEmit('graph');
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
_initGraphPanel(); // KaTeX к этому моменту точно загружен
|
|
if (!gSim) {
|
|
gSim = new GraphSim(document.getElementById('graph-canvas'));
|
|
gSim.onHover = updateInfoBar;
|
|
if (!document.getElementById('fn0').value.trim()) {
|
|
document.getElementById('fn0').value = 'sin(x)';
|
|
renderPreview(0); _fnDisplay(0);
|
|
gSim.fit();
|
|
gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
|
|
return;
|
|
}
|
|
}
|
|
gSim.fit();
|
|
gSim.draw();
|
|
}));
|
|
}
|
|
|
|
/* ── projectile ── */
|
|
|
|
function toLatex(expr) {
|
|
if (!expr) return '';
|
|
return expr
|
|
// strip leading y= if typed
|
|
.replace(/^\s*y\s*=\s*/i, '')
|
|
// inverse trig (before sin/cos/tan)
|
|
.replace(/\barcsin\b/g, '\\arcsin').replace(/\barccos\b/g, '\\arccos')
|
|
.replace(/\b(arctan|arctg|atan|acos|asin)\b/g, (_, w) =>
|
|
w === 'asin' ? '\\arcsin' : w === 'acos' ? '\\arccos' : '\\arctan')
|
|
// trig
|
|
.replace(/\bctg\b/g, '\\cot').replace(/\btg\b/g, '\\tan')
|
|
.replace(/\b(sin|cos|tan)\b/g, '\\$1')
|
|
// log / exp
|
|
.replace(/\bln\b/g, '\\ln').replace(/\blog2\b/g, '\\log_2')
|
|
.replace(/\blog\b/g, '\\log').replace(/\bexp\b/g, '\\exp')
|
|
// special functions: f(inner) <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> LaTeX form
|
|
.replace(/\bsqrt\(([^()]*)\)/g, '\\sqrt{$1}')
|
|
.replace(/\babs\(([^()]*)\)/g, '\\left|$1\\right|')
|
|
.replace(/\bfloor\(([^()]*)\)/g, '\\lfloor $1 \\rfloor')
|
|
.replace(/\bceil\(([^()]*)\)/g, '\\lceil $1 \\rceil')
|
|
.replace(/\b(round|sign)\b/g, '\\operatorname{$1}')
|
|
// constants
|
|
.replace(/\bpi\b/gi, '\\pi')
|
|
// power: wrap exponent in braces for multi-char
|
|
.replace(/\^(-?\d{2,})/g, '^{$1}')
|
|
// clean up multiplication
|
|
.replace(/([0-9])\s*\*\s*([a-zA-Z\\])/g, '$1\\,$2')
|
|
.replace(/\*/g, '\\cdot ');
|
|
}
|
|
|
|
function _katexInto(el, latex) {
|
|
try { el.innerHTML = katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }); return true; }
|
|
catch { el.innerHTML = ''; return false; }
|
|
}
|
|
|
|
/* Живое превью формулы под полем — показывается ТОЛЬКО пока строка в правке. */
|
|
function renderPreview(idx) {
|
|
const inp = document.getElementById('fn' + idx);
|
|
const prev = document.getElementById('fn' + idx + '-prev');
|
|
if (!prev) return;
|
|
const raw = inp?.value?.trim() || '';
|
|
if (!raw || typeof katex === 'undefined') { prev.innerHTML = ''; prev.classList.remove('has-content'); return; }
|
|
if (_katexInto(prev, toLatex(raw))) prev.classList.add('has-content');
|
|
else prev.classList.remove('has-content');
|
|
}
|
|
|
|
/* Введённая функция — отрисованной формулой KaTeX прямо в строке. */
|
|
function renderFnMath(idx) {
|
|
const inp = document.getElementById('fn' + idx);
|
|
const m = document.getElementById('fn' + idx + '-math');
|
|
if (!m || !inp) return;
|
|
const raw = inp.value.trim();
|
|
if (!raw || typeof katex === 'undefined') { m.innerHTML = ''; return; }
|
|
_katexInto(m, toLatex(raw));
|
|
}
|
|
|
|
/* Режим строки: не в фокусе и есть формула → показываем KaTeX; иначе — поле ввода. */
|
|
function _fnDisplay(idx) {
|
|
const inp = document.getElementById('fn' + idx);
|
|
const field = inp && inp.closest('.fn-field');
|
|
if (!field) return;
|
|
const showMath = (document.activeElement !== inp) && !!inp.value.trim() && typeof katex !== 'undefined';
|
|
if (showMath) { renderFnMath(idx); field.classList.add('has-math'); }
|
|
else field.classList.remove('has-math');
|
|
}
|
|
|
|
/* Вставка структуры формулы в активное поле (как редактор формул).
|
|
В токене символ | помечает позицию каретки, напр. 'sin(|)'. */
|
|
let _activeFnIdx = 0, _graphPanelInit = false;
|
|
function graphInsert(token) {
|
|
const el = document.getElementById('fn' + _activeFnIdx) || document.getElementById('fn0');
|
|
if (!el) return;
|
|
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math'); // в режим правки
|
|
el.focus();
|
|
let ins = String(token || ''); let caretInTok = -1;
|
|
const bar = ins.indexOf('|');
|
|
if (bar >= 0) { caretInTok = bar; ins = ins.slice(0, bar) + ins.slice(bar + 1); }
|
|
const s = (el.selectionStart != null) ? el.selectionStart : el.value.length;
|
|
const e = (el.selectionEnd != null) ? el.selectionEnd : el.value.length;
|
|
el.value = el.value.slice(0, s) + ins + el.value.slice(e);
|
|
const pos = s + (caretInTok >= 0 ? caretInTok : ins.length);
|
|
try { el.setSelectionRange(pos, pos); } catch (_) {}
|
|
updateFn(_activeFnIdx);
|
|
}
|
|
|
|
/* KaTeX на чипах/клавиатуре + math-поля + слежение за активным полем (идемпотентно). */
|
|
function _initGraphPanel() {
|
|
const root = document.getElementById('sim-graph');
|
|
if (!root || typeof katex === 'undefined') return;
|
|
root.querySelectorAll('.preset-btn[data-tex], .kp-btn[data-tex]').forEach(b => {
|
|
if (b.dataset.rendered) return;
|
|
if (_katexInto(b, b.dataset.tex)) b.dataset.rendered = '1';
|
|
});
|
|
if (!_graphPanelInit) {
|
|
_graphPanelInit = true;
|
|
[0, 1, 2].forEach(i => {
|
|
const el = document.getElementById('fn' + i);
|
|
const m = document.getElementById('fn' + i + '-math');
|
|
if (el) {
|
|
el.addEventListener('focus', () => { _activeFnIdx = i; _fnDisplay(i); });
|
|
el.addEventListener('blur', () => { _fnDisplay(i); });
|
|
}
|
|
// клик по формуле → правка текста на месте
|
|
if (m) m.addEventListener('mousedown', (ev) => { ev.preventDefault(); const f = m.closest('.fn-field'); if (f) f.classList.remove('has-math'); el && el.focus(); });
|
|
});
|
|
}
|
|
[0, 1, 2].forEach(i => { renderPreview(i); _fnDisplay(i); });
|
|
}
|
|
|
|
/* debounced formula update */
|
|
const _debounce = {};
|
|
let _graphSoundTs = 0;
|
|
function updateFn(idx) {
|
|
clearTimeout(_debounce[idx]);
|
|
renderPreview(idx); // instant preview
|
|
const now = performance.now();
|
|
if (window.LabFX && now - _graphSoundTs > 80) {
|
|
_graphSoundTs = now;
|
|
LabFX.sound.play('tick', { pitch: 1.0, volume: 0.1 });
|
|
}
|
|
_debounce[idx] = setTimeout(() => {
|
|
if (!gSim) return;
|
|
const raw = document.getElementById('fn' + idx).value;
|
|
const val = raw.replace(/^\s*y\s*=\s*/i, '');
|
|
const err = gSim.setFn(idx, val, FN_COLORS[idx]);
|
|
const errEl = document.getElementById('fn' + idx + '-err');
|
|
errEl.classList.toggle('show', !!err && !!val.trim());
|
|
}, 350);
|
|
}
|
|
|
|
function applyPreset(expr) {
|
|
for (let i = 0; i < 3; i++) {
|
|
const inp = document.getElementById('fn' + i);
|
|
if (!inp.value.trim()) {
|
|
inp.value = expr; updateFn(i); inp.focus(); return;
|
|
}
|
|
}
|
|
document.getElementById('fn0').value = expr; updateFn(0);
|
|
}
|
|
|
|
function clearAll() {
|
|
for (let i = 0; i < 3; i++) {
|
|
document.getElementById('fn' + i).value = '';
|
|
document.getElementById('fn' + i + '-prev').innerHTML = '';
|
|
document.getElementById('fn' + i + '-prev').classList.remove('has-content');
|
|
const m = document.getElementById('fn' + i + '-math'); if (m) m.innerHTML = '';
|
|
const f = document.getElementById('fn' + i)?.closest('.fn-field'); if (f) f.classList.remove('has-math');
|
|
document.getElementById('fn' + i + '-err').classList.remove('show');
|
|
if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
|
|
}
|
|
}
|
|
|
|
/* ── per-function controls + view controls ── */
|
|
const _EYE_ON = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
const _EYE_OFF = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-3 3.7M6.6 6.6A17 17 0 0 0 2 11s3.5 7 10 7a10 10 0 0 0 4.1-.9"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
|
|
|
|
function toggleFn(idx) {
|
|
if (!gSim) return;
|
|
const hidden = !gSim._hidden[idx];
|
|
gSim.setHidden(idx, hidden);
|
|
const row = document.getElementById('fn' + idx)?.closest('.fn-row');
|
|
if (row) row.classList.toggle('fn-hidden', hidden);
|
|
const eye = document.getElementById('fn' + idx + '-eye');
|
|
if (eye) { eye.innerHTML = hidden ? _EYE_OFF : _EYE_ON; eye.classList.toggle('off', hidden); }
|
|
}
|
|
function clearFn(idx) {
|
|
const el = document.getElementById('fn' + idx); if (!el) return;
|
|
el.value = ''; updateFn(idx);
|
|
const m = document.getElementById('fn' + idx + '-math'); if (m) m.innerHTML = '';
|
|
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math');
|
|
// снять скрытие, если было
|
|
if (gSim && gSim._hidden[idx]) toggleFn(idx);
|
|
el.focus();
|
|
}
|
|
function graphZoom(dir) { if (gSim) { dir > 0 ? gSim.zoomIn() : gSim.zoomOut(); } }
|
|
function graphFit() { if (gSim) gSim.resetView(); }
|
|
function toggleGraphPoints() {
|
|
if (!gSim) return;
|
|
const on = !gSim.showPts; gSim.setShowPoints(on);
|
|
const b = document.getElementById('graph-pts-btn'); if (b) b.classList.toggle('active', on);
|
|
}
|
|
|
|
/* hover info bar */
|
|
function fmtVal(v) {
|
|
if (v === null || v === undefined) return '—';
|
|
if (!isFinite(v)) return '∞';
|
|
const abs = Math.abs(v);
|
|
if (abs === 0) return '0';
|
|
if (abs < 0.001 || abs >= 1e6) return v.toExponential(3);
|
|
return parseFloat(v.toPrecision(6)).toString();
|
|
}
|
|
|
|
function updateInfoBar(mx, vals) {
|
|
document.getElementById('info-x').textContent = mx !== null ? fmtVal(mx) : '—';
|
|
document.getElementById('info-y0').textContent = vals ? fmtVal(vals[0]) : '—';
|
|
document.getElementById('info-y1').textContent = vals ? fmtVal(vals[1]) : '—';
|
|
document.getElementById('info-y2').textContent = vals ? fmtVal(vals[2]) : '—';
|
|
}
|
|
|
|
/* ════════════════════════════════
|
|
МОЛЕКУЛЯРНАЯ ФИЗИКА (unified: gas + brownian + states + diffusion)
|
|
════════════════════════════════ */
|
|
|
|
let _molMode = 'gas'; // 'gas' | 'brownian' | 'states' | 'diffusion'
|
|
|