2005 lines
115 KiB
JavaScript
2005 lines
115 KiB
JavaScript
'use strict';
|
||
/* ════════════════════════════════════════════════════════════════════════
|
||
SimBuilder — учительский редактор спек-симуляций (Фаза 4 SimForge).
|
||
|
||
Собирает JSON-спеку v1 (данные, не код) из форм-панелей и монтирует
|
||
живое превью через window.SimEngine.mount(host, spec). Любое числовое
|
||
свойство объекта принимает число ИЛИ строку-выражение; выражения
|
||
проверяются через window.SimExpr.compile (без eval/Function). Save/Load
|
||
через LS.customSim* (Фаза 3). Доступ — только teacher/admin (гейт в html).
|
||
|
||
Раскладка: левая колонка — панели-аккордеоны (Мета / Параметры / Объекты /
|
||
Графики·Физика); центр — превью + тулбар; перемонтаж движка с debounce при
|
||
любой правке. Drag-on-preview: клик/перетаскивание ставит x/y выбранного
|
||
объекта в мировых координатах (через inst._toWorld).
|
||
|
||
ВАЖНО: Без эмодзи (только inline SVG). ВАЖНО: Без eval/new Function. Vanilla JS.
|
||
════════════════════════════════════════════════════════════════════════ */
|
||
(function (global) {
|
||
|
||
/* ── Лимиты (зеркалят серверную validateSpec, Фаза 3) ── */
|
||
var LIMITS = {
|
||
params: 50, objects: 200, walls: 20, springs: 50, plots: 50,
|
||
exprLen: 500, points: 1000, jsonBytes: 200 * 1024
|
||
};
|
||
var SPEC_VERSION = 1;
|
||
// inline-стиль активного тумблера привязки (без зависимости от CSS-класса)
|
||
var SNAP_ACTIVE_CSS = 'background:var(--accent,#06D6E0);color:#0b1020;border-color:transparent';
|
||
var OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout'];
|
||
var CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
||
// ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера)
|
||
var RESERVED_PARAM = { e: true, E: true, pi: true, PI: true, t: true, w: true, h: true, tau: true };
|
||
|
||
/* ── Палитра имён функций/констант (из SimExpr) для подсказок ── */
|
||
function exprNames() {
|
||
var fns = [], consts = [];
|
||
if (global.SimExpr) {
|
||
Object.keys(global.SimExpr.FUNCTIONS || {}).forEach(function (k) { fns.push(k); });
|
||
Object.keys(global.SimExpr.CONSTANTS || {}).forEach(function (k) { consts.push(k); });
|
||
}
|
||
fns.sort(); consts.sort();
|
||
return { fns: fns, consts: consts };
|
||
}
|
||
|
||
/* ── escape для безопасной вставки в HTML-разметку ── */
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
function uid(prefix) {
|
||
return (prefix || 'o') + Math.random().toString(36).slice(2, 7) + (SimBuilder._seq++);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════
|
||
SimBuilder — модель состояния редактора + рендер панелей.
|
||
════════════════════════════════════════════════════════════════════════ */
|
||
var SimBuilder = {
|
||
_seq: 0,
|
||
|
||
create: function (opts) {
|
||
return new Builder(opts || {});
|
||
}
|
||
};
|
||
|
||
/* ── шаблон стартовой спеки (чистый лист) ── */
|
||
function blankState() {
|
||
return {
|
||
meta: { title: '', desc: '' },
|
||
subject: '',
|
||
grade: '',
|
||
cat: '',
|
||
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
|
||
time: { autoplay: false, loop: true, speed: 1 },
|
||
params: [],
|
||
objects: [],
|
||
plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects
|
||
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] }
|
||
};
|
||
}
|
||
|
||
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 };
|
||
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); })
|
||
};
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
|
||
/* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */
|
||
|
||
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; }
|
||
};
|
||
|
||
/* Переключить привязку к сетке (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.');
|
||
}
|
||
|
||
// размер 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.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);
|
||
};
|
||
|
||
/* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// 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 });
|
||
}
|
||
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);
|