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

617 lines
26 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 }
]
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.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._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._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;
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);
}
// первичная подгонка после 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' || type === 'vector') { 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);
}
// привязки для центра объекта (для 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];
};
/* ════════════════════ Рендер кадра ════════════════════ */
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();
// обновить трассы
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();
}
}
}
// нарисовать трассы под объектами
for (var ti = 0; ti < this._objs.length; ti++) {
var ot = this._objs[ti];
if (ot.trail && this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor);
}
}
// объекты
this._labelLayer.innerHTML = '';
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
};
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;
}
}
};
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; }
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
/* ── format helper ── */
function _fmt(v) {
if (!isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(3)).toString();
}
/* ════════════════════ 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);