feat(sim-builder): фаза 0 — рантайм SimEngine + безопасный движок выражений + адаптер LabRegistry
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
registerSpecSim — адаптер: JSON-спека -> манифест LabRegistry (Фаза 0).
|
||||
|
||||
Строит манифест и регистрирует его в window.LabRegistry, чтобы спек-симуляция
|
||||
открывалась тем же путём, что и ~40 рукописных симуляций (через openSim ->
|
||||
реестр). Никаких правок чужих манифестов — только register().
|
||||
|
||||
Контракт LabRegistry-манифеста (см. _registry.js): { id, cat, title, desc,
|
||||
preview, theory?, open(ctx), stop(), destroy() }.
|
||||
|
||||
Особенности интеграции с /lab:
|
||||
- openSim() прячет все ИЗВЕСТНЫЕ ему тела (ALL_SIM_BODIES) и зовёт _pauseAllSims()
|
||||
-> LabRegistry.stopActive() (наш stop спрячет наш хост). Спек-хосты openSim не
|
||||
знает, поэтому при switch именно stop() прошлой активной спек-симуляции прячет её.
|
||||
- Каждая спек-симуляция получает собственный хост-div внутри #lab-sim, создаётся
|
||||
лениво при первом open и переиспользуется.
|
||||
- Заголовок топбара ставим как делают рукописные _openX (sim-topbar-title).
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
var HOST_PREFIX = 'sim-spec-host-';
|
||||
|
||||
// Найти контейнер для тел симуляций (#lab-sim) — туда вставляем хост.
|
||||
function simContainer() {
|
||||
return document.getElementById('lab-sim') || document.body;
|
||||
}
|
||||
|
||||
// Лениво создать/получить хост-элемент для данного id.
|
||||
function ensureHost(id) {
|
||||
var hid = HOST_PREFIX + id;
|
||||
var el = document.getElementById(hid);
|
||||
if (el) return el;
|
||||
el = document.createElement('div');
|
||||
el.id = hid;
|
||||
el.className = 'sim-spec-host';
|
||||
// занимает то же место, что .sim-body-wrap у рукописных симуляций
|
||||
el.style.cssText = 'flex:1;min-height:0;display:none';
|
||||
var cont = simContainer();
|
||||
// вставить перед панелью теории, если она есть, иначе в конец
|
||||
var theory = document.getElementById('theory-panel');
|
||||
if (theory && theory.parentNode === cont) cont.insertBefore(el, theory);
|
||||
else cont.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Спрятать все спек-хосты (на случай переключения с одной спеки на другую,
|
||||
// когда openSim не знает наших хостов).
|
||||
function hideAllSpecHosts() {
|
||||
var nodes = document.querySelectorAll('.sim-spec-host');
|
||||
for (var i = 0; i < nodes.length; i++) nodes[i].style.display = 'none';
|
||||
}
|
||||
|
||||
function setTitle(spec) {
|
||||
var t = document.getElementById('sim-topbar-title');
|
||||
if (t) t.textContent = (spec.meta && spec.meta.title) || spec.title || 'Симуляция';
|
||||
}
|
||||
|
||||
/* preview из спеки: если задана строка/функция — использовать; иначе
|
||||
сгенерировать простой SVG-плейсхолдер с названием. */
|
||||
function buildPreview(spec) {
|
||||
if (spec.preview) return spec.preview;
|
||||
var title = (spec.meta && spec.meta.title) || spec.title || 'Симуляция';
|
||||
var bg = (spec.viewport && spec.viewport.bg) || '#0D0D1A';
|
||||
return function () {
|
||||
return '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<rect width="300" height="140" fill="' + _esc(bg) + '"/>' +
|
||||
'<line x1="20" y1="120" x2="280" y2="120" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||
'<line x1="30" y1="20" x2="30" y2="130" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||
'<path d="M30 120 Q120 30 270 110" fill="none" stroke="#06D6E0" stroke-width="2.5"/>' +
|
||||
'<circle cx="150" cy="64" r="5" fill="#9B5DE5"/>' +
|
||||
'<text x="150" y="135" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="10" font-family="Manrope,sans-serif">' +
|
||||
_esc(title) + '</text></svg>';
|
||||
};
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ── Главная функция ── */
|
||||
function registerSpecSim(spec) {
|
||||
if (!global.LabRegistry) {
|
||||
if (global.console) console.warn('[registerSpecSim] LabRegistry недоступен');
|
||||
return null;
|
||||
}
|
||||
if (!spec || !spec.id) {
|
||||
if (global.console) console.warn('[registerSpecSim] спека без id');
|
||||
return null;
|
||||
}
|
||||
var id = spec.id;
|
||||
var _inst = null; // активный SimEngine-инстанс этой симуляции
|
||||
|
||||
var manifest = {
|
||||
id: id,
|
||||
cat: spec.cat || 'phys',
|
||||
title: (spec.meta && spec.meta.title) || spec.title || id,
|
||||
desc: (spec.meta && spec.meta.desc) || spec.desc || '',
|
||||
preview: buildPreview(spec),
|
||||
theory: spec.theory || null,
|
||||
subject: spec.subject,
|
||||
grade: spec.grade,
|
||||
topics: spec.topics,
|
||||
_spec: spec, // храним исходную спеку (билдеру/доске пригодится)
|
||||
|
||||
open: function (ctx) {
|
||||
hideAllSpecHosts();
|
||||
var host = ensureHost(id);
|
||||
host.style.display = 'flex';
|
||||
setTitle(spec);
|
||||
// пере-смонтировать заново на каждый open (чистое состояние)
|
||||
if (_inst) { try { _inst.destroy(); } catch (e) {} _inst = null; }
|
||||
host.innerHTML = '';
|
||||
if (global.SimEngine) {
|
||||
_inst = global.SimEngine.mount(host, spec);
|
||||
manifest._instance = _inst;
|
||||
} else if (global.console) {
|
||||
console.warn('[registerSpecSim] SimEngine недоступен для', id);
|
||||
}
|
||||
},
|
||||
|
||||
stop: function () {
|
||||
if (_inst) { try { _inst.pause(); } catch (e) {} }
|
||||
var host = document.getElementById(HOST_PREFIX + id);
|
||||
if (host) host.style.display = 'none';
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
if (_inst) { try { _inst.destroy(); } catch (e) {} _inst = null; }
|
||||
manifest._instance = null;
|
||||
var host = document.getElementById(HOST_PREFIX + id);
|
||||
if (host) host.style.display = 'none';
|
||||
},
|
||||
|
||||
// доступ к живому инстансу (доска онлайн-урока, билдер — Фазы 4/7)
|
||||
instance: function () { return _inst; }
|
||||
};
|
||||
|
||||
return global.LabRegistry.register(manifest);
|
||||
}
|
||||
|
||||
global.registerSpecSim = registerSpecSim;
|
||||
global.SimAdapter = {
|
||||
register: registerSpecSim,
|
||||
ensureHost: ensureHost,
|
||||
hideAllSpecHosts: hideAllSpecHosts
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
_sim_demo — рукописная демо-спека «Бросок тела» (projectile) для проверки
|
||||
рантайма Фазы 0. Регистрируется как id 'customdemo' ТОЛЬКО за флагом — не
|
||||
светится ученикам в каталоге (карточка не добавляется в SIMS).
|
||||
|
||||
Включение для проверки (любой из вариантов):
|
||||
- URL: /lab?simdemo=1 (или ?sim=customdemo прямой deep-link)
|
||||
- глоб: window.LAB_SHOW_SPEC_DEMO = true (до загрузки labs-скриптов)
|
||||
- localStorage.setItem('lab-spec-demo','1')
|
||||
|
||||
Проверка: открыть /lab?simdemo=1 -> карточка появится; либо открыть
|
||||
/lab?sim=customdemo напрямую. Слайдеры угла/скорости меняют траекторию,
|
||||
play/pause/reset работают. Это ВРЕМЕННЫЙ раздел (удалить после Фазы 4).
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
function demoEnabled() {
|
||||
try {
|
||||
var qs = (global.location && global.location.search) || '';
|
||||
if (/[?&]simdemo=1\b/.test(qs)) return true;
|
||||
if (/[?&]sim=customdemo\b/.test(qs)) return true;
|
||||
if (global.LAB_SHOW_SPEC_DEMO === true) return true;
|
||||
if (global.localStorage && global.localStorage.getItem('lab-spec-demo') === '1') return true;
|
||||
} catch (e) { /* noop */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Спека v1: бросок тела. g фиксирован 10 -> y = v*sin(θ)*t - 5*t^2.
|
||||
var PROJECTILE_DEMO = {
|
||||
id: 'customdemo',
|
||||
cat: 'phys',
|
||||
meta: { title: 'Демо: бросок тела', desc: 'Спек-симуляция (Фаза 0). Угол и скорость — слайдеры.' },
|
||||
viewport: { xmin: 0, xmax: 60, ymin: 0, ymax: 30, grid: true, axes: true, bg: '#0D0D1A' },
|
||||
time: { autoplay: false, loop: true, duration: 8, speed: 1 },
|
||||
params: [
|
||||
{ name: 'theta', label: 'Угол θ', min: 0, max: 90, step: 1, value: 45, unit: '°' },
|
||||
{ name: 'v', label: 'Скорость v', min: 0, max: 30, step: 0.5, value: 20, unit: 'м/с' }
|
||||
],
|
||||
objects: [
|
||||
// снаряд: x = v*cos(θ)*t, y = v*sin(θ)*t - 5 t^2 (но не ниже 0)
|
||||
{
|
||||
id: 'ball', type: 'point',
|
||||
x: 'v*cos(theta*pi/180)*t',
|
||||
y: 'max(0, v*sin(theta*pi/180)*t - 5*t^2)',
|
||||
r: 7, color: '#06D6E0', trail: true, trailColor: '#9B5DE5'
|
||||
},
|
||||
// вектор начальной скорости из старта
|
||||
{
|
||||
type: 'vector', x1: 0, y1: 0,
|
||||
x2: 'cos(theta*pi/180)*v*0.4',
|
||||
y2: 'sin(theta*pi/180)*v*0.4',
|
||||
color: '#FFD166', width: 3
|
||||
},
|
||||
// земля
|
||||
{ type: 'segment', x1: 0, y1: 0, x2: 60, y2: 0, color: 'rgba(255,255,255,0.35)', width: 2 },
|
||||
// подпись над снарядом
|
||||
{
|
||||
type: 'label', latex: true,
|
||||
x: 'ball.x', y: 'ball.y + 2.5',
|
||||
text: 'v_0', color: '#06D6E0', size: 15
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function tryRegister() {
|
||||
if (!demoEnabled()) return;
|
||||
if (typeof global.registerSpecSim !== 'function') {
|
||||
if (global.console) console.warn('[sim-demo] registerSpecSim недоступен');
|
||||
return;
|
||||
}
|
||||
global.registerSpecSim(PROJECTILE_DEMO);
|
||||
|
||||
// Если каталог уже отрисован, добавить карточку демо вручную (минимально-
|
||||
// инвазивно: только когда флаг включён; не трогаем SIMS/каталожный рендер).
|
||||
addDemoCardIfNeeded();
|
||||
}
|
||||
|
||||
function addDemoCardIfNeeded() {
|
||||
var grid = document.getElementById('sim-grid');
|
||||
if (!grid) return;
|
||||
if (document.getElementById('sim-card-customdemo')) return;
|
||||
var m = global.LabRegistry && global.LabRegistry.get('customdemo');
|
||||
if (!m) return;
|
||||
var preview = global.LabRegistry.resolvePreview(m);
|
||||
var card = document.createElement('div');
|
||||
card.id = 'sim-card-customdemo';
|
||||
card.className = 'sim-card';
|
||||
card.setAttribute('onclick', "openSim('customdemo')");
|
||||
card.innerHTML = preview +
|
||||
'<div class="sim-body">' +
|
||||
'<span class="sim-cat ' + (m.cat || 'phys') + '">демо</span>' +
|
||||
'<div class="sim-title">' + esc(m.title) + '</div>' +
|
||||
'<div class="sim-desc">' + esc(m.desc || '') + '</div>' +
|
||||
'</div>';
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Зарегистрировать после готовности DOM/реестра. _register-all.js грузится
|
||||
// последним (defer); этот файл — после него, поэтому LabRegistry уже есть.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryRegister);
|
||||
} else {
|
||||
tryRegister();
|
||||
}
|
||||
|
||||
// экспонируем для ручной проверки из консоли
|
||||
global.LAB_SPEC_DEMO = PROJECTILE_DEMO;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,616 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
SimEngine — рантайм спек-симуляций конструктора (Фаза 0).
|
||||
|
||||
Берёт JSON-спеку (данные, не код) и монтирует интерактивную сцену в host:
|
||||
- canvas для геометрии/объектов (мир→экран по viewport, ось Y вверх);
|
||||
- оверлей-слой <div> для подписей с LaTeX (рендер через KaTeX — тем же путём,
|
||||
что graph.js: katex.renderToString);
|
||||
- панель контролов: слайдеры из spec.params[] + кнопки play/pause/reset.
|
||||
|
||||
Любое числовое свойство объекта может быть числом ИЛИ строкой-выражением.
|
||||
Выражения компилируются ОДИН РАЗ при mount (через SimExpr), в rAF-цикле — только
|
||||
evaluate по окружению env = { t, ...params, <obj>.x, <obj>.y, w, h }.
|
||||
|
||||
Никакого eval/new Function. Кривая формула не роняет цикл (SimExpr.evaluate -> 0).
|
||||
|
||||
── ФОРМАТ СПЕКИ v1 ──────────────────────────────────────────────────────
|
||||
{
|
||||
specVersion: 1,
|
||||
meta: { title, desc }, // подпись топбара/каталога
|
||||
viewport: { xmin, xmax, ymin, ymax, // мировые границы (мат. оси)
|
||||
grid?:true, axes?:true,
|
||||
bg?:'#0D0D1A' },
|
||||
params: [ // слайдеры -> переменные env
|
||||
{ name:'v', label:'Скорость', min:0, max:30, step:0.5, value:18, unit:'м/с' },
|
||||
{ name:'theta', label:'Угол', min:0, max:90, step:1, value:45, unit:'°' }
|
||||
],
|
||||
time: { autoplay?:false, loop?:true, duration?:0, speed?:1 }, // t-цикл
|
||||
objects: [ // числа ИЛИ строки-выражения
|
||||
{ id:'p', type:'point', x:'v*cos(theta*pi/180)*t', y:'...', r:6, color:'#06D6E0',
|
||||
trail?:true, trailColor?:'#06D6E0' },
|
||||
{ type:'segment', x1:0,y1:0, x2:'p.x', y2:'p.y', color:'#fff', width:2 },
|
||||
{ type:'vector', x1:..,y1:.., x2:..,y2:.., color, width },
|
||||
{ type:'circle', x, y, r, color, fill?, width },
|
||||
{ type:'rect', x, y, w, h, color, fill?, width }, // x,y = центр
|
||||
{ type:'polyline', points:[[x,y],...], color, width, closed? },
|
||||
{ type:'path', points:[[x,y],...], ... }, // alias polyline
|
||||
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 }
|
||||
]
|
||||
}
|
||||
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
|
||||
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
|
||||
|
||||
── API инстанса ──────────────────────────────────────────────────────────
|
||||
var inst = SimEngine.mount(host, spec);
|
||||
inst.play() inst.pause() inst.reset()
|
||||
inst.setParam(name, value)
|
||||
inst.getParam(name) -> number
|
||||
inst.isRunning() -> bool
|
||||
inst.destroy()
|
||||
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
var DEFAULT_BG = '#0D0D1A';
|
||||
|
||||
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
|
||||
|
||||
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
|
||||
function bind(value, dflt) {
|
||||
if (value === undefined || value === null) {
|
||||
var d = dflt;
|
||||
return { ev: function () { return d; }, constant: true };
|
||||
}
|
||||
var c = global.SimExpr ? global.SimExpr.compileValue(value)
|
||||
: { fn: function () { return num(value, dflt); }, constant: true };
|
||||
return { ev: c.fn, constant: !!c.constant, error: c.error || null };
|
||||
}
|
||||
|
||||
/* ════════════════════ Instance ════════════════════ */
|
||||
|
||||
function SimEngineInstance(host, spec) {
|
||||
this.host = host;
|
||||
this.spec = spec || {};
|
||||
this.params = {}; // name -> текущее значение (number)
|
||||
this._sliders = {}; // name -> input element
|
||||
this._objs = []; // подготовленные объекты с привязками
|
||||
this._trails = {}; // objId -> [[x,y],...] (мир-координаты)
|
||||
this._t = 0;
|
||||
this._running = false;
|
||||
this._raf = 0;
|
||||
this._last = 0;
|
||||
this._dpr = 1;
|
||||
this._cw = 0; this._ch = 0;
|
||||
this._destroyed = false;
|
||||
this._ro = null;
|
||||
this._build();
|
||||
}
|
||||
|
||||
SimEngineInstance.prototype._vp = function () {
|
||||
var v = this.spec.viewport || {};
|
||||
return {
|
||||
xmin: num(v.xmin, -1), xmax: num(v.xmax, 10),
|
||||
ymin: num(v.ymin, -1), ymax: num(v.ymax, 10),
|
||||
grid: v.grid !== false,
|
||||
axes: v.axes !== false,
|
||||
bg: v.bg || DEFAULT_BG
|
||||
};
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._build = function () {
|
||||
var self = this;
|
||||
var spec = this.spec;
|
||||
|
||||
// корень
|
||||
var root = document.createElement('div');
|
||||
root.className = 'sim-spec-root';
|
||||
root.style.cssText = 'flex:1;min-height:0;display:flex;width:100%;height:100%;background:' +
|
||||
(this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
|
||||
this.el = root;
|
||||
|
||||
// ── панель контролов слева ──
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'sim-spec-panel';
|
||||
panel.style.cssText = 'width:260px;flex-shrink:0;background:rgba(13,13,26,0.92);' +
|
||||
'border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column;' +
|
||||
'gap:10px;padding:16px 14px;overflow-y:auto';
|
||||
|
||||
// заголовок + кнопки play/pause/reset
|
||||
var ctrlRow = document.createElement('div');
|
||||
ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center';
|
||||
var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза');
|
||||
var btnReset = this._btn(this._resetIcon(), 'Сброс');
|
||||
this._btnPlay = btnPlay;
|
||||
btnPlay.addEventListener('click', function () { self._running ? self.pause() : self.play(); });
|
||||
btnReset.addEventListener('click', function () { self.reset(); });
|
||||
ctrlRow.appendChild(btnPlay);
|
||||
ctrlRow.appendChild(btnReset);
|
||||
panel.appendChild(ctrlRow);
|
||||
|
||||
// слайдеры параметров
|
||||
var params = Array.isArray(spec.params) ? spec.params : [];
|
||||
params.forEach(function (p) {
|
||||
if (!p || !p.name) return;
|
||||
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
|
||||
var val = num(p.value, min);
|
||||
self.params[p.name] = val;
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
|
||||
|
||||
var lblRow = document.createElement('div');
|
||||
lblRow.style.cssText = 'display:flex;justify-content:space-between;font-size:.74rem;color:rgba(255,255,255,.6)';
|
||||
var lblName = document.createElement('span');
|
||||
lblName.textContent = p.label || p.name;
|
||||
var lblVal = document.createElement('span');
|
||||
lblVal.style.cssText = 'color:#06D6E0;font-weight:700;font-variant-numeric:tabular-nums';
|
||||
lblVal.textContent = _fmt(val) + (p.unit ? ' ' + p.unit : '');
|
||||
lblRow.appendChild(lblName); lblRow.appendChild(lblVal);
|
||||
|
||||
var slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = String(min); slider.max = String(max); slider.step = String(step);
|
||||
slider.value = String(val);
|
||||
slider.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer';
|
||||
slider.addEventListener('input', function () {
|
||||
var v = parseFloat(slider.value);
|
||||
self.params[p.name] = v;
|
||||
lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : '');
|
||||
if (!self._running) self._renderFrame(); // живой предпросмотр на паузе
|
||||
});
|
||||
|
||||
wrap.appendChild(lblRow);
|
||||
wrap.appendChild(slider);
|
||||
panel.appendChild(wrap);
|
||||
self._sliders[p.name] = slider;
|
||||
});
|
||||
|
||||
// ── сцена справа (canvas + оверлей подписей) ──
|
||||
var stage = document.createElement('div');
|
||||
stage.className = 'sim-spec-stage';
|
||||
stage.style.cssText = 'flex:1;min-width:0;position:relative;overflow:hidden';
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
|
||||
stage.appendChild(canvas);
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
|
||||
var labels = document.createElement('div');
|
||||
labels.className = 'sim-spec-labels';
|
||||
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
|
||||
stage.appendChild(labels);
|
||||
this._labelLayer = labels;
|
||||
|
||||
root.appendChild(panel);
|
||||
root.appendChild(stage);
|
||||
this.host.appendChild(root);
|
||||
|
||||
// подготовить объекты (компиляция привязок один раз)
|
||||
this._prepareObjects();
|
||||
|
||||
// resize
|
||||
if (global.ResizeObserver) {
|
||||
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
|
||||
this._ro.observe(stage);
|
||||
}
|
||||
|
||||
// первичная подгонка после layout
|
||||
requestAnimationFrame(function () {
|
||||
self._fit();
|
||||
self.reset();
|
||||
var time = spec.time || {};
|
||||
if (time.autoplay) self.play();
|
||||
});
|
||||
};
|
||||
|
||||
/* кнопка контрола (inline SVG, без эмодзи) */
|
||||
SimEngineInstance.prototype._btn = function (innerSvg, title) {
|
||||
var b = document.createElement('button');
|
||||
b.title = title || '';
|
||||
b.innerHTML = innerSvg;
|
||||
b.style.cssText = 'min-width:34px;height:34px;border-radius:10px;border:1.5px solid rgba(255,255,255,.18);' +
|
||||
'background:transparent;color:rgba(255,255,255,.75);cursor:pointer;display:flex;align-items:center;' +
|
||||
'justify-content:center;padding:0 9px;transition:all .15s';
|
||||
b.addEventListener('mouseenter', function () { b.style.borderColor = '#9B5DE5'; b.style.color = '#9B5DE5'; });
|
||||
b.addEventListener('mouseleave', function () { b.style.borderColor = 'rgba(255,255,255,.18)'; b.style.color = 'rgba(255,255,255,.75)'; });
|
||||
return b;
|
||||
};
|
||||
SimEngineInstance.prototype._playIcon = function (play) {
|
||||
return play
|
||||
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
|
||||
};
|
||||
SimEngineInstance.prototype._resetIcon = function () {
|
||||
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
|
||||
};
|
||||
SimEngineInstance.prototype._syncPlayBtn = function () {
|
||||
if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running);
|
||||
};
|
||||
|
||||
/* Компиляция привязок объектов один раз. */
|
||||
SimEngineInstance.prototype._prepareObjects = function () {
|
||||
var raw = Array.isArray(this.spec.objects) ? this.spec.objects : [];
|
||||
var out = [];
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
var o = raw[i] || {};
|
||||
var type = o.type || 'point';
|
||||
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} };
|
||||
|
||||
// общие визуальные поля (не привязки)
|
||||
prep.color = o.color || '#06D6E0';
|
||||
prep.fillColor = o.fill || o.fillColor || null;
|
||||
prep.width = num(o.width, 2);
|
||||
prep.trail = !!o.trail;
|
||||
prep.trailColor = o.trailColor || prep.color;
|
||||
prep.latex = o.latex !== false; // подписи по умолчанию рендерятся KaTeX
|
||||
prep.size = num(o.size, 14);
|
||||
prep.text = o.text != null ? String(o.text) : '';
|
||||
|
||||
// числовые/выражения-привязки по типу
|
||||
var B = prep.b;
|
||||
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
|
||||
|
||||
if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); }
|
||||
else if (type === 'segment' || type === 'vector') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
|
||||
else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); }
|
||||
else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); }
|
||||
else if (type === 'polyline' || type === 'path') {
|
||||
prep.pts = (Array.isArray(o.points) ? o.points : []).map(function (pt) {
|
||||
return { x: bind(pt[0], 0), y: bind(pt[1], 0) };
|
||||
});
|
||||
prep.closed = !!o.closed;
|
||||
} else if (type === 'label') {
|
||||
bp('x', 0); bp('y', 0);
|
||||
}
|
||||
|
||||
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
|
||||
if (B.x && B.y) { prep.hasCenter = true; }
|
||||
|
||||
out.push(prep);
|
||||
}
|
||||
this._objs = out;
|
||||
};
|
||||
|
||||
/* Окружение для evaluate: t, params, w/h, и центры объектов (obj.x/obj.y). */
|
||||
SimEngineInstance.prototype._buildEnv = function () {
|
||||
var env = {};
|
||||
var p = this.params;
|
||||
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
|
||||
env.t = this._t;
|
||||
var vp = this._vp();
|
||||
env.w = vp.xmax - vp.xmin;
|
||||
env.h = vp.ymax - vp.ymin;
|
||||
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
|
||||
|
||||
// двухпроходно: центры объектов могут ссылаться друг на друга (одношагово,
|
||||
// без рекурсии — для большинства сцен достаточно одного прохода).
|
||||
for (var i = 0; i < this._objs.length; i++) {
|
||||
var o = this._objs[i];
|
||||
if (o.hasCenter) {
|
||||
var x = o.b.x.ev(env);
|
||||
var y = o.b.y.ev(env);
|
||||
env[o.id + '.x'] = x;
|
||||
env[o.id + '.y'] = y;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
};
|
||||
|
||||
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── */
|
||||
SimEngineInstance.prototype._fit = function () {
|
||||
var c = this.canvas; if (!c) return;
|
||||
var dpr = Math.min(global.devicePixelRatio || 1, 2);
|
||||
var stage = c.parentElement || c;
|
||||
var r = stage.getBoundingClientRect();
|
||||
var w = Math.max(1, Math.round(r.width));
|
||||
var h = Math.max(1, Math.round(r.height));
|
||||
this._dpr = dpr; this._cw = w; this._ch = h;
|
||||
c.width = w * dpr; c.height = h * dpr;
|
||||
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// вычислить масштаб «вписать viewport, равные оси»
|
||||
var vp = this._vp();
|
||||
var vw = vp.xmax - vp.xmin || 1;
|
||||
var vh = vp.ymax - vp.ymin || 1;
|
||||
var pad = 16;
|
||||
var sx = (w - pad * 2) / vw;
|
||||
var sy = (h - pad * 2) / vh;
|
||||
var s = Math.min(sx, sy);
|
||||
this._scale = s;
|
||||
// центр мира в центре canvas
|
||||
var cxWorld = (vp.xmin + vp.xmax) / 2;
|
||||
var cyWorld = (vp.ymin + vp.ymax) / 2;
|
||||
this._offX = w / 2 - cxWorld * s;
|
||||
this._offY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._toPx = function (mx, my) {
|
||||
return [this._offX + mx * this._scale, this._offY - my * this._scale];
|
||||
};
|
||||
|
||||
/* ════════════════════ Рендер кадра ════════════════════ */
|
||||
SimEngineInstance.prototype._renderFrame = function () {
|
||||
var ctx = this.ctx; if (!ctx) return;
|
||||
var W = this._cw, H = this._ch;
|
||||
if (!W || !H) return;
|
||||
var vp = this._vp();
|
||||
|
||||
ctx.fillStyle = vp.bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (vp.grid) this._drawGrid(ctx, W, H, vp);
|
||||
if (vp.axes) this._drawAxes(ctx, W, H, vp);
|
||||
|
||||
var env = this._buildEnv();
|
||||
|
||||
// обновить трассы
|
||||
for (var i = 0; i < this._objs.length; i++) {
|
||||
var o = this._objs[i];
|
||||
if (o.trail && o.hasCenter) {
|
||||
var tx = env[o.id + '.x'], ty = env[o.id + '.y'];
|
||||
if (typeof tx === 'number' && typeof ty === 'number') {
|
||||
var arr = this._trails[o.id] || (this._trails[o.id] = []);
|
||||
arr.push([tx, ty]);
|
||||
if (arr.length > 2000) arr.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// нарисовать трассы под объектами
|
||||
for (var ti = 0; ti < this._objs.length; ti++) {
|
||||
var ot = this._objs[ti];
|
||||
if (ot.trail && this._trails[ot.id] && this._trails[ot.id].length > 1) {
|
||||
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor);
|
||||
}
|
||||
}
|
||||
|
||||
// объекты
|
||||
this._labelLayer.innerHTML = '';
|
||||
for (var j = 0; j < this._objs.length; j++) {
|
||||
this._drawObject(ctx, this._objs[j], env);
|
||||
}
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
var px = this._toPx(pts[i][0], pts[i][1]);
|
||||
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
|
||||
var B = o.b;
|
||||
switch (o.type) {
|
||||
case 'point': {
|
||||
var p = this._toPx(B.x.ev(env), B.y.ev(env));
|
||||
// r точки — экранный радиус в пикселях (выражение допустимо)
|
||||
var r = Math.max(1, B.r.ev(env) || 6);
|
||||
ctx.save();
|
||||
ctx.fillStyle = o.color;
|
||||
ctx.shadowColor = o.color; ctx.shadowBlur = 8;
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'segment':
|
||||
case 'vector': {
|
||||
var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
|
||||
var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
||||
if (o.type === 'vector') this._arrowHead(ctx, a, b, o.color);
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
var c0 = this._toPx(B.x.ev(env), B.y.ev(env));
|
||||
var rad = Math.abs(B.r.ev(env)) * this._scale;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
|
||||
if (o.fillColor) { ctx.fillStyle = o.fillColor; }
|
||||
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
|
||||
if (o.fillColor) ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
var cx = B.x.ev(env), cy = B.y.ev(env);
|
||||
var rw = Math.abs(B.w.ev(env)), rh = Math.abs(B.h.ev(env));
|
||||
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
|
||||
var pw = rw * this._scale, ph = rh * this._scale;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
|
||||
if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); }
|
||||
ctx.strokeRect(tl[0], tl[1], pw, ph);
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'polyline':
|
||||
case 'path': {
|
||||
if (!o.pts || !o.pts.length) break;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
if (o.fillColor) ctx.fillStyle = o.fillColor;
|
||||
ctx.beginPath();
|
||||
for (var k = 0; k < o.pts.length; k++) {
|
||||
var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env));
|
||||
k === 0 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]);
|
||||
}
|
||||
if (o.closed) ctx.closePath();
|
||||
if (o.fillColor) ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
case 'label': {
|
||||
var lp = this._toPx(B.x.ev(env), B.y.ev(env));
|
||||
this._drawLabel(o, lp[0], lp[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) {
|
||||
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
var s = 9;
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.translate(b[0], b[1]); ctx.rotate(ang);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
/* Подпись: KaTeX (тем же путём, что graph.js) с фолбэком на текст. */
|
||||
SimEngineInstance.prototype._drawLabel = function (o, px, py) {
|
||||
var el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font-size:' + o.size +
|
||||
'px;color:' + o.color + ';white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.6)';
|
||||
el.style.left = px + 'px';
|
||||
el.style.top = py + 'px';
|
||||
var txt = o.text || '';
|
||||
if (o.latex && typeof global.katex !== 'undefined' && txt) {
|
||||
try {
|
||||
el.innerHTML = global.katex.renderToString(txt, { throwOnError: false, strict: false, displayMode: false });
|
||||
} catch (e) { el.textContent = txt; }
|
||||
} else {
|
||||
el.textContent = txt;
|
||||
}
|
||||
this._labelLayer.appendChild(el);
|
||||
};
|
||||
|
||||
/* ── сетка/оси (мат. координаты, Y вверх) ── */
|
||||
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
|
||||
var step = this._niceStep(vp);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
|
||||
ctx.lineWidth = 1;
|
||||
var x;
|
||||
var x0 = Math.ceil(vp.xmin / step) * step;
|
||||
for (x = x0; x <= vp.xmax + 1e-9; x += step) {
|
||||
var pxv = this._toPx(x, 0)[0];
|
||||
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
|
||||
}
|
||||
var y0 = Math.ceil(vp.ymin / step) * step, y;
|
||||
for (y = y0; y <= vp.ymax + 1e-9; y += step) {
|
||||
var pyv = this._toPx(0, y)[1];
|
||||
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._niceStep = function (vp) {
|
||||
var span = Math.max(vp.xmax - vp.xmin, vp.ymax - vp.ymin);
|
||||
var raw = span / 10;
|
||||
var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
|
||||
var arr = [1, 2, 5, 10];
|
||||
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= raw) return arr[i] * p;
|
||||
return p * 10;
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
|
||||
var o = this._toPx(0, 0);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.lineWidth = 1.5;
|
||||
// ось X (если 0 в диапазоне Y)
|
||||
if (0 >= vp.ymin && 0 <= vp.ymax) {
|
||||
ctx.beginPath(); ctx.moveTo(0, o[1]); ctx.lineTo(W, o[1]); ctx.stroke();
|
||||
}
|
||||
// ось Y (если 0 в диапазоне X)
|
||||
if (0 >= vp.xmin && 0 <= vp.xmax) {
|
||||
ctx.beginPath(); ctx.moveTo(o[0], 0); ctx.lineTo(o[0], H); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
/* ════════════════════ Жизненный цикл ════════════════════ */
|
||||
SimEngineInstance.prototype.play = function () {
|
||||
if (this._running || this._destroyed) return;
|
||||
this._running = true;
|
||||
this._syncPlayBtn();
|
||||
var self = this;
|
||||
var time = this.spec.time || {};
|
||||
var speed = num(time.speed, 1);
|
||||
var dur = num(time.duration, 0);
|
||||
var loop = time.loop !== false;
|
||||
this._last = (global.performance || Date).now();
|
||||
function frame(now) {
|
||||
if (!self._running) return;
|
||||
var dt = Math.min((now - self._last) / 1000, 0.05) * speed;
|
||||
self._last = now;
|
||||
self._t += dt;
|
||||
if (dur > 0 && self._t > dur) {
|
||||
if (loop) { self._t = 0; self._trails = {}; }
|
||||
else { self._t = dur; self._renderFrame(); self.pause(); return; }
|
||||
}
|
||||
self._renderFrame();
|
||||
self._raf = global.requestAnimationFrame(frame);
|
||||
}
|
||||
this._raf = global.requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.pause = function () {
|
||||
this._running = false;
|
||||
if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; }
|
||||
this._syncPlayBtn();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.reset = function () {
|
||||
this.pause();
|
||||
this._t = 0;
|
||||
this._trails = {};
|
||||
this._renderFrame();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.setParam = function (name, value) {
|
||||
var v = parseFloat(value);
|
||||
if (!isFinite(v)) return;
|
||||
this.params[name] = v;
|
||||
var sl = this._sliders[name];
|
||||
if (sl) { sl.value = String(v); sl.dispatchEvent(new Event('input')); }
|
||||
else if (!this._running) this._renderFrame();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
|
||||
SimEngineInstance.prototype.isRunning = function () { return this._running; };
|
||||
|
||||
SimEngineInstance.prototype.destroy = function () {
|
||||
this.pause();
|
||||
this._destroyed = true;
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
|
||||
this.el = null; this.canvas = null; this.ctx = null;
|
||||
};
|
||||
|
||||
/* ── format helper ── */
|
||||
function _fmt(v) {
|
||||
if (!isFinite(v)) return '—';
|
||||
if (Number.isInteger(v)) return String(v);
|
||||
return parseFloat(v.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
/* ════════════════════ public ════════════════════ */
|
||||
function mount(host, spec) {
|
||||
if (!host) throw new Error('SimEngine.mount: нет host-элемента');
|
||||
return new SimEngineInstance(host, spec || {});
|
||||
}
|
||||
|
||||
global.SimEngine = {
|
||||
mount: mount,
|
||||
_Instance: SimEngineInstance // экспонируем для тестов/билдера (Фаза 4)
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
SimExpr — безопасный движок выражений для конструктора симуляций (Фаза 0).
|
||||
|
||||
Спека симуляции — это ДАННЫЕ, которые шарятся между людьми, поэтому код
|
||||
выражений НИКОГДА не исполняется через eval/new Function. Здесь — собственный
|
||||
конвейер: токенайзер → AST → evaluate(ast, env). Логика расширяет парсер
|
||||
y=f(x) из graph.js (тот же подход к токенам и неявному умножению), но:
|
||||
- окружение многопеременное: любой идентификатор берётся из env (params, t,
|
||||
значения объектов), а не только x;
|
||||
- добавлены сравнения (< <= > >= == !=), логика (&& ||), тернарник ?:,
|
||||
функции min/max/mod/log(base,x) и константы pi/e;
|
||||
- результат компиляции — AST + замыкание fn(env), считается детерминированно.
|
||||
|
||||
Ошибки времени выполнения (деление на 0, NaN, неизвестный идентификатор) НЕ
|
||||
кидаются из fn(env): они дают 0 (или флаг через evalSafe), чтобы один кривой
|
||||
кадр не ронял весь рантайм. Ошибки КОМПИЛЯЦИИ (синтаксис) возвращаются строкой
|
||||
в compile(src).error и не бросаются.
|
||||
|
||||
API:
|
||||
SimExpr.compile(src) -> { ast, fn, error }
|
||||
fn(env) -> number (никогда не бросает; при сбое -> 0)
|
||||
SimExpr.evaluate(ast, env) -> number (никогда не бросает; при сбое -> 0)
|
||||
SimExpr.evalSafe(ast, env) -> { value, error } (для отладки/билдера)
|
||||
SimExpr.FUNCTIONS -> Set имён whitelisted-функций (для подсветки/билдера)
|
||||
SimExpr.CONSTANTS -> Set имён констант (pi, e)
|
||||
|
||||
env — простой объект { имя: number }. Имена объектов спеки удобно передавать
|
||||
как "obj.x"/"obj.y" — для этого допускается точка в идентификаторе.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
/* ── whitelist функций (имя -> арность: 1, 2 или -1 для переменной) ── */
|
||||
// -1 => принимает >=1 аргумент (min/max). log: 1 арг = ln по основанию e? нет —
|
||||
// log(x) трактуем как десятичный (как в graph.js log===log10), log(b,x)=log_b(x).
|
||||
var FN_ARITY = {
|
||||
sin: 1, cos: 1, tan: 1, tg: 1, ctg: 1, cot: 1,
|
||||
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
|
||||
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
|
||||
floor: 1, ceil: 1, round: 1, sign: 1,
|
||||
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1
|
||||
};
|
||||
|
||||
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
|
||||
// там, где можно дать NaN -> наружу станет 0).
|
||||
var FN_IMPL = {
|
||||
sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan,
|
||||
ctg: function (x) { return 1 / Math.tan(x); },
|
||||
cot: function (x) { return 1 / Math.tan(x); },
|
||||
asin: Math.asin, acos: Math.acos, atan: Math.atan,
|
||||
arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
|
||||
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
|
||||
ln: Math.log,
|
||||
log: function (a, b) {
|
||||
// log(x) -> log10(x); log(base, x) -> log_base(x)
|
||||
if (b === undefined) return Math.log(a) / Math.LN10;
|
||||
return Math.log(b) / Math.log(a);
|
||||
},
|
||||
log2: Math.log2, log10: Math.log10,
|
||||
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
|
||||
min: Math.min, max: Math.max,
|
||||
mod: function (a, b) { return b === 0 ? 0 : a % b; },
|
||||
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot
|
||||
};
|
||||
|
||||
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
|
||||
|
||||
/* ════════════════════ TOKENIZER ════════════════════ */
|
||||
|
||||
function tokenize(src) {
|
||||
var out = [];
|
||||
var i = 0, n = src.length;
|
||||
while (i < n) {
|
||||
var ch = src[i];
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
|
||||
|
||||
/* число */
|
||||
if ((ch >= '0' && ch <= '9') || (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
||||
var j = i;
|
||||
while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||
if (j < n && src[j] === '.') { j++; while (j < n && src[j] >= '0' && src[j] <= '9') j++; }
|
||||
if (j < n && (src[j] === 'e' || src[j] === 'E')) {
|
||||
var k = j + 1;
|
||||
if (k < n && (src[k] === '+' || src[k] === '-')) k++;
|
||||
if (k < n && src[k] >= '0' && src[k] <= '9') {
|
||||
j = k; while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||
}
|
||||
}
|
||||
out.push({ t: 'num', v: parseFloat(src.slice(i, j)) });
|
||||
i = j; continue;
|
||||
}
|
||||
|
||||
/* идентификатор (буквы/цифры/_/.) — точка допускает obj.x */
|
||||
if (isIdentStart(ch)) {
|
||||
var p = i + 1;
|
||||
while (p < n && isIdentPart(src[p])) p++;
|
||||
out.push({ t: 'id', v: src.slice(i, p) });
|
||||
i = p; continue;
|
||||
}
|
||||
|
||||
/* двухсимвольные операторы */
|
||||
var two = src.substr(i, 2);
|
||||
if (two === '<=' || two === '>=' || two === '==' || two === '!=' ||
|
||||
two === '&&' || two === '||') {
|
||||
out.push({ t: 'op', v: two }); i += 2; continue;
|
||||
}
|
||||
|
||||
/* односимвольные операторы / скобки / запятая / ?: */
|
||||
if ('+-*/^%()<>?:,'.indexOf(ch) !== -1) {
|
||||
out.push({ t: 'op', v: ch }); i++; continue;
|
||||
}
|
||||
// одиночный '=' трактуем как сравнение (часто пишут a=b по привычке)
|
||||
if (ch === '=') { out.push({ t: 'op', v: '==' }); i++; continue; }
|
||||
// одиночный '!' — логическое НЕ
|
||||
if (ch === '!') { out.push({ t: 'op', v: '!' }); i++; continue; }
|
||||
|
||||
throw new Error('Неизвестный символ: «' + ch + '»');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isIdentStart(ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
|
||||
}
|
||||
function isIdentPart(ch) {
|
||||
return isIdentStart(ch) || (ch >= '0' && ch <= '9') || ch === '.';
|
||||
}
|
||||
|
||||
/* Вставка неявного умножения: 2x -> 2*x, 2(.. -> 2*(.., )( -> )*(, )x -> )*x.
|
||||
Функция перед '(' умножение НЕ получает. */
|
||||
function insertImplicitMul(tokens) {
|
||||
var out = [];
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
out.push(tokens[i]);
|
||||
var cur = tokens[i], nxt = tokens[i + 1];
|
||||
if (!nxt) continue;
|
||||
var curIsFn = cur.t === 'id' && Object.prototype.hasOwnProperty.call(FN_ARITY, cur.v);
|
||||
var curEnds = cur.t === 'num' ||
|
||||
(cur.t === 'id' && !curIsFn) ||
|
||||
(cur.t === 'op' && cur.v === ')');
|
||||
var nxtStarts = nxt.t === 'num' ||
|
||||
nxt.t === 'id' ||
|
||||
(nxt.t === 'op' && nxt.v === '(');
|
||||
// не вставляем '*' если cur — функция (тогда дальше идёт её '(')
|
||||
if (curEnds && nxtStarts) out.push({ t: 'op', v: '*' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ════════════════════ PARSER (рекурсивный спуск -> AST) ════════════════════
|
||||
|
||||
Грамматика (по убыванию приоритета связывания):
|
||||
ternary := logicOr ('?' ternary ':' ternary)?
|
||||
logicOr := logicAnd ('||' logicAnd)*
|
||||
logicAnd := compare ('&&' compare)*
|
||||
compare := addSub (('<'|'<='|'>'|'>='|'=='|'!=') addSub)?
|
||||
addSub := mulDiv (('+'|'-') mulDiv)*
|
||||
mulDiv := power (('*'|'/'|'%') power)*
|
||||
power := unary ('^' power)? // правоассоциативно
|
||||
unary := ('-'|'+'|'!') unary | primary
|
||||
primary := num | ident | const | fn(args...) | '(' ternary ')'
|
||||
|
||||
Узлы AST:
|
||||
{ k:'num', v }
|
||||
{ k:'var', name }
|
||||
{ k:'const', v }
|
||||
{ k:'bin', op, a, b }
|
||||
{ k:'un', op, a }
|
||||
{ k:'cmp', op, a, b }
|
||||
{ k:'logic', op, a, b }
|
||||
{ k:'not', a }
|
||||
{ k:'cond', c, a, b }
|
||||
{ k:'call', name, args:[...] }
|
||||
*/
|
||||
|
||||
function parse(tokens) {
|
||||
var pos = 0;
|
||||
function peek() { return tokens[pos]; }
|
||||
function next() { return tokens[pos++]; }
|
||||
function expect(v) {
|
||||
var t = peek();
|
||||
if (!t || t.v !== v) throw new Error('Ожидалось «' + v + '»');
|
||||
pos++;
|
||||
}
|
||||
function isOp(v) { var t = peek(); return t && t.t === 'op' && t.v === v; }
|
||||
|
||||
function ternary() {
|
||||
var c = logicOr();
|
||||
if (isOp('?')) {
|
||||
next();
|
||||
var a = ternary();
|
||||
expect(':');
|
||||
var b = ternary();
|
||||
return { k: 'cond', c: c, a: a, b: b };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function logicOr() {
|
||||
var l = logicAnd();
|
||||
while (isOp('||')) { next(); l = { k: 'logic', op: '||', a: l, b: logicAnd() }; }
|
||||
return l;
|
||||
}
|
||||
function logicAnd() {
|
||||
var l = compare();
|
||||
while (isOp('&&')) { next(); l = { k: 'logic', op: '&&', a: l, b: compare() }; }
|
||||
return l;
|
||||
}
|
||||
function compare() {
|
||||
var l = addSub();
|
||||
var t = peek();
|
||||
if (t && t.t === 'op' && (t.v === '<' || t.v === '<=' || t.v === '>' ||
|
||||
t.v === '>=' || t.v === '==' || t.v === '!=')) {
|
||||
var op = next().v;
|
||||
return { k: 'cmp', op: op, a: l, b: addSub() };
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function addSub() {
|
||||
var l = mulDiv();
|
||||
while (isOp('+') || isOp('-')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: mulDiv() }; }
|
||||
return l;
|
||||
}
|
||||
function mulDiv() {
|
||||
var l = power();
|
||||
while (isOp('*') || isOp('/') || isOp('%')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: power() }; }
|
||||
return l;
|
||||
}
|
||||
function power() {
|
||||
var base = unary();
|
||||
if (isOp('^')) { next(); return { k: 'bin', op: '^', a: base, b: power() }; }
|
||||
return base;
|
||||
}
|
||||
function unary() {
|
||||
if (isOp('-')) { next(); return { k: 'un', op: '-', a: unary() }; }
|
||||
if (isOp('+')) { next(); return unary(); }
|
||||
if (isOp('!')) { next(); return { k: 'not', a: unary() }; }
|
||||
return primary();
|
||||
}
|
||||
function primary() {
|
||||
var t = peek();
|
||||
if (!t) throw new Error('Неожиданный конец выражения');
|
||||
|
||||
if (t.t === 'num') { next(); return { k: 'num', v: t.v }; }
|
||||
|
||||
if (t.t === 'id') {
|
||||
next();
|
||||
var name = t.v;
|
||||
if (Object.prototype.hasOwnProperty.call(FN_ARITY, name)) {
|
||||
expect('(');
|
||||
var args = [];
|
||||
if (!isOp(')')) {
|
||||
args.push(ternary());
|
||||
while (isOp(',')) { next(); args.push(ternary()); }
|
||||
}
|
||||
expect(')');
|
||||
checkArity(name, args.length);
|
||||
return { k: 'call', name: name, args: args };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(CONSTANTS, name)) {
|
||||
return { k: 'const', v: CONSTANTS[name] };
|
||||
}
|
||||
// переменная окружения
|
||||
return { k: 'var', name: name };
|
||||
}
|
||||
|
||||
if (t.t === 'op' && t.v === '(') {
|
||||
next();
|
||||
var e = ternary();
|
||||
expect(')');
|
||||
return e;
|
||||
}
|
||||
|
||||
throw new Error('Неожиданный токен: «' + t.v + '»');
|
||||
}
|
||||
|
||||
var ast = ternary();
|
||||
if (pos !== tokens.length) throw new Error('Лишние токены после выражения');
|
||||
return ast;
|
||||
}
|
||||
|
||||
function checkArity(name, got) {
|
||||
var ar = FN_ARITY[name];
|
||||
if (ar === -1) { // >=1
|
||||
if (got < 1) throw new Error('Функции «' + name + '» нужен хотя бы 1 аргумент');
|
||||
return;
|
||||
}
|
||||
if (ar === -2) { // 1..2 (log)
|
||||
if (got < 1 || got > 2) throw new Error('Функция «' + name + '» принимает 1 или 2 аргумента');
|
||||
return;
|
||||
}
|
||||
if (got !== ar) throw new Error('Функция «' + name + '» принимает ' + ar + ' арг., дано ' + got);
|
||||
}
|
||||
|
||||
/* ════════════════════ EVALUATE ════════════════════
|
||||
Чистый интерпретатор AST по окружению env. НЕ бросает наружу (см. evaluate).
|
||||
Внутренний _ev может вернуть NaN/Infinity — нормализуется в evaluate. */
|
||||
|
||||
function _ev(node, env) {
|
||||
switch (node.k) {
|
||||
case 'num': return node.v;
|
||||
case 'const': return node.v;
|
||||
case 'var': {
|
||||
var val = env ? env[node.name] : undefined;
|
||||
return typeof val === 'number' ? val : 0; // неизвестная переменная -> 0
|
||||
}
|
||||
case 'un': // только '-'
|
||||
return -_ev(node.a, env);
|
||||
case 'not':
|
||||
return _ev(node.a, env) ? 0 : 1;
|
||||
case 'bin': {
|
||||
var a = _ev(node.a, env), b = _ev(node.b, env);
|
||||
switch (node.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return b === 0 ? 0 : a / b; // деление на 0 -> 0
|
||||
case '%': return b === 0 ? 0 : a % b;
|
||||
case '^': return Math.pow(a, b);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'cmp': {
|
||||
var x = _ev(node.a, env), y = _ev(node.b, env);
|
||||
switch (node.op) {
|
||||
case '<': return x < y ? 1 : 0;
|
||||
case '<=': return x <= y ? 1 : 0;
|
||||
case '>': return x > y ? 1 : 0;
|
||||
case '>=': return x >= y ? 1 : 0;
|
||||
case '==': return x === y ? 1 : 0;
|
||||
case '!=': return x !== y ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'logic': {
|
||||
if (node.op === '&&') return (_ev(node.a, env) && _ev(node.b, env)) ? 1 : 0;
|
||||
return (_ev(node.a, env) || _ev(node.b, env)) ? 1 : 0;
|
||||
}
|
||||
case 'cond':
|
||||
return _ev(node.c, env) ? _ev(node.a, env) : _ev(node.b, env);
|
||||
case 'call': {
|
||||
var fn = FN_IMPL[node.name];
|
||||
var args = node.args;
|
||||
if (args.length === 1) return fn(_ev(args[0], env));
|
||||
if (args.length === 2) return fn(_ev(args[0], env), _ev(args[1], env));
|
||||
// переменное число (min/max/hypot)
|
||||
var vals = new Array(args.length);
|
||||
for (var i = 0; i < args.length; i++) vals[i] = _ev(args[i], env);
|
||||
return fn.apply(null, vals);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Никогда не бросает; NaN/Infinity -> 0.
|
||||
function evaluate(ast, env) {
|
||||
if (!ast) return 0;
|
||||
var v;
|
||||
try { v = _ev(ast, env); } catch (e) { return 0; }
|
||||
return (typeof v === 'number' && isFinite(v)) ? v : 0;
|
||||
}
|
||||
|
||||
// Для отладки/билдера: возвращает значение + флаг (NaN/Infinity -> error).
|
||||
function evalSafe(ast, env) {
|
||||
if (!ast) return { value: 0, error: 'нет выражения' };
|
||||
var v;
|
||||
try { v = _ev(ast, env); } catch (e) { return { value: 0, error: String(e.message || e) }; }
|
||||
if (typeof v !== 'number' || !isFinite(v)) return { value: 0, error: 'не число (NaN/∞)' };
|
||||
return { value: v, error: null };
|
||||
}
|
||||
|
||||
/* ════════════════════ COMPILE ════════════════════
|
||||
Возвращает { ast, fn, error }. Синтаксическая ошибка -> error:строка, fn:()=>0. */
|
||||
|
||||
function compile(src) {
|
||||
if (src == null) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||
var raw = String(src).trim();
|
||||
// допускаем ведущее "y=" / "name=" (привычка), срезаем как в graph.js
|
||||
raw = raw.replace(/^\s*[a-zA-Z_][a-zA-Z_0-9.]*\s*=(?![=])\s*/, '');
|
||||
if (!raw) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||
var ast;
|
||||
try {
|
||||
var toks = insertImplicitMul(tokenize(raw));
|
||||
ast = parse(toks);
|
||||
} catch (e) {
|
||||
return { ast: null, fn: function () { return 0; }, error: String(e.message || e) };
|
||||
}
|
||||
var fn = function (env) { return evaluate(ast, env); };
|
||||
return { ast: ast, fn: fn, error: null };
|
||||
}
|
||||
|
||||
/* Хелпер: значение — число вернуть как есть; строка — скомпилировать и вернуть
|
||||
{ fn(env), error, constant }. Используется движком для свойств-привязок. */
|
||||
function compileValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
var c = value;
|
||||
return { fn: function () { return c; }, error: null, constant: true, ast: { k: 'num', v: c } };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
var r = compile(value);
|
||||
return { fn: r.fn, error: r.error, constant: false, ast: r.ast };
|
||||
}
|
||||
// прочие типы -> 0
|
||||
return { fn: function () { return 0; }, error: null, constant: true, ast: { k: 'num', v: 0 } };
|
||||
}
|
||||
|
||||
var FUNCTIONS = {}; // имя -> true (как «множество» без ES6 Set ради совместимости)
|
||||
Object.keys(FN_ARITY).forEach(function (k) { FUNCTIONS[k] = true; });
|
||||
var CONSTSET = {};
|
||||
Object.keys(CONSTANTS).forEach(function (k) { CONSTSET[k] = true; });
|
||||
|
||||
global.SimExpr = {
|
||||
compile: compile,
|
||||
compileValue: compileValue,
|
||||
evaluate: evaluate,
|
||||
evalSafe: evalSafe,
|
||||
tokenize: tokenize, // экспортируем для тестов/билдера
|
||||
parse: function (src) { // удобный helper: строка -> AST (бросает при ошибке)
|
||||
return parse(insertImplicitMul(tokenize(String(src).trim())));
|
||||
},
|
||||
FUNCTIONS: FUNCTIONS,
|
||||
CONSTANTS: CONSTSET
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user