'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimEngine — рантайм спек-симуляций конструктора (Фаза 0).
Берёт JSON-спеку (данные, не код) и монтирует интерактивную сцену в host:
- canvas для геометрии/объектов (мир→экран по viewport, ось Y вверх);
- оверлей-слой
для подписей с LaTeX (рендер через KaTeX — тем же путём,
что graph.js: katex.renderToString);
- панель контролов: слайдеры из spec.params[] + кнопки play/pause/reset.
Любое числовое свойство объекта может быть числом ИЛИ строкой-выражением.
Выражения компилируются ОДИН РАЗ при mount (через SimExpr), в rAF-цикле — только
evaluate по окружению env = { t, ...params, .x, .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() (или 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? } },
// ── Фаза 2 (физика) ──
// Любой point/circle может стать физическим телом (интегрируется движком,
// а не формулой). Начальные x/y/vx/vy — числа ИЛИ выражения от params/констант
// (вычисляются один раз при reset/init, далее интегрируются).
{ id:'ball', type:'circle', r:0.6,
x:'x0', y:'y0', // начальная позиция (вычисляется на reset)
body:{ mass?:1, vx?:'0', vy?:'0', fixed?:false } }
],
// ── ФИЗИКА (Фаза 2) ── глобальный блок сил/мира. enabled включает интегратор.
physics: {
enabled: true,
gravity: { x:0, y:-9.8 }, // ускорение свободного падения (мир/с^2)
friction?: 0, // линейное вязкое затухание скорости (1/с)
restitution?: 0.9, // упругость столкновений 0..1 (деф. 1)
dt?: 1/240, // фикс. шаг интегратора (деф. 1/240, кламп)
walls?: [ // отражающие стены (мир-координаты)
{ side:'bottom'|'top'|'left'|'right' }, // авто из viewport-границы
{ x1,y1, x2,y2 } // произвольный отрезок-стена
],
springs?: [
{ a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка
k:40, length:2, damping?:0.5 }
]
}
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
.x / .y для объектов, у которых заданы числовые/выраж. x,y.
Для физических тел (body) в env кладутся .x/.y/.vx/.vy ИЗ СОСТОЯНИЯ
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
── ИНТЕРАКЦИИ (Фаза 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 };
}
/* ════════════════════════════════════════════════════════════════════════
SimPhysics — фиксированно-шаговый физический интегратор для спек-движка.
Опирается на ту же математику, что и LabFX.motion.spring (_fx_motion.js):
полу-неявный (симплектический) метод Эйлера
a = F/m ; v += a*dt ; x += v*dt
— но обобщён на набор связанных тел с гравитацией, пружинами, вязким
трением и упругими столкновениями (круг-круг, круг-стена). Шаг dt
фиксированный (накопитель), что даёт устойчивость (энергия не «взрывается»).
Тело: { id, x, y, vx, vy, mass, radius, fixed }.
world.step(bodies, springs, opts, dt) — продвигает состояние на один кадр.
Без eval/Function, без DOM — переиспользуемо headless (Ф3/билдер/тесты).
════════════════════════════════════════════════════════════════════════ */
var SimPhysics = (function () {
var MAX_V = 1e4; // кламп скорости (защита от взрыва численной нестабильности)
var MAX_SUBSTEPS = 8; // не более N фикс-шагов на кадр (защита от спирали смерти)
function clampV(v) { return v > MAX_V ? MAX_V : (v < -MAX_V ? -MAX_V : v); }
/* Один фиксированный шаг интегратора (полу-неявный Эйлер). */
function integrate(bodies, springs, opts, dt) {
var gx = opts.gx || 0, gy = opts.gy || 0;
var friction = opts.friction || 0; // вязкое затухание (1/с)
var i, b;
// 1) силы -> ускорение -> скорость (симплектический Эйлер): начинаем с гравитации
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) { b.vx = 0; b.vy = 0; continue; }
b.fx = gx * b.mass;
b.fy = gy * b.mass;
}
// 2) силы пружин (Гука + демпфирование по относительной скорости вдоль оси)
for (i = 0; i < springs.length; i++) {
applySpring(springs[i], dt);
}
// 3) интеграция скорости и позиции
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) continue;
var ax = b.fx / b.mass, ay = b.fy / b.mass;
b.vx += ax * dt;
b.vy += ay * dt;
// вязкое трение: экспоненциальное затухание скорости (устойчиво при любом dt)
if (friction > 0) {
var damp = Math.exp(-friction * dt);
b.vx *= damp; b.vy *= damp;
}
b.vx = clampV(b.vx); b.vy = clampV(b.vy);
b.x += b.vx * dt;
b.y += b.vy * dt;
}
}
/* Пружина между двумя концами. Конец — тело {x,y,vx,vy,mass,fixed} или
якорь {x,y,fixed:true,mass:Infinity}. Сила добавляется в b.fx/b.fy тел. */
function applySpring(s, dt) {
var a = s.a, b = s.b;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-6) return; // совпали — направление не определено
var nx = dx / dist, ny = dy / dist;
var ext = dist - s.length; // растяжение (>0) / сжатие (<0)
var fMag = s.k * ext; // закон Гука
// демпфирование вдоль оси пружины
if (s.damping) {
var rvx = (b.vx || 0) - (a.vx || 0);
var rvy = (b.vy || 0) - (a.vy || 0);
var relAlong = rvx * nx + rvy * ny;
fMag += s.damping * relAlong;
}
var fxs = fMag * nx, fys = fMag * ny;
// сила тянет a к b (если растянута) и отталкивает (если сжата)
if (!a.fixed && a.fx !== undefined) { a.fx += fxs; a.fy += fys; }
if (!b.fixed && b.fx !== undefined) { b.fx -= fxs; b.fy -= fys; }
}
/* Столкновения круг-круг (упругие, по нормали) + круг-стена/границы. */
function resolveCollisions(bodies, walls, restitution) {
var e = (typeof restitution === 'number') ? restitution : 1;
var i, j;
// круг-круг (broadphase O(n^2) — N мало)
for (i = 0; i < bodies.length; i++) {
for (j = i + 1; j < bodies.length; j++) {
collideCircles(bodies[i], bodies[j], e);
}
}
// круг-стена
for (i = 0; i < bodies.length; i++) {
for (j = 0; j < walls.length; j++) {
collideWall(bodies[i], walls[j], e);
}
}
}
function collideCircles(a, b, e) {
if (a.fixed && b.fixed) return;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var minD = a.radius + b.radius;
if (dist >= minD || dist < 1e-9) return;
var nx = dx / dist, ny = dy / dist;
// позиционная коррекция (распихать, чтобы не слипались) — по обратным массам
var imA = a.fixed ? 0 : 1 / a.mass;
var imB = b.fixed ? 0 : 1 / b.mass;
var imSum = imA + imB; if (imSum === 0) return;
var pen = minD - dist;
a.x -= nx * pen * (imA / imSum);
a.y -= ny * pen * (imA / imSum);
b.x += nx * pen * (imB / imSum);
b.y += ny * pen * (imB / imSum);
// относительная скорость вдоль нормали
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var velN = rvx * nx + rvy * ny;
if (velN > 0) return; // уже разлетаются
var jImp = -(1 + e) * velN / imSum;
var ix = jImp * nx, iy = jImp * ny;
a.vx -= ix * imA; a.vy -= iy * imA;
b.vx += ix * imB; b.vy += iy * imB;
}
/* Стена: либо нормаль+offset (из границы viewport), либо отрезок x1y1-x2y2. */
function collideWall(b, wall, e) {
if (b.fixed) return;
var nx = wall.nx, ny = wall.ny; // нормаль внутрь рабочей области
// расстояние от центра тела до плоскости стены вдоль нормали
var d = (b.x - wall.px) * nx + (b.y - wall.py) * ny;
if (d >= b.radius) return; // не касается
var pen = b.radius - d;
b.x += nx * pen; b.y += ny * pen; // вытолкнуть
var velN = b.vx * nx + b.vy * ny;
if (velN < 0) { // движется в стену — отразить
b.vx -= (1 + e) * velN * nx;
b.vy -= (1 + e) * velN * ny;
}
}
/* Продвинуть мир на dtFrame секунд фиксированными подшагами dt (накопитель). */
function step(state, dtFrame) {
var dt = state.dt;
state.acc += dtFrame;
var n = 0;
while (state.acc >= dt && n < MAX_SUBSTEPS) {
integrate(state.bodies, state.springs, state.opts, dt);
resolveCollisions(state.bodies, state.walls, state.opts.restitution);
state.acc -= dt;
n++;
}
// защита от спирали смерти: если накопилось слишком много — сбросить остаток
if (state.acc > dt) state.acc = 0;
}
return {
step: step,
integrate: integrate,
resolveCollisions: resolveCollisions,
_MAX_V: MAX_V
};
})();
global.SimPhysics = SimPhysics;
/* ════════════════════ 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-бейджей
// ── физика (Фаза 2) ──
this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc }
this._bodyById = {}; // objId -> body (для drag/env/пружин)
this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy }
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) {
// на паузе в начале — пересобрать нач. условия физ-тел (предпросмотр старта)
if (self._phys && self._t === 0) self._preparePhysics();
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
? ''
: '';
};
SimEngineInstance.prototype._resetIcon = function () {
return '';
};
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); }
}
// физическое тело (point/circle): интегрируется движком, а не формулой.
// Начальные x/y/vx/vy компилируем как выражения от params/констант (вычисляются
// один раз при reset/init), масса/радиус/fixed — статические.
if (o.body && (type === 'point' || type === 'circle')) {
var bd = o.body;
prep.body = {
mass0: bind(bd.mass !== undefined ? bd.mass : 1, 1), // масса: число/выражение от params
fixed: !!bd.fixed,
vx0: bind(bd.vx, 0), // нач. скорость X (число/выражение)
vy0: bind(bd.vy, 0), // нач. скорость Y
// радиус: для circle — мировой r (выражение); для point — экранный, мир-эквивалент
isCircle: (type === 'circle')
};
}
// 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;
};
/* ── физика: есть ли в спеке тела/включён ли интегратор ── */
SimEngineInstance.prototype._physEnabled = function () {
var ph = this.spec.physics;
if (!ph || ph.enabled === false) return false;
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].body) return true;
return false;
};
/* Собрать/пересобрать состояние физики (вызывается на reset/init).
Начальные позиции/скорости вычисляются из выражений по env с params (без t). */
SimEngineInstance.prototype._preparePhysics = function () {
this._phys = null;
this._bodyById = {};
if (!this._physEnabled() || !global.SimPhysics) return;
var ph = this.spec.physics || {};
var vp = this._vp();
// env без t (нач. условия зависят только от params/констант/размеров вьюпорта)
var env = this._buildParamEnv();
var bodies = [];
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (!o.body) continue;
var x0 = o.b.x ? o.b.x.ev(env) : 0;
var y0 = o.b.y ? o.b.y.ev(env) : 0;
// радиус тела в МИРОВЫХ единицах (для коллизий). circle: o.r — мировой.
// point: o.r — экранные px -> переводим в мир через текущий масштаб (фолбэк 0.3).
var rWorld;
if (o.body.isCircle) {
rWorld = o.b.r ? Math.abs(o.b.r.ev(env)) : 0.5;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.5;
} else {
var rpx = o.b.r ? o.b.r.ev(env) : 6;
rWorld = (this._scale && isFinite(rpx)) ? rpx / this._scale : 0.3;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.3;
}
var mass = num(o.body.mass0.ev(env), 1);
if (!(mass > 0) || !isFinite(mass)) mass = 1; // защита: масса > 0
var body = {
id: o.id,
x: num(x0, 0), y: num(y0, 0),
vx: num(o.body.vx0.ev(env), 0),
vy: num(o.body.vy0.ev(env), 0),
mass: mass,
radius: rWorld,
fixed: o.body.fixed,
fx: 0, fy: 0
};
bodies.push(body);
this._bodyById[o.id] = body;
}
// пружины: концы — id тела или якорь-точка [x,y]
var springs = [];
var rawSprings = Array.isArray(ph.springs) ? ph.springs : [];
for (var s = 0; s < rawSprings.length; s++) {
var rs = rawSprings[s] || {};
var endA = this._resolveSpringEnd(rs.a, env);
var endB = this._resolveSpringEnd(rs.b, env);
if (!endA || !endB) continue;
springs.push({
a: endA, b: endB,
k: num(bind(rs.k, 20).ev(env), 20), // k/length/damping — числа ИЛИ выражения от params
length: num(bind(rs.length, 1).ev(env), 1),
damping: num(bind(rs.damping, 0).ev(env), 0)
});
}
// стены: именованные стороны (из границ viewport) + произвольные отрезки
var walls = [];
var rawWalls = Array.isArray(ph.walls) ? ph.walls : [];
for (var w = 0; w < rawWalls.length; w++) {
var wl = this._buildWall(rawWalls[w], vp);
if (wl) walls.push(wl);
}
var dt = num(ph.dt, 1 / 240);
dt = _clamp(dt, 1 / 2000, 1 / 30); // кламп шага для устойчивости
this._phys = {
bodies: bodies,
springs: springs,
walls: walls,
opts: {
// gravity/friction/restitution — числа ИЛИ выражения от params (вычисляются на reset)
gx: ph.gravity ? num(bind(ph.gravity.x, 0).ev(env), 0) : 0,
gy: ph.gravity ? num(bind(ph.gravity.y, 0).ev(env), 0) : 0,
friction: num(bind(ph.friction, 0).ev(env), 0),
restitution: (ph.restitution === undefined || ph.restitution === null)
? 1 : _clamp(num(bind(ph.restitution, 1).ev(env), 1), 0, 1)
},
dt: dt,
acc: 0
};
};
/* конец пружины: строка-id тела -> сам body; массив [x,y]/[xExpr,yExpr] -> якорь. */
SimEngineInstance.prototype._resolveSpringEnd = function (end, env) {
if (typeof end === 'string') {
return this._bodyById[end] || null;
}
if (Array.isArray(end)) {
var ax = bind(end[0], 0).ev(env);
var ay = bind(end[1], 0).ev(env);
// якорь — «бесконечно тяжёлое» неподвижное тело (без fx-аккумуляции)
return { x: num(ax, 0), y: num(ay, 0), vx: 0, vy: 0, fixed: true };
}
return null;
};
/* стена -> { px,py (точка на стене), nx,ny (нормаль внутрь области) }. */
SimEngineInstance.prototype._buildWall = function (wl, vp) {
if (!wl) return null;
if (wl.side) {
switch (wl.side) {
case 'bottom': return { px: 0, py: vp.ymin, nx: 0, ny: 1 };
case 'top': return { px: 0, py: vp.ymax, nx: 0, ny: -1 };
case 'left': return { px: vp.xmin, py: 0, nx: 1, ny: 0 };
case 'right': return { px: vp.xmax, py: 0, nx: -1, ny: 0 };
}
return null;
}
// произвольный отрезок: нормаль = перпендикуляр, ориентированная к центру области
if (wl.x1 !== undefined && wl.x2 !== undefined) {
var x1 = num(wl.x1, 0), y1 = num(wl.y1, 0), x2 = num(wl.x2, 0), y2 = num(wl.y2, 0);
var dx = x2 - x1, dy = y2 - y1;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len, ny = dx / len;
// ориентировать нормаль к центру viewport
var cx = (vp.xmin + vp.xmax) / 2, cy = (vp.ymin + vp.ymax) / 2;
var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
if ((cx - mx) * nx + (cy - my) * ny < 0) { nx = -nx; ny = -ny; }
return { px: x1, py: y1, nx: nx, ny: ny };
}
return null;
};
/* env только из params/вьюпорта (без t, без obj.x) — для нач. условий тел. */
SimEngineInstance.prototype._buildParamEnv = 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 = 0;
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;
return env;
};
/* Окружение для 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;
// 1) физ-тела: x/y/vx/vy берутся ИЗ СОСТОЯНИЯ ИНТЕГРАТОРА, а не из выражения.
// Кладём их в env ПЕРВЫМИ — снимает forward-ref проблему однопроходного env:
// кинематические объекты, ссылающиеся на тело, видят его актуальную позицию.
if (this._phys) {
var bs = this._phys.bodies;
for (var bi = 0; bi < bs.length; bi++) {
var bb = bs[bi];
env[bb.id + '.x'] = bb.x;
env[bb.id + '.y'] = bb.y;
env[bb.id + '.vx'] = bb.vx;
env[bb.id + '.vy'] = bb.vy;
}
}
// 2) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.hasCenter && !o.body) {
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-интеракции (мышь + тач) ════════════════════
Перетаскиваемы: (1) объекты с prep.drag — ручки, пишут мир-коорд. в параметр;
(2) физ-тела (prep.body, не fixed) — тащишь напрямую: задаёт позицию тела, при
отпускании сообщает скорость (бросок). Слушаем pointer events на canvas. */
SimEngineInstance.prototype._hasHandles = function () {
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.drag) return true;
if (o.body && !o.body.fixed) 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];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = self._bodyById[o.id];
if (!body) continue;
ox = body.x; oy = body.y; hit = true;
// у тела допуск = max(HIT_PX, его экранный радиус)
} else if (o.drag && o.b.x && o.b.y) {
ox = o.b.x.ev(env); oy = o.b.y.ev(env); hit = true;
}
if (!hit) continue;
var p = self._toPx(ox, oy);
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
var tol = bestD;
if (o.body) {
var rb = self._bodyById[o.id];
var rpx = rb ? rb.radius * (self._scale || 1) : HIT_PX;
var t = Math.max(HIT_PX, rpx); t = t * t;
if (d <= t && d <= bestD) { bestD = d; best = o; }
continue;
}
if (d <= tol) { 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';
if (h.body) {
// захват тела: запоминаем для оценки скорости броска
var body = self._bodyById[h.id];
self._dragBody = { body: body, lastW: self._toWorld(xy[0], xy[1]), lastT: _nowMs(), vx: 0, vy: 0 };
if (body) { body.vx = 0; body.vy = 0; }
} else {
self._dragBody = null;
}
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;
// тело: при отпускании сообщить накопленную скорость (бросок)
if (self._dragBody && self._dragBody.body && !self._dragBody.body.fixed) {
self._dragBody.body.vx = SimEngineInstance._clampThrow(self._dragBody.vx);
self._dragBody.body.vy = SimEngineInstance._clampThrow(self._dragBody.vy);
}
self._dragging = null;
self._dragBody = 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._clampThrow = function (v) {
var MAX = 40;
if (!isFinite(v)) return 0;
return v > MAX ? MAX : (v < -MAX ? -MAX : v);
};
/* записать мир-координату курсора: в параметр(ы) ручки ИЛИ в позицию тела */
SimEngineInstance.prototype._applyDrag = function (h, lx, ly) {
var w = this._toWorld(lx, ly);
// ── перетаскивание физ-тела: ставим позицию, оцениваем скорость для броска ──
if (h.body && this._dragBody && this._dragBody.body) {
var body = this._dragBody.body;
body.x = w[0]; body.y = w[1];
var now = _nowMs();
var dt = (now - this._dragBody.lastT) / 1000;
if (dt > 0.0005) {
// экспоненциальное сглаживание оценки скорости
var ivx = (w[0] - this._dragBody.lastW[0]) / dt;
var ivy = (w[1] - this._dragBody.lastW[1]) / dt;
this._dragBody.vx = this._dragBody.vx * 0.5 + ivx * 0.5;
this._dragBody.vy = this._dragBody.vy * 0.5 + ivy * 0.5;
this._dragBody.lastW = w;
this._dragBody.lastT = now;
}
body.vx = 0; body.vy = 0; // пока держим — тело не интегрируется по скорости
if (!this._running) this._renderFrame();
return;
}
// ── перетаскивание ручки (Ф1): пишем в параметр(ы) ──
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);
}
}
// пружины (под объектами, поверх трасс)
if (this._phys && this._phys.springs.length) this._drawSprings(ctx);
// объекты
this._labelLayer.innerHTML = '';
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
};
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
SimEngineInstance.prototype._drawSprings = function (ctx) {
var sp = this._phys.springs;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
for (var i = 0; i < sp.length; i++) {
var s = sp[i];
var a = this._toPx(s.a.x, s.a.y);
var b = this._toPx(s.b.x, s.b.y);
var dx = b[0] - a[0], dy = b[1] - a[1];
var len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); continue; }
var ux = dx / len, uy = dy / len; // вдоль
var px = -uy, py = ux; // перпендикуляр
var coils = Math.max(4, Math.min(24, Math.round(len / 14)));
var amp = 5;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
for (var c = 1; c < coils; c++) {
var f = c / coils;
var off = (c % 2 === 0) ? amp : -amp;
ctx.lineTo(a[0] + ux * len * f + px * off, a[1] + uy * len * f + py * off);
}
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
ctx.restore();
};
/* накопить точку 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': {
// физ-тело: позиция из интегратора (env уже содержит obj.x/obj.y тела);
// формульная точка: из выражения.
var pxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var pyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var p = this._toPx(pxw, pyw);
// 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 cxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var cyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var c0 = this._toPx(cxw, cyw);
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 += '' + _esc(o.label) + '';
html += '' + _esc(valTxt) + '';
if (o.unit && !res.error) html += '' + _esc(o.unit) + '';
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 = {}; self._preparePhysics(); }
else { self._t = dur; self._renderFrame(); self.pause(); return; }
}
// продвинуть физику фиксированными подшагами (если есть)
if (self._phys) self._stepPhysics(dt);
self._renderFrame();
self._raf = global.requestAnimationFrame(frame);
}
this._raf = global.requestAnimationFrame(frame);
};
/* продвинуть физику; удерживаемое тело временно «приколото» (fixed), чтобы
интегратор не уносил его, пока его тащит палец/мышь. */
SimEngineInstance.prototype._stepPhysics = function (dtFrame) {
if (!global.SimPhysics) return;
var held = (this._dragBody && this._dragBody.body) ? this._dragBody.body : null;
var wasFixed = held ? held.fixed : false;
if (held) { held.fixed = true; held.vx = 0; held.vy = 0; }
global.SimPhysics.step(this._phys, dtFrame);
if (held) held.fixed = wasFixed;
};
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._dragBody = null;
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
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;
this._dragBody = null;
this._phys = null;
this._bodyById = {};
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, '&').replace(//g, '>');
}
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); }
function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); }
/* ════════════════════ 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);