Files
Learn_System/frontend/js/labs/_sim_engine.js
T

920 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimEngine — рантайм спек-симуляций конструктора (Фаза 0).
Берёт JSON-спеку (данные, не код) и монтирует интерактивную сцену в host:
- canvas для геометрии/объектов (мир→экран по viewport, ось Y вверх);
- оверлей-слой <div> для подписей с LaTeX (рендер через KaTeX — тем же путём,
что graph.js: katex.renderToString);
- панель контролов: слайдеры из spec.params[] + кнопки play/pause/reset.
Любое числовое свойство объекта может быть числом ИЛИ строкой-выражением.
Выражения компилируются ОДИН РАЗ при mount (через SimExpr), в rAF-цикле — только
evaluate по окружению env = { t, ...params, <obj>.x, <obj>.y, w, h }.
Никакого eval/new Function. Кривая формула не роняет цикл (SimExpr.evaluate -> 0).
── ФОРМАТ СПЕКИ v1 ──────────────────────────────────────────────────────
{
specVersion: 1,
meta: { title, desc }, // подпись топбара/каталога
viewport: { xmin, xmax, ymin, ymax, // мировые границы (мат. оси)
grid?:true, axes?:true,
bg?:'#0D0D1A' },
params: [ // слайдеры -> переменные env
{ name:'v', label:'Скорость', min:0, max:30, step:0.5, value:18, unit:'м/с' },
{ name:'theta', label:'Угол', min:0, max:90, step:1, value:45, unit:'°' }
],
time: { autoplay?:false, loop?:true, duration?:0, speed?:1 }, // t-цикл
objects: [ // числа ИЛИ строки-выражения
{ id:'p', type:'point', x:'v*cos(theta*pi/180)*t', y:'...', r:6, color:'#06D6E0',
trail?:true, trailColor?:'#06D6E0' },
{ type:'segment', x1:0,y1:0, x2:'p.x', y2:'p.y', color:'#fff', width:2 },
{ type:'vector', x1:..,y1:.., x2:..,y2:.., color, width },
{ type:'circle', x, y, r, color, fill?, width },
{ type:'rect', x, y, w, h, color, fill?, width }, // x,y = центр
{ type:'polyline', points:[[x,y],...], color, width, closed? },
{ type:'path', points:[[x,y],...], ... }, // alias polyline
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 },
// ── Фаза 1 ──
{ type:'plot', // график выражения в мир-коорд.
expr:'sin(x)', // f(<var>) (или f(t) при trace)
var:'x', // имя свободной переменной (деф. 'x')
range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени
color?, width? },
{ type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy)
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
{ type:'readout', // живой числовой бейдж
label?:'R', expr:'...', unit?:'м', precision?:2,
x?, y?, // мир-координаты (деф. угол вьюпорта)
color? },
// Любой point/circle может стать перетаскиваемой ручкой:
{ type:'point', x:'x0', y:'y0',
drag:{ param:'x0', axis:'x'|'y'|'xy', min?, max?, paramY? } }
]
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
перетаскивание мышью/тачем (pointer events) пишет мир-координату курсора в
параметр(ы). axis:'x' -> param=x; 'y' -> param=y; 'xy' -> param=x, paramY=y
(или param используется для x, paramY для y). Позиция ручки следует за
параметром, когда её не тащат (x/y объекта обычно = тот же параметр).
Хит-тест — в экранных пикселях с допуском; ручки имеют приоритет.
── API инстанса ──────────────────────────────────────────────────────────
var inst = SimEngine.mount(host, spec);
inst.play() inst.pause() inst.reset()
inst.setParam(name, value)
inst.getParam(name) -> number
inst.isRunning() -> bool
inst.destroy()
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var DEFAULT_BG = '#0D0D1A';
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
var d = dflt;
return { ev: function () { return d; }, constant: true };
}
var c = global.SimExpr ? global.SimExpr.compileValue(value)
: { fn: function () { return num(value, dflt); }, constant: true };
return { ev: c.fn, constant: !!c.constant, error: c.error || null };
}
/* ════════════════════ Instance ════════════════════ */
function SimEngineInstance(host, spec) {
this.host = host;
this.spec = spec || {};
this.params = {}; // name -> текущее значение (number)
this._sliders = {}; // name -> input element
this._paramRange = {}; // name -> { min, max } (для clamp при drag)
this._objs = []; // подготовленные объекты с привязками
this._trails = {}; // objId -> [[x,y],...] (мир-координаты)
this._t = 0;
this._running = false;
this._raf = 0;
this._last = 0;
this._dpr = 1;
this._cw = 0; this._ch = 0;
this._destroyed = false;
this._ro = null;
this._dragging = null; // текущая перетаскиваемая ручка (drag)
this._readoutSlot = 0; // счётчик автопозиционируемых readout-бейджей
this._build();
}
SimEngineInstance.prototype._vp = function () {
var v = this.spec.viewport || {};
return {
xmin: num(v.xmin, -1), xmax: num(v.xmax, 10),
ymin: num(v.ymin, -1), ymax: num(v.ymax, 10),
grid: v.grid !== false,
axes: v.axes !== false,
bg: v.bg || DEFAULT_BG
};
};
SimEngineInstance.prototype._build = function () {
var self = this;
var spec = this.spec;
// корень
var root = document.createElement('div');
root.className = 'sim-spec-root';
root.style.cssText = 'flex:1;min-height:0;display:flex;width:100%;height:100%;background:' +
(this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
this.el = root;
// ── панель контролов слева ──
var panel = document.createElement('div');
panel.className = 'sim-spec-panel';
panel.style.cssText = 'width:260px;flex-shrink:0;background:rgba(13,13,26,0.92);' +
'border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column;' +
'gap:10px;padding:16px 14px;overflow-y:auto';
// заголовок + кнопки play/pause/reset
var ctrlRow = document.createElement('div');
ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center';
var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза');
var btnReset = this._btn(this._resetIcon(), 'Сброс');
this._btnPlay = btnPlay;
btnPlay.addEventListener('click', function () { self._running ? self.pause() : self.play(); });
btnReset.addEventListener('click', function () { self.reset(); });
ctrlRow.appendChild(btnPlay);
ctrlRow.appendChild(btnReset);
panel.appendChild(ctrlRow);
// слайдеры параметров
var params = Array.isArray(spec.params) ? spec.params : [];
params.forEach(function (p) {
if (!p || !p.name) return;
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
var val = num(p.value, min);
self.params[p.name] = val;
self._paramRange[p.name] = { min: min, max: max }; // для clamp при drag
var wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
var lblRow = document.createElement('div');
lblRow.style.cssText = 'display:flex;justify-content:space-between;font-size:.74rem;color:rgba(255,255,255,.6)';
var lblName = document.createElement('span');
lblName.textContent = p.label || p.name;
var lblVal = document.createElement('span');
lblVal.style.cssText = 'color:#06D6E0;font-weight:700;font-variant-numeric:tabular-nums';
lblVal.textContent = _fmt(val) + (p.unit ? ' ' + p.unit : '');
lblRow.appendChild(lblName); lblRow.appendChild(lblVal);
var slider = document.createElement('input');
slider.type = 'range';
slider.min = String(min); slider.max = String(max); slider.step = String(step);
slider.value = String(val);
slider.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer';
slider.addEventListener('input', function () {
var v = parseFloat(slider.value);
self.params[p.name] = v;
lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : '');
if (!self._running) self._renderFrame(); // живой предпросмотр на паузе
});
wrap.appendChild(lblRow);
wrap.appendChild(slider);
panel.appendChild(wrap);
self._sliders[p.name] = slider;
});
// ── сцена справа (canvas + оверлей подписей) ──
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'flex:1;min-width:0;position:relative;overflow:hidden';
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
stage.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
var labels = document.createElement('div');
labels.className = 'sim-spec-labels';
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
stage.appendChild(labels);
this._labelLayer = labels;
root.appendChild(panel);
root.appendChild(stage);
this.host.appendChild(root);
// подготовить объекты (компиляция привязок один раз)
this._prepareObjects();
// resize
if (global.ResizeObserver) {
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
this._ro.observe(stage);
}
// drag-интеракции (мышь + тач через pointer events)
this._setupDrag();
// первичная подгонка после layout
requestAnimationFrame(function () {
self._fit();
self.reset();
var time = spec.time || {};
if (time.autoplay) self.play();
});
};
/* кнопка контрола (inline SVG, без эмодзи) */
SimEngineInstance.prototype._btn = function (innerSvg, title) {
var b = document.createElement('button');
b.title = title || '';
b.innerHTML = innerSvg;
b.style.cssText = 'min-width:34px;height:34px;border-radius:10px;border:1.5px solid rgba(255,255,255,.18);' +
'background:transparent;color:rgba(255,255,255,.75);cursor:pointer;display:flex;align-items:center;' +
'justify-content:center;padding:0 9px;transition:all .15s';
b.addEventListener('mouseenter', function () { b.style.borderColor = '#9B5DE5'; b.style.color = '#9B5DE5'; });
b.addEventListener('mouseleave', function () { b.style.borderColor = 'rgba(255,255,255,.18)'; b.style.color = 'rgba(255,255,255,.75)'; });
return b;
};
SimEngineInstance.prototype._playIcon = function (play) {
return play
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
};
SimEngineInstance.prototype._resetIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
};
SimEngineInstance.prototype._syncPlayBtn = function () {
if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running);
};
/* Компиляция привязок объектов один раз. */
SimEngineInstance.prototype._prepareObjects = function () {
var raw = Array.isArray(this.spec.objects) ? this.spec.objects : [];
var out = [];
for (var i = 0; i < raw.length; i++) {
var o = raw[i] || {};
var type = o.type || 'point';
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} };
// общие визуальные поля (не привязки)
prep.color = o.color || '#06D6E0';
prep.fillColor = o.fill || o.fillColor || null;
prep.width = num(o.width, 2);
prep.trail = !!o.trail;
prep.trailColor = o.trailColor || prep.color;
prep.latex = o.latex !== false; // подписи по умолчанию рендерятся KaTeX
prep.size = num(o.size, 14);
prep.text = o.text != null ? String(o.text) : '';
// числовые/выражения-привязки по типу
var B = prep.b;
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); }
else if (type === 'segment') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
else if (type === 'vector') {
// vector: либо x1/y1/x2/y2, либо origin:[ox,oy] + dx/dy
if (Array.isArray(o.origin) || o.dx !== undefined || o.dy !== undefined) {
var org = Array.isArray(o.origin) ? o.origin : [0, 0];
B.x1 = bind(org[0], 0); B.y1 = bind(org[1], 0);
// конец = origin + (dx,dy): сумма привязок
var ox = B.x1, oy = B.y1;
var dxB = bind(o.dx, 1), dyB = bind(o.dy, 1);
B.x2 = { ev: function (env) { return ox.ev(env) + dxB.ev(env); } };
B.y2 = { ev: function (env) { return oy.ev(env) + dyB.ev(env); } };
} else {
bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1);
}
}
else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); }
else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); }
else if (type === 'polyline' || type === 'path') {
prep.pts = (Array.isArray(o.points) ? o.points : []).map(function (pt) {
return { x: bind(pt[0], 0), y: bind(pt[1], 0) };
});
prep.closed = !!o.closed;
} else if (type === 'label') {
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
} else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
: { fn: function () { return 0; }, ast: null };
prep.exprFn = { ev: rc.fn };
prep.exprAst = rc.ast;
prep.label = o.label != null ? String(o.label) : '';
prep.unit = o.unit != null ? String(o.unit) : '';
prep.precision = Math.max(0, Math.min(8, num(o.precision, 2) | 0));
prep.hasPos = (o.x !== undefined && o.y !== undefined);
if (prep.hasPos) { bp('x', 0); bp('y', 0); }
}
// drag-интеракция (point/circle): объект становится ручкой
if (o.drag && (type === 'point' || type === 'circle')) {
var dg = o.drag;
prep.drag = {
param: dg.param || null, // axis x|y -> этот параметр; xy -> X
paramY: dg.paramY || null, // axis xy -> Y (обязателен для 2D-ручки)
axis: (dg.axis === 'y' || dg.axis === 'xy') ? dg.axis : 'x',
min: typeof dg.min === 'number' ? dg.min : -Infinity,
max: typeof dg.max === 'number' ? dg.max : Infinity
};
}
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
if (B.x && B.y) { prep.hasCenter = true; }
out.push(prep);
}
this._objs = out;
};
/* Окружение для evaluate: t, params, w/h, и центры объектов (obj.x/obj.y). */
SimEngineInstance.prototype._buildEnv = function () {
var env = {};
var p = this.params;
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
env.t = this._t;
var vp = this._vp();
env.w = vp.xmax - vp.xmin;
env.h = vp.ymax - vp.ymin;
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
// двухпроходно: центры объектов могут ссылаться друг на друга (одношагово,
// без рекурсии — для большинства сцен достаточно одного прохода).
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.hasCenter) {
var x = o.b.x.ev(env);
var y = o.b.y.ev(env);
env[o.id + '.x'] = x;
env[o.id + '.y'] = y;
}
}
return env;
};
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── */
SimEngineInstance.prototype._fit = function () {
var c = this.canvas; if (!c) return;
var dpr = Math.min(global.devicePixelRatio || 1, 2);
var stage = c.parentElement || c;
var r = stage.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
this._dpr = dpr; this._cw = w; this._ch = h;
c.width = w * dpr; c.height = h * dpr;
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// вычислить масштаб «вписать viewport, равные оси»
var vp = this._vp();
var vw = vp.xmax - vp.xmin || 1;
var vh = vp.ymax - vp.ymin || 1;
var pad = 16;
var sx = (w - pad * 2) / vw;
var sy = (h - pad * 2) / vh;
var s = Math.min(sx, sy);
this._scale = s;
// центр мира в центре canvas
var cxWorld = (vp.xmin + vp.xmax) / 2;
var cyWorld = (vp.ymin + vp.ymax) / 2;
this._offX = w / 2 - cxWorld * s;
this._offY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
};
SimEngineInstance.prototype._toPx = function (mx, my) {
return [this._offX + mx * this._scale, this._offY - my * this._scale];
};
/* экран (px, относит. stage) -> мир (обратно к _toPx) */
SimEngineInstance.prototype._toWorld = function (px, py) {
var s = this._scale || 1;
return [(px - this._offX) / s, (this._offY - py) / s];
};
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
Объекты с prep.drag — перетаскиваемые ручки. Слушаем pointer events на
canvas: pointerdown -> хит-тест ближайшей ручки в пикселях; pointermove ->
записываем мир-координату курсора в параметр(ы) (clamp по min/max). */
SimEngineInstance.prototype._hasHandles = function () {
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].drag) return true;
return false;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
var HIT_PX = 16; // допуск хит-теста в пикселях
function localXY(ev) {
var r = self.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
}
function pickHandle(lx, ly) {
// хит-тест в экранных пикселях, ближайшая в пределах допуска
var env = self._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < self._objs.length; i++) {
var o = self._objs[i];
if (!o.drag || !o.b.x || !o.b.y) continue;
var p = self._toPx(o.b.x.ev(env), o.b.y.ev(env));
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
if (d <= bestD) { bestD = d; best = o; }
}
return best;
}
this._onPointerDown = function (ev) {
if (ev.button != null && ev.button !== 0) return; // только левая кнопка/тач
var xy = localXY(ev);
var h = pickHandle(xy[0], xy[1]);
if (!h) return;
self._dragging = h;
ev.preventDefault();
try { self.canvas.setPointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'grabbing';
self._applyDrag(h, xy[0], xy[1]);
};
this._onPointerMove = function (ev) {
var xy = localXY(ev);
if (self._dragging) {
ev.preventDefault();
self._applyDrag(self._dragging, xy[0], xy[1]);
} else {
// hover-курсор над ручкой
self.canvas.style.cursor = pickHandle(xy[0], xy[1]) ? 'grab' : 'default';
}
};
this._onPointerUp = function (ev) {
if (!self._dragging) return;
self._dragging = null;
try { self.canvas.releasePointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'default';
};
var c = this.canvas;
c.style.touchAction = 'none'; // не давать браузеру скроллить/зумить при тач-drag
c.addEventListener('pointerdown', this._onPointerDown);
c.addEventListener('pointermove', this._onPointerMove);
c.addEventListener('pointerup', this._onPointerUp);
c.addEventListener('pointercancel', this._onPointerUp);
};
/* записать мир-координату курсора в параметр(ы) ручки */
SimEngineInstance.prototype._applyDrag = function (h, lx, ly) {
var w = this._toWorld(lx, ly);
var d = h.drag;
if (d.axis === 'x') {
if (d.param) this._setParamClamped(d.param, w[0], d);
} else if (d.axis === 'y') {
if (d.param) this._setParamClamped(d.param, w[1], d);
} else { // 'xy'
if (d.param) this._setParamClamped(d.param, w[0], d);
if (d.paramY) this._setParamClamped(d.paramY, w[1], d);
}
if (!this._running) this._renderFrame(); // живой отклик на паузе
};
SimEngineInstance.prototype._setParamClamped = function (name, value, d) {
var v = _clamp(value, d.min, d.max);
// дополнительно зажать в диапазон самого параметра (слайдера), не полагаясь на DOM
var pr = this._paramRange[name];
if (pr) v = _clamp(v, pr.min, pr.max);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) {
sl.value = String(v);
// синхронизировать подпись значения слайдера (input-обработчик обновит её)
sl.dispatchEvent(new Event('input'));
}
};
/* ════════════════════ Рендер кадра ════════════════════ */
SimEngineInstance.prototype._renderFrame = function () {
var ctx = this.ctx; if (!ctx) return;
var W = this._cw, H = this._ch;
if (!W || !H) return;
var vp = this._vp();
ctx.fillStyle = vp.bg;
ctx.fillRect(0, 0, W, H);
if (vp.grid) this._drawGrid(ctx, W, H, vp);
if (vp.axes) this._drawAxes(ctx, W, H, vp);
var env = this._buildEnv();
this._readoutSlot = 0; // сброс счётчика бейджей-readout на каждый кадр
// обновить трассы (point/circle с trail + plot с trace)
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.trail && o.hasCenter) {
var tx = env[o.id + '.x'], ty = env[o.id + '.y'];
if (typeof tx === 'number' && typeof ty === 'number') {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([tx, ty]);
if (arr.length > 2000) arr.shift();
}
} else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env);
}
}
// нарисовать трассы под объектами
for (var ti = 0; ti < this._objs.length; ti++) {
var ot = this._objs[ti];
if ((ot.trail || (ot.type === 'plot' && ot.trace)) &&
this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor || ot.color);
}
}
// объекты
this._labelLayer.innerHTML = '';
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
};
/* накопить точку plot-trace (var=t) во след по времени */
SimEngineInstance.prototype._accumPlotTrace = function (o, env) {
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
var prev = env[o.varName];
env[o.varName] = env.t;
var ty = o.exprFn.ev(env);
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
if (typeof ty === 'number' && isFinite(ty)) {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([env.t, ty]);
if (arr.length > 2000) arr.shift();
}
};
SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) {
ctx.save();
ctx.strokeStyle = color;
ctx.globalAlpha = 0.55;
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
ctx.beginPath();
for (var i = 0; i < pts.length; i++) {
var px = this._toPx(pts[i][0], pts[i][1]);
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
};
SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
var B = o.b;
switch (o.type) {
case 'point': {
var p = this._toPx(B.x.ev(env), B.y.ev(env));
// r точки — экранный радиус в пикселях (выражение допустимо)
var r = Math.max(1, B.r.ev(env) || 6);
ctx.save();
ctx.fillStyle = o.color;
ctx.shadowColor = o.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
break;
}
case 'segment':
case 'vector': {
var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
if (o.type === 'vector') this._arrowHead(ctx, a, b, o.color);
ctx.restore();
break;
}
case 'circle': {
var c0 = this._toPx(B.x.ev(env), B.y.ev(env));
var rad = Math.abs(B.r.ev(env)) * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; }
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (o.fillColor) ctx.fill();
ctx.stroke();
ctx.restore();
break;
}
case 'rect': {
var cx = B.x.ev(env), cy = B.y.ev(env);
var rw = Math.abs(B.w.ev(env)), rh = Math.abs(B.h.ev(env));
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); }
ctx.strokeRect(tl[0], tl[1], pw, ph);
ctx.restore();
break;
}
case 'polyline':
case 'path': {
if (!o.pts || !o.pts.length) break;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
if (o.fillColor) ctx.fillStyle = o.fillColor;
ctx.beginPath();
for (var k = 0; k < o.pts.length; k++) {
var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env));
k === 0 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]);
}
if (o.closed) ctx.closePath();
if (o.fillColor) ctx.fill();
ctx.stroke();
ctx.restore();
break;
}
case 'label': {
var lp = this._toPx(B.x.ev(env), B.y.ev(env));
this._drawLabel(o, lp[0], lp[1]);
break;
}
case 'plot': {
this._drawPlot(ctx, o, env);
break;
}
case 'readout': {
this._drawReadout(o, env);
break;
}
}
};
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
var n = o.samples;
var step = (b - a) / (n - 1);
// env-копия с дополнительной свободной переменной (не мутируем общий env навсегда)
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
ctx.strokeStyle = o.color;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
var started = false;
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = o.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
var px = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
else ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
SimEngineInstance.prototype._drawReadout = function (o, env) {
// мягкая ошибка через evalSafe (NaN/∞/синтаксис -> error, бейдж покажет «—»)
var res = (global.SimExpr && o.exprAst)
? global.SimExpr.evalSafe(o.exprAst, env)
: { value: o.exprFn.ev(env), error: null };
var el = document.createElement('div');
var px, py;
if (o.hasPos) {
var p = this._toPx(o.b.x.ev(env), o.b.y.ev(env));
px = p[0]; py = p[1];
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);' + _readoutBadgeCss(o.color);
} else {
// дефолт: верх-правый угол сцены, бейджи столбиком
var idx = (this._readoutSlot = (this._readoutSlot || 0) + 1) - 1;
el.style.cssText = 'position:absolute;right:10px;top:' + (10 + idx * 30) + 'px;' +
_readoutBadgeCss(o.color);
}
if (o.hasPos) { el.style.left = px + 'px'; el.style.top = py + 'px'; }
var valTxt = res.error ? '—' : _fmtFixed(res.value, o.precision);
var html = '';
if (o.label) html += '<span style="opacity:.7;margin-right:5px">' + _esc(o.label) + '</span>';
html += '<span style="font-weight:700;font-variant-numeric:tabular-nums">' + _esc(valTxt) + '</span>';
if (o.unit && !res.error) html += '<span style="opacity:.6;margin-left:3px">' + _esc(o.unit) + '</span>';
el.innerHTML = html;
this._labelLayer.appendChild(el);
};
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var s = 9;
ctx.save();
ctx.fillStyle = color;
ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55);
ctx.closePath(); ctx.fill();
ctx.restore();
};
/* Подпись: KaTeX (тем же путём, что graph.js) с фолбэком на текст. */
SimEngineInstance.prototype._drawLabel = function (o, px, py) {
var el = document.createElement('div');
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font-size:' + o.size +
'px;color:' + o.color + ';white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.6)';
el.style.left = px + 'px';
el.style.top = py + 'px';
var txt = o.text || '';
if (o.latex && typeof global.katex !== 'undefined' && txt) {
try {
el.innerHTML = global.katex.renderToString(txt, { throwOnError: false, strict: false, displayMode: false });
} catch (e) { el.textContent = txt; }
} else {
el.textContent = txt;
}
this._labelLayer.appendChild(el);
};
/* ── сетка/оси (мат. координаты, Y вверх) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var step = this._niceStep(vp);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
var x;
var x0 = Math.ceil(vp.xmin / step) * step;
for (x = x0; x <= vp.xmax + 1e-9; x += step) {
var pxv = this._toPx(x, 0)[0];
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
var y0 = Math.ceil(vp.ymin / step) * step, y;
for (y = y0; y <= vp.ymax + 1e-9; y += step) {
var pyv = this._toPx(0, y)[1];
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
ctx.restore();
};
SimEngineInstance.prototype._niceStep = function (vp) {
var span = Math.max(vp.xmax - vp.xmin, vp.ymax - vp.ymin);
var raw = span / 10;
var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
var arr = [1, 2, 5, 10];
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= raw) return arr[i] * p;
return p * 10;
};
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var o = this._toPx(0, 0);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
// ось X (если 0 в диапазоне Y)
if (0 >= vp.ymin && 0 <= vp.ymax) {
ctx.beginPath(); ctx.moveTo(0, o[1]); ctx.lineTo(W, o[1]); ctx.stroke();
}
// ось Y (если 0 в диапазоне X)
if (0 >= vp.xmin && 0 <= vp.xmax) {
ctx.beginPath(); ctx.moveTo(o[0], 0); ctx.lineTo(o[0], H); ctx.stroke();
}
ctx.restore();
};
/* ════════════════════ Жизненный цикл ════════════════════ */
SimEngineInstance.prototype.play = function () {
if (this._running || this._destroyed) return;
this._running = true;
this._syncPlayBtn();
var self = this;
var time = this.spec.time || {};
var speed = num(time.speed, 1);
var dur = num(time.duration, 0);
var loop = time.loop !== false;
this._last = (global.performance || Date).now();
function frame(now) {
if (!self._running) return;
var dt = Math.min((now - self._last) / 1000, 0.05) * speed;
self._last = now;
self._t += dt;
if (dur > 0 && self._t > dur) {
if (loop) { self._t = 0; self._trails = {}; }
else { self._t = dur; self._renderFrame(); self.pause(); return; }
}
self._renderFrame();
self._raf = global.requestAnimationFrame(frame);
}
this._raf = global.requestAnimationFrame(frame);
};
SimEngineInstance.prototype.pause = function () {
this._running = false;
if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; }
this._syncPlayBtn();
};
SimEngineInstance.prototype.reset = function () {
this.pause();
this._t = 0;
this._trails = {};
this._renderFrame();
};
SimEngineInstance.prototype.setParam = function (name, value) {
var v = parseFloat(value);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) { sl.value = String(v); sl.dispatchEvent(new Event('input')); }
else if (!this._running) this._renderFrame();
};
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
SimEngineInstance.prototype.isRunning = function () { return this._running; };
SimEngineInstance.prototype.destroy = function () {
this.pause();
this._destroyed = true;
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
// снять drag-слушатели
if (this.canvas && this._onPointerDown) {
var c = this.canvas;
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._dragging = null;
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
/* ── format helpers ── */
function _fmt(v) {
if (!isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(3)).toString();
}
function _fmtFixed(v, prec) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(prec);
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _readoutBadgeCss(color) {
return 'pointer-events:none;font-family:Manrope,system-ui,sans-serif;font-size:.78rem;' +
'color:' + (color || '#06D6E0') + ';background:rgba(13,13,26,0.78);' +
'border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:3px 9px;' +
'white-space:nowrap;backdrop-filter:blur(2px)';
}
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
/* ════════════════════ public ════════════════════ */
function mount(host, spec) {
if (!host) throw new Error('SimEngine.mount: нет host-элемента');
return new SimEngineInstance(host, spec || {});
}
global.SimEngine = {
mount: mount,
_Instance: SimEngineInstance // экспонируем для тестов/билдера (Фаза 4)
};
})(typeof window !== 'undefined' ? window : globalThis);