'use strict'; /* ════════════════════════════════════════════════════════════════════════ SimBuilder — учительский редактор спек-симуляций (Фаза 4 SimForge). Собирает JSON-спеку v1 (данные, не код) из форм-панелей и монтирует живое превью через window.SimEngine.mount(host, spec). Любое числовое свойство объекта принимает число ИЛИ строку-выражение; выражения проверяются через window.SimExpr.compile (без eval/Function). Save/Load через LS.customSim* (Фаза 3). Доступ — только teacher/admin (гейт в html). Раскладка: левая колонка — панели-аккордеоны (Мета / Параметры / Объекты / Графики·Физика); центр — превью + тулбар; перемонтаж движка с debounce при любой правке. Drag-on-preview: клик/перетаскивание ставит x/y выбранного объекта в мировых координатах (через inst._toWorld). ВАЖНО: Без эмодзи (только inline SVG). ВАЖНО: Без eval/new Function. Vanilla JS. ════════════════════════════════════════════════════════════════════════ */ (function (global) { /* ── Лимиты (зеркалят серверную validateSpec, Фаза 3) ── */ var LIMITS = { params: 50, objects: 200, walls: 20, springs: 50, plots: 50, exprLen: 500, points: 1000, jsonBytes: 200 * 1024 }; var SPEC_VERSION = 1; // inline-стиль активного тумблера привязки (без зависимости от CSS-класса) var SNAP_ACTIVE_CSS = 'background:var(--accent,#06D6E0);color:#0b1020;border-color:transparent'; var OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout']; var CATS = ['math', 'phys', 'chem', 'bio', 'game']; // ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера) var RESERVED_PARAM = { e: true, E: true, pi: true, PI: true, t: true, w: true, h: true, tau: true }; /* ── Палитра имён функций/констант (из SimExpr) для подсказок ── */ function exprNames() { var fns = [], consts = []; if (global.SimExpr) { Object.keys(global.SimExpr.FUNCTIONS || {}).forEach(function (k) { fns.push(k); }); Object.keys(global.SimExpr.CONSTANTS || {}).forEach(function (k) { consts.push(k); }); } fns.sort(); consts.sort(); return { fns: fns, consts: consts }; } /* ── escape для безопасной вставки в HTML-разметку ── */ function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function uid(prefix) { return (prefix || 'o') + Math.random().toString(36).slice(2, 7) + (SimBuilder._seq++); } /* ════════════════════════════════════════════════════════════════════════ SimBuilder — модель состояния редактора + рендер панелей. ════════════════════════════════════════════════════════════════════════ */ var SimBuilder = { _seq: 0, create: function (opts) { return new Builder(opts || {}); } }; /* ── шаблон стартовой спеки (чистый лист) ── */ function blankState() { return { meta: { title: '', desc: '' }, subject: '', grade: '', cat: '', viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true }, time: { autoplay: false, loop: true, speed: 1 }, params: [], objects: [], plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] } }; } 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 = '
Ошибка сборки превью: ' + esc(e.message || e) + '
'; } this.bindPreviewDrag(); }; /* Drag-on-preview (P5): прямое манипулирование выбранным объектом на сцене. Поддержаны ВСЕ позиционируемые типы — точка/окружность/подпись/показатель/ прямоугольник (x,y), отрезок/вектор (оба конца x1,y1 и x2,y2; вектор также в форме origin+dx/dy), ломаная/путь (перетаскивание вершин массива points). Выбирается ближайшая «ручка» в пределах допуска; иначе — целое тело (двигаем все координаты сразу). Поля-ВЫРАЖЕНИЯ (не числа) НЕ перезаписываем молча. При включённой привязке (this._snap) мир-координаты округляются к шагу сетки. Работает только когда выбран объект и движок не запущен (иначе мешает встроенному drag/анимации движка). */ Builder.prototype.bindPreviewDrag = function () { var self = this; if (!this.inst || !this.inst.canvas) return; var canvas = this.inst.canvas; var drag = null; // активная сессия: { kind, keys, start, base } var HIT = 14; // допуск хит-теста ручки в экранных px function objSel() { if (!self._selObjId) return null; return self.st.objects.find(function (o) { return o._uid === self._selObjId; }) || null; } function worldAt(ev) { var r = canvas.getBoundingClientRect(); var px = ev.clientX - r.left, py = ev.clientY - r.top; if (typeof self.inst._toWorld === 'function') return self.inst._toWorld(px, py); return null; } function pxAt(mx, my) { if (typeof self.inst._toPx === 'function') return self.inst._toPx(mx, my); return null; } // шаг привязки: минорный шаг сетки движка (~34px) или 0.5 как запасной function snapStep() { if (self.inst && typeof self.inst._niceStep === 'function') { var s = self.inst._niceStep(34); if (isFinite(s) && s > 0) return s; } return 0.5; } function snapVal(v) { if (!self._snap) return round2(v); var step = snapStep(); return round2(Math.round(v / step) * step); } // числовое значение поля или null, если выражение/пусто (не трогаем выражения) function numField(obj, key) { var v = obj[key]; if (v === '' || v == null) return 0; // пустое трактуем как 0 (можно двигать) if (typeof v === 'number') return v; var n = Number(v); return (isFinite(n) && String(v).trim() !== '') ? n : null; // null = выражение } // массив вершин полилинии/пути из строки JSON; null при ошибке function parsePoints(obj) { try { var arr = JSON.parse(obj.points); if (Array.isArray(arr)) return arr; } catch (e) {} return null; } /* Перечислить «ручки» объекта (мир-координаты + сеттер). exprBlocked=true, если соответствующее поле — выражение (ручка показывается, но не двигается). */ function handlesOf(obj) { var hs = []; if (!obj) return hs; var t = obj.type; function pt(keyX, keyY, label) { var wx = numField(obj, keyX), wy = numField(obj, keyY); var blocked = (wx === null || wy === null); hs.push({ label: label, blocked: blocked, wx: blocked ? null : wx, wy: blocked ? null : wy, set: function (x, y) { if (!blocked) { obj[keyX] = snapVal(x); obj[keyY] = snapVal(y); } } }); } if (t === 'segment' || t === 'vector') { // вектор может быть задан origin(x1,y1)+dx,dy ИЛИ x1y1x2y2 var hasDxDy = ('dx' in obj || 'dy' in obj) && !('x2' in obj && 'y2' in obj); pt('x1', 'y1', 'origin'); if (hasDxDy) { // конец = origin + (dx,dy); ручка пишет dx/dy var ox = numField(obj, 'x1'), oy = numField(obj, 'y1'); var dx = numField(obj, 'dx'), dy = numField(obj, 'dy'); var blk = (ox === null || oy === null || dx === null || dy === null); hs.push({ label: 'end', blocked: blk, wx: blk ? null : ox + dx, wy: blk ? null : oy + dy, set: function (x, y) { if (!blk) { obj.dx = snapVal(x - ox); obj.dy = snapVal(y - oy); } } }); } else { pt('x2', 'y2', 'end'); } } else if (t === 'polyline' || t === 'path') { var arr = parsePoints(obj); if (arr) { arr.forEach(function (p, idx) { if (!Array.isArray(p) || p.length < 2) return; var nx = Number(p[0]), ny = Number(p[1]); if (!isFinite(nx) || !isFinite(ny)) return; // выражение-вершина — не двигаем hs.push({ label: 'v' + idx, blocked: false, wx: nx, wy: ny, _vidx: idx, set: function (x, y) { var a = parsePoints(obj); if (!a || !a[idx]) return; a[idx][0] = snapVal(x); a[idx][1] = snapVal(y); obj.points = JSON.stringify(a); } }); }); } } else { // x/y типы: point, circle, label, readout, rect pt('x', 'y', 'pos'); } return hs; } /* Хит-тест: ближайшая незаблокированная ручка под экранной точкой (px,py). */ function pickHandle(obj, lx, ly) { var hs = handlesOf(obj), best = null, bestD = HIT * HIT; for (var i = 0; i < hs.length; i++) { var h = hs[i]; if (h.blocked || h.wx == null) continue; var p = pxAt(h.wx, h.wy); if (!p) continue; var dx = p[0] - lx, dy = p[1] - ly, d = dx * dx + dy * dy; if (d <= bestD) { bestD = d; best = h; } } return best; } // незаблокированные ручки объекта function movableHandles(obj) { return handlesOf(obj).filter(function (h) { return !h.blocked && h.wx != null; }); } canvas.addEventListener('pointerdown', function (ev) { if (!self._selObjId) return; if (self.inst && self.inst.isRunning && self.inst.isRunning()) return; var obj = objSel(); if (!obj) return; var w = worldAt(ev); if (!w) return; var r = canvas.getBoundingClientRect(); var lx = ev.clientX - r.left, ly = ev.clientY - r.top; // снапшот в историю ПЕРЕД началом перемещения (один на сессию drag) self.pushHistory(); var handle = pickHandle(obj, lx, ly); var move = movableHandles(obj); // режим: 'handle' (конкретная ручка), 'place' (клик ставит единственную точку), // 'body' (несколько ручек — двигаем тело относительно стартовой точки), 'none' var mode; if (handle) mode = 'handle'; else if (move.length === 1) { handle = move[0]; mode = 'place'; } // point/circle/label/readout/rect else if (move.length > 1) mode = 'body'; // segment/vector/polyline else mode = 'none'; drag = { obj: obj, mode: mode, handle: handle, startW: w, moved: false, baseHandles: (mode === 'body') ? move.map(function (h) { return { wx: h.wx, wy: h.wy, set: h.set }; }) : null }; try { canvas.setPointerCapture(ev.pointerId); } catch (e) {} // handle/place — сразу поставить ручку в точку клика (place = клик ставит) if (mode === 'handle' || mode === 'place') { handle.set(w[0], w[1]); drag.moved = true; self.refreshObjFields(obj._uid); self.scheduleRemount(false); } ev.preventDefault(); }); canvas.addEventListener('pointermove', function (ev) { if (!drag) return; var w = worldAt(ev); if (!w) return; var obj = drag.obj; if (drag.mode === 'handle' || drag.mode === 'place') { drag.handle.set(w[0], w[1]); } else if (drag.mode === 'body') { // тело целиком: сдвиг всех ручек от стартовой мировой точки var dxw = w[0] - drag.startW[0], dyw = w[1] - drag.startW[1]; drag.baseHandles.forEach(function (h) { h.set(h.wx + dxw, h.wy + dyw); }); } else return; drag.moved = true; self.refreshObjFields(obj._uid); self.scheduleRemount(false); }); function end() { // если состояние не менялось (mode 'none' или body без движения) — откатить // лишний снапшот, чтобы Ctrl+Z не «застревал» на пустых записях. if (drag && !drag.moved && self._undo.length) { var top = self._undo[self._undo.length - 1]; var cur; try { cur = JSON.stringify(self.st); } catch (e) { cur = null; } if (cur != null && cur === top) { self._undo.pop(); self.updateHistoryButtons(); } } drag = null; } canvas.addEventListener('pointerup', end); canvas.addEventListener('pointercancel', end); // курсор-подсказка canvas.style.cursor = this._selObjId ? 'crosshair' : ''; }; /* ════════════════════════ ТУЛБАР ════════════════════════ */ Builder.prototype.renderToolbar = function () { var self = this; var t = this.toolbarHost; if (!t) return; var statusBadge = this.status === 'published' ? 'Опубликовано' : 'Черновик'; // Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать». var pubBtn = this.status === 'published' ? '' : ''; // «Раздать классу» доступна только для уже сохранённой симуляции. var shareBtn = this.simId ? '' : ''; t.innerHTML = '
' + '' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '' + statusBadge + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + shareBtn + pubBtn + '
'; t.querySelectorAll('[data-a]').forEach(function (b) { b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); }); }); this.bindKeyboardShortcuts(); }; /* Глобальные горячие клавиши истории (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y). Игнорируем, если фокус в поле ввода (там работает нативная отмена текста). */ Builder.prototype.bindKeyboardShortcuts = function () { var self = this; if (this._keyHandler) return; // вешаем один раз this._keyHandler = function (ev) { if (!(ev.ctrlKey || ev.metaKey)) return; var tag = (ev.target && ev.target.tagName) || ''; var editable = ev.target && (ev.target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); if (editable) return; // не перехватываем правку текста в полях var k = (ev.key || '').toLowerCase(); if (k === 'z' && !ev.shiftKey) { ev.preventDefault(); self.undo(); } else if ((k === 'z' && ev.shiftKey) || k === 'y') { ev.preventDefault(); self.redo(); } }; global.document.addEventListener('keydown', this._keyHandler); }; Builder.prototype.onToolbar = function (action) { if (action === 'test') { if (this.inst && this.inst.play) this.inst.play(); return; } if (action === 'reset') { if (this.inst && this.inst.reset) this.inst.reset(); return; } if (action === 'save') { this.save(false); return; } if (action === 'publish') { this.save(true); return; } if (action === 'unpublish') { this.setStatus('draft'); return; } if (action === 'share') { this.openShareModal(); return; } if (action === 'template') { this.openTemplateModal(); return; } if (action === 'undo') { this.undo(); return; } if (action === 'redo') { this.redo(); return; } if (action === 'snap') { this.toggleSnap(); return; } }; /* Переключить привязку к сетке (drag будет округлять к шагу сетки). */ Builder.prototype.toggleSnap = function () { this._snap = !this._snap; var t = this.toolbarHost; if (t) { var b = t.querySelector('[data-a="snap"]'); if (b) { b.setAttribute('aria-pressed', this._snap ? 'true' : 'false'); b.setAttribute('style', this._snap ? SNAP_ACTIVE_CSS : ''); } } if (global.LS && global.LS.toast) global.LS.toast(this._snap ? 'Привязка к сетке включена' : 'Привязка к сетке выключена', 'info', 1600); }; /* Изменить статус публикации уже сохранённой симуляции (PUT status). */ Builder.prototype.setStatus = function (status) { var self = this; if (!this.simId) { this.save(status === 'published'); return; } global.LS.customSimUpdate(this.simId, { status: status }).then(function () { self.status = status; self.renderToolbar(); global.LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success'); }).catch(function (e) { global.LS.toast((e && e.message) || 'Ошибка', 'error'); }); }; /* Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует + уведомляет учеников со ссылкой /lab?sim=custom:). */ Builder.prototype.openShareModal = function () { var self = this; if (!this.simId) { global.LS.toast('Сначала сохраните симуляцию', 'warn'); return; } global.LS.getClasses().then(function (classes) { if (!Array.isArray(classes) || !classes.length) { global.LS.toast('Нет классов для раздачи', 'warn'); return; } var opts = classes.map(function (c) { return ''; }).join(''); var content = '
' + '' + '' + '
Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.
' + '
'; 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 ''; }).join(''); var content = '
' + cards + '
Шаблон заменит текущую сцену и создаст новую симуляцию.
'; var m = global.LS.modal({ title: 'Создать из шаблона', content: content, size: 'sm', actions: [ { label: 'Закрыть', onClick: function () { m.close(); } } ] }); m.body.querySelectorAll('[data-tpl]').forEach(function (b) { b.addEventListener('click', function () { var tpl = TEMPLATES[Number(b.getAttribute('data-tpl'))]; if (!tpl) return; var apply = function () { self.simId = null; try { global.history.replaceState({}, '', '/sim-builder'); } catch (e) {} // loadFromSim ждёт sim-объект; собираем синтетический из спеки шаблона. var spec = JSON.parse(JSON.stringify(tpl.spec)); self.loadFromSim({ id: null, status: 'draft', version: 1, title: (spec.meta && spec.meta.title) || tpl.name, description: (spec.meta && spec.meta.desc) || '', subject: spec.subject || '', grade: spec.grade != null ? spec.grade : '', cat: tpl.cat || spec.cat || '', spec: spec }); m.close(); global.LS.toast('Шаблон загружен', 'success'); }; var hasContent = self.st.params.length || self.st.objects.length || self.st.plots.length; if (hasContent && !global.confirm('Заменить текущую сцену шаблоном «' + tpl.name + '»?')) return; apply(); }); }); }; /* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */ /* Возвращает массив строк-ошибок (пусто = всё валидно). */ Builder.prototype.validate = function () { var st = this.st, errs = []; if (!trimStr(st.meta.title)) errs.push('Укажите заголовок симуляции.'); // params if (st.params.length > LIMITS.params) errs.push('Слишком много параметров (макс ' + LIMITS.params + ').'); var seen = {}; st.params.forEach(function (p, i) { var nm = trimStr(p.name); if (!nm) { errs.push('Параметр #' + (i + 1) + ': пустое имя.'); return; } if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(nm)) errs.push('Параметр «' + nm + '»: имя должно быть идентификатором (буквы/цифры/_, не с цифры).'); if (RESERVED_PARAM[nm]) errs.push('Имя «' + nm + '» зарезервировано (' + (nm === 'e' ? 'число Эйлера' : 'служебное') + '). Выберите другое.'); if (seen[nm]) errs.push('Дубликат параметра «' + nm + '».'); seen[nm] = true; if (numOr(p.min, 0) > numOr(p.max, 0)) errs.push('Параметр «' + nm + '»: min больше max.'); }); // objects + plots var total = st.objects.length + st.plots.length; if (total > LIMITS.objects) errs.push('Слишком много объектов (макс ' + LIMITS.objects + ').'); // выражения объектов var self = this; function checkExpr(v, where) { if (typeof v !== 'string' || v === '') return; if (v.length > LIMITS.exprLen) errs.push(where + ': выражение длиннее ' + LIMITS.exprLen + ' симв.'); var c = global.SimExpr ? global.SimExpr.compile(v) : { error: null }; if (c.error) errs.push(where + ': ' + c.error); } st.objects.forEach(function (o, i) { exprFieldsOf(o).forEach(function (f) { checkExpr(o[f], 'Объект #' + (i + 1) + ' (' + o.type + '), поле «' + f + '»'); }); }); // выражения графиков: кривые + границы диапазона st.plots.forEach(function (o, i) { (Array.isArray(o.curves) ? o.curves : []).forEach(function (cv, ci) { checkExpr(cv.expr, 'График #' + (i + 1) + ', кривая ' + (ci + 1)); }); checkExpr(typeof o.range_a === 'string' ? o.range_a : '', 'График #' + (i + 1) + ', «от»'); checkExpr(typeof o.range_b === 'string' ? o.range_b : '', 'График #' + (i + 1) + ', «до»'); }); // physics if (st.physics.enabled) { if (st.physics.walls.length > LIMITS.walls) errs.push('Слишком много стен (макс ' + LIMITS.walls + ').'); if (st.physics.springs.length > LIMITS.springs) errs.push('Слишком много пружин (макс ' + LIMITS.springs + ').'); var r = numOr(st.physics.restitution, 0.9); if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.'); } // размер 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: '
' + '
Исправьте перед сохранением:
', 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) ? '' + count + '' : ''; return '
' + '' + '
' + bodyHtml + '
' + '
'; } /* ── Мета ── */ Builder.prototype.sectionMeta = function () { var st = this.st; var catOpts = [''].concat(CATS.map(function (c) { return ''; })).join(''); var body = field('Заголовок', '') + field('Описание', '') + '
' + field('Предмет', '') + field('Класс', '') + '
' + field('Категория', '') + '
' + '
Поле сцены (мировые координаты)
' + '
' + miniField('x от', '') + miniField('x до', '') + miniField('y от', '') + miniField('y до', '') + '
' + '
' + checkbox('vp', 'grid', 'Сетка', st.viewport.grid) + checkbox('vp', 'axes', 'Оси', st.viewport.axes) + checkbox('time', 'autoplay', 'Автозапуск', st.time.autoplay) + checkbox('time', 'loop', 'Зацикл. t', st.time.loop) + '
'; return section('meta', 'Метаданные и сцена', body, this._open.meta); }; /* ── Параметры ── */ Builder.prototype.sectionParams = function () { var rows = this.st.params.map(function (p, i) { return '
' + '
' + '' + '' + '' + '
' + '
' + miniField('min', '') + miniField('max', '') + miniField('шаг', '') + miniField('старт', '') + '
' + '' + '
'; }).join(''); var body = (rows || '
Нет параметров. Добавьте слайдер.
') + ''; 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 ''; }).join(''); var body = (rows || '
Нет объектов. Добавьте фигуру/точку/подпись.
') + '
' + '' + '' + '
'; 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 ''; } if (f.kind === 'color') { // fill/trailColor — очищаемые («нет заливки»); основной color — нет var clearable = (f.key === 'fill' || f.key === 'fillColor' || f.key === 'trailColor'); return colorCtl(f.label, 'data-of="' + f.key + '"', o[f.key], clearable); } if (f.kind === 'text') { return miniField(f.label, ''); } // expr — число или выражение, с проверкой var v = (o[f.key] == null ? '' : o[f.key]); var err = exprError(v); return '
' + '' + '' + (err ? '' + esc(err) + '' : '') + '
'; }).join(''); // ── блок «Стиль» (P4): opacity/lineStyle/pointStyle/glow/gradient ── var style = STYLE_FOR[o.type] ? this.styleBlock(o) : ''; // ── label с LaTeX-превью ── var latexPrev = ''; if (o.type === 'label' && o.text) { latexPrev = '
'; } return '
' + '
' + '' + (TYPE_LABEL[o.type] || o.type) + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '
' + inner + latexPrev + '
' + style + '
'; }; /* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + glow + градиент. Применимость полей зависит от типа (STYLE_FOR[type] = {opacity,line,point,glow,grad}). */ Builder.prototype.styleBlock = function (o) { var cfg = STYLE_FOR[o.type]; var ctrls = []; if (cfg.opacity) ctrls.push(rangeCtl('непрозр.', 'data-of="opacity"', o.opacity, 0, 1, 0.05)); if (cfg.line) ctrls.push(selectCtl('линия', 'data-of="lineStyle"', o.lineStyle || 'solid', LINE_STYLE_OPTS)); if (cfg.point) ctrls.push(selectCtl('стиль точки', 'data-of="pointStyle"', o.pointStyle || 'filled', POINT_STYLE_OPTS)); var grad = ''; if (cfg.grad) { var g = Array.isArray(o.gradient) ? o.gradient : []; var on = (g.length >= 2); grad = '' + ''; } var glow = cfg.glow ? '' : ''; return '
' + '
Стиль
' + (ctrls.length ? '
' + ctrls.join('') + '
' : '') + glow + grad + '
'; }; /* Редактор одного графика (plot): plot-уровневые поля (var/range/trace/fill/marker/legend) + список кривых (curveEditor). */ Builder.prototype.plotEditor = function (o, i) { var hidden = !!o.hidden; var n = this.st.plots.length; var rangeErr = (o.range_a !== '' && o.range_a != null) ? exprError(o.range_a) : (exprError(o.range_b) || ''); var curves = Array.isArray(o.curves) ? o.curves : []; var curveHtml = curves.map(function (cv, ci) { return curveEditor(cv, i, ci, (curves.length > 1)); }).join(''); return '
' + '
' + 'График' + '' + '' + '' + '' + '' + '
' + '
' + curveHtml + '
' + '' + '
' + miniField('перем.', '') + miniField('от', '') + miniField('до', '') + miniField('точек', '') + '
' + '
' + selectCtl('маркеры', 'data-plf="plotMarker"', o.plotMarker || 'none', MARKER_OPTS) + '' + '
' + '
' + '' + '' + '
' + (rangeErr ? 'диапазон: ' + esc(rangeErr) + '' : '') + '
'; }; /* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */ function curveEditor(cv, pi, ci, removable) { var exprErr = exprError(cv.expr); return '
' + '
' + '' + '' + (exprErr ? '' + esc(exprErr) + '' : '') + '
' + '
' + colorCtl('цвет', 'data-cvf="color"', cv.color, true) + miniField('подпись', '') + '
' + '
' + miniField('толщ.', '') + selectCtl('линия', 'data-cvf="lineStyle"', cv.lineStyle || 'solid', LINE_STYLE_OPTS) + selectCtl('маркер', 'data-cvf="marker"', cv.marker || 'none', MARKER_OPTS) + '
' + '
' + rangeCtl('непрозр.', 'data-cvf="opacity"', cv.opacity, 0, 1, 0.05) + '' + (cv.fill ? colorCtl('цвет зал.', 'data-cvf="fillColor"', cv.fillColor, true) : '') + '
' + '
'; } /* ── Графики + Физика ── */ Builder.prototype.sectionPlotsPhysics = function () { var self = this; // plots — каждый график: список кривых + plot-уровневые поля var plotRows = this.st.plots.map(function (o, i) { return self.plotEditor(o, i); }).join(''); var plotsBody = (plotRows || '
Нет графиков. Добавьте график функции — можно несколько кривых.
') + ''; // physics var ph = this.st.physics; var bodyHint = '
Сделать объект физ-телом: добавьте ему поле «масса» (' + ICON.cog + ' в объекте). Тип point/circle.
'; var physBody = '' + '
' + '
' + miniField('гравитация x', '') + miniField('гравитация y', '') + '
' + '
' + miniField('трение', '') + miniField('упругость 0..1', '') + '
' + // walls '
Стены
' + ph.walls.map(function (w, i) { var sideOpts = ['', 'bottom', 'top', 'left', 'right'].map(function (s) { return ''; }).join(''); return '
' + '' + (w.side ? '' : '
' + miniField('x1', '') + miniField('y1', '') + miniField('x2', '') + miniField('y2', '') + '
') + '' + '
'; }).join('') + '' + // springs '
Пружины
' + ph.springs.map(function (s, i) { return '
' + '
' + miniField('конец A', '') + miniField('конец B', '') + '
' + '
' + miniField('k', '') + miniField('длина', '') + miniField('демпф.', '') + '' + '
' + '
'; }).join('') + '' + bodyHint + '
'; 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 '
' + esc(title) + '
' + arr.map(function (n) { return ''; }).join('') + '
'; } var content = '
' + chips('Параметры', params, 'var') + chips('Объекты (id.x / id.y)', objRefs, 'var') + chips('Время / размеры', ['t', 'w', 'h'], 'var') + chips('Константы', names.consts, 'const') + chips('Функции', names.fns, 'fn') + '
'; var m = global.LS.modal({ title: 'Палитра выражений', size: 'md', content: content, actions: [{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }] }); m.body.querySelectorAll('[data-ins]').forEach(function (b) { b.addEventListener('click', function () { var ins = b.getAttribute('data-ins'); var kind = b.getAttribute('data-kind'); var add = (kind === 'fn') ? ins + '()' : ins; insertAtCursor(input, add); // обновить состояние из input input.dispatchEvent(new Event('input', { bubbles: true })); }); }); }; /* ════════════════════════ ХЕЛПЕРЫ ════════════════════════ */ function field(label, inner) { return ''; } function miniField(label, inner) { return ''; } function checkbox(grp, key, label, checked) { return ''; } /* ── Контролы стиля (P4) ────────────────────────────────────────────────── Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая). attr — строка вида 'data-of="color"' (атрибут привязки события). Цвет: нативный (sync) + текст (точное значение / rgba/named) + кнопка очистки (для fill/trailColor «нет заливки»). Текст — источник истины. */ // привести произвольный цвет к #rrggbb для нативного пикера (иначе #000000) function toHexColor(v) { var s = String(v == null ? '' : v).trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); if (/^#[0-9a-fA-F]{3}$/.test(s)) { return ('#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase(); } return '#000000'; } // colorAttr — 'data-of="color"' и т.п.; clearable — показывать кнопку «нет» function colorCtl(label, colorAttr, value, clearable) { var v = (value == null ? '' : value); var hex = toHexColor(v); var clr = clearable ? '' : ''; return ''; } // слайдер 0..1 (opacity) с числовым отображением function rangeCtl(label, attr, value, mn, mx, st) { var num = (value == null || value === '') ? '' : value; var sliderVal = (num === '') ? mx : num; return ''; } // select по списку [{v,l}] function selectCtl(label, attr, value, opts) { var o = opts.map(function (op) { return ''; }).join(''); return ''; } var LINE_STYLE_OPTS = [{ v: 'solid', l: 'сплошная' }, { v: 'dashed', l: 'штрих' }, { v: 'dotted', l: 'точки' }]; var POINT_STYLE_OPTS = [{ v: 'filled', l: 'заполн.' }, { v: 'hollow', l: 'контур' }, { v: 'ring', l: 'кольцо' }, { v: 'cross', l: 'крест' }]; var MARKER_OPTS = [{ v: 'none', l: 'нет' }, { v: 'dot', l: 'точка' }, { v: 'ring', l: 'кольцо' }]; /* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */ function exprError(v) { if (v === '' || v == null) return ''; if (typeof v === 'number') return ''; var n = Number(v); if (!isNaN(n) && String(v).trim() !== '') return ''; // чистое число if (!global.SimExpr) return ''; var c = global.SimExpr.compile(String(v)); return c.error || ''; } function numOr(v, d) { var n = parseFloat(v); return isFinite(n) ? n : d; } function numOr2(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; } function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } function round2(v) { return Math.round(v * 100) / 100; } function trimStr(s) { return String(s == null ? '' : s).trim(); } function insertAtCursor(input, text) { if (!input) return; var start = input.selectionStart, end = input.selectionEnd; if (start == null) { input.value += text; return; } var v = input.value; input.value = v.slice(0, start) + text + v.slice(end); var pos = start + text.length - (text.slice(-1) === ')' ? 1 : 0); try { input.focus(); input.setSelectionRange(pos, pos); } catch (e) {} } /* дефолтный объект каждого типа (с _uid). */ function defaultObject(type) { var base = { _uid: uid('o'), type: type, id: '' }; switch (type) { case 'point': return Object.assign(base, { x: 0, y: 0, r: 6, color: '#06D6E0', trail: false }); case 'circle': return Object.assign(base, { x: 0, y: 0, r: 1, color: '#9B5DE5', fill: '', width: 2 }); case 'rect': return Object.assign(base, { x: 0, y: 0, w: 2, h: 1, color: '#9B5DE5', fill: '', width: 2 }); case 'segment': return Object.assign(base, { x1: 0, y1: 0, x2: 5, y2: 5, color: '#ffffff', width: 2 }); case 'vector': return Object.assign(base, { x1: 0, y1: 0, x2: 3, y2: 2, color: '#F15BB5', width: 2 }); case 'polyline': return Object.assign(base, { points: '[[0,0],[2,2],[4,0]]', color: '#06D6E0', width: 2, closed: false }); case 'path': return Object.assign(base, { points: '[[0,0],[2,2],[4,0]]', color: '#06D6E0', width: 2, closed: false }); case 'label': return Object.assign(base, { x: 0, y: 0, text: 'A', latex: true, color: '#ffffff', size: 14 }); case 'readout': return Object.assign(base, { label: 'R', expr: '0', unit: '', precision: 2, x: '', y: '', color: '#06D6E0' }); default: return Object.assign(base, { x: 0, y: 0 }); } } /* поля-выражения объекта (для валидации). polyline.points — массив, не выражение. */ function exprFieldsOf(o) { switch (o.type) { case 'point': return ['x', 'y', 'r']; case 'circle': return ['x', 'y', 'r', 'width']; case 'rect': return ['x', 'y', 'w', 'h', 'width']; case 'segment': return ['x1', 'y1', 'x2', 'y2', 'width']; case 'vector': return ['x1', 'y1', 'x2', 'y2', 'width']; case 'label': return ['x', 'y', 'size']; case 'readout': return ['expr', 'x', 'y']; case 'plot': return ['expr']; default: return []; } } /* поля редактора по типу: kind = expr | text | color | check */ var OBJ_FIELDS = { point: [{ key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'r', label: 'радиус (px)', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'trail', label: 'След', kind: 'check' }, { key: 'trailColor', label: 'цвет следа', kind: 'color' }], circle: [{ key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'r', label: 'радиус', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'fill', label: 'заливка', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }], rect: [{ key: 'x', label: 'x (центр)', kind: 'expr' }, { key: 'y', label: 'y (центр)', kind: 'expr' }, { key: 'w', label: 'ширина', kind: 'expr' }, { key: 'h', label: 'высота', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'fill', label: 'заливка', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }], segment: [{ key: 'x1', label: 'x1', kind: 'expr' }, { key: 'y1', label: 'y1', kind: 'expr' }, { key: 'x2', label: 'x2', kind: 'expr' }, { key: 'y2', label: 'y2', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }], vector: [{ key: 'x1', label: 'x1', kind: 'expr' }, { key: 'y1', label: 'y1', kind: 'expr' }, { key: 'x2', label: 'x2', kind: 'expr' }, { key: 'y2', label: 'y2', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }], polyline:[{ key: 'points', label: 'точки [[x,y],…]', kind: 'text', ph: '[[0,0],[2,2]]' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }, { key: 'closed', label: 'Замкнуть', kind: 'check' }], path: [{ key: 'points', label: 'точки [[x,y],…]', kind: 'text', ph: '[[0,0],[2,2]]' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'width', label: 'толщина', kind: 'expr' }, { key: 'closed', label: 'Замкнуть', kind: 'check' }], label: [{ key: 'text', label: 'текст (LaTeX)', kind: 'text', ph: '\\\\vec{v}' }, { key: 'x', label: 'x', kind: 'expr' }, { key: 'y', label: 'y', kind: 'expr' }, { key: 'size', label: 'размер', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }, { key: 'latex', label: 'LaTeX', kind: 'check' }], readout: [{ key: 'label', label: 'подпись', kind: 'text', ph: 'R' }, { key: 'expr', label: 'выражение', kind: 'expr' }, { key: 'unit', label: 'ед.', kind: 'text' }, { key: 'precision', label: 'знаков', kind: 'expr' }, { key: 'x', label: 'x (опц.)', kind: 'expr' }, { key: 'y', label: 'y (опц.)', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }] }; /* Какие style-контролы (P4) показывать у типа. opacity/glow — почти у всех рисуемых; line (lineStyle) — у линий/контуров; point (pointStyle) — только point; grad (gradient-заливка) — у circle/rect (есть заливка). */ var STYLE_FOR = { point: { opacity: true, glow: true, point: true }, segment: { opacity: true, glow: true, line: true }, vector: { opacity: true, glow: true, line: true }, circle: { opacity: true, glow: true, line: true, grad: true }, rect: { opacity: true, glow: true, line: true, grad: true }, polyline: { opacity: true, glow: true, line: true }, path: { opacity: true, glow: true, line: true } }; var TYPE_LABEL = { point: 'Точка', segment: 'Отрезок', vector: 'Вектор', circle: 'Окружность', rect: 'Прямоугольник', polyline: 'Ломаная', path: 'Путь', label: 'Подпись', plot: 'График', readout: 'Показатель' }; var CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игра' }; var WALL_LABEL = { bottom: 'Низ', top: 'Верх', left: 'Лево', right: 'Право' }; /* inline SVG-иконки (.ic-стиля; без эмодзи) */ var ICON = { play: '', reset: '', save: '', send: '', plus: '', trash: '', chev: '', target: '', cog: '', template: '', unpublish: '', up: '', down: '', copy: '', eye: '', eyeOff: '', clearX: '', undo: '', redo: '', grid: '' }; /* ── Встроенные шаблоны стартовых спек (Фаза 6) ────────────────────────── Данные, не код. Каждый — полноценная валидная спека v1: «Создать из шаблона» загружает её через loadFromSim как новую симуляцию. */ var TEMPLATES = [ { name: 'Пустая сцена', cat: 'phys', desc: 'Чистый холст с осями и сеткой — начать с нуля.', spec: { specVersion: 1, meta: { title: 'Новая симуляция', desc: '' }, viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true }, time: { autoplay: false, loop: true, speed: 1 }, params: [], objects: [] } }, { name: 'Математический маятник', cat: 'phys', desc: 'Груз на нити: угол колеблется по гармоническому закону.', spec: { specVersion: 1, meta: { title: 'Маятник', desc: 'Колебания груза на нити' }, viewport: { xmin: -3, xmax: 3, ymin: -3.4, ymax: 0.6, grid: true, axes: true }, time: { autoplay: true, loop: true, speed: 1 }, params: [ { name: 'L', label: 'Длина нити', min: 0.5, max: 3, step: 0.1, value: 2.4, unit: 'м' }, { name: 'A', label: 'Амплитуда', min: 0.1, max: 1, step: 0.05, value: 0.5, unit: 'рад' } ], objects: [ { type: 'segment', x1: 0, y1: 0, x2: 'L*sin(A*cos(2.2*t))', y2: '-L*cos(A*cos(2.2*t))', color: '#94a3b8', width: 2 }, { id: 'bob', type: 'circle', x: 'L*sin(A*cos(2.2*t))', y: '-L*cos(A*cos(2.2*t))', r: 0.18, color: '#9B5DE5' } ] } }, { name: 'График y = f(x)', cat: 'math', desc: 'Параметрический график функции с настраиваемыми коэффициентами.', spec: { specVersion: 1, meta: { title: 'График функции', desc: 'y = a*sin(b*x)' }, viewport: { xmin: -6.5, xmax: 6.5, ymin: -3.5, ymax: 3.5, grid: true, axes: true }, time: { autoplay: false, loop: true, speed: 1 }, params: [ { name: 'a', label: 'Амплитуда a', min: -3, max: 3, step: 0.1, value: 2 }, { name: 'b', label: 'Частота b', min: 0.2, max: 4, step: 0.1, value: 1 } ], objects: [ { type: 'plot', expr: 'a*sin(b*x)', var: 'x', range: [-6.5, 6.5], samples: 200, color: '#06D6E0', width: 2 } ] } }, { name: 'Бросок тела', cat: 'phys', desc: 'Траектория тела под углом к горизонту (кинематика).', spec: { specVersion: 1, meta: { title: 'Бросок тела', desc: 'Движение в поле тяжести' }, viewport: { xmin: -1, xmax: 22, ymin: -1, ymax: 12, grid: true, axes: true }, time: { autoplay: true, loop: true, speed: 1 }, params: [ { name: 'v', label: 'Скорость', min: 5, max: 25, step: 0.5, value: 16, unit: 'м/с' }, { name: 'ang', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: 'град' } ], objects: [ { id: 'b', type: 'circle', x: 'v*cos(ang*pi/180)*t', y: 'v*sin(ang*pi/180)*t - 4.9*t*t', r: 0.25, color: '#9B5DE5' }, { type: 'plot', expr: '(x*tan(ang*pi/180)) - (4.9*x*x)/((v*cos(ang*pi/180))^2)', var: 'x', range: [0, 22], samples: 150, color: 'rgba(6,214,224,0.5)', width: 1.5 } ] } } ]; /* plot-объект сериализуется особым путём (range_a/range_b -> range, без UI-полей); все прочие типы — через _stripObjOrig (удаление _uid и пустых полей). */ var _stripObjOrig = stripObj; stripObj = function (o) { if (o && o.type === 'plot') return normalizePlotForSpec(o); return _stripObjOrig(o); }; global.SimBuilder = SimBuilder; })(typeof window !== 'undefined' ? window : globalThis);