'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimBuilder — учительский редактор спек-симуляций (Фаза 4 SimForge).
Собирает JSON-спеку v1 (данные, не код) из форм-панелей и монтирует
живое превью через window.SimEngine.mount(host, spec). Любое числовое
свойство объекта принимает число ИЛИ строку-выражение; выражения
проверяются через window.SimExpr.compile (без eval/Function). Save/Load
через LS.customSim* (Фаза 3). Доступ — только teacher/admin (гейт в html).
Раскладка: левая колонка — панели-аккордеоны (Мета / Параметры / Объекты /
Графики·Физика); центр — превью + тулбар; перемонтаж движка с debounce при
любой правке. Drag-on-preview: клик/перетаскивание ставит x/y выбранного
объекта в мировых координатах (через inst._toWorld).
ВАЖНО: Без эмодзи (только inline SVG). ВАЖНО: Без eval/new Function. Vanilla JS.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* ── Лимиты (зеркалят серверную validateSpec, Фаза 3) ── */
var LIMITS = {
params: 50, objects: 200, walls: 20, springs: 50, plots: 50,
exprLen: 500, points: 1000, jsonBytes: 200 * 1024
};
var SPEC_VERSION = 1;
// inline-стиль активного тумблера привязки (без зависимости от CSS-класса)
var SNAP_ACTIVE_CSS = 'background:var(--accent,#06D6E0);color:#0b1020;border-color:transparent';
var OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout'];
var CATS = ['math', 'phys', 'chem', 'bio', 'game'];
// ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера)
var RESERVED_PARAM = { e: true, E: true, pi: true, PI: true, t: true, w: true, h: true, tau: true };
/* ── Палитра имён функций/констант (из SimExpr) для подсказок ── */
function exprNames() {
var fns = [], consts = [];
if (global.SimExpr) {
Object.keys(global.SimExpr.FUNCTIONS || {}).forEach(function (k) { fns.push(k); });
Object.keys(global.SimExpr.CONSTANTS || {}).forEach(function (k) { consts.push(k); });
}
fns.sort(); consts.sort();
return { fns: fns, consts: consts };
}
/* ── escape для безопасной вставки в HTML-разметку ── */
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
function uid(prefix) {
return (prefix || 'o') + Math.random().toString(36).slice(2, 7) + (SimBuilder._seq++);
}
/* ════════════════════════════════════════════════════════════════════════
SimBuilder — модель состояния редактора + рендер панелей.
════════════════════════════════════════════════════════════════════════ */
var SimBuilder = {
_seq: 0,
create: function (opts) {
return new Builder(opts || {});
}
};
/* ── шаблон стартовой спеки (чистый лист) ── */
function blankState() {
return {
meta: { title: '', desc: '' },
subject: '',
grade: '',
cat: '',
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
time: { autoplay: false, loop: true, speed: 1 },
params: [],
objects: [],
plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] },
// P5-Квантик: игровой слой (goal + игровые метаданные). enabled=false → goal/game
// не попадают в спеку (обычная симуляция ведёт себя ровно как раньше).
game: {
enabled: false,
when: '', title: '', hint: '', hold: '', fail: '',
stars: [], // [{ when, label }], макс 3
chapter: '', order: '', par_ms: ''
}
};
}
function Builder(opts) {
this.host = opts.host; // DOM-узел-контейнер всей страницы (.app-layout > .sb-content > root)
this.previewHost = opts.previewHost; // DOM-узел, куда монтируется SimEngine
this.panelHost = opts.panelHost; // DOM-узел левой колонки панелей
this.toolbarHost = opts.toolbarHost; // DOM-узел тулбара превью
this.simId = opts.simId || null; // если редактируем существующий
this.status = 'draft'; // draft | published
this.version = 1;
this.st = blankState();
this.inst = null; // текущий инстанс SimEngine
this._remountTimer = null;
this._selObjId = null; // выбранный для drag-on-preview объект
this._placing = false; // режим «поставить объект кликом»
this._open = { meta: true, params: true, objects: true, plots: true, game: false };
this._lastSpec = null;
// P5: прямое манипулирование + история
this._snap = false; // привязка к сетке при drag
this._undo = []; // стек снапшотов JSON состояния (для отката)
this._redo = []; // стек откатанных снапшотов (для повтора)
this._undoMax = 50; // ограничение глубины истории
this._fieldSnapTaken = false; // взят ли снапшот для текущего сеанса правки поля
this._keyHandler = null; // ссылка на keydown-листенер (для отвязки)
}
/* ════════════════════════ ИСТОРИЯ (Undo / Redo, P5) ════════════════════════
Снапшот — сериализуемое состояние редактора this.st (JSON). Снимаем ПЕРЕД
мутацией; restore возвращает структуру + перерисовывает панели и превью.
Глубина ограничена _undoMax; любой новый снапшот сбрасывает redo-стек. */
/* Снять снапшот текущего состояния (вызывать ДО мутации). dedupe — не пушить
дубликат верхушки стека (например при повторных drag-сессиях). */
Builder.prototype.pushHistory = function () {
var snap;
try { snap = JSON.stringify(this.st); } catch (e) { return; }
if (this._undo.length && this._undo[this._undo.length - 1] === snap) return; // без дублей
this._undo.push(snap);
if (this._undo.length > this._undoMax) this._undo.shift();
this._redo.length = 0; // новая ветка истории — повтор сбрасывается
this.updateHistoryButtons();
};
/* Применить снапшот к состоянию (восстановить структуру, перерисовать). */
Builder.prototype._restoreSnapshot = function (snap) {
var st;
try { st = JSON.parse(snap); } catch (e) { return; }
this.st = st;
// выбранный объект мог исчезнуть в восстановленном состоянии
var selId = this._selObjId;
if (selId && !(st.objects || []).some(function (o) { return o._uid === selId; })) {
this._selObjId = null;
}
this.renderPanels();
this.scheduleRemount(false);
this.updateHistoryButtons();
};
Builder.prototype.undo = function () {
if (!this._undo.length) return;
var cur;
try { cur = JSON.stringify(this.st); } catch (e) { cur = null; }
var snap = this._undo.pop();
if (cur != null) this._redo.push(cur);
if (this._redo.length > this._undoMax) this._redo.shift();
this._restoreSnapshot(snap);
};
Builder.prototype.redo = function () {
if (!this._redo.length) return;
var cur;
try { cur = JSON.stringify(this.st); } catch (e) { cur = null; }
var snap = this._redo.pop();
if (cur != null) this._undo.push(cur);
if (this._undo.length > this._undoMax) this._undo.shift();
this._restoreSnapshot(snap);
};
/* Снапшот для правки поля: один на сессию (между focusin и blur). Вызывать в
начале input/change-обработчика поля — Ctrl+Z откатит всё значение разом. */
Builder.prototype.snapField = function () {
if (this._fieldSnapTaken) return;
this.pushHistory();
this._fieldSnapTaken = true;
};
/* Обновить disabled-состояние кнопок undo/redo в тулбаре. */
Builder.prototype.updateHistoryButtons = function () {
var t = this.toolbarHost; if (!t) return;
var u = t.querySelector('[data-a="undo"]'), r = t.querySelector('[data-a="redo"]');
if (u) u.disabled = !this._undo.length;
if (r) r.disabled = !this._redo.length;
};
/* ════════════════════════ ПУБЛИЧНЫЙ API ════════════════════════ */
Builder.prototype.init = function () {
this.renderToolbar();
this.renderPanels();
this.scheduleRemount(true);
};
/* Загрузить существующую спеку (sim.spec + мета) в состояние. */
Builder.prototype.loadFromSim = function (sim) {
this.simId = sim.id;
this.status = sim.status || 'draft';
this.version = sim.version || 1;
var spec = sim.spec || {};
var st = blankState();
st.meta = { title: (spec.meta && spec.meta.title) || sim.title || '', desc: (spec.meta && spec.meta.desc) || sim.description || '' };
st.subject = sim.subject || '';
st.grade = (sim.grade == null ? '' : sim.grade);
st.cat = sim.cat || '';
var vp = spec.viewport || {};
st.viewport = {
xmin: numOr(vp.xmin, -1), xmax: numOr(vp.xmax, 10),
ymin: numOr(vp.ymin, -1), ymax: numOr(vp.ymax, 10),
grid: vp.grid !== false, axes: vp.axes !== false
};
var time = spec.time || {};
st.time = { autoplay: !!time.autoplay, loop: time.loop !== false, speed: numOr(time.speed, 1) };
// params
st.params = (Array.isArray(spec.params) ? spec.params : []).map(function (p) {
return {
name: String(p.name || ''), label: p.label || '',
min: numOr(p.min, 0), max: numOr(p.max, 100), step: numOr(p.step, 1),
value: numOr(p.value, 0), unit: p.unit || ''
};
});
// objects + plots (plot выделяем в отдельный список UI)
var objs = Array.isArray(spec.objects) ? spec.objects : [];
st.objects = []; st.plots = [];
objs.forEach(function (o) {
if (o.type === 'plot') {
st.plots.push(loadPlot(o));
} else {
st.objects.push(Object.assign({ _uid: uid('o') }, o));
}
});
// physics
var ph = spec.physics || {};
st.physics = {
enabled: !!ph.enabled,
gx: numOr(ph.gravity && ph.gravity.x, 0),
gy: numOr(ph.gravity && ph.gravity.y, -9.8),
friction: numOr(ph.friction, 0),
restitution: numOr(ph.restitution, 0.9),
walls: (Array.isArray(ph.walls) ? ph.walls : []).map(function (w) { return Object.assign({ _uid: uid('w') }, w); }),
springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); })
};
// game/goal (P5-Квантик): раскладываем spec.goal + spec.game обратно в st.game.
st.game = loadGame(spec.goal, spec.game);
this.st = st;
// свежая загрузка (открытие симуляции / шаблон) — история начинается заново
this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false;
this.renderToolbar();
this.renderPanels();
this.scheduleRemount(true);
};
/* Сборка чистой JSON-спеки v1 из состояния (для движка / сохранения). */
Builder.prototype.buildSpec = function () {
var st = this.st;
var objects = [];
// обычные объекты (скрытые hidden:true не попадают в спеку — движок не знает о hidden)
st.objects.forEach(function (o) { if (o.hidden) return; objects.push(stripObj(o)); });
// plot-объекты
st.plots.forEach(function (o) { if (o.hidden) return; objects.push(stripObj(o)); });
var spec = {
specVersion: SPEC_VERSION,
meta: { title: trimStr(st.meta.title), desc: trimStr(st.meta.desc) },
viewport: {
xmin: numOr(st.viewport.xmin, -1), xmax: numOr(st.viewport.xmax, 10),
ymin: numOr(st.viewport.ymin, -1), ymax: numOr(st.viewport.ymax, 10),
grid: !!st.viewport.grid, axes: !!st.viewport.axes
},
time: { autoplay: !!st.time.autoplay, loop: !!st.time.loop, speed: numOr(st.time.speed, 1) },
params: st.params.filter(function (p) { return p.name; }).map(function (p) {
var o = { name: p.name, min: numOr(p.min, 0), max: numOr(p.max, 100), step: numOr(p.step, 1), value: numOr(p.value, numOr(p.min, 0)) };
if (p.label) o.label = trimStr(p.label);
if (p.unit) o.unit = trimStr(p.unit);
return o;
}),
objects: objects
};
if (st.physics.enabled) {
var ph = {
enabled: true,
gravity: { x: numOr(st.physics.gx, 0), y: numOr(st.physics.gy, 0) },
friction: numOr(st.physics.friction, 0),
restitution: clamp01(numOr(st.physics.restitution, 0.9)),
walls: st.physics.walls.map(stripWall),
springs: st.physics.springs.map(stripSpring)
};
spec.physics = ph;
}
// game/goal (P5-Квантик): материализуем игровой слой, если он включён.
// goal{when,title,hint,hold,fail,stars[]} и game{chapter,order,par_ms}.
if (st.game && st.game.enabled) {
var goal = buildGoal(st.game);
if (goal) spec.goal = goal;
var game = buildGameMeta(st.game);
if (game) spec.game = game;
}
return spec;
};
/* ── Удаление UI-метаданных (_uid, пустых и дефолтных стилевых полей) из объекта спеки.
Дефолты стиля не сериализуем — спека минимальна и round-trip стабилен (loadFromSim
восстанавливает их обратно из дефолтов контролов). hidden никогда не идёт в спеку. ── */
function isDefaultStyle(k, v) {
if (k === 'hidden') return true; // UI-флаг, не для движка
if (k === 'glow' && v === false) return true;
if (k === 'trail' && v === false) return true;
if (k === 'closed' && v === false) return true;
if (k === 'lineStyle' && v === 'solid') return true;
if (k === 'pointStyle' && v === 'filled') return true;
if (k === 'opacity' && (v === 1 || v === '1')) return true;
return false;
}
function stripObj(o) {
var out = {};
Object.keys(o).forEach(function (k) {
if (k === '_uid') return;
var v = o[k];
if (v === '' || v === undefined || v === null) return;
if (isDefaultStyle(k, v)) return;
out[k] = v;
});
return out;
}
function stripWall(w) {
var out = {};
if (w.side) out.side = w.side;
if (w.x1 !== '' && w.x1 != null) { out.x1 = numOr(w.x1, 0); out.y1 = numOr(w.y1, 0); out.x2 = numOr(w.x2, 0); out.y2 = numOr(w.y2, 0); }
return out;
}
function stripSpring(s) {
var out = { k: numOr(s.k, 40), length: numOr(s.length, 1) };
out.a = parseEnd(s.a);
out.b = parseEnd(s.b);
if (s.damping !== '' && s.damping != null) out.damping = numOr(s.damping, 0);
return out;
}
// конец пружины: "id" или "[x,y]" / "x,y" -> id-строка или [x,y]
function parseEnd(v) {
if (Array.isArray(v)) return v;
var s = String(v == null ? '' : v).trim();
var m = s.match(/^\[?\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]?$/);
if (m) return [parseFloat(m[1]), parseFloat(m[2])];
return s;
}
/* ── Игровой слой (P5-Квантик): goal/game ⇄ UI-состояние st.game ───────────
st.game = { enabled, when, title, hint, hold, fail, stars:[{when,label}],
chapter, order, par_ms }. Хранит «как введено» (строки/числа) —
материализация в spec.goal/spec.game на сборке, разбор обратно на загрузке. */
/* spec.goal + spec.game -> st.game (для loadFromSim). Включаем игровой режим,
если в спеке присутствует goal ИЛИ game. */
function loadGame(goal, game) {
var g = {
enabled: false,
when: '', title: '', hint: '', hold: '', fail: '',
stars: [], chapter: '', order: '', par_ms: ''
};
if (goal && typeof goal === 'object') {
g.enabled = true;
g.when = goal.when == null ? '' : String(goal.when);
g.title = goal.title == null ? '' : String(goal.title);
g.hint = goal.hint == null ? '' : String(goal.hint);
g.fail = goal.fail == null ? '' : String(goal.fail);
g.hold = (goal.hold == null || goal.hold === '') ? '' : goal.hold;
g.stars = (Array.isArray(goal.stars) ? goal.stars : []).map(function (s) {
s = s || {};
return {
_uid: uid('star'),
when: s.when == null ? '' : String(s.when),
label: s.label == null ? '' : String(s.label)
};
});
}
if (game && typeof game === 'object') {
g.enabled = true;
g.chapter = game.chapter == null ? '' : String(game.chapter);
g.order = (game.order == null || game.order === '') ? '' : game.order;
g.par_ms = (game.par_ms == null || game.par_ms === '') ? '' : game.par_ms;
}
return g;
}
/* st.game -> spec.goal (или null, если нет ни одного содержательного поля). */
function buildGoal(gm) {
var out = {};
if (trimStr(gm.when)) out.when = trimStr(gm.when);
if (trimStr(gm.title)) out.title = trimStr(gm.title);
if (trimStr(gm.hint)) out.hint = trimStr(gm.hint);
if (trimStr(gm.fail)) out.fail = trimStr(gm.fail);
if (gm.hold !== '' && gm.hold != null && isFinite(parseFloat(gm.hold))) out.hold = parseFloat(gm.hold);
var stars = (Array.isArray(gm.stars) ? gm.stars : []).map(function (s) {
var os = {};
if (trimStr(s.when)) os.when = trimStr(s.when);
if (trimStr(s.label)) os.label = trimStr(s.label);
return os;
}).filter(function (s) { return s.when || s.label; }).slice(0, 3);
if (stars.length) out.stars = stars;
return Object.keys(out).length ? out : null;
}
/* st.game -> spec.game (метаданные уровня; null, если все пусты). */
function buildGameMeta(gm) {
var out = {};
if (trimStr(gm.chapter)) out.chapter = trimStr(gm.chapter);
if (gm.order !== '' && gm.order != null && isFinite(parseFloat(gm.order))) out.order = parseFloat(gm.order);
if (gm.par_ms !== '' && gm.par_ms != null && isFinite(parseFloat(gm.par_ms))) out.par_ms = parseFloat(gm.par_ms);
return Object.keys(out).length ? out : null;
}
/* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */
Builder.prototype.scheduleRemount = function (immediate) {
var self = this;
if (this._remountTimer) { clearTimeout(this._remountTimer); this._remountTimer = null; }
if (immediate) { this.remount(); return; }
this._remountTimer = setTimeout(function () { self.remount(); }, 280);
};
Builder.prototype.remount = function () {
if (!global.SimEngine || !this.previewHost) return;
var wasRunning = this.inst && this.inst.isRunning && this.inst.isRunning();
try { if (this.inst) this.inst.destroy(); } catch (e) {}
this.inst = null;
this.previewHost.innerHTML = '';
var spec = this.buildSpec();
this._lastSpec = spec;
try {
this.inst = global.SimEngine.mount(this.previewHost, spec);
if (wasRunning && this.inst.play) this.inst.play();
} catch (e) {
this.previewHost.innerHTML = '
Ошибка сборки превью: ' + esc(e.message || e) + '
';
}
this.bindPreviewDrag();
};
/* Drag-on-preview (P5): прямое манипулирование выбранным объектом на сцене.
Поддержаны ВСЕ позиционируемые типы — точка/окружность/подпись/показатель/
прямоугольник (x,y), отрезок/вектор (оба конца x1,y1 и x2,y2; вектор также в
форме origin+dx/dy), ломаная/путь (перетаскивание вершин массива points).
Выбирается ближайшая «ручка» в пределах допуска; иначе — целое тело (двигаем
все координаты сразу). Поля-ВЫРАЖЕНИЯ (не числа) НЕ перезаписываем молча.
При включённой привязке (this._snap) мир-координаты округляются к шагу сетки.
Работает только когда выбран объект и движок не запущен (иначе мешает
встроенному drag/анимации движка). */
Builder.prototype.bindPreviewDrag = function () {
var self = this;
if (!this.inst || !this.inst.canvas) return;
var canvas = this.inst.canvas;
var drag = null; // активная сессия: { kind, keys, start, base }
var HIT = 14; // допуск хит-теста ручки в экранных px
function objSel() {
if (!self._selObjId) return null;
return self.st.objects.find(function (o) { return o._uid === self._selObjId; }) || null;
}
function worldAt(ev) {
var r = canvas.getBoundingClientRect();
var px = ev.clientX - r.left, py = ev.clientY - r.top;
if (typeof self.inst._toWorld === 'function') return self.inst._toWorld(px, py);
return null;
}
function pxAt(mx, my) {
if (typeof self.inst._toPx === 'function') return self.inst._toPx(mx, my);
return null;
}
// шаг привязки: минорный шаг сетки движка (~34px) или 0.5 как запасной
function snapStep() {
if (self.inst && typeof self.inst._niceStep === 'function') {
var s = self.inst._niceStep(34);
if (isFinite(s) && s > 0) return s;
}
return 0.5;
}
function snapVal(v) {
if (!self._snap) return round2(v);
var step = snapStep();
return round2(Math.round(v / step) * step);
}
// числовое значение поля или null, если выражение/пусто (не трогаем выражения)
function numField(obj, key) {
var v = obj[key];
if (v === '' || v == null) return 0; // пустое трактуем как 0 (можно двигать)
if (typeof v === 'number') return v;
var n = Number(v);
return (isFinite(n) && String(v).trim() !== '') ? n : null; // null = выражение
}
// массив вершин полилинии/пути из строки JSON; null при ошибке
function parsePoints(obj) {
try {
var arr = JSON.parse(obj.points);
if (Array.isArray(arr)) return arr;
} catch (e) {}
return null;
}
/* Перечислить «ручки» объекта (мир-координаты + сеттер). exprBlocked=true,
если соответствующее поле — выражение (ручка показывается, но не двигается). */
function handlesOf(obj) {
var hs = [];
if (!obj) return hs;
var t = obj.type;
function pt(keyX, keyY, label) {
var wx = numField(obj, keyX), wy = numField(obj, keyY);
var blocked = (wx === null || wy === null);
hs.push({
label: label, blocked: blocked,
wx: blocked ? null : wx, wy: blocked ? null : wy,
set: function (x, y) { if (!blocked) { obj[keyX] = snapVal(x); obj[keyY] = snapVal(y); } }
});
}
if (t === 'segment' || t === 'vector') {
// вектор может быть задан origin(x1,y1)+dx,dy ИЛИ x1y1x2y2
var hasDxDy = ('dx' in obj || 'dy' in obj) && !('x2' in obj && 'y2' in obj);
pt('x1', 'y1', 'origin');
if (hasDxDy) {
// конец = origin + (dx,dy); ручка пишет dx/dy
var ox = numField(obj, 'x1'), oy = numField(obj, 'y1');
var dx = numField(obj, 'dx'), dy = numField(obj, 'dy');
var blk = (ox === null || oy === null || dx === null || dy === null);
hs.push({
label: 'end', blocked: blk,
wx: blk ? null : ox + dx, wy: blk ? null : oy + dy,
set: function (x, y) { if (!blk) { obj.dx = snapVal(x - ox); obj.dy = snapVal(y - oy); } }
});
} else {
pt('x2', 'y2', 'end');
}
} else if (t === 'polyline' || t === 'path') {
var arr = parsePoints(obj);
if (arr) {
arr.forEach(function (p, idx) {
if (!Array.isArray(p) || p.length < 2) return;
var nx = Number(p[0]), ny = Number(p[1]);
if (!isFinite(nx) || !isFinite(ny)) return; // выражение-вершина — не двигаем
hs.push({
label: 'v' + idx, blocked: false, wx: nx, wy: ny, _vidx: idx,
set: function (x, y) {
var a = parsePoints(obj); if (!a || !a[idx]) return;
a[idx][0] = snapVal(x); a[idx][1] = snapVal(y);
obj.points = JSON.stringify(a);
}
});
});
}
} else {
// x/y типы: point, circle, label, readout, rect
pt('x', 'y', 'pos');
}
return hs;
}
/* Хит-тест: ближайшая незаблокированная ручка под экранной точкой (px,py). */
function pickHandle(obj, lx, ly) {
var hs = handlesOf(obj), best = null, bestD = HIT * HIT;
for (var i = 0; i < hs.length; i++) {
var h = hs[i];
if (h.blocked || h.wx == null) continue;
var p = pxAt(h.wx, h.wy); if (!p) continue;
var dx = p[0] - lx, dy = p[1] - ly, d = dx * dx + dy * dy;
if (d <= bestD) { bestD = d; best = h; }
}
return best;
}
// незаблокированные ручки объекта
function movableHandles(obj) {
return handlesOf(obj).filter(function (h) { return !h.blocked && h.wx != null; });
}
canvas.addEventListener('pointerdown', function (ev) {
if (!self._selObjId) return;
if (self.inst && self.inst.isRunning && self.inst.isRunning()) return;
var obj = objSel(); if (!obj) return;
var w = worldAt(ev); if (!w) return;
var r = canvas.getBoundingClientRect();
var lx = ev.clientX - r.left, ly = ev.clientY - r.top;
// снапшот в историю ПЕРЕД началом перемещения (один на сессию drag)
self.pushHistory();
var handle = pickHandle(obj, lx, ly);
var move = movableHandles(obj);
// режим: 'handle' (конкретная ручка), 'place' (клик ставит единственную точку),
// 'body' (несколько ручек — двигаем тело относительно стартовой точки), 'none'
var mode;
if (handle) mode = 'handle';
else if (move.length === 1) { handle = move[0]; mode = 'place'; } // point/circle/label/readout/rect
else if (move.length > 1) mode = 'body'; // segment/vector/polyline
else mode = 'none';
drag = { obj: obj, mode: mode, handle: handle, startW: w, moved: false,
baseHandles: (mode === 'body') ? move.map(function (h) { return { wx: h.wx, wy: h.wy, set: h.set }; }) : null };
try { canvas.setPointerCapture(ev.pointerId); } catch (e) {}
// handle/place — сразу поставить ручку в точку клика (place = клик ставит)
if (mode === 'handle' || mode === 'place') {
handle.set(w[0], w[1]); drag.moved = true;
self.refreshObjFields(obj._uid); self.scheduleRemount(false);
}
ev.preventDefault();
});
canvas.addEventListener('pointermove', function (ev) {
if (!drag) return;
var w = worldAt(ev); if (!w) return;
var obj = drag.obj;
if (drag.mode === 'handle' || drag.mode === 'place') {
drag.handle.set(w[0], w[1]);
} else if (drag.mode === 'body') {
// тело целиком: сдвиг всех ручек от стартовой мировой точки
var dxw = w[0] - drag.startW[0], dyw = w[1] - drag.startW[1];
drag.baseHandles.forEach(function (h) { h.set(h.wx + dxw, h.wy + dyw); });
} else return;
drag.moved = true;
self.refreshObjFields(obj._uid);
self.scheduleRemount(false);
});
function end() {
// если состояние не менялось (mode 'none' или body без движения) — откатить
// лишний снапшот, чтобы Ctrl+Z не «застревал» на пустых записях.
if (drag && !drag.moved && self._undo.length) {
var top = self._undo[self._undo.length - 1];
var cur; try { cur = JSON.stringify(self.st); } catch (e) { cur = null; }
if (cur != null && cur === top) { self._undo.pop(); self.updateHistoryButtons(); }
}
drag = null;
}
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
// курсор-подсказка
canvas.style.cursor = this._selObjId ? 'crosshair' : '';
};
/* ════════════════════════ ТУЛБАР ════════════════════════ */
Builder.prototype.renderToolbar = function () {
var self = this;
var t = this.toolbarHost;
if (!t) return;
var statusBadge = this.status === 'published'
? 'Опубликовано '
: 'Черновик ';
// Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать».
var pubBtn = this.status === 'published'
? '' + ICON.unpublish + ' Снять '
: '' + ICON.send + ' Опубликовать ';
// «Раздать классу» доступна только для уже сохранённой симуляции.
var shareBtn = this.simId
? '' + ICON.send + ' Раздать '
: '';
t.innerHTML =
'' +
'' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + ' ' +
statusBadge +
'
' +
'' +
'' + ICON.undo + ' ' +
'' + ICON.redo + ' ' +
'' + ICON.grid + ' ' +
'' + ICON.template + ' Шаблон ' +
'' + ICON.play + ' Тест ' +
'' + ICON.reset + ' Сброс ' +
'' + ICON.save + ' Сохранить ' +
shareBtn +
pubBtn +
'
';
t.querySelectorAll('[data-a]').forEach(function (b) {
b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); });
});
this.bindKeyboardShortcuts();
};
/* Глобальные горячие клавиши истории (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y).
Игнорируем, если фокус в поле ввода (там работает нативная отмена текста). */
Builder.prototype.bindKeyboardShortcuts = function () {
var self = this;
if (this._keyHandler) return; // вешаем один раз
this._keyHandler = function (ev) {
if (!(ev.ctrlKey || ev.metaKey)) return;
var tag = (ev.target && ev.target.tagName) || '';
var editable = ev.target && (ev.target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT');
if (editable) return; // не перехватываем правку текста в полях
var k = (ev.key || '').toLowerCase();
if (k === 'z' && !ev.shiftKey) { ev.preventDefault(); self.undo(); }
else if ((k === 'z' && ev.shiftKey) || k === 'y') { ev.preventDefault(); self.redo(); }
};
global.document.addEventListener('keydown', this._keyHandler);
};
Builder.prototype.onToolbar = function (action) {
if (action === 'test') { if (this.inst && this.inst.play) this.inst.play(); return; }
if (action === 'reset') { if (this.inst && this.inst.reset) this.inst.reset(); return; }
if (action === 'save') { this.save(false); return; }
if (action === 'publish') { this.save(true); return; }
if (action === 'unpublish') { this.setStatus('draft'); return; }
if (action === 'share') { this.openShareModal(); return; }
if (action === 'template') { this.openTemplateModal(); return; }
if (action === 'undo') { this.undo(); return; }
if (action === 'redo') { this.redo(); return; }
if (action === 'snap') { this.toggleSnap(); return; }
};
/* «Играть»: открыть текущую (в работе) спеку в игровом режиме для теста уровня.
Монтируем тот же SimEngine в модалке — слой цели (HUD/победа/звёзды) активируется
САМ наличием блока goal (Фаза 0 движка), как и в /quantik. Без сохранения/сети —
тестируем прямо черновик. Если goal не задан, подсказываем включить игровой слой. */
Builder.prototype.playGame = function () {
var self = this;
var spec = this.buildSpec();
if (!spec.goal || !spec.goal.when) {
global.LS.toast('Задайте цель (поле «победа») и включите игровой уровень', 'warn', 2600);
return;
}
if (!global.SimEngine) { global.LS.toast('Движок не загружен', 'error'); return; }
// Модалка с хост-узлом сцены; SimEngine монтируется после открытия. Инстанс
// уничтожается в onClose — он срабатывает на ЛЮБОЕ закрытие (X / оверлей / Escape /
// кнопка «Закрыть»), поэтому отдельный destroy в onClick кнопки не нужен.
var host = global.document.createElement('div');
host.style.cssText = 'position:relative;width:100%;height:min(70vh,560px);background:#0D0D1A;border-radius:10px;overflow:hidden';
var inst = null;
var m = global.LS.modal({
title: 'Тест уровня', size: 'lg', content: '',
onClose: function () { if (inst) { try { inst.destroy(); } catch (e) {} inst = null; } },
actions: [
{ label: 'Сброс', onClick: function () { if (inst && inst.reset) { try { inst.reset(); } catch (e) {} } } },
{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }
]
});
m.body.appendChild(host);
try {
inst = global.SimEngine.mount(host, spec);
if (inst && inst.play) inst.play();
} catch (e) {
host.innerHTML = 'Ошибка запуска: ' + esc(e.message || e) + '
';
}
};
/* Переключить привязку к сетке (drag будет округлять к шагу сетки). */
Builder.prototype.toggleSnap = function () {
this._snap = !this._snap;
var t = this.toolbarHost;
if (t) {
var b = t.querySelector('[data-a="snap"]');
if (b) {
b.setAttribute('aria-pressed', this._snap ? 'true' : 'false');
b.setAttribute('style', this._snap ? SNAP_ACTIVE_CSS : '');
}
}
if (global.LS && global.LS.toast) global.LS.toast(this._snap ? 'Привязка к сетке включена' : 'Привязка к сетке выключена', 'info', 1600);
};
/* Изменить статус публикации уже сохранённой симуляции (PUT status). */
Builder.prototype.setStatus = function (status) {
var self = this;
if (!this.simId) { this.save(status === 'published'); return; }
global.LS.customSimUpdate(this.simId, { status: status }).then(function () {
self.status = status;
self.renderToolbar();
global.LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка', 'error');
});
};
/* Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует +
уведомляет учеников со ссылкой /lab?sim=custom:). */
Builder.prototype.openShareModal = function () {
var self = this;
if (!this.simId) { global.LS.toast('Сначала сохраните симуляцию', 'warn'); return; }
global.LS.getClasses().then(function (classes) {
if (!Array.isArray(classes) || !classes.length) { global.LS.toast('Нет классов для раздачи', 'warn'); return; }
var opts = classes.map(function (c) {
return '' + esc(c.name) + ' ';
}).join('');
var content = '' +
'
Класс ' +
'
' + opts + ' ' +
'
Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.
' +
'
';
var m = global.LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
{ label: 'Отмена', onClick: function () { m.close(); } },
{ label: 'Раздать', primary: true, onClick: function () {
var sel = m.body.querySelector('#sbu-share-class');
var classId = sel ? Number(sel.value) : NaN;
global.LS.customSimShare(self.simId, { classId: classId }).then(function (r) {
m.close();
self.status = 'published';
self.renderToolbar();
global.LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
});
} }
] });
}).catch(function () { global.LS.toast('Не удалось загрузить классы', 'error'); });
};
/* Старт из шаблона: выбор готовой спеки -> загрузка в редактор как НОВАЯ
симуляция (simId сбрасывается, чтобы первое «Сохранить» создало запись). */
Builder.prototype.openTemplateModal = function () {
var self = this;
var cards = TEMPLATES.map(function (tpl, i) {
return '' +
'' + esc(tpl.name) + ' ' +
'' + esc(tpl.desc) + ' ' +
' ';
}).join('');
var content = '' + cards +
'
Шаблон заменит текущую сцену и создаст новую симуляцию.
';
var m = global.LS.modal({ title: 'Создать из шаблона', content: content, size: 'sm', actions: [
{ label: 'Закрыть', onClick: function () { m.close(); } }
] });
m.body.querySelectorAll('[data-tpl]').forEach(function (b) {
b.addEventListener('click', function () {
var tpl = TEMPLATES[Number(b.getAttribute('data-tpl'))];
if (!tpl) return;
var apply = function () {
self.simId = null;
try { global.history.replaceState({}, '', '/sim-builder'); } catch (e) {}
// loadFromSim ждёт sim-объект; собираем синтетический из спеки шаблона.
var spec = JSON.parse(JSON.stringify(tpl.spec));
self.loadFromSim({
id: null, status: 'draft', version: 1,
title: (spec.meta && spec.meta.title) || tpl.name,
description: (spec.meta && spec.meta.desc) || '',
subject: spec.subject || '', grade: spec.grade != null ? spec.grade : '',
cat: tpl.cat || spec.cat || '', spec: spec
});
m.close();
global.LS.toast('Шаблон загружен', 'success');
};
var hasContent = self.st.params.length || self.st.objects.length || self.st.plots.length;
if (hasContent && !global.confirm('Заменить текущую сцену шаблоном «' + tpl.name + '»?')) return;
apply();
});
});
};
/* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */
/* Возвращает массив строк-ошибок (пусто = всё валидно). */
Builder.prototype.validate = function () {
var st = this.st, errs = [];
if (!trimStr(st.meta.title)) errs.push('Укажите заголовок симуляции.');
// params
if (st.params.length > LIMITS.params) errs.push('Слишком много параметров (макс ' + LIMITS.params + ').');
var seen = {};
st.params.forEach(function (p, i) {
var nm = trimStr(p.name);
if (!nm) { errs.push('Параметр #' + (i + 1) + ': пустое имя.'); return; }
if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(nm)) errs.push('Параметр «' + nm + '»: имя должно быть идентификатором (буквы/цифры/_, не с цифры).');
if (RESERVED_PARAM[nm]) errs.push('Имя «' + nm + '» зарезервировано (' + (nm === 'e' ? 'число Эйлера' : 'служебное') + '). Выберите другое.');
if (seen[nm]) errs.push('Дубликат параметра «' + nm + '».');
seen[nm] = true;
if (numOr(p.min, 0) > numOr(p.max, 0)) errs.push('Параметр «' + nm + '»: min больше max.');
});
// objects + plots
var total = st.objects.length + st.plots.length;
if (total > LIMITS.objects) errs.push('Слишком много объектов (макс ' + LIMITS.objects + ').');
// выражения объектов
var self = this;
function checkExpr(v, where) {
if (typeof v !== 'string' || v === '') return;
if (v.length > LIMITS.exprLen) errs.push(where + ': выражение длиннее ' + LIMITS.exprLen + ' симв.');
var c = global.SimExpr ? global.SimExpr.compile(v) : { error: null };
if (c.error) errs.push(where + ': ' + c.error);
}
st.objects.forEach(function (o, i) {
exprFieldsOf(o).forEach(function (f) {
checkExpr(o[f], 'Объект #' + (i + 1) + ' (' + o.type + '), поле «' + f + '»');
});
});
// выражения графиков: кривые + границы диапазона
st.plots.forEach(function (o, i) {
(Array.isArray(o.curves) ? o.curves : []).forEach(function (cv, ci) {
checkExpr(cv.expr, 'График #' + (i + 1) + ', кривая ' + (ci + 1));
});
checkExpr(typeof o.range_a === 'string' ? o.range_a : '', 'График #' + (i + 1) + ', «от»');
checkExpr(typeof o.range_b === 'string' ? o.range_b : '', 'График #' + (i + 1) + ', «до»');
});
// physics
if (st.physics.enabled) {
if (st.physics.walls.length > LIMITS.walls) errs.push('Слишком много стен (макс ' + LIMITS.walls + ').');
if (st.physics.springs.length > LIMITS.springs) errs.push('Слишком много пружин (макс ' + LIMITS.springs + ').');
var r = numOr(st.physics.restitution, 0.9);
if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.');
}
// game/goal (P5-Квантик): проверяем выражения цели/проигрыша/звёзд
if (st.game && st.game.enabled) {
checkExpr(typeof st.game.when === 'string' ? st.game.when : '', 'Цель: условие победы (when)');
checkExpr(typeof st.game.fail === 'string' ? st.game.fail : '', 'Цель: условие проигрыша (fail)');
if (!trimStr(st.game.when)) errs.push('Игровой уровень: укажите условие победы (when).');
var starList = Array.isArray(st.game.stars) ? st.game.stars : [];
if (starList.length > 3) errs.push('Максимум 3 звезды.');
starList.forEach(function (s, i) {
checkExpr(typeof s.when === 'string' ? s.when : '', 'Звезда #' + (i + 1) + ', условие');
});
}
// размер JSON
try {
var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size;
if (bytes > LIMITS.jsonBytes) errs.push('Спека слишком большая (' + Math.round(bytes / 1024) + ' КБ, макс 200 КБ).');
} catch (e) {}
return errs;
};
/* ════════════════════════ SAVE / LOAD ════════════════════════ */
Builder.prototype.save = function (publish) {
var self = this;
var errs = this.validate();
if (errs.length) {
global.LS.modal({
title: 'Не удаётся сохранить', size: 'sm',
content: '' +
'
Исправьте перед сохранением:
' +
errs.slice(0, 12).map(function (e) { return '' + esc(e) + ' '; }).join('') +
(errs.length > 12 ? '…и ещё ' + (errs.length - 12) + ' ' : '') +
' ',
actions: [{ label: 'Понятно', primary: true, onClick: function () { this.close(); } }]
});
return;
}
var spec = this.buildSpec();
var meta = {
title: trimStr(this.st.meta.title),
description: trimStr(this.st.meta.desc),
subject: trimStr(this.st.subject) || null,
grade: (this.st.grade === '' || this.st.grade == null) ? null : parseInt(this.st.grade, 10),
cat: this.st.cat || null,
spec: spec
};
if (publish) meta.status = 'published';
var p;
if (this.simId) {
p = global.LS.customSimUpdate(this.simId, meta);
} else {
if (publish) meta.status = 'published';
p = global.LS.customSimCreate(meta);
}
p.then(function (res) {
if (!self.simId && res && res.id) {
self.simId = res.id;
// обновить URL, чтобы повторное «Сохранить» делало update, а reload грузил эту симуляцию
try { global.history.replaceState({}, '', '/sim-builder?id=' + res.id); } catch (e) {}
}
if (publish) self.status = 'published';
else if (self.status !== 'published') self.status = 'draft';
self.renderToolbar();
global.LS.toast(publish ? 'Опубликовано' : 'Сохранено', 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка сохранения', 'error');
});
};
/* ════════════════════════ РЕНДЕР ПАНЕЛЕЙ ════════════════════════ */
Builder.prototype.renderPanels = function () {
var p = this.panelHost;
if (!p) return;
p.innerHTML =
this.sectionMeta() +
this.sectionParams() +
this.sectionObjects() +
this.sectionPlotsPhysics() +
this.sectionGame();
this.wirePanels();
};
/* ── секция-аккордеон ── */
function section(key, title, bodyHtml, open, count) {
var cnt = (count != null) ? '' + count + ' ' : '';
return '' +
'
' +
'' + esc(title) + ' ' + cnt +
'' + ICON.chev + ' ' +
' ' +
'
' + bodyHtml + '
' +
'
';
}
/* ── Мета ── */
Builder.prototype.sectionMeta = function () {
var st = this.st;
var catOpts = ['— нет — '].concat(CATS.map(function (c) {
return '' + CAT_LABEL[c] + ' ';
})).join('');
var body =
field('Заголовок', ' ') +
field('Описание', '') +
'' +
field('Предмет', ' ') +
field('Класс', ' ') +
'
' +
field('Категория', '' + catOpts + ' ') +
'
' +
'Поле сцены (мировые координаты)
' +
'' +
miniField('x от', ' ') +
miniField('x до', ' ') +
miniField('y от', ' ') +
miniField('y до', ' ') +
'
' +
'' +
checkbox('vp', 'grid', 'Сетка', st.viewport.grid) +
checkbox('vp', 'axes', 'Оси', st.viewport.axes) +
checkbox('time', 'autoplay', 'Автозапуск', st.time.autoplay) +
checkbox('time', 'loop', 'Зацикл. t', st.time.loop) +
'
';
return section('meta', 'Метаданные и сцена', body, this._open.meta);
};
/* ── Параметры ── */
Builder.prototype.sectionParams = function () {
var rows = this.st.params.map(function (p, i) {
return '';
}).join('');
var body = (rows || 'Нет параметров. Добавьте слайдер.
') +
'' + ICON.plus + ' Параметр ';
return section('params', 'Параметры (слайдеры)', body, this._open.params, this.st.params.length);
};
/* ── Объекты ── */
Builder.prototype.sectionObjects = function () {
var self = this;
var rows = this.st.objects.map(function (o, i) { return self.objectEditor(o, i); }).join('');
var typeOpts = OBJECT_TYPES.filter(function (t) { return t !== 'plot'; })
.map(function (t) { return '' + TYPE_LABEL[t] + ' '; }).join('');
var body = (rows || 'Нет объектов. Добавьте фигуру/точку/подпись.
') +
'' +
'' + typeOpts + ' ' +
'' + ICON.plus + ' Объект ' +
'
';
return section('objects', 'Объекты', body, this._open.objects, this.st.objects.length);
};
/* Редактор одного объекта: поля зависят от типа. */
Builder.prototype.objectEditor = function (o, i) {
var selected = (this._selObjId === o._uid);
var hidden = !!o.hidden;
var n = this.st.objects.length;
var fields = OBJ_FIELDS[o.type] || [];
var inner = fields.map(function (f) {
if (f.kind === 'check') {
return ' ' + esc(f.label) + ' ';
}
if (f.kind === 'color') {
// fill/trailColor — очищаемые («нет заливки»); основной color — нет
var clearable = (f.key === 'fill' || f.key === 'fillColor' || f.key === 'trailColor');
return colorCtl(f.label, 'data-of="' + f.key + '"', o[f.key], clearable);
}
if (f.kind === 'text') {
return miniField(f.label, ' ');
}
// expr — число или выражение, с проверкой
var v = (o[f.key] == null ? '' : o[f.key]);
var err = exprError(v);
return '' +
'' + esc(f.label) +
'fx ' +
' ' +
' ' +
(err ? '' + esc(err) + ' ' : '') +
'
';
}).join('');
// ── блок «Стиль» (P4): opacity/lineStyle/pointStyle/glow/gradient ──
var style = STYLE_FOR[o.type] ? this.styleBlock(o) : '';
// ── label с LaTeX-превью ──
var latexPrev = '';
if (o.type === 'label' && o.text) {
latexPrev = '
';
}
return '' +
'
' +
'' + (TYPE_LABEL[o.type] || o.type) + ' ' +
' ' +
'' + ICON.up + ' ' +
'' + ICON.down + ' ' +
'' + (hidden ? ICON.eyeOff : ICON.eye) + ' ' +
'' + ICON.copy + ' ' +
'' + ICON.target + ' ' +
'' + ICON.trash + ' ' +
'
' +
'
' + inner + latexPrev + '
' +
style +
'
';
};
/* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + glow + градиент.
Применимость полей зависит от типа (STYLE_FOR[type] = {opacity,line,point,glow,grad}). */
Builder.prototype.styleBlock = function (o) {
var cfg = STYLE_FOR[o.type];
var ctrls = [];
if (cfg.opacity) ctrls.push(rangeCtl('непрозр.', 'data-of="opacity"', o.opacity, 0, 1, 0.05));
if (cfg.line) ctrls.push(selectCtl('линия', 'data-of="lineStyle"', o.lineStyle || 'solid', LINE_STYLE_OPTS));
if (cfg.point) ctrls.push(selectCtl('стиль точки', 'data-of="pointStyle"', o.pointStyle || 'filled', POINT_STYLE_OPTS));
var grad = '';
if (cfg.grad) {
var g = Array.isArray(o.gradient) ? o.gradient : [];
var on = (g.length >= 2);
grad =
' Градиент-заливка ' +
'' +
colorCtl('от', 'data-grad="0"', g[0] || toHexColor(o.color) , false) +
colorCtl('до', 'data-grad="1"', g[1] || '#1b1b2e', false) +
'
';
}
var glow = cfg.glow
? ' Свечение (glow) '
: '';
return '' +
'
Стиль
' +
(ctrls.length ? '
' + ctrls.join('') + '
' : '') +
glow + grad +
'
';
};
/* Редактор одного графика (plot): plot-уровневые поля (var/range/trace/fill/marker/legend)
+ список кривых (curveEditor). */
Builder.prototype.plotEditor = function (o, i) {
var hidden = !!o.hidden;
var n = this.st.plots.length;
var rangeErr = (o.range_a !== '' && o.range_a != null) ? exprError(o.range_a) : (exprError(o.range_b) || '');
var curves = Array.isArray(o.curves) ? o.curves : [];
var curveHtml = curves.map(function (cv, ci) { return curveEditor(cv, i, ci, (curves.length > 1)); }).join('');
return '';
};
/* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */
function curveEditor(cv, pi, ci, removable) {
var exprErr = exprError(cv.expr);
return '' +
'
' +
'выражение' +
'' +
'fx ' +
(removable ? '' + ICON.trash + ' ' : '') +
' ' +
' ' +
' ' +
(exprErr ? '' + esc(exprErr) + ' ' : '') +
'
' +
'
' +
colorCtl('цвет', 'data-cvf="color"', cv.color, true) +
miniField('подпись', ' ') +
'
' +
'
' +
miniField('толщ.', ' ') +
selectCtl('линия', 'data-cvf="lineStyle"', cv.lineStyle || 'solid', LINE_STYLE_OPTS) +
selectCtl('маркер', 'data-cvf="marker"', cv.marker || 'none', MARKER_OPTS) +
'
' +
'
' +
rangeCtl('непрозр.', 'data-cvf="opacity"', cv.opacity, 0, 1, 0.05) +
'заливка ' +
' вкл ' +
(cv.fill ? colorCtl('цвет зал.', 'data-cvf="fillColor"', cv.fillColor, true) : ' ') +
'
' +
'
';
}
/* ── Графики + Физика ── */
Builder.prototype.sectionPlotsPhysics = function () {
var self = this;
// plots — каждый график: список кривых + plot-уровневые поля
var plotRows = this.st.plots.map(function (o, i) { return self.plotEditor(o, i); }).join('');
var plotsBody = (plotRows || 'Нет графиков. Добавьте график функции — можно несколько кривых.
') +
'' + ICON.plus + ' График ';
// physics
var ph = this.st.physics;
var bodyHint = 'Сделать объект физ-телом: добавьте ему поле «масса» (' + ICON.cog + ' в объекте). Тип point/circle.
';
var physBody =
' Включить физику ' +
'' +
'
' +
miniField('гравитация x', ' ') +
miniField('гравитация y', ' ') +
'
' +
'
' +
miniField('трение', ' ') +
miniField('упругость 0..1', ' ') +
'
' +
// walls
'
Стены
' +
ph.walls.map(function (w, i) {
var sideOpts = ['', 'bottom', 'top', 'left', 'right'].map(function (s) {
return '
' + (s === '' ? '— отрезок —' : WALL_LABEL[s]) + ' ';
}).join('');
return '
';
}).join('') +
'
' + ICON.plus + ' Стена ' +
// springs
'
Пружины
' +
ph.springs.map(function (s, i) {
return '
';
}).join('') +
'
' + ICON.plus + ' Пружина ' +
bodyHint +
'
';
return section('plots', 'Графики', plotsBody, this._open.plots, this.st.plots.length) +
section('physics', 'Физика', physBody, !!ph.enabled);
};
/* ── Игровой уровень (P5-Квантик) ─────────────────────────────────────────
Панель «Цель» собирает блок goal (when/title/hint/hold/fail) + список звёзд
(макс 3) + игровые метаданные (chapter/order/par_ms). Тумблер «Это игровой
уровень» включает слой; выключенный — goal/game НЕ попадают в спеку.
Выражения (when/fail/звёзды) проверяются inline через SimExpr.compile. */
Builder.prototype.sectionGame = function () {
var gm = this.st.game || {};
var on = !!gm.enabled;
// строка-выражение цели/проигрыша с inline-ошибкой
function exprRow(key, label, val, ph) {
var err = exprError(val);
return '' +
'' + esc(label) +
'fx ' +
' ' +
' ' +
(err ? '' + esc(err) + ' ' : '') +
'
';
}
var stars = Array.isArray(gm.stars) ? gm.stars : [];
var starRows = stars.map(function (s, i) {
var err = exprError(s.when);
return '' +
'
' +
'Звезда ' + (i + 1) + ' ' +
' ' +
'' + ICON.trash + ' ' +
'
' +
'
' +
'условие' +
'fx ' +
' ' +
' ' +
(err ? '' + esc(err) + ' ' : '') +
'
' +
miniField('подпись', '
') +
'
';
}).join('');
var inner =
' Это игровой уровень (Квантик) ' +
'' +
'
Цель
' +
exprRow('when', 'победа (when)', gm.when, 'напр. gate.hit или hypot(ball.x-8,ball.y-1)<0.8') +
exprRow('fail', 'проигрыш (fail) — опц.', gm.fail, 'напр. ball.y < -1 || t > 8') +
'
' +
field('Заголовок цели', ' ') +
miniField('удержать, с (hold)', ' ') +
'
' +
field('Подсказка', '
') +
'
Звёзды (макс 3)
' +
'
' + (starRows || '
Нет звёзд-бонусов. Победа = 1-я звезда автоматически.
') + '
' +
(stars.length < 3 ? '
' + ICON.plus + ' Звезда ' : '') +
'
' +
'
Метаданные уровня
' +
'
' +
miniField('глава', ' ') +
miniField('порядок', ' ') +
miniField('норматив, мс', ' ') +
' ' +
'
' +
'
' + ICON.play + ' Играть (тест уровня) ' +
'
';
return section('game', 'Игровой уровень (цель/звёзды)', inner, this._open.game);
};
/* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */
Builder.prototype.wirePanels = function () {
var self = this;
var p = this.panelHost;
// ── История правки полей (P5): один снапшот на «сессию» правки поля.
// На focusin поля сбрасываем флаг; первый input/change снимает снапшот.
// Так Ctrl+Z откатывает целиком отредактированное значение, а не посимвольно. ──
p.addEventListener('focusin', function (ev) {
var tg = ev.target, tag = tg && tg.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') self._fieldSnapTaken = false;
});
// аккордеоны
p.querySelectorAll('[data-sec-toggle]').forEach(function (b) {
b.addEventListener('click', function () {
var key = b.getAttribute('data-sec-toggle');
var sec = p.querySelector('[data-sec="' + key + '"]');
if (sec) sec.classList.toggle('open');
self._open[key] = sec ? sec.classList.contains('open') : false;
});
});
// meta inputs (title/desc -> st.meta.X ; subject/grade/cat -> st.X)
p.querySelectorAll('[data-meta]').forEach(function (el) {
var evt = el.tagName === 'SELECT' ? 'change' : 'input';
el.addEventListener(evt, function () {
self.snapField();
var k = el.getAttribute('data-meta');
if (k === 'title' || k === 'desc') self.st.meta[k] = el.value;
else self.st[k] = el.value;
self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-vp]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-vp');
if (el.type === 'checkbox') self.st.viewport[k] = el.checked;
else self.st.viewport[k] = el.value === '' ? '' : parseFloat(el.value);
self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-grp]').forEach(function (el) {
el.addEventListener('change', function () {
self.pushHistory();
var grp = el.getAttribute('data-grp'), k = el.getAttribute('data-k');
var target = grp === 'vp' ? self.st.viewport : self.st.time;
target[k] = el.checked;
self.scheduleRemount(false);
});
});
// params
p.querySelectorAll('.sbu-param').forEach(function (row) {
var i = parseInt(row.getAttribute('data-pi'), 10);
row.querySelectorAll('[data-pf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-pf');
self.st.params[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
self.scheduleRemount(false);
});
});
});
p.querySelectorAll('[data-pdel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.params.splice(parseInt(b.getAttribute('data-pdel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
// objects
p.querySelectorAll('.sbu-obj').forEach(function (row) {
var i = parseInt(row.getAttribute('data-oi'), 10);
row.querySelectorAll('[data-of]').forEach(function (el) {
var evt = el.type === 'checkbox' ? 'change' : 'input';
el.addEventListener(evt, function () {
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
var k = el.getAttribute('data-of');
if (el.type === 'checkbox') self.st.objects[i][k] = el.checked;
else if (el.type === 'range') {
self.st.objects[i][k] = (el.value === '' ? '' : parseFloat(el.value));
var vb = el.closest('.sbu-range-mini'); var lbl = vb && vb.querySelector('.sbu-range-val');
if (lbl) lbl.textContent = el.value;
} else self.st.objects[i][k] = el.value;
// обновить inline-ошибку выражения и LaTeX-превью без полного рендера
self.updateFieldFeedback(el, self.st.objects[i]);
self.scheduleRemount(false);
});
});
// нативный color-picker -> синхрон с текстовым полем рядом (текст = источник истины)
self.wireColorControls(row, function () { self.scheduleRemount(false); });
// градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient
var gOn = row.querySelector('[data-grad-on]');
if (gOn) gOn.addEventListener('change', function () {
self.pushHistory();
var obj = self.st.objects[i];
var gr = row.querySelector('.sbu-grad-row');
if (gOn.checked) {
if (gr) gr.style.display = '';
var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]');
obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ];
} else { delete obj.gradient; if (gr) gr.style.display = 'none'; }
self.scheduleRemount(false);
});
row.querySelectorAll('[data-grad]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var obj = self.st.objects[i];
var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]');
obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ];
self.scheduleRemount(false);
});
});
});
p.querySelectorAll('[data-odel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
var i = parseInt(b.getAttribute('data-odel'), 10);
var o = self.st.objects[i];
if (o && o._uid === self._selObjId) self._selObjId = null;
self.st.objects.splice(i, 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
// z-order: вверх (раньше в массиве = под низом отрисовки) / вниз
p.querySelectorAll('[data-oup]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-oup'), 10);
if (i > 0) { self.pushHistory(); var a = self.st.objects; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; }
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-odown]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-odown'), 10);
var a = self.st.objects;
if (i < a.length - 1) { self.pushHistory(); var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; }
self.renderPanels(); self.scheduleRemount(false);
});
});
// видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем)
p.querySelectorAll('[data-ohide]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
var i = parseInt(b.getAttribute('data-ohide'), 10);
var o = self.st.objects[i]; if (!o) return;
if (o.hidden) delete o.hidden; else o.hidden = true;
self.renderPanels(); self.scheduleRemount(false);
});
});
// дублировать объект (новый _uid + новая ссылка; вставить сразу после)
p.querySelectorAll('[data-odup]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-odup'), 10);
if (self.st.objects.length + self.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
var o = self.st.objects[i]; if (!o) return;
self.pushHistory();
var clone = JSON.parse(JSON.stringify(o));
clone._uid = uid('o');
if (clone.id) clone.id = clone.id + '_copy';
self.st.objects.splice(i + 1, 0, clone);
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-place]').forEach(function (b) {
b.addEventListener('click', function () {
var uidv = b.getAttribute('data-place');
self._selObjId = (self._selObjId === uidv) ? null : uidv;
self.renderPanels();
if (self.inst && self.inst.canvas) self.inst.canvas.style.cursor = self._selObjId ? 'crosshair' : '';
if (self._selObjId) global.LS.toast('Кликните на сцене, чтобы поставить объект', 'info', 2200);
});
});
p.querySelectorAll('[data-fx]').forEach(function (b) {
b.addEventListener('click', function () {
var key = b.getAttribute('data-fx');
var input = b.closest('.sbu-of').querySelector('[data-of]');
self.openPalette(input);
});
});
// plots
p.querySelectorAll('.sbu-plot').forEach(function (row) {
var i = parseInt(row.getAttribute('data-plti'), 10);
// plot-уровневые поля (var/range/samples/trace/legend/plotFill/plotMarker)
row.querySelectorAll('[data-plf]').forEach(function (el) {
var evt = el.type === 'checkbox' ? 'change' : 'input';
el.addEventListener(evt, function () {
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
var k = el.getAttribute('data-plf');
if (el.type === 'checkbox') self.st.plots[i][k] = el.checked;
else self.st.plots[i][k] = el.value;
self.updateFieldFeedback(el, null);
self.scheduleRemount(false);
});
});
// кривые
row.querySelectorAll('.sbu-curve').forEach(function (cr) {
var ci = parseInt(cr.getAttribute('data-ci'), 10);
cr.querySelectorAll('[data-cvf]').forEach(function (el) {
var evt = (el.type === 'checkbox') ? 'change' : 'input';
el.addEventListener(evt, function () {
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
var k = el.getAttribute('data-cvf');
var cv = self.st.plots[i].curves[ci]; if (!cv) return;
if (el.type === 'checkbox') {
cv[k] = el.checked;
if (k === 'fill') { self.renderPanels(); self.scheduleRemount(false); return; } // показать/скрыть «цвет зал.»
} else if (el.type === 'range') {
cv[k] = (el.value === '' ? '' : parseFloat(el.value));
var vb = el.closest('.sbu-range-mini'); var lbl = vb && vb.querySelector('.sbu-range-val');
if (lbl) lbl.textContent = el.value;
} else cv[k] = el.value;
self.updateFieldFeedback(el, null);
self.scheduleRemount(false);
});
});
self.wireColorControls(cr);
// fx-палитра для выражения кривой
var fx = cr.querySelector('[data-cvfx]');
if (fx) fx.addEventListener('click', function () {
self.openPalette(cr.querySelector('[data-cvf="expr"]'));
});
// удалить кривую
var cdel = cr.querySelector('[data-curvedel]');
if (cdel) cdel.addEventListener('click', function () {
var arr = self.st.plots[i].curves;
if (arr.length > 1) { self.pushHistory(); arr.splice(ci, 1); }
self.renderPanels(); self.scheduleRemount(false);
});
});
// plot-level color-контролы (на будущее; кривые имеют свои)
self.wireColorControls(row);
});
p.querySelectorAll('[data-curveadd]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-curveadd'), 10);
var plt = self.st.plots[i]; if (!plt) return;
self.pushHistory();
plt.curves = Array.isArray(plt.curves) ? plt.curves : [];
plt.curves.push(defaultCurve('', ''));
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-pltdel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.plots.splice(parseInt(b.getAttribute('data-pltdel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-pltup]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-pltup'), 10);
if (i > 0) { self.pushHistory(); var a = self.st.plots; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; }
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-pltdown]').forEach(function (b) {
b.addEventListener('click', function () {
var i = parseInt(b.getAttribute('data-pltdown'), 10);
var a = self.st.plots;
if (i < a.length - 1) { self.pushHistory(); var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; }
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('[data-plthide]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
var i = parseInt(b.getAttribute('data-plthide'), 10);
var o = self.st.plots[i]; if (!o) return;
if (o.hidden) delete o.hidden; else o.hidden = true;
self.renderPanels(); self.scheduleRemount(false);
});
});
// physics
var phEnabled = p.querySelector('[data-phys="enabled"]');
if (phEnabled) phEnabled.addEventListener('change', function () {
self.pushHistory();
self.st.physics.enabled = phEnabled.checked;
self.renderPanels(); self.scheduleRemount(false);
});
p.querySelectorAll('[data-phf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-phf');
self.st.physics[k] = el.value === '' ? '' : parseFloat(el.value);
self.scheduleRemount(false);
});
});
p.querySelectorAll('.sbu-wall').forEach(function (row) {
var i = parseInt(row.getAttribute('data-wi'), 10);
row.querySelectorAll('[data-wf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-wf');
self.st.physics.walls[i][k] = (k === 'side') ? el.value : el.value;
if (k === 'side') { self.renderPanels(); }
self.scheduleRemount(false);
});
el.addEventListener('change', function () { if (el.getAttribute('data-wf') === 'side') { self.renderPanels(); self.scheduleRemount(false); } });
});
});
p.querySelectorAll('[data-wdel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.physics.walls.splice(parseInt(b.getAttribute('data-wdel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
p.querySelectorAll('.sbu-spring').forEach(function (row) {
var i = parseInt(row.getAttribute('data-spi'), 10);
row.querySelectorAll('[data-spf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-spf');
self.st.physics.springs[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
self.scheduleRemount(false);
});
});
});
p.querySelectorAll('[data-spdel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.physics.springs.splice(parseInt(b.getAttribute('data-spdel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
// ── Игровой слой (P5-Квантик) ──
var gameOn = p.querySelector('[data-game="enabled"]');
if (gameOn) gameOn.addEventListener('change', function () {
self.pushHistory();
self.st.game.enabled = gameOn.checked;
self.renderPanels(); self.scheduleRemount(false);
});
// goal/game поля (when/fail/title/hint/hold/chapter/order/par_ms)
p.querySelectorAll('[data-gf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-gf');
self.st.game[k] = el.value;
self.updateFieldFeedback(el, null); // inline-ошибка выражения (when/fail)
self.scheduleRemount(false);
});
});
// звёзды: поля when/label
p.querySelectorAll('.sbu-star').forEach(function (row) {
var i = parseInt(row.getAttribute('data-si'), 10);
row.querySelectorAll('[data-sf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-sf');
if (self.st.game.stars[i]) self.st.game.stars[i][k] = el.value;
self.updateFieldFeedback(el, null);
self.scheduleRemount(false);
});
});
});
p.querySelectorAll('[data-stardel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.game.stars.splice(parseInt(b.getAttribute('data-stardel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
// fx-палитра для goal-выражений и условий звёзд
p.querySelectorAll('[data-gfx]').forEach(function (b) {
b.addEventListener('click', function () {
var key = b.getAttribute('data-gfx');
self.openPalette(p.querySelector('[data-gf="' + key + '"]'));
});
});
p.querySelectorAll('[data-sfx]').forEach(function (b) {
b.addEventListener('click', function () {
var row = b.closest('.sbu-star');
self.openPalette(row && row.querySelector('[data-sf="when"]'));
});
});
// «Играть (тест уровня)» внутри панели
p.querySelectorAll('[data-a2="play-game"]').forEach(function (b) {
b.addEventListener('click', function () { self.playGame(); });
});
// add buttons
p.querySelectorAll('[data-add]').forEach(function (b) {
b.addEventListener('click', function () { self.onAdd(b.getAttribute('data-add')); });
});
this.renderLatexPreviews();
};
/* Синхронизация color-контролов внутри row: нативный пикер -> текст (и dispatch input,
чтобы сработал основной обработчик data-of/data-plf/data-cvf); текст -> пикер;
кнопка очистки -> пустое значение («нет заливки»). onChange — доп. ремонт (для grad). */
Builder.prototype.wireColorControls = function (row, onChange) {
row.querySelectorAll('.sbu-color-wrap').forEach(function (wrap) {
var pick = wrap.querySelector('[data-color-pick]');
var txt = wrap.querySelector('input.sbu-in-color');
var clr = wrap.querySelector('[data-color-clear]');
if (pick && txt) {
pick.addEventListener('input', function () {
txt.value = pick.value;
txt.dispatchEvent(new Event('input', { bubbles: true }));
});
txt.addEventListener('input', function () {
var h = toHexColor(txt.value);
if (h !== '#000000' || /^#0{3,6}$/i.test(String(txt.value).trim())) pick.value = h;
});
}
if (clr && txt) {
clr.addEventListener('click', function () {
txt.value = '';
txt.dispatchEvent(new Event('input', { bubbles: true }));
});
}
});
};
/* Привести checkbox data-grp атрибуты в соответствие (vp/time) — генерим в checkbox()
с data-grp/data-k. Но проще: переиспользуем общий обработчик. */
Builder.prototype.onAdd = function (what) {
if (what === 'param') {
if (this.st.params.length >= LIMITS.params) { global.LS.toast('Достигнут лимит параметров', 'warn'); return; }
this.pushHistory();
this.st.params.push({ name: '', label: '', min: 0, max: 10, step: 1, value: 0, unit: '' });
} else if (what === 'object') {
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
var sel = this.panelHost.querySelector('#sbu-newtype');
var type = sel ? sel.value : 'point';
this.pushHistory();
this.st.objects.push(defaultObject(type));
} else if (what === 'plot') {
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
this.pushHistory();
this.st.plots.push({
_uid: uid('plt'), type: 'plot', 'var': 'x', range_a: '', range_b: '', samples: '',
trace: false, legend: true, plotFill: false, plotMarker: 'none',
curves: [defaultCurve('sin(x)', '#F15BB5')]
});
} else if (what === 'wall') {
if (this.st.physics.walls.length >= LIMITS.walls) { global.LS.toast('Достигнут лимит стен', 'warn'); return; }
this.pushHistory();
this.st.physics.walls.push({ _uid: uid('w'), side: 'bottom', x1: '', y1: '', x2: '', y2: '' });
} else if (what === 'spring') {
if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; }
this.pushHistory();
this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 });
} else if (what === 'star') {
this.st.game.stars = Array.isArray(this.st.game.stars) ? this.st.game.stars : [];
if (this.st.game.stars.length >= 3) { global.LS.toast('Максимум 3 звезды', 'warn'); return; }
this.pushHistory();
this.st.game.stars.push({ _uid: uid('star'), when: '', label: '' });
}
this.renderPanels();
this.scheduleRemount(false);
};
/* дефолтная кривая plot (UI-модель). */
function defaultCurve(expr, color) {
return {
_uid: uid('cv'), expr: (expr == null ? '' : expr), color: (color || ''),
label: '', width: '', lineStyle: 'solid', opacity: '', fill: '', fillColor: '', marker: 'none'
};
}
/* Загрузка spec-plot -> UI-модель: список curves[] + plot-уровневые поля.
Поддерживает легаси (одиночный expr/exprs[]) и P3-формат (curves[]). */
function loadPlot(o) {
var ui = { _uid: uid('plt'), type: 'plot' };
ui['var'] = o['var'] || 'x';
if (Array.isArray(o.range)) { ui.range_a = o.range[0]; ui.range_b = o.range[1]; }
else { ui.range_a = ''; ui.range_b = ''; }
ui.trace = !!o.trace;
ui.samples = (o.samples != null ? o.samples : '');
ui.plotFill = (o.fill === true) ? true : (typeof o.fill === 'string' ? o.fill : false);
ui.plotMarker = (o.marker === 'dot' || o.marker === 'ring') ? o.marker : 'none';
ui.legend = (o.legend === false) ? false : true;
if (o.hidden) ui.hidden = true;
// источник кривых: curves[] -> exprs[] -> expr (легаси)
var defs = [];
if (Array.isArray(o.curves) && o.curves.length) {
defs = o.curves.map(function (cv) { return (cv && typeof cv === 'object') ? cv : { expr: cv }; });
} else if (Array.isArray(o.exprs) && o.exprs.length) {
defs = o.exprs.map(function (ex) { return { expr: ex }; });
} else {
defs = [{ expr: o.expr != null ? o.expr : '', color: o.color }];
}
// plot-уровневые стили (легаси width/lineStyle/opacity) наследуются кривой, если у неё не задано
ui.curves = defs.map(function (cv) {
cv = cv || {};
var c = defaultCurve(cv.expr, cv.color || '');
if (cv.label != null) c.label = String(cv.label);
var w = (cv.width != null && cv.width !== '') ? cv.width : o.width;
if (w != null && w !== '' && isFinite(parseFloat(w))) c.width = w;
var ls = (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
: ((o.lineStyle === 'dashed' || o.lineStyle === 'dotted') ? o.lineStyle : '');
if (ls) c.lineStyle = ls;
var op = (cv.opacity != null && cv.opacity !== '') ? cv.opacity : o.opacity;
if (op != null && op !== '' && isFinite(parseFloat(op))) c.opacity = op;
if (cv.fill === true) c.fill = true;
else if (typeof cv.fill === 'string' && cv.fill) { c.fill = true; c.fillColor = cv.fill; }
if (cv.marker === 'dot' || cv.marker === 'ring') c.marker = cv.marker;
return c;
});
if (!ui.curves.length) ui.curves = [defaultCurve('', '')];
return ui;
}
/* Перед сборкой spec plot-объект нужно «материализовать»: range + curves + убрать UI-поля.
Если ровно одна «простая» кривая (только expr + опц. color) и нет plot-уровневых стилей —
эмитим легаси-форму (expr/color) для обратной совместимости и стабильного round-trip. */
function normalizePlotForSpec(o) {
var out = { type: 'plot', var: o['var'] || 'x' };
var a = o.range_a, b = o.range_b;
if (!((a === '' || a == null) && (b === '' || b == null))) {
out.range = [parseRangeVal(a), parseRangeVal(b)];
}
if (o.trace) out.trace = true;
if (o.samples !== '' && o.samples != null && isFinite(parseFloat(o.samples))) out.samples = parseFloat(o.samples);
if (o.plotFill === true) out.fill = true;
else if (typeof o.plotFill === 'string' && o.plotFill) out.fill = o.plotFill;
if (o.plotMarker === 'dot' || o.plotMarker === 'ring') out.marker = o.plotMarker;
var curves = Array.isArray(o.curves) ? o.curves : [];
var built = curves.map(stripCurve).filter(function (c) { return c.expr !== '' && c.expr != null; });
// легенда: явно выключаем, если стоит false (по умолчанию движок включает при наличии label)
if (o.legend === false) out.legend = false;
var single = (built.length === 1) ? built[0] : null;
var simpleSingle = single && !single.label && single.width == null && (!single.lineStyle || single.lineStyle === 'solid') &&
single.opacity == null && single.fill == null && (!single.marker || single.marker === 'none');
if (simpleSingle && out.fill == null && out.marker == null) {
// легаси-форма: одиночное выражение + цвет
out.expr = single.expr;
if (single.color) out.color = single.color;
} else if (built.length) {
out.curves = built;
} else {
out.expr = ''; // пустой график (валидация поймает)
}
return out;
}
/* кривую (UI) -> минимальный объект кривой спеки (без _uid/дефолтов). */
function stripCurve(cv) {
var out = { expr: (cv.expr == null ? '' : cv.expr) };
if (cv.color) out.color = cv.color;
if (cv.label) out.label = cv.label;
if (cv.width !== '' && cv.width != null && isFinite(parseFloat(cv.width))) out.width = parseFloat(cv.width);
if (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') out.lineStyle = cv.lineStyle;
if (cv.opacity !== '' && cv.opacity != null && isFinite(parseFloat(cv.opacity))) out.opacity = parseFloat(cv.opacity);
if (cv.fill === true) out.fill = (cv.fillColor && String(cv.fillColor).trim()) ? cv.fillColor : true;
if (cv.marker === 'dot' || cv.marker === 'ring') out.marker = cv.marker;
return out;
}
function parseRangeVal(v) {
if (v === '' || v == null) return 0;
var n = parseFloat(v);
return isFinite(n) ? n : String(v); // допускаем выражение-границу (xmin/xmax)
}
/* Обновить inline-ошибку выражения у конкретного поля без полного ререндера. */
Builder.prototype.updateFieldFeedback = function (el, obj) {
var wrap = el.closest('.sbu-of');
if (!wrap) return;
var err = exprError(el.value);
wrap.classList.toggle('has-err', !!err);
var errEl = wrap.querySelector('.sbu-of-err');
if (err) {
if (!errEl) { errEl = document.createElement('span'); errEl.className = 'sbu-of-err'; wrap.appendChild(errEl); }
errEl.textContent = err;
} else if (errEl) { errEl.remove(); }
// LaTeX-превью для label.text
if (obj && obj.type === 'label' && el.getAttribute('data-of') === 'text') {
this.renderLatexPreviews();
}
};
/* Перерисовать поля одного объекта (после drag-on-preview) без потери фокуса панели. */
Builder.prototype.refreshObjFields = function (uidv) {
var row = this.panelHost.querySelector('.sbu-obj.sel');
if (!row) {
// найти по uid
var objs = this.st.objects;
for (var i = 0; i < objs.length; i++) {
if (objs[i]._uid === uidv) { row = this.panelHost.querySelector('.sbu-obj[data-oi="' + i + '"]'); break; }
}
}
if (!row) return;
var obj = null;
var idx = parseInt(row.getAttribute('data-oi'), 10);
obj = this.st.objects[idx];
if (!obj) return;
// числовые поля координат всех типов + points (polyline/path) + dx/dy (vector)
['x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points'].forEach(function (k) {
var inp = row.querySelector('[data-of="' + k + '"]');
if (inp && obj[k] != null) inp.value = obj[k];
});
};
/* Рендер LaTeX-превью подписей (KaTeX). Безопасно через textContent + renderMathInElement. */
Builder.prototype.renderLatexPreviews = function () {
var nodes = this.panelHost.querySelectorAll('.sbu-latex');
nodes.forEach(function (n) {
var src = n.getAttribute('data-latex') || '';
n.textContent = src;
if (global.renderMathInElement) {
try {
global.renderMathInElement(n, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
], throwOnError: false
});
} catch (e) {}
} else if (global.katex) {
// одиночная формула без разделителей
try { n.innerHTML = global.katex.renderToString(src, { throwOnError: false }); } catch (e) {}
}
});
};
/* Палитра: всплывающее меню функций/констант/параметров. Вставляет имя в input. */
Builder.prototype.openPalette = function (input) {
var self = this;
var names = exprNames();
var params = this.st.params.filter(function (p) { return p.name; }).map(function (p) { return p.name; });
// ссылки на объекты с id -> id.x / id.y
var objRefs = [];
this.st.objects.forEach(function (o) { if (o.id) { objRefs.push(o.id + '.x'); objRefs.push(o.id + '.y'); } });
function chips(title, arr, kind) {
if (!arr.length) return '';
return '' + esc(title) + '
' +
arr.map(function (n) { return '' + esc(n) + ' '; }).join('') +
'
';
}
var content =
'' +
chips('Параметры', params, 'var') +
chips('Объекты (id.x / id.y)', objRefs, 'var') +
chips('Время / размеры', ['t', 'w', 'h'], 'var') +
chips('Константы', names.consts, 'const') +
chips('Функции', names.fns, 'fn') +
'
';
var m = global.LS.modal({
title: 'Палитра выражений', size: 'md', content: content,
actions: [{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }]
});
m.body.querySelectorAll('[data-ins]').forEach(function (b) {
b.addEventListener('click', function () {
var ins = b.getAttribute('data-ins');
var kind = b.getAttribute('data-kind');
var add = (kind === 'fn') ? ins + '()' : ins;
insertAtCursor(input, add);
// обновить состояние из input
input.dispatchEvent(new Event('input', { bubbles: true }));
});
});
};
/* ════════════════════════ ХЕЛПЕРЫ ════════════════════════ */
function field(label, inner) {
return '' + esc(label) + ' ' + inner + ' ';
}
function miniField(label, inner) {
return '' + esc(label) + ' ' + inner + ' ';
}
function checkbox(grp, key, label, checked) {
return ' ' + esc(label) + ' ';
}
/* ── Контролы стиля (P4) ──────────────────────────────────────────────────
Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая).
attr — строка вида 'data-of="color"' (атрибут привязки события).
Цвет: нативный (sync) + текст (точное значение / rgba/named) +
кнопка очистки (для fill/trailColor «нет заливки»). Текст — источник истины. */
// привести произвольный цвет к #rrggbb для нативного пикера (иначе #000000)
function toHexColor(v) {
var s = String(v == null ? '' : v).trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
return ('#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase();
}
return '#000000';
}
// colorAttr — 'data-of="color"' и т.п.; clearable — показывать кнопку «нет»
function colorCtl(label, colorAttr, value, clearable) {
var v = (value == null ? '' : value);
var hex = toHexColor(v);
var clr = clearable
? '' + ICON.clearX + ' '
: '';
return '' +
'' + esc(label) + ' ' +
'' +
' ' +
' ' +
clr +
' ' +
' ';
}
// слайдер 0..1 (opacity) с числовым отображением
function rangeCtl(label, attr, value, mn, mx, st) {
var num = (value == null || value === '') ? '' : value;
var sliderVal = (num === '') ? mx : num;
return '' +
'' + esc(label) + ' ' + esc(num === '' ? mx : num) + ' ' +
' ' +
' ';
}
// select по списку [{v,l}]
function selectCtl(label, attr, value, opts) {
var o = opts.map(function (op) {
return '' + esc(op.l) + ' ';
}).join('');
return '' + esc(label) + ' ' +
'' + o + ' ';
}
var LINE_STYLE_OPTS = [{ v: 'solid', l: 'сплошная' }, { v: 'dashed', l: 'штрих' }, { v: 'dotted', l: 'точки' }];
var POINT_STYLE_OPTS = [{ v: 'filled', l: 'заполн.' }, { v: 'hollow', l: 'контур' }, { v: 'ring', l: 'кольцо' }, { v: 'cross', l: 'крест' }];
var MARKER_OPTS = [{ v: 'none', l: 'нет' }, { v: 'dot', l: 'точка' }, { v: 'ring', l: 'кольцо' }];
/* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */
function exprError(v) {
if (v === '' || v == null) return '';
if (typeof v === 'number') return '';
var n = Number(v);
if (!isNaN(n) && String(v).trim() !== '') return ''; // чистое число
if (!global.SimExpr) return '';
var c = global.SimExpr.compile(String(v));
return c.error || '';
}
function numOr(v, d) { var n = parseFloat(v); return isFinite(n) ? n : d; }
function numOr2(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
function round2(v) { return Math.round(v * 100) / 100; }
function trimStr(s) { return String(s == null ? '' : s).trim(); }
function insertAtCursor(input, text) {
if (!input) return;
var start = input.selectionStart, end = input.selectionEnd;
if (start == null) { input.value += text; return; }
var v = input.value;
input.value = v.slice(0, start) + text + v.slice(end);
var pos = start + text.length - (text.slice(-1) === ')' ? 1 : 0);
try { input.focus(); input.setSelectionRange(pos, pos); } catch (e) {}
}
/* дефолтный объект каждого типа (с _uid). */
function defaultObject(type) {
var base = { _uid: uid('o'), type: type, id: '' };
switch (type) {
case 'point': return Object.assign(base, { x: 0, y: 0, r: 6, color: '#06D6E0', trail: false });
case 'circle': return Object.assign(base, { x: 0, y: 0, r: 1, color: '#9B5DE5', fill: '', width: 2 });
case 'rect': return Object.assign(base, { x: 0, y: 0, w: 2, h: 1, color: '#9B5DE5', fill: '', width: 2 });
case 'segment': return Object.assign(base, { x1: 0, y1: 0, x2: 5, y2: 5, color: '#ffffff', width: 2 });
case 'vector': return Object.assign(base, { x1: 0, y1: 0, x2: 3, y2: 2, color: '#F15BB5', width: 2 });
case 'polyline': return Object.assign(base, { points: '[[0,0],[2,2],[4,0]]', color: '#06D6E0', width: 2, closed: false });
case 'path': return Object.assign(base, { points: '[[0,0],[2,2],[4,0]]', color: '#06D6E0', width: 2, closed: false });
case 'label': return Object.assign(base, { x: 0, y: 0, text: 'A', latex: true, color: '#ffffff', size: 14 });
case 'readout': return Object.assign(base, { label: 'R', expr: '0', unit: '', precision: 2, x: '', y: '', color: '#06D6E0' });
default: return Object.assign(base, { x: 0, y: 0 });
}
}
/* поля-выражения объекта (для валидации). polyline.points — массив, не выражение. */
function exprFieldsOf(o) {
switch (o.type) {
case 'point': return ['x', 'y', 'r'];
case 'circle': return ['x', 'y', 'r', 'width'];
case 'rect': return ['x', 'y', 'w', 'h', 'width'];
case 'segment': return ['x1', 'y1', 'x2', 'y2', 'width'];
case 'vector': return ['x1', 'y1', 'x2', 'y2', 'width'];
case 'label': return ['x', 'y', 'size'];
case 'readout': return ['expr', 'x', 'y'];
case 'plot': return ['expr'];
default: return [];
}
}
/* поля редактора по типу: kind = expr | text | color | check */
var OBJ_FIELDS = {
point: [{ key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'r', label: 'радиус (px)', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'trail', label: 'След', kind: 'check' }, { key: 'trailColor', label: 'цвет следа', kind: 'color' }],
circle: [{ key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'r', label: 'радиус', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'fill', label: 'заливка', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }],
rect: [{ key: 'x', label: 'x (центр)', kind: 'expr' }, { key: 'y', label: 'y (центр)', kind: 'expr' }, { key: 'w', label: 'ширина', kind: 'expr' }, { key: 'h', label: 'высота', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'fill', label: 'заливка', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }],
segment: [{ key: 'x1', label: 'x1', kind: 'expr' }, { key: 'y1', label: 'y1', kind: 'expr' }, { key: 'x2', label: 'x2', kind: 'expr' }, { key: 'y2', label: 'y2', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }],
vector: [{ key: 'x1', label: 'x1', kind: 'expr' }, { key: 'y1', label: 'y1', kind: 'expr' }, { key: 'x2', label: 'x2', kind: 'expr' }, { key: 'y2', label: 'y2', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }],
polyline:[{ key: 'points', label: 'точки [[x,y],…]', kind: 'text', ph: '[[0,0],[2,2]]' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }, { key: 'closed', label: 'Замкнуть', kind: 'check' }],
path: [{ key: 'points', label: 'точки [[x,y],…]', kind: 'text', ph: '[[0,0],[2,2]]' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }, { key: 'closed', label: 'Замкнуть', kind: 'check' }],
label: [{ key: 'text', label: 'текст (LaTeX)', kind: 'text', ph: '\\\\vec{v}' }, { key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'size', label: 'размер', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'latex', label: 'LaTeX', kind: 'check' }],
readout: [{ key: 'label', label: 'подпись', kind: 'text', ph: 'R' }, { key: 'expr', label: 'выражение', kind: 'expr' }, { key: 'unit', label: 'ед.', kind: 'text' }, { key: 'precision', label: 'знаков', kind: 'expr' }, { key: 'x', label: 'x (опц.)', kind: 'expr' }, { key: 'y', label: 'y (опц.)', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }]
};
/* Какие style-контролы (P4) показывать у типа.
opacity/glow — почти у всех рисуемых; line (lineStyle) — у линий/контуров;
point (pointStyle) — только point; grad (gradient-заливка) — у circle/rect (есть заливка). */
var STYLE_FOR = {
point: { opacity: true, glow: true, point: true },
segment: { opacity: true, glow: true, line: true },
vector: { opacity: true, glow: true, line: true },
circle: { opacity: true, glow: true, line: true, grad: true },
rect: { opacity: true, glow: true, line: true, grad: true },
polyline: { opacity: true, glow: true, line: true },
path: { opacity: true, glow: true, line: true }
};
var TYPE_LABEL = {
point: 'Точка', segment: 'Отрезок', vector: 'Вектор', circle: 'Окружность',
rect: 'Прямоугольник', polyline: 'Ломаная', path: 'Путь', label: 'Подпись',
plot: 'График', readout: 'Показатель'
};
var CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игра' };
var WALL_LABEL = { bottom: 'Низ', top: 'Верх', left: 'Лево', right: 'Право' };
/* inline SVG-иконки (.ic-стиля; без эмодзи) */
var ICON = {
play: ' ',
reset: ' ',
save: ' ',
send: ' ',
plus: ' ',
trash: ' ',
chev: ' ',
target: ' ',
cog: ' ',
template: ' ',
unpublish: ' ',
up: ' ',
down: ' ',
copy: ' ',
eye: ' ',
eyeOff: ' ',
clearX: ' ',
undo: ' ',
redo: ' ',
grid: ' '
};
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────
Данные, не код. Каждый — полноценная валидная спека v1: «Создать из шаблона»
загружает её через loadFromSim как новую симуляцию. */
var TEMPLATES = [
{
name: 'Пустая сцена', cat: 'phys',
desc: 'Чистый холст с осями и сеткой — начать с нуля.',
spec: {
specVersion: 1, meta: { title: 'Новая симуляция', desc: '' },
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
time: { autoplay: false, loop: true, speed: 1 }, params: [], objects: []
}
},
{
name: 'Математический маятник', cat: 'phys',
desc: 'Груз на нити: угол колеблется по гармоническому закону.',
spec: {
specVersion: 1, meta: { title: 'Маятник', desc: 'Колебания груза на нити' },
viewport: { xmin: -3, xmax: 3, ymin: -3.4, ymax: 0.6, grid: true, axes: true },
time: { autoplay: true, loop: true, speed: 1 },
params: [
{ name: 'L', label: 'Длина нити', min: 0.5, max: 3, step: 0.1, value: 2.4, unit: 'м' },
{ name: 'A', label: 'Амплитуда', min: 0.1, max: 1, step: 0.05, value: 0.5, unit: 'рад' }
],
objects: [
{ type: 'segment', x1: 0, y1: 0, x2: 'L*sin(A*cos(2.2*t))', y2: '-L*cos(A*cos(2.2*t))', color: '#94a3b8', width: 2 },
{ id: 'bob', type: 'circle', x: 'L*sin(A*cos(2.2*t))', y: '-L*cos(A*cos(2.2*t))', r: 0.18, color: '#9B5DE5' }
]
}
},
{
name: 'График y = f(x)', cat: 'math',
desc: 'Параметрический график функции с настраиваемыми коэффициентами.',
spec: {
specVersion: 1, meta: { title: 'График функции', desc: 'y = a*sin(b*x)' },
viewport: { xmin: -6.5, xmax: 6.5, ymin: -3.5, ymax: 3.5, grid: true, axes: true },
time: { autoplay: false, loop: true, speed: 1 },
params: [
{ name: 'a', label: 'Амплитуда a', min: -3, max: 3, step: 0.1, value: 2 },
{ name: 'b', label: 'Частота b', min: 0.2, max: 4, step: 0.1, value: 1 }
],
objects: [
{ type: 'plot', expr: 'a*sin(b*x)', var: 'x', range: [-6.5, 6.5], samples: 200, color: '#06D6E0', width: 2 }
]
}
},
{
name: 'Бросок тела', cat: 'phys',
desc: 'Траектория тела под углом к горизонту (кинематика).',
spec: {
specVersion: 1, meta: { title: 'Бросок тела', desc: 'Движение в поле тяжести' },
viewport: { xmin: -1, xmax: 22, ymin: -1, ymax: 12, grid: true, axes: true },
time: { autoplay: true, loop: true, speed: 1 },
params: [
{ name: 'v', label: 'Скорость', min: 5, max: 25, step: 0.5, value: 16, unit: 'м/с' },
{ name: 'ang', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: 'град' }
],
objects: [
{ id: 'b', type: 'circle', x: 'v*cos(ang*pi/180)*t', y: 'v*sin(ang*pi/180)*t - 4.9*t*t', r: 0.25, color: '#9B5DE5' },
{ type: 'plot', expr: '(x*tan(ang*pi/180)) - (4.9*x*x)/((v*cos(ang*pi/180))^2)', var: 'x', range: [0, 22], samples: 150, color: 'rgba(6,214,224,0.5)', width: 1.5 }
]
}
}
];
/* plot-объект сериализуется особым путём (range_a/range_b -> range, без UI-полей);
все прочие типы — через _stripObjOrig (удаление _uid и пустых полей). */
var _stripObjOrig = stripObj;
stripObj = function (o) {
if (o && o.type === 'plot') return normalizePlotForSpec(o);
return _stripObjOrig(o);
};
global.SimBuilder = SimBuilder;
})(typeof window !== 'undefined' ? window : globalThis);