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

1897 lines
92 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? } },
// ── Фаза 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 (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
Для физических тел (body) в env кладутся <objId>.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';
/* Приятная дефолт-палитра объектов (если color не задан в спеке) — циклически по
индексу объекта. Холодно-яркие тона, контрастные на тёмном фоне DEFAULT_BG. */
var DEFAULT_PALETTE = [
'#22D3EE', // cyan
'#A78BFA', // violet
'#F472B6', // pink
'#34D399', // emerald
'#FBBF24', // amber
'#60A5FA', // blue
'#FB7185', // rose
'#4ADE80' // green
];
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
/* dash-паттерн для lineStyle (в пикселях; масштабируется толщиной линии). */
function _dashFor(style, width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
if (style === 'dashed') return [Math.max(6, w * 3), Math.max(4, w * 2.2)];
if (style === 'dotted') return [Math.max(1, w * 0.9), Math.max(3, w * 2)];
return null; // solid
}
/* нормализовать opacity к [0..1]; не число -> 1 (без прозрачности). */
function _opacity(v) {
if (typeof v !== 'number' || !isFinite(v)) return 1;
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
/* Компилятор свойства: число/строка -> { 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;
// ── вид (zoom/pan) ──
this._scale = 1; this._offX = 0; this._offY = 0; // эффективный transform
this._baseScale = 1; this._baseOffX = 0; this._baseOffY = 0; // базовый fit
this._zoom = 1; // пользовательский множитель к базовому масштабу
this._viewLocked = false; // true после зума/пана — ресайз не сбрасывает вид
this._panning = null; // активный pan { lastX, lastY }
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;position:relative;display:block;width:100%;height:100%;' +
'overflow:hidden;background:' + (this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
this.el = root;
// ── сцена на ВСЮ площадь хоста (canvas + оверлей подписей) ──
// (раньше сцена делила строку flex с фикс-панелью 260px и визуально съезжала
// вправо у пустых симуляций — теперь сцена занимает весь root, центрирована.)
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'position:absolute;inset:0;overflow:hidden';
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
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(stage);
this._stage = stage;
// ── плавающая панель контролов (overlay поверх сцены, сворачиваемая) ──
// pointer-events только на самой карточке: пустое место сцены под ней доступно
// для pan/drag. Если параметров нет — панель минимальна (только play/reset).
var panel = document.createElement('div');
panel.className = 'sim-spec-panel';
panel.style.cssText = 'position:absolute;left:10px;top:10px;z-index:5;max-width:248px;' +
'max-height:calc(100% - 20px);display:flex;flex-direction:column;gap:10px;' +
'background:rgba(13,13,26,0.82);border:1px solid rgba(255,255,255,0.10);border-radius:14px;' +
'padding:10px 12px;overflow-y:auto;backdrop-filter:blur(6px);pointer-events:auto;' +
'box-shadow:0 8px 28px rgba(0,0,0,0.35)';
this._panel = panel;
// заголовок: 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);
// тело панели (слайдеры) — сворачивается
var pBody = document.createElement('div');
pBody.style.cssText = 'display:flex;flex-direction:column;gap:10px';
this._panelBody = pBody;
var params = Array.isArray(spec.params) ? spec.params : [];
// кнопка сворачивания — только если есть что сворачивать (есть параметры)
if (params.length) {
var btnCollapse = this._btn(this._chevIcon(true), 'Свернуть панель');
this._btnCollapse = btnCollapse;
btnCollapse.style.marginLeft = 'auto';
btnCollapse.addEventListener('click', function () { self._togglePanel(); });
ctrlRow.appendChild(btnCollapse);
}
panel.appendChild(ctrlRow);
// слайдеры параметров
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;gap:8px;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);
pBody.appendChild(wrap);
self._sliders[p.name] = slider;
});
if (params.length) panel.appendChild(pBody);
stage.appendChild(panel);
// ── кнопки управления видом («Вписать» / «Сбросить вид») в углу сцены ──
this._buildViewControls(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();
// zoom (колесо) + pan (drag пустого места)
this._setupZoomPan();
// первичная подгонка после layout
requestAnimationFrame(function () {
self._fit();
self.reset();
var time = spec.time || {};
if (time.autoplay) self.play();
});
};
/* свернуть/развернуть тело панели параметров */
SimEngineInstance.prototype._togglePanel = function () {
if (!this._panelBody) return;
this._panelCollapsed = !this._panelCollapsed;
this._panelBody.style.display = this._panelCollapsed ? 'none' : 'flex';
if (this._btnCollapse) this._btnCollapse.innerHTML = this._chevIcon(!this._panelCollapsed);
};
/* кнопки вида в правом-нижнем углу сцены: «Вписать» и «Сбросить вид» */
SimEngineInstance.prototype._buildViewControls = function (stage) {
var self = this;
var bar = document.createElement('div');
bar.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:5;display:flex;gap:6px;pointer-events:auto';
var btnFit = this._btn(this._fitIcon(), 'Вписать в окно');
var btnResetView = this._btn(this._resetViewIcon(), 'Сбросить вид');
btnFit.addEventListener('click', function () { self.fitView(); });
btnResetView.addEventListener('click', function () { self.resetView(); });
bar.appendChild(btnFit);
bar.appendChild(btnResetView);
stage.appendChild(bar);
this._viewBar = bar;
};
/* кнопка контрола (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>';
};
// шеврон: up=панель развёрнута (клик свернёт), down=панель свёрнута (клик развернёт)
SimEngineInstance.prototype._chevIcon = function (expanded) {
return expanded
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 15 12 9 18 15"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>';
};
// «вписать»: рамка-кадрирование
SimEngineInstance.prototype._fitIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M4 8V5a1 1 0 0 1 1-1h3"/><path d="M16 4h3a1 1 0 0 1 1 1v3"/><path d="M20 16v3a1 1 0 0 1-1 1h-3"/><path d="M8 20H5a1 1 0 0 1-1-1v-3"/></svg>';
};
// «сбросить вид»: круговая стрелка с точкой-прицелом
SimEngineInstance.prototype._resetViewIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3"/><path d="M12 18v3"/><path d="M3 12h3"/><path d="M18 12h3"/></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: {} };
// общие визуальные поля (не привязки)
// цвет: явный из спеки, иначе циклическая дефолт-палитра (приятнее единого #06D6E0)
prep.color = o.color || DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
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) : '';
// ── P2: расширенные стили графики (рендерятся ТОЛЬКО на canvas — XSS-безопасно) ──
// lineStyle: solid|dashed|dotted -> setLineDash; opacity 0..1 -> globalAlpha
prep.lineStyle = (o.lineStyle === 'dashed' || o.lineStyle === 'dotted') ? o.lineStyle : 'solid';
prep.opacity = (o.opacity === undefined || o.opacity === null) ? 1 : _opacity(o.opacity);
// glow/shadow: свечение акцентных объектов (по умолчанию ВЫКЛ — производительность)
prep.glow = (o.glow === true || (o.shadow && o.shadow !== false)) || false;
prep.glowColor = (o.shadow && typeof o.shadow === 'string') ? o.shadow
: (o.glowColor || prep.color);
prep.glowBlur = num((o.shadow && typeof o.shadow === 'object') ? o.shadow.blur : o.glowBlur, 12);
// линейный градиент заливки: gradient:[c0,c1] (цвета только в canvas-стоки)
prep.gradient = (Array.isArray(o.gradient) && o.gradient.length >= 2)
? [String(o.gradient[0]), String(o.gradient[1])] : null;
// стиль точки: filled|hollow|cross|ring (деф. filled — заполненный кружок с обводкой)
prep.pointStyle = (o.pointStyle === 'hollow' || o.pointStyle === 'cross' || o.pointStyle === 'ring')
? o.pointStyle : 'filled';
// трасса: затухание (fade — старые сегменты прозрачнее, деф. вкл), длина и толщина
prep.trailFade = (o.trailFade === false) ? false : true;
prep.trailWidth = num(o.trailWidth, 1.6);
prep.trailLen = (typeof o.trailLen === 'number' && o.trailLen > 1)
? Math.min(5000, o.trailLen | 0) : 2000;
// числовые/выражения-привязки по типу
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 вверх) с сохранением пропорций ──
Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с
наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу;
при активном пользовательском виде (_viewLocked) ресайз НЕ сбрасывает зум/пан —
мир-центр и пользовательский масштаб сохраняются. */
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));
// запомнить мир-точку под центром canvas (для сохранения вида при ресайзе)
var hadView = this._viewLocked && this._cw && this._ch && this._scale;
var worldCx, worldCy;
if (hadView) { var wc = this._toWorld(this._cw / 2, this._ch / 2); worldCx = wc[0]; worldCy = wc[1]; }
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);
var cxWorld = (vp.xmin + vp.xmax) / 2;
var cyWorld = (vp.ymin + vp.ymax) / 2;
this._baseScale = s;
this._baseOffX = w / 2 - cxWorld * s;
this._baseOffY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
if (hadView) {
// сохранить пользовательский зум-коэффициент и мир-центр после ресайза
var z = this._zoom || 1;
this._scale = s * z;
this._offX = w / 2 - worldCx * this._scale;
this._offY = h / 2 + worldCy * this._scale;
} else {
// нет активного пользовательского вида — эффективный = базовый
this._zoom = 1;
this._scale = this._baseScale;
this._offX = this._baseOffX;
this._offY = this._baseOffY;
}
};
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];
};
/* «Вписать»: вернуться к базовому fit-виду по viewport (как при mount). */
SimEngineInstance.prototype.fitView = function () {
this._viewLocked = false;
this._zoom = 1;
this._fit();
this._renderFrame();
};
/* «Сбросить вид» — то же, что «Вписать» (база = центрированный viewport). */
SimEngineInstance.prototype.resetView = function () { this.fitView(); };
/* ════════════════════ 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;
};
var HIT_PX = 16; // допуск хит-теста ручек/тел в экранных пикселях
/* локальные координаты pointer-события относительно canvas */
SimEngineInstance.prototype._localXY = function (ev) {
var r = this.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
};
/* хит-тест: ближайшая ручка/тело под (lx,ly) в пределах допуска, иначе null.
Общий для drag (приоритет ручек) и zoom/pan (pan только когда вернулся null). */
SimEngineInstance.prototype._pickHandleAt = function (lx, ly) {
var env = this._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = this._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 = this._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 = this._bodyById[o.id];
var rpx = rb ? rb.radius * (this._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;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
function localXY(ev) { return self._localXY(ev); }
function pickHandle(lx, ly) { return self._pickHandleAt(lx, ly); }
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 if (!self._panning) {
// hover-курсор: 'grab' и над ручкой/телом, и над пустым местом (pan)
self.canvas.style.cursor = 'grab';
}
};
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);
};
/* ════════════════════ Zoom (колесо к курсору) + Pan (drag пустого места) ════
Колесо масштабирует относительно позиции курсора: мир-точка под курсором
остаётся под курсором. Pan — перетаскивание ПУСТОГО места (приоритет у ручек/
тел: если pickHandle нашёл объект или drag уже активен — pan не стартует).
Множитель зума ограничен [MIN_ZOOM, MAX_ZOOM] относительно базового fit. */
SimEngineInstance.prototype._setupZoomPan = function () {
if (!this.canvas) return;
var self = this;
var c = this.canvas;
c.style.touchAction = 'none';
c.style.cursor = 'grab'; // пустое место сцены можно панить
// ── колесо: зум к курсору ──
this._onWheel = function (ev) {
ev.preventDefault();
var xy = self._localXY(ev);
var factor = Math.pow(1.0015, -ev.deltaY); // плавно; вверх (deltaY<0) — приблизить
self._zoomAt(xy[0], xy[1], factor);
};
c.addEventListener('wheel', this._onWheel, { passive: false });
// ── pan: pointer на пустом месте ──
this._onPanDown = function (ev) {
if (ev.button != null && ev.button !== 0) return;
if (self._dragging) return; // ручка/тело уже захвачены
var xy = self._localXY(ev);
if (self._pickHandleAt(xy[0], xy[1])) return; // попали в ручку/тело — не паним
self._panning = { x: xy[0], y: xy[1], pid: ev.pointerId };
ev.preventDefault();
try { c.setPointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'grabbing';
};
this._onPanMove = function (ev) {
if (!self._panning) return;
ev.preventDefault();
var xy = self._localXY(ev);
var dx = xy[0] - self._panning.x, dy = xy[1] - self._panning.y;
self._panning.x = xy[0]; self._panning.y = xy[1];
self._offX += dx; self._offY += dy;
self._viewLocked = true;
if (!self._running) self._renderFrame();
};
this._onPanUp = function (ev) {
if (!self._panning) return;
self._panning = null;
try { c.releasePointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'default';
};
c.addEventListener('pointerdown', this._onPanDown);
c.addEventListener('pointermove', this._onPanMove);
c.addEventListener('pointerup', this._onPanUp);
c.addEventListener('pointercancel', this._onPanUp);
};
/* зум вокруг экранной точки (lx,ly): мир-точка под курсором не сдвигается. */
SimEngineInstance.prototype._zoomAt = function (lx, ly, factor) {
var MIN_ZOOM = 0.1, MAX_ZOOM = 50;
var base = this._baseScale || this._scale || 1;
var newZoom = _clamp((this._zoom || 1) * factor, MIN_ZOOM, MAX_ZOOM);
if (newZoom === this._zoom) return;
// мир под курсором до зума
var w = this._toWorld(lx, ly);
this._zoom = newZoom;
this._scale = base * newZoom;
// сдвинуть offset так, чтобы (w) снова оказался под (lx,ly)
this._offX = lx - w[0] * this._scale;
this._offY = ly + w[1] * this._scale; // Y инвертирована
this._viewLocked = true;
if (!this._running) this._renderFrame();
};
/* кламп скорости броска (мир/с), чтобы рывок мыши не запускал тело в космос */
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 > o.trailLen) 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);
}
}
// пружины (под объектами, поверх трасс)
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();
}
};
/* Трасса: ломаная по накопленным точкам. С затуханием (trailFade) старые сегменты
рисуются прозрачнее новых (по-сегментно с растущей alpha) — «комета». Без fade —
одной линией (быстрее). Цвет/толщина/длина — из полей объекта (trailColor/trailWidth). */
SimEngineInstance.prototype._drawTrail = function (ctx, pts, o) {
var color = (o.trailColor || o.color);
var lw = o.trailWidth || 1.6;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
var n = pts.length;
if (!o.trailFade || n < 3) {
// без затухания — одной линией с постоянной полупрозрачностью
ctx.globalAlpha = 0.55;
ctx.beginPath();
for (var i = 0; i < n; 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();
return;
}
// с затуханием: посегментно, alpha растёт от хвоста (старые) к голове (новые)
var prev = this._toPx(pts[0][0], pts[0][1]);
for (var j = 1; j < n; j++) {
var cur = this._toPx(pts[j][0], pts[j][1]);
var f = j / (n - 1); // 0 (хвост) .. 1 (голова)
ctx.globalAlpha = 0.08 + f * 0.6; // плавно ярче к голове
ctx.beginPath();
ctx.moveTo(prev[0], prev[1]);
ctx.lineTo(cur[0], cur[1]);
ctx.stroke();
prev = cur;
}
ctx.restore();
};
/* Применить общие стили объекта к ctx (вызывать ВНУТРИ ctx.save()/restore() в каждой
ветке _drawObject, чтобы состояние не «протекало» между объектами). Ставит:
globalAlpha (opacity), lineWidth, lineJoin/lineCap (round — сглаженные стыки/торцы),
setLineDash (lineStyle), shadow (glow). Цвета НЕ ставит (их задаёт ветка по типу). */
SimEngineInstance.prototype._applyStroke = function (ctx, o) {
ctx.globalAlpha = o.opacity;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var dash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(dash || []);
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
};
/* Построить заливку для объекта: либо линейный градиент gradient:[c0,c1] по bbox
(x0,y0)-(x1,y1) в экранных px, либо сплошной fillColor. Возвращает CanvasGradient
или строку-цвет (оба — безопасные canvas-стоки), либо null если заливки нет. */
SimEngineInstance.prototype._fillStyleFor = function (ctx, o, x0, y0, x1, y1) {
if (o.gradient) {
try {
var g = ctx.createLinearGradient(x0, y0, x1, y1);
g.addColorStop(0, o.gradient[0]);
g.addColorStop(1, o.gradient[1]);
return g;
} catch (e) { /* мусорный цвет в градиенте — упасть на fillColor */ }
}
return o.fillColor || null;
};
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);
this._drawPoint(ctx, o, p[0], p[1], r);
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();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
if (o.type === 'vector') {
// укоротить тело стрелки на длину головы, чтобы линия не торчала сквозь остриё
var ang0 = Math.atan2(b[1] - a[1], b[0] - a[0]);
var headLen = this._arrowHeadLen(o.width);
var bx = b[0] - Math.cos(ang0) * headLen * 0.9;
var by = b[1] - Math.sin(ang0) * headLen * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]); // голова — всегда сплошная заливка
this._arrowHead(ctx, a, b, o.color, o.width);
} else {
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
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();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// заливка: градиент (вертикальный по bbox круга) или сплошной fillColor
var cFill = this._fillStyleFor(ctx, o, c0[0], c0[1] - rad, c0[0], c0[1] + rad);
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (cFill) { ctx.fillStyle = cFill; ctx.setLineDash([]); ctx.fill(); }
if (o.width > 0) {
var cDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(cDash || []);
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();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
var rFill = this._fillStyleFor(ctx, o, tl[0], tl[1], tl[0], tl[1] + ph); // верт. градиент
if (rFill) { ctx.fillStyle = rFill; ctx.setLineDash([]); ctx.fillRect(tl[0], tl[1], pw, ph); }
if (o.width > 0) {
var rDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(rDash || []);
ctx.strokeRect(tl[0], tl[1], pw, ph);
}
ctx.restore();
break;
}
case 'polyline':
case 'path': {
if (!o.pts || !o.pts.length) break;
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// bbox в экранных px для градиента (если задан)
var minX = Infinity, minY = Infinity, maxY = -Infinity;
var screenPts = [];
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));
screenPts.push(pp);
if (pp[0] < minX) minX = pp[0];
if (pp[1] < minY) minY = pp[1];
if (pp[1] > maxY) maxY = pp[1];
}
var plFill = this._fillStyleFor(ctx, o, minX, minY, minX, maxY);
ctx.beginPath();
for (var k2 = 0; k2 < screenPts.length; k2++) {
k2 === 0 ? ctx.moveTo(screenPts[k2][0], screenPts[k2][1])
: ctx.lineTo(screenPts[k2][0], screenPts[k2][1]);
}
if (o.closed) ctx.closePath();
// заливка только для замкнутого контура (path closed / polygon)
if (plFill && o.closed) { ctx.fillStyle = plFill; var savedDash = ctx.getLineDash ? ctx.getLineDash() : []; ctx.setLineDash([]); ctx.fill(); ctx.setLineDash(savedDash); }
if (o.width > 0) 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();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
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);
};
/* длина головы стрелки (px) — масштабируется от толщины линии (мин. 9px). */
SimEngineInstance.prototype._arrowHeadLen = function (width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
return Math.max(9, w * 3.2);
};
/* Аккуратная заполненная стрелка-голова (треугольник с лёгким вырезом у основания —
«барбед» силуэт, не «галочка»). Масштаб от width. Рисуется сплошной заливкой. */
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color, width) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var len = this._arrowHeadLen(width);
var halfW = len * 0.42; // полуширина основания
var notch = len * 0.26; // глубина выреза у основания (барб)
ctx.save();
ctx.fillStyle = color;
ctx.lineJoin = 'round';
ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0); // остриё
ctx.lineTo(-len, -halfW); // левое крыло
ctx.lineTo(-len + notch, 0); // вырез у основания
ctx.lineTo(-len, halfW); // правое крыло
ctx.closePath();
ctx.fill();
ctx.restore();
};
/* Точка: стиль pointStyle (filled|hollow|cross|ring). Применяет opacity/glow.
filled — заполненный кружок с тонкой обводкой (красивый дефолт);
hollow — только обводка; ring — толстое кольцо; cross — крестик. */
SimEngineInstance.prototype._drawPoint = function (ctx, o, px, py, r) {
ctx.save();
ctx.globalAlpha = o.opacity;
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var style = o.pointStyle;
if (style === 'cross') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
var c = r;
ctx.beginPath();
ctx.moveTo(px - c, py - c); ctx.lineTo(px + c, py + c);
ctx.moveTo(px - c, py + c); ctx.lineTo(px + c, py - c);
ctx.stroke();
} else if (style === 'hollow') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else if (style === 'ring') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(2, r * 0.5);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else {
// filled (деф.): заполненный кружок + тонкая контрастная обводка для чёткости
var fill = (o.fillColor || o.color);
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill();
if (!o.glow) { // тонкая обводка чётче без свечения
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
}
}
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);
};
/* видимые мир-границы текущего вьюпорта экрана (учёт zoom/pan).
Возвращает {xmin,xmax,ymin,ymax} в мировых координатах для всей области canvas. */
SimEngineInstance.prototype._visibleWorld = function (W, H) {
var a = this._toWorld(0, 0); // верх-лево экрана
var b = this._toWorld(W, H); // низ-право экрана
return {
xmin: Math.min(a[0], b[0]), xmax: Math.max(a[0], b[0]),
ymin: Math.min(a[1], b[1]), ymax: Math.max(a[1], b[1])
};
};
/* «красивый» шаг сетки (1/2/5·10^n), чтобы минорная линия была ~targetPx пикселей.
Завязан на текущий масштаб _scale (мир→px), поэтому адаптивен к zoom. */
SimEngineInstance.prototype._niceStep = function (targetPx) {
var s = this._scale || 1;
var worldPerTarget = targetPx / s; // сколько мир-единиц в targetPx
var p = Math.pow(10, Math.floor(Math.log10(worldPerTarget || 1)));
var arr = [1, 2, 5, 10];
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= worldPerTarget) return arr[i] * p;
return p * 10;
};
/* ── сетка: минорные (бледные/тонкие) + мажорные (каждые 5 минорных) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var minor = this._niceStep(34); // целевой шаг минорной линии ~34px
var major = minor * 5; // мажор каждые 5 минорных
this._gridStep = major; // запомнить для подписей осей
var EPS = minor * 1e-6;
// минорные
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
this._gridLines(ctx, W, H, vw, minor, EPS);
// мажорные (поверх, ярче)
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
this._gridLines(ctx, W, H, vw, major, EPS);
ctx.restore();
};
/* нарисовать набор линий с шагом step через всю область canvas (вертикали по X,
горизонтали по Y). Координаты округляются к .5px для резкости (без «ступенек»).
Major vs minor различаются ТОЛЬКО step + strokeStyle, который ставит вызывающий. */
SimEngineInstance.prototype._gridLines = function (ctx, W, H, vw, step, EPS) {
if (!(step > 0) || !isFinite(step)) return;
var x, y;
var x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
var pxv = Math.round(this._toPx(x, 0)[0]) + 0.5;
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
var y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
var pyv = Math.round(this._toPx(0, y)[1]) + 0.5;
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
};
/* ── оси X/Y с числовыми подписями делений + маркер origin (0,0) ── */
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var o = this._toPx(0, 0);
var step = this._gridStep || this._niceStep(34) * 5;
var EPS = step * 1e-6;
// позиции осей: на 0, либо прижаты к краю canvas, если 0 вне видимой области
var axisYpx = _clamp(o[1], 0, H); // экранная Y линии оси X
var axisXpx = _clamp(o[0], 0, W); // экранная X линии оси Y
var xAtEdge = (o[1] < 0 || o[1] > H);
var yAtEdge = (o[0] < 0 || o[0] > W);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.42)';
ctx.lineWidth = 1.4;
// ось X
ctx.beginPath(); ctx.moveTo(0, Math.round(axisYpx) + 0.5); ctx.lineTo(W, Math.round(axisYpx) + 0.5); ctx.stroke();
// ось Y
ctx.beginPath(); ctx.moveTo(Math.round(axisXpx) + 0.5, 0); ctx.lineTo(Math.round(axisXpx) + 0.5, H); ctx.stroke();
// ── подписи делений (на тёмном фоне: светлый текст с тенью) ──
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.shadowColor = 'rgba(0,0,0,0.85)';
ctx.shadowBlur = 3;
var dec = _stepDecimals(step);
// подписи по X (под осью X)
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
var labY = _clamp(axisYpx + 4, 2, H - 14);
var x, x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
if (Math.abs(x) < EPS) continue; // origin подпишем отдельно
var px = this._toPx(x, 0)[0];
if (px < 14 || px > W - 4) continue;
ctx.fillText(_axisNum(x, dec), px, labY);
}
// подписи по Y (слева от оси Y)
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
var labX = _clamp(axisXpx - 6, 22, W - 2);
var y, y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
if (Math.abs(y) < EPS) continue;
var py = this._toPx(0, y)[1];
if (py < 8 || py > H - 8) continue;
ctx.fillText(_axisNum(y, dec), labX, py);
}
ctx.shadowBlur = 0;
// ── маркер origin (0,0): точка + подпись «0», только если 0 в видимой области ──
if (!xAtEdge && !yAtEdge) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(o[0], o[1], 2.5, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.shadowColor = 'rgba(0,0,0,0.85)'; ctx.shadowBlur = 3;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.fillText('0', o[0] - 5, o[1] + 4);
ctx.shadowBlur = 0;
}
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) {
var c = this.canvas;
if (this._onPointerDown) {
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
// снять zoom/pan-слушатели
if (this._onWheel) c.removeEventListener('wheel', this._onWheel);
if (this._onPanDown) {
c.removeEventListener('pointerdown', this._onPanDown);
c.removeEventListener('pointermove', this._onPanMove);
c.removeEventListener('pointerup', this._onPanUp);
c.removeEventListener('pointercancel', this._onPanUp);
}
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._onWheel = this._onPanDown = this._onPanMove = this._onPanUp = null;
this._dragging = null;
this._panning = 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);
}
/* сколько знаков после запятой нужно для шага сетки (1/2/5·10^n) */
function _stepDecimals(step) {
if (!(step > 0) || !isFinite(step)) return 0;
var d = Math.ceil(-Math.log10(step));
return d > 0 ? Math.min(d, 6) : 0;
}
/* подпись деления оси: компактно, без лишних нулей; крупные/мелкие — экспонента */
function _axisNum(v, dec) {
if (!isFinite(v)) return '';
var a = Math.abs(v);
if (a !== 0 && (a >= 1e5 || a < 1e-4)) return v.toExponential(0).replace('e+', 'e');
var s = v.toFixed(dec);
// убрать хвостовые нули после точки
if (s.indexOf('.') >= 0) s = s.replace(/\.?0+$/, '');
return s;
}
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); }
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);