'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'; /* Приятная дефолт-палитра объектов (если 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 ? '' : ''; }; SimEngineInstance.prototype._resetIcon = function () { return ''; }; // шеврон: up=панель развёрнута (клик свернёт), down=панель свёрнута (клик развернёт) SimEngineInstance.prototype._chevIcon = function (expanded) { return expanded ? '' : ''; }; // «вписать»: рамка-кадрирование SimEngineInstance.prototype._fitIcon = function () { return ''; }; // «сбросить вид»: круговая стрелка с точкой-прицелом SimEngineInstance.prototype._resetViewIcon = 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: {} }; // общие визуальные поля (не привязки) // цвет: явный из спеки, иначе циклическая дефолт-палитра (приятнее единого #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 += '' + _esc(o.label) + ''; html += '' + _esc(valTxt) + ''; if (o.unit && !res.error) html += '' + _esc(o.unit) + ''; 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, '&').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);