'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; 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; } /* ════════════════════════ ПУБЛИЧНЫЙ 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) { var clone = Object.assign({ _uid: uid('o') }, o); if (o.type === 'plot') { // Восстановить UI-поля диапазона из spec range[a,b], иначе при пересохранении // normalizePlotForSpec не увидит range_a/range_b и диапазон молча потеряется. if (Array.isArray(o.range)) { clone.range_a = o.range[0]; clone.range_b = o.range[1]; } delete clone.range; st.plots.push(clone); } else st.objects.push(clone); }); // 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.renderToolbar(); this.renderPanels(); this.scheduleRemount(true); }; /* Сборка чистой JSON-спеки v1 из состояния (для движка / сохранения). */ Builder.prototype.buildSpec = function () { var st = this.st; var objects = []; // обычные объекты st.objects.forEach(function (o) { objects.push(stripObj(o)); }); // plot-объекты st.plots.forEach(function (o) { 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 и пустых полей) из объекта спеки ── */ 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; 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: клик по сцене ставит x/y выбранного объекта в мир-коорд. Перетаскивание двигает его. Работает только когда выбран объект и движок не запущен (иначе мешает встроенному drag/анимации движка). */ Builder.prototype.bindPreviewDrag = function () { var self = this; if (!this.inst || !this.inst.canvas) return; var canvas = this.inst.canvas; var dragging = false; 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; // конвертация px->мир через геометрию движка (_toWorld учитывает scale/offset) if (typeof self.inst._toWorld === 'function') return self.inst._toWorld(px, py); return null; } function applyTo(obj, w) { if (!obj || !w) return; var x = round2(w[0]), y = round2(w[1]); // point/circle/label/readout -> x,y ; rect -> x,y (центр) if ('x' in obj || ['point', 'circle', 'label', 'readout', 'rect'].indexOf(obj.type) !== -1) { obj.x = x; obj.y = y; } else if (obj.type === 'segment' || obj.type === 'vector') { obj.x2 = x; obj.y2 = y; // двигаем конец } self.refreshObjFields(obj._uid); self.scheduleRemount(false); } 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; dragging = true; try { canvas.setPointerCapture(ev.pointerId); } catch (e) {} applyTo(obj, worldAt(ev)); ev.preventDefault(); }); canvas.addEventListener('pointermove', function (ev) { if (!dragging) return; applyTo(objSel(), worldAt(ev)); }); function end() { dragging = false; } 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' ? 'Опубликовано' : 'Черновик'; t.innerHTML = '
' + '' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '' + statusBadge + '
' + '
' + '' + '' + '' + '' + '
'; t.querySelectorAll('[data-a]').forEach(function (b) { b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); }); }); }; 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; } }; /* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */ /* Возвращает массив строк-ошибок (пусто = всё валидно). */ 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; st.objects.concat(st.plots).forEach(function (o, i) { exprFieldsOf(o).forEach(function (f) { var v = o[f]; if (typeof v !== 'string' || v === '') return; if (v.length > LIMITS.exprLen) errs.push('Объект #' + (i + 1) + ': выражение «' + f + '» длиннее ' + LIMITS.exprLen + ' симв.'); var c = global.SimExpr ? global.SimExpr.compile(v) : { error: null }; if (c.error) errs.push('Объект #' + (i + 1) + ' (' + o.type + '), поле «' + f + '»: ' + c.error); }); }); // 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 fields = OBJ_FIELDS[o.type] || []; var inner = fields.map(function (f) { if (f.kind === 'check') { return ''; } if (f.kind === 'color') { return miniField(f.label, ''); } 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(''); // ── label с LaTeX-превью ── var latexPrev = ''; if (o.type === 'label' && o.text) { latexPrev = '
'; } return '
' + '
' + '' + (TYPE_LABEL[o.type] || o.type) + '' + '' + '' + '' + '
' + '
' + inner + latexPrev + '
' + '
'; }; /* ── Графики + Физика ── */ Builder.prototype.sectionPlotsPhysics = function () { var self = this; // plots var plotRows = this.st.plots.map(function (o, i) { var exprErr = exprError(o.expr); var rangeErr = (o.range_a !== '' && o.range_a != null) ? exprError(o.range_a) : (exprError(o.range_b) || ''); return '
' + '
' + 'График' + '' + '
' + '
' + '' + '' + (exprErr ? '' + esc(exprErr) + '' : '') + '
' + '
' + miniField('перем.', '') + miniField('от', '') + miniField('до', '') + miniField('цвет', '') + '
' + '' + (rangeErr ? 'диапазон: ' + esc(rangeErr) + '' : '') + '
'; }).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; // аккордеоны 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 () { 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 () { 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 () { 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 () { 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.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 () { var k = el.getAttribute('data-of'); if (el.type === 'checkbox') self.st.objects[i][k] = el.checked; else self.st.objects[i][k] = el.value; // обновить inline-ошибку выражения и LaTeX-превью без полного рендера self.updateFieldFeedback(el, self.st.objects[i]); self.scheduleRemount(false); }); }); }); p.querySelectorAll('[data-odel]').forEach(function (b) { b.addEventListener('click', function () { 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); }); }); 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); row.querySelectorAll('[data-plf]').forEach(function (el) { var evt = el.type === 'checkbox' ? 'change' : 'input'; el.addEventListener(evt, function () { 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); }); }); }); p.querySelectorAll('[data-pltdel]').forEach(function (b) { b.addEventListener('click', function () { self.st.plots.splice(parseInt(b.getAttribute('data-pltdel'), 10), 1); self.renderPanels(); self.scheduleRemount(false); }); }); p.querySelectorAll('[data-pltfx]').forEach(function (b) { b.addEventListener('click', function () { var input = b.closest('.sbu-of').querySelector('[data-plf]'); self.openPalette(input); }); }); // physics var phEnabled = p.querySelector('[data-phys="enabled"]'); if (phEnabled) phEnabled.addEventListener('change', function () { self.st.physics.enabled = phEnabled.checked; self.renderPanels(); self.scheduleRemount(false); }); p.querySelectorAll('[data-phf]').forEach(function (el) { el.addEventListener('input', function () { 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 () { 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.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 () { 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.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(); }; /* Привести 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.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.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.st.plots.push({ _uid: uid('plt'), type: 'plot', expr: 'sin(x)', var: 'x', range_a: '', range_b: '', color: '#F15BB5', trace: false }); } else if (what === 'wall') { if (this.st.physics.walls.length >= LIMITS.walls) { global.LS.toast('Достигнут лимит стен', 'warn'); return; } 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.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 }); } this.renderPanels(); this.scheduleRemount(false); }; /* Перед сборкой spec plot-объект нужно «материализовать»: range + убрать UI-поля. */ function normalizePlotForSpec(o) { var out = { type: 'plot', expr: o.expr == null ? '' : o.expr, var: o.var || 'x' }; if (o.color) out.color = o.color; if (o.trace) out.trace = true; var a = o.range_a, b = o.range_b; if (!((a === '' || a == null) && (b === '' || b == null))) { out.range = [parseRangeVal(a), parseRangeVal(b)]; } 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; ['x', 'y', 'x2', 'y2'].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 ''; } /* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */ 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' }] }; 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: '' }; /* 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);