Files
Maxim Dolgolyov c780b6fd96 @
feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая)
панель в sim-builder задаёт блок goal (when/title/hint/hold/fail) + до 3
звёзд + game-мету (chapter/order/par_ms); выражения проверяются inline через
SimExpr.compile (без eval). buildSpec/loadFromSim — round-trip без потерь
(goal/game пишутся только при включённом слое; обычная sim не меняется).
Кнопка «Играть» монтирует черновик в SimEngine-модалке (HUD цели из Ф0).
QuantikLevels стал async: подмешивает custom_sims cat=game (свои+
published) в реестр (custom:<dbid>), offline-safe, строки без goal
отбрасываются; deep-link /quantik?level=custom:<id> с серверной проверкой
доступа (own|published → иначе 403/404), мимо геймплейного гейта unlockStars.
Раздача классу — реюз share Ф6 (game-aware ссылка + durable pushNotif).
Правки sim-builder строго аддитивны (параллельная сессия). npm test 259/8
baseline; quantik-authoring 6/6; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 16:09:10 +03:00

2263 lines
130 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 = '<div style="padding:40px;color:#ef4444;font-size:.85rem">Ошибка сборки превью: ' + esc(e.message || e) + '</div>';
}
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'
? '<span class="sbu-badge sbu-badge-pub">Опубликовано</span>'
: '<span class="sbu-badge">Черновик</span>';
// Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать».
var pubBtn = this.status === 'published'
? '<button class="btn-ghost sbu-tb-btn" data-a="unpublish" title="Вернуть в черновик">' + ICON.unpublish + ' Снять</button>'
: '<button class="btn-primary sbu-tb-btn" data-a="publish">' + ICON.send + ' Опубликовать</button>';
// «Раздать классу» доступна только для уже сохранённой симуляции.
var shareBtn = this.simId
? '<button class="btn-ghost sbu-tb-btn" data-a="share" title="Раздать классу">' + ICON.send + ' Раздать</button>'
: '';
t.innerHTML =
'<div class="sbu-tb-left">' +
'<span class="sbu-tb-title">' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '</span>' +
statusBadge +
'</div>' +
'<div class="sbu-tb-right">' +
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="undo" title="Отменить (Ctrl+Z)"' + (this._undo.length ? '' : ' disabled') + '>' + ICON.undo + '</button>' +
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="redo" title="Повторить (Ctrl+Shift+Z)"' + (this._redo.length ? '' : ' disabled') + '>' + ICON.redo + '</button>' +
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="snap" title="Привязка к сетке" aria-pressed="' + (this._snap ? 'true' : 'false') + '"' + (this._snap ? ' style="' + SNAP_ACTIVE_CSS + '"' : '') + '>' + ICON.grid + '</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="template" title="Создать из шаблона">' + ICON.template + ' Шаблон</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="test" title="Запустить превью">' + ICON.play + ' Тест</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="reset" title="Сброс превью">' + ICON.reset + ' Сброс</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="save">' + ICON.save + ' Сохранить</button>' +
shareBtn +
pubBtn +
'</div>';
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 = '<div style="padding:30px;color:#ef4444">Ошибка запуска: ' + esc(e.message || e) + '</div>';
}
};
/* Переключить привязку к сетке (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:<id>). */
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 '<option value="' + esc(c.id) + '">' + esc(c.name) + '</option>';
}).join('');
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
'<select id="sbu-share-class" class="sbu-in">' + opts + '</select>' +
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
'</div>';
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 '<button type="button" data-tpl="' + i + '" style="text-align:left;display:flex;flex-direction:column;gap:4px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#fff;cursor:pointer">' +
'<span style="font-weight:800;font-size:.84rem;color:var(--text)">' + esc(tpl.name) + '</span>' +
'<span style="font-size:.74rem;color:var(--text-3)">' + esc(tpl.desc) + '</span>' +
'</button>';
}).join('');
var content = '<div style="display:flex;flex-direction:column;gap:8px">' + cards +
'<div style="font-size:.74rem;color:var(--text-3);margin-top:4px">Шаблон заменит текущую сцену и создаст новую симуляцию.</div></div>';
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: '<div style="display:flex;flex-direction:column;gap:8px;font-size:.85rem;color:var(--text-2)">' +
'<div>Исправьте перед сохранением:</div><ul style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:5px">' +
errs.slice(0, 12).map(function (e) { return '<li>' + esc(e) + '</li>'; }).join('') +
(errs.length > 12 ? '<li>…и ещё ' + (errs.length - 12) + '</li>' : '') +
'</ul></div>',
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) ? '<span class="sbu-sec-count">' + count + '</span>' : '';
return '<div class="sbu-sec' + (open ? ' open' : '') + '" data-sec="' + key + '">' +
'<button class="sbu-sec-hdr" data-sec-toggle="' + key + '">' +
'<span class="sbu-sec-title">' + esc(title) + '</span>' + cnt +
'<span class="sbu-sec-chev">' + ICON.chev + '</span>' +
'</button>' +
'<div class="sbu-sec-body">' + bodyHtml + '</div>' +
'</div>';
}
/* ── Мета ── */
Builder.prototype.sectionMeta = function () {
var st = this.st;
var catOpts = ['<option value="">— нет —</option>'].concat(CATS.map(function (c) {
return '<option value="' + c + '"' + (st.cat === c ? ' selected' : '') + '>' + CAT_LABEL[c] + '</option>';
})).join('');
var body =
field('Заголовок', '<input class="sbu-in" data-meta="title" value="' + esc(st.meta.title) + '" placeholder="Бросок тела под углом" />') +
field('Описание', '<textarea class="sbu-in" data-meta="desc" rows="2" placeholder="Краткое описание">' + esc(st.meta.desc) + '</textarea>') +
'<div class="sbu-row2">' +
field('Предмет', '<input class="sbu-in" data-meta="subject" value="' + esc(st.subject) + '" placeholder="Физика" />') +
field('Класс', '<input class="sbu-in" type="number" min="1" max="11" data-meta="grade" value="' + esc(st.grade) + '" placeholder="9" />') +
'</div>' +
field('Категория', '<select class="sbu-in" data-meta="cat">' + catOpts + '</select>') +
'<div class="sbu-divider"></div>' +
'<div class="sbu-sub">Поле сцены (мировые координаты)</div>' +
'<div class="sbu-row4">' +
miniField('x от', '<input class="sbu-in" type="number" data-vp="xmin" value="' + esc(st.viewport.xmin) + '" />') +
miniField('x до', '<input class="sbu-in" type="number" data-vp="xmax" value="' + esc(st.viewport.xmax) + '" />') +
miniField('y от', '<input class="sbu-in" type="number" data-vp="ymin" value="' + esc(st.viewport.ymin) + '" />') +
miniField('y до', '<input class="sbu-in" type="number" data-vp="ymax" value="' + esc(st.viewport.ymax) + '" />') +
'</div>' +
'<div class="sbu-checks">' +
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) +
'</div>';
return section('meta', 'Метаданные и сцена', body, this._open.meta);
};
/* ── Параметры ── */
Builder.prototype.sectionParams = function () {
var rows = this.st.params.map(function (p, i) {
return '<div class="sbu-param" data-pi="' + i + '">' +
'<div class="sbu-param-top">' +
'<input class="sbu-in sbu-in-sm" data-pf="name" value="' + esc(p.name) + '" placeholder="имя (v)" title="Имя переменной (исп. в выражениях)" />' +
'<input class="sbu-in sbu-in-sm" data-pf="label" value="' + esc(p.label) + '" placeholder="подпись" />' +
'<button class="sbu-icon-btn sbu-del" data-pdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-row4">' +
miniField('min', '<input class="sbu-in" type="number" data-pf="min" value="' + esc(p.min) + '" />') +
miniField('max', '<input class="sbu-in" type="number" data-pf="max" value="' + esc(p.max) + '" />') +
miniField('шаг', '<input class="sbu-in" type="number" data-pf="step" value="' + esc(p.step) + '" />') +
miniField('старт', '<input class="sbu-in" type="number" data-pf="value" value="' + esc(p.value) + '" />') +
'</div>' +
'<input class="sbu-in sbu-in-sm" data-pf="unit" value="' + esc(p.unit) + '" placeholder="ед. изм. (м/с)" />' +
'</div>';
}).join('');
var body = (rows || '<div class="sbu-empty-sm">Нет параметров. Добавьте слайдер.</div>') +
'<button class="sbu-add" data-add="param">' + ICON.plus + ' Параметр</button>';
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 '<option value="' + t + '">' + TYPE_LABEL[t] + '</option>'; }).join('');
var body = (rows || '<div class="sbu-empty-sm">Нет объектов. Добавьте фигуру/точку/подпись.</div>') +
'<div class="sbu-add-row">' +
'<select class="sbu-in sbu-in-sm" id="sbu-newtype">' + typeOpts + '</select>' +
'<button class="sbu-add" data-add="object">' + ICON.plus + ' Объект</button>' +
'</div>';
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 '<label class="sbu-of-check"><input type="checkbox" data-of="' + f.key + '"' + (o[f.key] ? ' checked' : '') + '/> ' + esc(f.label) + '</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, '<input class="sbu-in" data-of="' + f.key + '" value="' + esc(o[f.key] == null ? '' : o[f.key]) + '" placeholder="' + esc(f.ph || '') + '" />');
}
// expr — число или выражение, с проверкой
var v = (o[f.key] == null ? '' : o[f.key]);
var err = exprError(v);
return '<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">' + esc(f.label) +
'<button class="sbu-fx" data-fx="' + f.key + '" title="Палитра функций/параметров">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-of="' + f.key + '" value="' + esc(v) + '" placeholder="' + esc(f.ph || 'число или выражение') + '" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>';
}).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 = '<div class="sbu-latex" data-latex="' + esc(o.text) + '"></div>';
}
return '<div class="sbu-obj' + (selected ? ' sel' : '') + (hidden ? ' is-hidden' : '') + '" data-oi="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">' + (TYPE_LABEL[o.type] || o.type) + '</span>' +
'<input class="sbu-in sbu-in-id" data-of="id" value="' + esc(o.id == null ? '' : o.id) + '" placeholder="id" title="Идентификатор (для ссылок obj.x/obj.y)" />' +
'<button class="sbu-icon-btn sbu-zord" data-oup="' + i + '" title="Выше"' + (i === 0 ? ' disabled' : '') + '>' + ICON.up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-odown="' + i + '" title="Ниже"' + (i === n - 1 ? ' disabled' : '') + '>' + ICON.down + '</button>' +
'<button class="sbu-icon-btn' + (hidden ? ' active' : '') + '" data-ohide="' + i + '" title="' + (hidden ? 'Показать' : 'Скрыть') + '">' + (hidden ? ICON.eyeOff : ICON.eye) + '</button>' +
'<button class="sbu-icon-btn sbu-dup" data-odup="' + i + '" title="Дублировать">' + ICON.copy + '</button>' +
'<button class="sbu-icon-btn sbu-place" data-place="' + o._uid + '" title="Поставить/двигать на сцене">' + ICON.target + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-odel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-obj-fields">' + inner + latexPrev + '</div>' +
style +
'</div>';
};
/* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + 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 =
'<label class="sbu-of-check"><input type="checkbox" data-grad-on' + (on ? ' checked' : '') + '/> Градиент-заливка</label>' +
'<div class="sbu-grad-row"' + (on ? '' : ' style="display:none"') + '>' +
colorCtl('от', 'data-grad="0"', g[0] || toHexColor(o.color) , false) +
colorCtl('до', 'data-grad="1"', g[1] || '#1b1b2e', false) +
'</div>';
}
var glow = cfg.glow
? '<label class="sbu-of-check"><input type="checkbox" data-of="glow"' + (o.glow ? ' checked' : '') + '/> Свечение (glow)</label>'
: '';
return '<div class="sbu-obj-style">' +
'<div class="sbu-sub">Стиль</div>' +
(ctrls.length ? '<div class="sbu-style-row">' + ctrls.join('') + '</div>' : '') +
glow + grad +
'</div>';
};
/* Редактор одного графика (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 '<div class="sbu-plot' + (hidden ? ' is-hidden' : '') + '" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<span style="flex:1"></span>' +
'<button class="sbu-icon-btn sbu-zord" data-pltup="' + i + '" title="Выше"' + (i === 0 ? ' disabled' : '') + '>' + ICON.up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-pltdown="' + i + '" title="Ниже"' + (i === n - 1 ? ' disabled' : '') + '>' + ICON.down + '</button>' +
'<button class="sbu-icon-btn' + (hidden ? ' active' : '') + '" data-plthide="' + i + '" title="' + (hidden ? 'Показать' : 'Скрыть') + '">' + (hidden ? ICON.eyeOff : ICON.eye) + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-curves">' + curveHtml + '</div>' +
'<button class="sbu-add sbu-add-sm" data-curveadd="' + i + '">' + ICON.plus + ' Кривая</button>' +
'<div class="sbu-row4">' +
miniField('перем.', '<input class="sbu-in" data-plf="var" value="' + esc(o['var'] == null ? 'x' : o['var']) + '" placeholder="x" />') +
miniField('от', '<input class="sbu-in" data-plf="range_a" value="' + esc(o.range_a == null ? '' : o.range_a) + '" placeholder="xmin" />') +
miniField('до', '<input class="sbu-in" data-plf="range_b" value="' + esc(o.range_b == null ? '' : o.range_b) + '" placeholder="xmax" />') +
miniField('точек', '<input class="sbu-in" type="number" data-plf="samples" value="' + esc(o.samples == null ? '' : o.samples) + '" placeholder="200" />') +
'</div>' +
'<div class="sbu-style-row">' +
selectCtl('маркеры', 'data-plf="plotMarker"', o.plotMarker || 'none', MARKER_OPTS) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка под всеми</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-plf="plotFill"' + (o.plotFill ? ' checked' : '') + '/> вкл</label></label>' +
'</div>' +
'<div class="sbu-checks">' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + (o.trace ? ' checked' : '') + '/> След по времени (trace)</label>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="legend"' + (o.legend !== false ? ' checked' : '') + '/> Легенда</label>' +
'</div>' +
(rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc(rangeErr) + '</span>' : '') +
'</div>';
};
/* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */
function curveEditor(cv, pi, ci, removable) {
var exprErr = exprError(cv.expr);
return '<div class="sbu-curve" data-pi="' + pi + '" data-ci="' + ci + '">' +
'<div class="sbu-of' + (exprErr ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">выражение' +
'<span style="display:flex;gap:4px;align-items:center">' +
'<button class="sbu-fx" data-cvfx>fx</button>' +
(removable ? '<button class="sbu-icon-btn sbu-del sbu-curve-del" data-curvedel title="Удалить кривую">' + ICON.trash + '</button>' : '') +
'</span>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-cvf="expr" value="' + esc(cv.expr == null ? '' : cv.expr) + '" placeholder="sin(x)" />' +
(exprErr ? '<span class="sbu-of-err">' + esc(exprErr) + '</span>' : '') +
'</div>' +
'<div class="sbu-row2">' +
colorCtl('цвет', 'data-cvf="color"', cv.color, true) +
miniField('подпись', '<input class="sbu-in sbu-in-sm" data-cvf="label" value="' + esc(cv.label == null ? '' : cv.label) + '" placeholder="легенда" />') +
'</div>' +
'<div class="sbu-style-row">' +
miniField('толщ.', '<input class="sbu-in sbu-in-sm" type="number" step="0.5" data-cvf="width" value="' + esc(cv.width == null ? '' : cv.width) + '" placeholder="2" />') +
selectCtl('линия', 'data-cvf="lineStyle"', cv.lineStyle || 'solid', LINE_STYLE_OPTS) +
selectCtl('маркер', 'data-cvf="marker"', cv.marker || 'none', MARKER_OPTS) +
'</div>' +
'<div class="sbu-style-row">' +
rangeCtl('непрозр.', 'data-cvf="opacity"', cv.opacity, 0, 1, 0.05) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-cvf="fill"' + (cv.fill ? ' checked' : '') + '/> вкл</label></label>' +
(cv.fill ? colorCtl('цвет зал.', 'data-cvf="fillColor"', cv.fillColor, true) : '<span></span>') +
'</div>' +
'</div>';
}
/* ── Графики + Физика ── */
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 || '<div class="sbu-empty-sm">Нет графиков. Добавьте график функции — можно несколько кривых.</div>') +
'<button class="sbu-add" data-add="plot">' + ICON.plus + ' График</button>';
// physics
var ph = this.st.physics;
var bodyHint = '<div class="sbu-sub" style="margin-top:6px">Сделать объект физ-телом: добавьте ему поле «масса» (' + ICON.cog + ' в объекте). Тип point/circle.</div>';
var physBody =
'<label class="sbu-of-check sbu-phys-toggle"><input type="checkbox" data-phys="enabled"' + (ph.enabled ? ' checked' : '') + '/> Включить физику</label>' +
'<div class="sbu-phys-fields"' + (ph.enabled ? '' : ' style="opacity:.45;pointer-events:none"') + '>' +
'<div class="sbu-row2">' +
miniField('гравитация x', '<input class="sbu-in" type="number" data-phf="gx" value="' + esc(ph.gx) + '" />') +
miniField('гравитация y', '<input class="sbu-in" type="number" data-phf="gy" value="' + esc(ph.gy) + '" />') +
'</div>' +
'<div class="sbu-row2">' +
miniField('трение', '<input class="sbu-in" type="number" step="0.1" data-phf="friction" value="' + esc(ph.friction) + '" />') +
miniField('упругость 0..1', '<input class="sbu-in" type="number" step="0.05" min="0" max="1" data-phf="restitution" value="' + esc(ph.restitution) + '" />') +
'</div>' +
// walls
'<div class="sbu-sub">Стены</div>' +
ph.walls.map(function (w, i) {
var sideOpts = ['', 'bottom', 'top', 'left', 'right'].map(function (s) {
return '<option value="' + s + '"' + (w.side === s ? ' selected' : '') + '>' + (s === '' ? '— отрезок —' : WALL_LABEL[s]) + '</option>';
}).join('');
return '<div class="sbu-wall" data-wi="' + i + '">' +
'<select class="sbu-in sbu-in-sm" data-wf="side">' + sideOpts + '</select>' +
(w.side ? '' :
'<div class="sbu-row4">' +
miniField('x1', '<input class="sbu-in" data-wf="x1" value="' + esc(w.x1 == null ? '' : w.x1) + '" />') +
miniField('y1', '<input class="sbu-in" data-wf="y1" value="' + esc(w.y1 == null ? '' : w.y1) + '" />') +
miniField('x2', '<input class="sbu-in" data-wf="x2" value="' + esc(w.x2 == null ? '' : w.x2) + '" />') +
miniField('y2', '<input class="sbu-in" data-wf="y2" value="' + esc(w.y2 == null ? '' : w.y2) + '" />') +
'</div>') +
'<button class="sbu-icon-btn sbu-del" data-wdel="' + i + '" title="Удалить стену">' + ICON.trash + '</button>' +
'</div>';
}).join('') +
'<button class="sbu-add sbu-add-sm" data-add="wall">' + ICON.plus + ' Стена</button>' +
// springs
'<div class="sbu-sub">Пружины</div>' +
ph.springs.map(function (s, i) {
return '<div class="sbu-spring" data-spi="' + i + '">' +
'<div class="sbu-row2">' +
miniField('конец A', '<input class="sbu-in" data-spf="a" value="' + esc(s.a == null ? '' : s.a) + '" placeholder="id или x,y" />') +
miniField('конец B', '<input class="sbu-in" data-spf="b" value="' + esc(s.b == null ? '' : s.b) + '" placeholder="id или x,y" />') +
'</div>' +
'<div class="sbu-row4">' +
miniField('k', '<input class="sbu-in" type="number" data-spf="k" value="' + esc(s.k == null ? '' : s.k) + '" />') +
miniField('длина', '<input class="sbu-in" type="number" data-spf="length" value="' + esc(s.length == null ? '' : s.length) + '" />') +
miniField('демпф.', '<input class="sbu-in" type="number" data-spf="damping" value="' + esc(s.damping == null ? '' : s.damping) + '" />') +
'<button class="sbu-icon-btn sbu-del" data-spdel="' + i + '" title="Удалить пружину">' + ICON.trash + '</button>' +
'</div>' +
'</div>';
}).join('') +
'<button class="sbu-add sbu-add-sm" data-add="spring">' + ICON.plus + ' Пружина</button>' +
bodyHint +
'</div>';
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 '<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">' + esc(label) +
'<button class="sbu-fx" data-gfx="' + key + '" title="Палитра функций/параметров">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-gf="' + key + '" value="' + esc(val == null ? '' : val) + '" placeholder="' + esc(ph || 'условие (выражение)') + '" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>';
}
var stars = Array.isArray(gm.stars) ? gm.stars : [];
var starRows = stars.map(function (s, i) {
var err = exprError(s.when);
return '<div class="sbu-star" data-si="' + i + '">' +
'<div class="sbu-star-hdr">' +
'<span class="sbu-obj-type">Звезда ' + (i + 1) + '</span>' +
'<span style="flex:1"></span>' +
'<button class="sbu-icon-btn sbu-del" data-stardel="' + i + '" title="Удалить звезду">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">условие' +
'<button class="sbu-fx" data-sfx="' + i + '" title="Палитра">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-sf="when" value="' + esc(s.when == null ? '' : s.when) + '" placeholder="напр. coin.hit" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>' +
miniField('подпись', '<input class="sbu-in" data-sf="label" value="' + esc(s.label == null ? '' : s.label) + '" placeholder="Собрал кристалл" />') +
'</div>';
}).join('');
var inner =
'<label class="sbu-of-check sbu-phys-toggle"><input type="checkbox" data-game="enabled"' + (on ? ' checked' : '') + '/> Это игровой уровень (Квантик)</label>' +
'<div class="sbu-game-fields"' + (on ? '' : ' style="opacity:.45;pointer-events:none"') + '>' +
'<div class="sbu-sub">Цель</div>' +
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') +
'<div class="sbu-row2">' +
field('Заголовок цели', '<input class="sbu-in" data-gf="title" value="' + esc(gm.title || '') + '" placeholder="Попади в портал" />') +
miniField('удержать, с (hold)', '<input class="sbu-in" type="number" step="0.1" min="0" data-gf="hold" value="' + esc(gm.hold == null ? '' : gm.hold) + '" placeholder="0" />') +
'</div>' +
field('Подсказка', '<textarea class="sbu-in" data-gf="hint" rows="2" placeholder="Краткая подсказка игроку">' + esc(gm.hint || '') + '</textarea>') +
'<div class="sbu-sub">Звёзды (макс 3)</div>' +
'<div class="sbu-stars-list">' + (starRows || '<div class="sbu-empty-sm">Нет звёзд-бонусов. Победа = 1-я звезда автоматически.</div>') + '</div>' +
(stars.length < 3 ? '<button class="sbu-add sbu-add-sm" data-add="star">' + ICON.plus + ' Звезда</button>' : '') +
'<div class="sbu-divider"></div>' +
'<div class="sbu-sub">Метаданные уровня</div>' +
'<div class="sbu-row4">' +
miniField('глава', '<input class="sbu-in" data-gf="chapter" value="' + esc(gm.chapter || '') + '" placeholder="kinematics" />') +
miniField('порядок', '<input class="sbu-in" type="number" data-gf="order" value="' + esc(gm.order == null ? '' : gm.order) + '" placeholder="1" />') +
miniField('норматив, мс', '<input class="sbu-in" type="number" data-gf="par_ms" value="' + esc(gm.par_ms == null ? '' : gm.par_ms) + '" placeholder="1500" />') +
'<span></span>' +
'</div>' +
'<button class="sbu-add sbu-add-sm" data-a2="play-game">' + ICON.play + ' Играть (тест уровня)</button>' +
'</div>';
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 '<div class="sbu-pal-grp"><div class="sbu-pal-title">' + esc(title) + '</div><div class="sbu-pal-chips">' +
arr.map(function (n) { return '<button class="sbu-pal-chip" data-ins="' + esc(n) + '" data-kind="' + kind + '">' + esc(n) + '</button>'; }).join('') +
'</div></div>';
}
var content =
'<div class="sbu-pal">' +
chips('Параметры', params, 'var') +
chips('Объекты (id.x / id.y)', objRefs, 'var') +
chips('Время / размеры', ['t', 'w', 'h'], 'var') +
chips('Константы', names.consts, 'const') +
chips('Функции', names.fns, 'fn') +
'</div>';
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 '<label class="sbu-field"><span class="sbu-field-lbl">' + esc(label) + '</span>' + inner + '</label>';
}
function miniField(label, inner) {
return '<label class="sbu-mini"><span class="sbu-mini-lbl">' + esc(label) + '</span>' + inner + '</label>';
}
function checkbox(grp, key, label, checked) {
return '<label class="sbu-chk"><input type="checkbox" data-grp="' + grp + '" data-k="' + key + '"' + (checked ? ' checked' : '') + '/> ' + esc(label) + '</label>';
}
/* ── Контролы стиля (P4) ──────────────────────────────────────────────────
Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая).
attr — строка вида 'data-of="color"' (атрибут привязки события).
Цвет: нативный <input type=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
? '<button type="button" class="sbu-color-clr" data-color-clear title="Нет заливки">' + ICON.clearX + '</button>'
: '';
return '<label class="sbu-mini sbu-color-mini">' +
'<span class="sbu-mini-lbl">' + esc(label) + '</span>' +
'<span class="sbu-color-wrap">' +
'<input type="color" class="sbu-color-pick" data-color-pick value="' + esc(hex) + '" title="Выбрать цвет" />' +
'<input class="sbu-in sbu-in-sm sbu-in-color" ' + colorAttr + ' value="' + esc(v) + '" placeholder="#06D6E0" />' +
clr +
'</span>' +
'</label>';
}
// слайдер 0..1 (opacity) с числовым отображением
function rangeCtl(label, attr, value, mn, mx, st) {
var num = (value == null || value === '') ? '' : value;
var sliderVal = (num === '') ? mx : num;
return '<label class="sbu-mini sbu-range-mini">' +
'<span class="sbu-mini-lbl">' + esc(label) + ' <b class="sbu-range-val">' + esc(num === '' ? mx : num) + '</b></span>' +
'<input type="range" class="sbu-range" ' + attr + ' min="' + mn + '" max="' + mx + '" step="' + st + '" value="' + esc(sliderVal) + '" />' +
'</label>';
}
// select по списку [{v,l}]
function selectCtl(label, attr, value, opts) {
var o = opts.map(function (op) {
return '<option value="' + esc(op.v) + '"' + (String(value || '') === String(op.v) ? ' selected' : '') + '>' + esc(op.l) + '</option>';
}).join('');
return '<label class="sbu-mini"><span class="sbu-mini-lbl">' + esc(label) + '</span>' +
'<select class="sbu-in sbu-in-sm" ' + attr + '>' + o + '</select></label>';
}
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: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
reset: '<svg viewBox="0 0 24 24" width="14" height="14" 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>',
save: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
send: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
plus: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
trash: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
chev: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>',
target: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
cog: '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
template: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>',
unpublish: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>',
up: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="18 15 12 9 6 15"/></svg>',
down: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
eye: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>',
eyeOff: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
clearX: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
undo: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 7v6h6"/><path d="M3 13a9 9 0 1 0 3-7.7L3 8"/></svg>',
redo: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 7v6h-6"/><path d="M21 13a9 9 0 1 1-3-7.7L21 8"/></svg>',
grid: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="18" height="18" rx="1.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>'
};
/* ── Встроенные шаблоны стартовых спек (Фаза 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);