'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 } ] } Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также .x / .y для объектов, у которых заданы числовые/выраж. x,y. ── API инстанса ────────────────────────────────────────────────────────── var inst = SimEngine.mount(host, spec); inst.play() inst.pause() inst.reset() inst.setParam(name, value) inst.getParam(name) -> number inst.isRunning() -> bool inst.destroy() inst.el -> корневой DOM-узел (для скрытия/показа адаптером) ════════════════════════════════════════════════════════════════════════ */ (function (global) { var DEFAULT_BG = '#0D0D1A'; function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; } /* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */ function bind(value, dflt) { if (value === undefined || value === null) { var d = dflt; return { ev: function () { return d; }, constant: true }; } var c = global.SimExpr ? global.SimExpr.compileValue(value) : { fn: function () { return num(value, dflt); }, constant: true }; return { ev: c.fn, constant: !!c.constant, error: c.error || null }; } /* ════════════════════ Instance ════════════════════ */ function SimEngineInstance(host, spec) { this.host = host; this.spec = spec || {}; this.params = {}; // name -> текущее значение (number) this._sliders = {}; // name -> input element this._objs = []; // подготовленные объекты с привязками this._trails = {}; // objId -> [[x,y],...] (мир-координаты) this._t = 0; this._running = false; this._raf = 0; this._last = 0; this._dpr = 1; this._cw = 0; this._ch = 0; this._destroyed = false; this._ro = null; this._build(); } SimEngineInstance.prototype._vp = function () { var v = this.spec.viewport || {}; return { xmin: num(v.xmin, -1), xmax: num(v.xmax, 10), ymin: num(v.ymin, -1), ymax: num(v.ymax, 10), grid: v.grid !== false, axes: v.axes !== false, bg: v.bg || DEFAULT_BG }; }; SimEngineInstance.prototype._build = function () { var self = this; var spec = this.spec; // корень var root = document.createElement('div'); root.className = 'sim-spec-root'; root.style.cssText = 'flex:1;min-height:0;display:flex;width:100%;height:100%;background:' + (this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif'; this.el = root; // ── панель контролов слева ── var panel = document.createElement('div'); panel.className = 'sim-spec-panel'; panel.style.cssText = 'width:260px;flex-shrink:0;background:rgba(13,13,26,0.92);' + 'border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column;' + 'gap:10px;padding:16px 14px;overflow-y:auto'; // заголовок + кнопки play/pause/reset var ctrlRow = document.createElement('div'); ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center'; var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза'); var btnReset = this._btn(this._resetIcon(), 'Сброс'); this._btnPlay = btnPlay; btnPlay.addEventListener('click', function () { self._running ? self.pause() : self.play(); }); btnReset.addEventListener('click', function () { self.reset(); }); ctrlRow.appendChild(btnPlay); ctrlRow.appendChild(btnReset); panel.appendChild(ctrlRow); // слайдеры параметров var params = Array.isArray(spec.params) ? spec.params : []; params.forEach(function (p) { if (!p || !p.name) return; var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1); var val = num(p.value, min); self.params[p.name] = val; var wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px'; var lblRow = document.createElement('div'); lblRow.style.cssText = 'display:flex;justify-content:space-between;font-size:.74rem;color:rgba(255,255,255,.6)'; var lblName = document.createElement('span'); lblName.textContent = p.label || p.name; var lblVal = document.createElement('span'); lblVal.style.cssText = 'color:#06D6E0;font-weight:700;font-variant-numeric:tabular-nums'; lblVal.textContent = _fmt(val) + (p.unit ? ' ' + p.unit : ''); lblRow.appendChild(lblName); lblRow.appendChild(lblVal); var slider = document.createElement('input'); slider.type = 'range'; slider.min = String(min); slider.max = String(max); slider.step = String(step); slider.value = String(val); slider.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer'; slider.addEventListener('input', function () { var v = parseFloat(slider.value); self.params[p.name] = v; lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : ''); if (!self._running) self._renderFrame(); // живой предпросмотр на паузе }); wrap.appendChild(lblRow); wrap.appendChild(slider); panel.appendChild(wrap); self._sliders[p.name] = slider; }); // ── сцена справа (canvas + оверлей подписей) ── var stage = document.createElement('div'); stage.className = 'sim-spec-stage'; stage.style.cssText = 'flex:1;min-width:0;position:relative;overflow:hidden'; var canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; stage.appendChild(canvas); this.canvas = canvas; this.ctx = canvas.getContext('2d'); var labels = document.createElement('div'); labels.className = 'sim-spec-labels'; labels.style.cssText = 'position:absolute;inset:0;pointer-events:none'; stage.appendChild(labels); this._labelLayer = labels; root.appendChild(panel); root.appendChild(stage); this.host.appendChild(root); // подготовить объекты (компиляция привязок один раз) this._prepareObjects(); // resize if (global.ResizeObserver) { this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); }); this._ro.observe(stage); } // первичная подгонка после layout requestAnimationFrame(function () { self._fit(); self.reset(); var time = spec.time || {}; if (time.autoplay) self.play(); }); }; /* кнопка контрола (inline SVG, без эмодзи) */ SimEngineInstance.prototype._btn = function (innerSvg, title) { var b = document.createElement('button'); b.title = title || ''; b.innerHTML = innerSvg; b.style.cssText = 'min-width:34px;height:34px;border-radius:10px;border:1.5px solid rgba(255,255,255,.18);' + 'background:transparent;color:rgba(255,255,255,.75);cursor:pointer;display:flex;align-items:center;' + 'justify-content:center;padding:0 9px;transition:all .15s'; b.addEventListener('mouseenter', function () { b.style.borderColor = '#9B5DE5'; b.style.color = '#9B5DE5'; }); b.addEventListener('mouseleave', function () { b.style.borderColor = 'rgba(255,255,255,.18)'; b.style.color = 'rgba(255,255,255,.75)'; }); return b; }; SimEngineInstance.prototype._playIcon = function (play) { return play ? '' : ''; }; SimEngineInstance.prototype._resetIcon = function () { return ''; }; SimEngineInstance.prototype._syncPlayBtn = function () { if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running); }; /* Компиляция привязок объектов один раз. */ SimEngineInstance.prototype._prepareObjects = function () { var raw = Array.isArray(this.spec.objects) ? this.spec.objects : []; var out = []; for (var i = 0; i < raw.length; i++) { var o = raw[i] || {}; var type = o.type || 'point'; var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} }; // общие визуальные поля (не привязки) prep.color = o.color || '#06D6E0'; prep.fillColor = o.fill || o.fillColor || null; prep.width = num(o.width, 2); prep.trail = !!o.trail; prep.trailColor = o.trailColor || prep.color; prep.latex = o.latex !== false; // подписи по умолчанию рендерятся KaTeX prep.size = num(o.size, 14); prep.text = o.text != null ? String(o.text) : ''; // числовые/выражения-привязки по типу var B = prep.b; function bp(key, dflt) { B[key] = bind(o[key], dflt); } if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); } else if (type === 'segment' || type === 'vector') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); } else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); } else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); } else if (type === 'polyline' || type === 'path') { prep.pts = (Array.isArray(o.points) ? o.points : []).map(function (pt) { return { x: bind(pt[0], 0), y: bind(pt[1], 0) }; }); prep.closed = !!o.closed; } else if (type === 'label') { bp('x', 0); bp('y', 0); } // привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label if (B.x && B.y) { prep.hasCenter = true; } out.push(prep); } this._objs = out; }; /* Окружение для evaluate: t, params, w/h, и центры объектов (obj.x/obj.y). */ SimEngineInstance.prototype._buildEnv = function () { var env = {}; var p = this.params; for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k]; env.t = this._t; var vp = this._vp(); env.w = vp.xmax - vp.xmin; env.h = vp.ymax - vp.ymin; env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax; // двухпроходно: центры объектов могут ссылаться друг на друга (одношагово, // без рекурсии — для большинства сцен достаточно одного прохода). for (var i = 0; i < this._objs.length; i++) { var o = this._objs[i]; if (o.hasCenter) { var x = o.b.x.ev(env); var y = o.b.y.ev(env); env[o.id + '.x'] = x; env[o.id + '.y'] = y; } } return env; }; /* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── */ SimEngineInstance.prototype._fit = function () { var c = this.canvas; if (!c) return; var dpr = Math.min(global.devicePixelRatio || 1, 2); var stage = c.parentElement || c; var r = stage.getBoundingClientRect(); var w = Math.max(1, Math.round(r.width)); var h = Math.max(1, Math.round(r.height)); this._dpr = dpr; this._cw = w; this._ch = h; c.width = w * dpr; c.height = h * dpr; if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // вычислить масштаб «вписать viewport, равные оси» var vp = this._vp(); var vw = vp.xmax - vp.xmin || 1; var vh = vp.ymax - vp.ymin || 1; var pad = 16; var sx = (w - pad * 2) / vw; var sy = (h - pad * 2) / vh; var s = Math.min(sx, sy); this._scale = s; // центр мира в центре canvas var cxWorld = (vp.xmin + vp.xmax) / 2; var cyWorld = (vp.ymin + vp.ymax) / 2; this._offX = w / 2 - cxWorld * s; this._offY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется }; SimEngineInstance.prototype._toPx = function (mx, my) { return [this._offX + mx * this._scale, this._offY - my * this._scale]; }; /* ════════════════════ Рендер кадра ════════════════════ */ SimEngineInstance.prototype._renderFrame = function () { var ctx = this.ctx; if (!ctx) return; var W = this._cw, H = this._ch; if (!W || !H) return; var vp = this._vp(); ctx.fillStyle = vp.bg; ctx.fillRect(0, 0, W, H); if (vp.grid) this._drawGrid(ctx, W, H, vp); if (vp.axes) this._drawAxes(ctx, W, H, vp); var env = this._buildEnv(); // обновить трассы for (var i = 0; i < this._objs.length; i++) { var o = this._objs[i]; if (o.trail && o.hasCenter) { var tx = env[o.id + '.x'], ty = env[o.id + '.y']; if (typeof tx === 'number' && typeof ty === 'number') { var arr = this._trails[o.id] || (this._trails[o.id] = []); arr.push([tx, ty]); if (arr.length > 2000) arr.shift(); } } } // нарисовать трассы под объектами for (var ti = 0; ti < this._objs.length; ti++) { var ot = this._objs[ti]; if (ot.trail && this._trails[ot.id] && this._trails[ot.id].length > 1) { this._drawTrail(ctx, this._trails[ot.id], ot.trailColor); } } // объекты this._labelLayer.innerHTML = ''; for (var j = 0; j < this._objs.length; j++) { this._drawObject(ctx, this._objs[j], env); } }; SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) { ctx.save(); ctx.strokeStyle = color; ctx.globalAlpha = 0.55; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.beginPath(); for (var i = 0; i < pts.length; i++) { var px = this._toPx(pts[i][0], pts[i][1]); i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]); } ctx.stroke(); ctx.restore(); }; SimEngineInstance.prototype._drawObject = function (ctx, o, env) { var B = o.b; switch (o.type) { case 'point': { var p = this._toPx(B.x.ev(env), B.y.ev(env)); // r точки — экранный радиус в пикселях (выражение допустимо) var r = Math.max(1, B.r.ev(env) || 6); ctx.save(); ctx.fillStyle = o.color; ctx.shadowColor = o.color; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fill(); ctx.restore(); break; } case 'segment': case 'vector': { var a = this._toPx(B.x1.ev(env), B.y1.ev(env)); var b = this._toPx(B.x2.ev(env), B.y2.ev(env)); ctx.save(); ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); if (o.type === 'vector') this._arrowHead(ctx, a, b, o.color); ctx.restore(); break; } case 'circle': { var c0 = this._toPx(B.x.ev(env), B.y.ev(env)); var rad = Math.abs(B.r.ev(env)) * this._scale; ctx.save(); ctx.strokeStyle = o.color; ctx.lineWidth = o.width; if (o.fillColor) { ctx.fillStyle = o.fillColor; } ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2); if (o.fillColor) ctx.fill(); ctx.stroke(); ctx.restore(); break; } case 'rect': { var cx = B.x.ev(env), cy = B.y.ev(env); var rw = Math.abs(B.w.ev(env)), rh = Math.abs(B.h.ev(env)); var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх) var pw = rw * this._scale, ph = rh * this._scale; ctx.save(); ctx.strokeStyle = o.color; ctx.lineWidth = o.width; if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); } ctx.strokeRect(tl[0], tl[1], pw, ph); ctx.restore(); break; } case 'polyline': case 'path': { if (!o.pts || !o.pts.length) break; ctx.save(); ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; if (o.fillColor) ctx.fillStyle = o.fillColor; ctx.beginPath(); for (var k = 0; k < o.pts.length; k++) { var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env)); k === 0 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]); } if (o.closed) ctx.closePath(); if (o.fillColor) ctx.fill(); ctx.stroke(); ctx.restore(); break; } case 'label': { var lp = this._toPx(B.x.ev(env), B.y.ev(env)); this._drawLabel(o, lp[0], lp[1]); break; } } }; SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) { var ang = Math.atan2(b[1] - a[1], b[0] - a[0]); var s = 9; ctx.save(); ctx.fillStyle = color; ctx.translate(b[0], b[1]); ctx.rotate(ang); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55); ctx.closePath(); ctx.fill(); ctx.restore(); }; /* Подпись: KaTeX (тем же путём, что graph.js) с фолбэком на текст. */ SimEngineInstance.prototype._drawLabel = function (o, px, py) { var el = document.createElement('div'); el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font-size:' + o.size + 'px;color:' + o.color + ';white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.6)'; el.style.left = px + 'px'; el.style.top = py + 'px'; var txt = o.text || ''; if (o.latex && typeof global.katex !== 'undefined' && txt) { try { el.innerHTML = global.katex.renderToString(txt, { throwOnError: false, strict: false, displayMode: false }); } catch (e) { el.textContent = txt; } } else { el.textContent = txt; } this._labelLayer.appendChild(el); }; /* ── сетка/оси (мат. координаты, Y вверх) ── */ SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) { var step = this._niceStep(vp); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.065)'; ctx.lineWidth = 1; var x; var x0 = Math.ceil(vp.xmin / step) * step; for (x = x0; x <= vp.xmax + 1e-9; x += step) { var pxv = this._toPx(x, 0)[0]; ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke(); } var y0 = Math.ceil(vp.ymin / step) * step, y; for (y = y0; y <= vp.ymax + 1e-9; y += step) { var pyv = this._toPx(0, y)[1]; ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke(); } ctx.restore(); }; SimEngineInstance.prototype._niceStep = function (vp) { var span = Math.max(vp.xmax - vp.xmin, vp.ymax - vp.ymin); var raw = span / 10; var p = Math.pow(10, Math.floor(Math.log10(raw || 1))); var arr = [1, 2, 5, 10]; for (var i = 0; i < arr.length; i++) if (arr[i] * p >= raw) return arr[i] * p; return p * 10; }; SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) { var o = this._toPx(0, 0); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1.5; // ось X (если 0 в диапазоне Y) if (0 >= vp.ymin && 0 <= vp.ymax) { ctx.beginPath(); ctx.moveTo(0, o[1]); ctx.lineTo(W, o[1]); ctx.stroke(); } // ось Y (если 0 в диапазоне X) if (0 >= vp.xmin && 0 <= vp.xmax) { ctx.beginPath(); ctx.moveTo(o[0], 0); ctx.lineTo(o[0], H); ctx.stroke(); } ctx.restore(); }; /* ════════════════════ Жизненный цикл ════════════════════ */ SimEngineInstance.prototype.play = function () { if (this._running || this._destroyed) return; this._running = true; this._syncPlayBtn(); var self = this; var time = this.spec.time || {}; var speed = num(time.speed, 1); var dur = num(time.duration, 0); var loop = time.loop !== false; this._last = (global.performance || Date).now(); function frame(now) { if (!self._running) return; var dt = Math.min((now - self._last) / 1000, 0.05) * speed; self._last = now; self._t += dt; if (dur > 0 && self._t > dur) { if (loop) { self._t = 0; self._trails = {}; } else { self._t = dur; self._renderFrame(); self.pause(); return; } } self._renderFrame(); self._raf = global.requestAnimationFrame(frame); } this._raf = global.requestAnimationFrame(frame); }; SimEngineInstance.prototype.pause = function () { this._running = false; if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; } this._syncPlayBtn(); }; SimEngineInstance.prototype.reset = function () { this.pause(); this._t = 0; this._trails = {}; this._renderFrame(); }; SimEngineInstance.prototype.setParam = function (name, value) { var v = parseFloat(value); if (!isFinite(v)) return; this.params[name] = v; var sl = this._sliders[name]; if (sl) { sl.value = String(v); sl.dispatchEvent(new Event('input')); } else if (!this._running) this._renderFrame(); }; SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; }; SimEngineInstance.prototype.isRunning = function () { return this._running; }; SimEngineInstance.prototype.destroy = function () { this.pause(); this._destroyed = true; if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; } if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el); this.el = null; this.canvas = null; this.ctx = null; }; /* ── format helper ── */ function _fmt(v) { if (!isFinite(v)) return '—'; if (Number.isInteger(v)) return String(v); return parseFloat(v.toFixed(3)).toString(); } /* ════════════════════ public ════════════════════ */ function mount(host, spec) { if (!host) throw new Error('SimEngine.mount: нет host-элемента'); return new SimEngineInstance(host, spec || {}); } global.SimEngine = { mount: mount, _Instance: SimEngineInstance // экспонируем для тестов/билдера (Фаза 4) }; })(typeof window !== 'undefined' ? window : globalThis);