Files
Learn_System/frontend/js/sim-builder.js

1284 lines
72 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimBuilder — учительский редактор спек-симуляций (Фаза 4 SimForge).
Собирает JSON-спеку v1 (данные, не код) из форм-панелей и монтирует
живое превью через window.SimEngine.mount(host, spec). Любое числовое
свойство объекта принимает число ИЛИ строку-выражение; выражения
проверяются через window.SimExpr.compile (без eval/Function). Save/Load
через LS.customSim* (Фаза 3). Доступ — только teacher/admin (гейт в html).
Раскладка: левая колонка — панели-аккордеоны (Мета / Параметры / Объекты /
Графики·Физика); центр — превью + тулбар; перемонтаж движка с debounce при
любой правке. Drag-on-preview: клик/перетаскивание ставит x/y выбранного
объекта в мировых координатах (через inst._toWorld).
ВАЖНО: Без эмодзи (только inline SVG). ВАЖНО: Без eval/new Function. Vanilla JS.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* ── Лимиты (зеркалят серверную validateSpec, Фаза 3) ── */
var LIMITS = {
params: 50, objects: 200, walls: 20, springs: 50, plots: 50,
exprLen: 500, points: 1000, jsonBytes: 200 * 1024
};
var SPEC_VERSION = 1;
var OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout'];
var CATS = ['math', 'phys', 'chem', 'bio', 'game'];
// ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера)
var RESERVED_PARAM = { e: true, E: true, pi: true, PI: true, t: true, w: true, h: true, tau: true };
/* ── Палитра имён функций/констант (из SimExpr) для подсказок ── */
function exprNames() {
var fns = [], consts = [];
if (global.SimExpr) {
Object.keys(global.SimExpr.FUNCTIONS || {}).forEach(function (k) { fns.push(k); });
Object.keys(global.SimExpr.CONSTANTS || {}).forEach(function (k) { consts.push(k); });
}
fns.sort(); consts.sort();
return { fns: fns, consts: consts };
}
/* ── escape для безопасной вставки в HTML-разметку ── */
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function uid(prefix) {
return (prefix || 'o') + Math.random().toString(36).slice(2, 7) + (SimBuilder._seq++);
}
/* ════════════════════════════════════════════════════════════════════════
SimBuilder — модель состояния редактора + рендер панелей.
════════════════════════════════════════════════════════════════════════ */
var SimBuilder = {
_seq: 0,
create: function (opts) {
return new Builder(opts || {});
}
};
/* ── шаблон стартовой спеки (чистый лист) ── */
function blankState() {
return {
meta: { title: '', desc: '' },
subject: '',
grade: '',
cat: '',
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
time: { autoplay: false, loop: true, speed: 1 },
params: [],
objects: [],
plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] }
};
}
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 = '<div style="padding:40px;color:#ef4444;font-size:.85rem">Ошибка сборки превью: ' + esc(e.message || e) + '</div>';
}
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'
? '<span class="sbu-badge sbu-badge-pub">Опубликовано</span>'
: '<span class="sbu-badge">Черновик</span>';
// Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать».
var pubBtn = this.status === 'published'
? '<button class="btn-ghost sbu-tb-btn" data-a="unpublish" title="Вернуть в черновик">' + ICON.unpublish + ' Снять</button>'
: '<button class="btn-primary sbu-tb-btn" data-a="publish">' + ICON.send + ' Опубликовать</button>';
// «Раздать классу» доступна только для уже сохранённой симуляции.
var shareBtn = this.simId
? '<button class="btn-ghost sbu-tb-btn" data-a="share" title="Раздать классу">' + ICON.send + ' Раздать</button>'
: '';
t.innerHTML =
'<div class="sbu-tb-left">' +
'<span class="sbu-tb-title">' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '</span>' +
statusBadge +
'</div>' +
'<div class="sbu-tb-right">' +
'<button class="btn-ghost sbu-tb-btn" data-a="template" title="Создать из шаблона">' + ICON.template + ' Шаблон</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="test" title="Запустить превью">' + ICON.play + ' Тест</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="reset" title="Сброс превью">' + ICON.reset + ' Сброс</button>' +
'<button class="btn-ghost sbu-tb-btn" data-a="save">' + ICON.save + ' Сохранить</button>' +
shareBtn +
pubBtn +
'</div>';
t.querySelectorAll('[data-a]').forEach(function (b) {
b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); });
});
};
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; }
};
/* Изменить статус публикации уже сохранённой симуляции (PUT status). */
Builder.prototype.setStatus = function (status) {
var self = this;
if (!this.simId) { this.save(status === 'published'); return; }
global.LS.customSimUpdate(this.simId, { status: status }).then(function () {
self.status = status;
self.renderToolbar();
global.LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка', 'error');
});
};
/* Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует +
уведомляет учеников со ссылкой /lab?sim=custom:<id>). */
Builder.prototype.openShareModal = function () {
var self = this;
if (!this.simId) { global.LS.toast('Сначала сохраните симуляцию', 'warn'); return; }
global.LS.getClasses().then(function (classes) {
if (!Array.isArray(classes) || !classes.length) { global.LS.toast('Нет классов для раздачи', 'warn'); return; }
var opts = classes.map(function (c) {
return '<option value="' + esc(c.id) + '">' + esc(c.name) + '</option>';
}).join('');
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
'<select id="sbu-share-class" class="sbu-in">' + opts + '</select>' +
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
'</div>';
var m = global.LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
{ label: 'Отмена', onClick: function () { m.close(); } },
{ label: 'Раздать', primary: true, onClick: function () {
var sel = m.body.querySelector('#sbu-share-class');
var classId = sel ? Number(sel.value) : NaN;
global.LS.customSimShare(self.simId, { classId: classId }).then(function (r) {
m.close();
self.status = 'published';
self.renderToolbar();
global.LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
});
} }
] });
}).catch(function () { global.LS.toast('Не удалось загрузить классы', 'error'); });
};
/* Старт из шаблона: выбор готовой спеки -> загрузка в редактор как НОВАЯ
симуляция (simId сбрасывается, чтобы первое «Сохранить» создало запись). */
Builder.prototype.openTemplateModal = function () {
var self = this;
var cards = TEMPLATES.map(function (tpl, i) {
return '<button type="button" data-tpl="' + i + '" style="text-align:left;display:flex;flex-direction:column;gap:4px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#fff;cursor:pointer">' +
'<span style="font-weight:800;font-size:.84rem;color:var(--text)">' + esc(tpl.name) + '</span>' +
'<span style="font-size:.74rem;color:var(--text-3)">' + esc(tpl.desc) + '</span>' +
'</button>';
}).join('');
var content = '<div style="display:flex;flex-direction:column;gap:8px">' + cards +
'<div style="font-size:.74rem;color:var(--text-3);margin-top:4px">Шаблон заменит текущую сцену и создаст новую симуляцию.</div></div>';
var m = global.LS.modal({ title: 'Создать из шаблона', content: content, size: 'sm', actions: [
{ label: 'Закрыть', onClick: function () { m.close(); } }
] });
m.body.querySelectorAll('[data-tpl]').forEach(function (b) {
b.addEventListener('click', function () {
var tpl = TEMPLATES[Number(b.getAttribute('data-tpl'))];
if (!tpl) return;
var apply = function () {
self.simId = null;
try { global.history.replaceState({}, '', '/sim-builder'); } catch (e) {}
// loadFromSim ждёт sim-объект; собираем синтетический из спеки шаблона.
var spec = JSON.parse(JSON.stringify(tpl.spec));
self.loadFromSim({
id: null, status: 'draft', version: 1,
title: (spec.meta && spec.meta.title) || tpl.name,
description: (spec.meta && spec.meta.desc) || '',
subject: spec.subject || '', grade: spec.grade != null ? spec.grade : '',
cat: tpl.cat || spec.cat || '', spec: spec
});
m.close();
global.LS.toast('Шаблон загружен', 'success');
};
var hasContent = self.st.params.length || self.st.objects.length || self.st.plots.length;
if (hasContent && !global.confirm('Заменить текущую сцену шаблоном «' + tpl.name + '»?')) return;
apply();
});
});
};
/* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */
/* Возвращает массив строк-ошибок (пусто = всё валидно). */
Builder.prototype.validate = function () {
var st = this.st, errs = [];
if (!trimStr(st.meta.title)) errs.push('Укажите заголовок симуляции.');
// params
if (st.params.length > LIMITS.params) errs.push('Слишком много параметров (макс ' + LIMITS.params + ').');
var seen = {};
st.params.forEach(function (p, i) {
var nm = trimStr(p.name);
if (!nm) { errs.push('Параметр #' + (i + 1) + ': пустое имя.'); return; }
if (!/^[a-zA-Z_][a-zA-Z_0-9]*$/.test(nm)) errs.push('Параметр «' + nm + '»: имя должно быть идентификатором (буквы/цифры/_, не с цифры).');
if (RESERVED_PARAM[nm]) errs.push('Имя «' + nm + '» зарезервировано (' + (nm === 'e' ? 'число Эйлера' : 'служебное') + '). Выберите другое.');
if (seen[nm]) errs.push('Дубликат параметра «' + nm + '».');
seen[nm] = true;
if (numOr(p.min, 0) > numOr(p.max, 0)) errs.push('Параметр «' + nm + '»: min больше max.');
});
// objects + plots
var total = st.objects.length + st.plots.length;
if (total > LIMITS.objects) errs.push('Слишком много объектов (макс ' + LIMITS.objects + ').');
// выражения (объекты + графики)
var self = this;
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: '<div style="display:flex;flex-direction:column;gap:8px;font-size:.85rem;color:var(--text-2)">' +
'<div>Исправьте перед сохранением:</div><ul style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:5px">' +
errs.slice(0, 12).map(function (e) { return '<li>' + esc(e) + '</li>'; }).join('') +
(errs.length > 12 ? '<li>…и ещё ' + (errs.length - 12) + '</li>' : '') +
'</ul></div>',
actions: [{ label: 'Понятно', primary: true, onClick: function () { this.close(); } }]
});
return;
}
var spec = this.buildSpec();
var meta = {
title: trimStr(this.st.meta.title),
description: trimStr(this.st.meta.desc),
subject: trimStr(this.st.subject) || null,
grade: (this.st.grade === '' || this.st.grade == null) ? null : parseInt(this.st.grade, 10),
cat: this.st.cat || null,
spec: spec
};
if (publish) meta.status = 'published';
var p;
if (this.simId) {
p = global.LS.customSimUpdate(this.simId, meta);
} else {
if (publish) meta.status = 'published';
p = global.LS.customSimCreate(meta);
}
p.then(function (res) {
if (!self.simId && res && res.id) {
self.simId = res.id;
// обновить URL, чтобы повторное «Сохранить» делало update, а reload грузил эту симуляцию
try { global.history.replaceState({}, '', '/sim-builder?id=' + res.id); } catch (e) {}
}
if (publish) self.status = 'published';
else if (self.status !== 'published') self.status = 'draft';
self.renderToolbar();
global.LS.toast(publish ? 'Опубликовано' : 'Сохранено', 'success');
}).catch(function (e) {
global.LS.toast((e && e.message) || 'Ошибка сохранения', 'error');
});
};
/* ════════════════════════ РЕНДЕР ПАНЕЛЕЙ ════════════════════════ */
Builder.prototype.renderPanels = function () {
var p = this.panelHost;
if (!p) return;
p.innerHTML =
this.sectionMeta() +
this.sectionParams() +
this.sectionObjects() +
this.sectionPlotsPhysics();
this.wirePanels();
};
/* ── секция-аккордеон ── */
function section(key, title, bodyHtml, open, count) {
var cnt = (count != null) ? '<span class="sbu-sec-count">' + count + '</span>' : '';
return '<div class="sbu-sec' + (open ? ' open' : '') + '" data-sec="' + key + '">' +
'<button class="sbu-sec-hdr" data-sec-toggle="' + key + '">' +
'<span class="sbu-sec-title">' + esc(title) + '</span>' + cnt +
'<span class="sbu-sec-chev">' + ICON.chev + '</span>' +
'</button>' +
'<div class="sbu-sec-body">' + bodyHtml + '</div>' +
'</div>';
}
/* ── Мета ── */
Builder.prototype.sectionMeta = function () {
var st = this.st;
var catOpts = ['<option value="">— нет —</option>'].concat(CATS.map(function (c) {
return '<option value="' + c + '"' + (st.cat === c ? ' selected' : '') + '>' + CAT_LABEL[c] + '</option>';
})).join('');
var body =
field('Заголовок', '<input class="sbu-in" data-meta="title" value="' + esc(st.meta.title) + '" placeholder="Бросок тела под углом" />') +
field('Описание', '<textarea class="sbu-in" data-meta="desc" rows="2" placeholder="Краткое описание">' + esc(st.meta.desc) + '</textarea>') +
'<div class="sbu-row2">' +
field('Предмет', '<input class="sbu-in" data-meta="subject" value="' + esc(st.subject) + '" placeholder="Физика" />') +
field('Класс', '<input class="sbu-in" type="number" min="1" max="11" data-meta="grade" value="' + esc(st.grade) + '" placeholder="9" />') +
'</div>' +
field('Категория', '<select class="sbu-in" data-meta="cat">' + catOpts + '</select>') +
'<div class="sbu-divider"></div>' +
'<div class="sbu-sub">Поле сцены (мировые координаты)</div>' +
'<div class="sbu-row4">' +
miniField('x от', '<input class="sbu-in" type="number" data-vp="xmin" value="' + esc(st.viewport.xmin) + '" />') +
miniField('x до', '<input class="sbu-in" type="number" data-vp="xmax" value="' + esc(st.viewport.xmax) + '" />') +
miniField('y от', '<input class="sbu-in" type="number" data-vp="ymin" value="' + esc(st.viewport.ymin) + '" />') +
miniField('y до', '<input class="sbu-in" type="number" data-vp="ymax" value="' + esc(st.viewport.ymax) + '" />') +
'</div>' +
'<div class="sbu-checks">' +
checkbox('vp', 'grid', 'Сетка', st.viewport.grid) +
checkbox('vp', 'axes', 'Оси', st.viewport.axes) +
checkbox('time', 'autoplay', 'Автозапуск', st.time.autoplay) +
checkbox('time', 'loop', 'Зацикл. t', st.time.loop) +
'</div>';
return section('meta', 'Метаданные и сцена', body, this._open.meta);
};
/* ── Параметры ── */
Builder.prototype.sectionParams = function () {
var rows = this.st.params.map(function (p, i) {
return '<div class="sbu-param" data-pi="' + i + '">' +
'<div class="sbu-param-top">' +
'<input class="sbu-in sbu-in-sm" data-pf="name" value="' + esc(p.name) + '" placeholder="имя (v)" title="Имя переменной (исп. в выражениях)" />' +
'<input class="sbu-in sbu-in-sm" data-pf="label" value="' + esc(p.label) + '" placeholder="подпись" />' +
'<button class="sbu-icon-btn sbu-del" data-pdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-row4">' +
miniField('min', '<input class="sbu-in" type="number" data-pf="min" value="' + esc(p.min) + '" />') +
miniField('max', '<input class="sbu-in" type="number" data-pf="max" value="' + esc(p.max) + '" />') +
miniField('шаг', '<input class="sbu-in" type="number" data-pf="step" value="' + esc(p.step) + '" />') +
miniField('старт', '<input class="sbu-in" type="number" data-pf="value" value="' + esc(p.value) + '" />') +
'</div>' +
'<input class="sbu-in sbu-in-sm" data-pf="unit" value="' + esc(p.unit) + '" placeholder="ед. изм. (м/с)" />' +
'</div>';
}).join('');
var body = (rows || '<div class="sbu-empty-sm">Нет параметров. Добавьте слайдер.</div>') +
'<button class="sbu-add" data-add="param">' + ICON.plus + ' Параметр</button>';
return section('params', 'Параметры (слайдеры)', body, this._open.params, this.st.params.length);
};
/* ── Объекты ── */
Builder.prototype.sectionObjects = function () {
var self = this;
var rows = this.st.objects.map(function (o, i) { return self.objectEditor(o, i); }).join('');
var typeOpts = OBJECT_TYPES.filter(function (t) { return t !== 'plot'; })
.map(function (t) { return '<option value="' + t + '">' + TYPE_LABEL[t] + '</option>'; }).join('');
var body = (rows || '<div class="sbu-empty-sm">Нет объектов. Добавьте фигуру/точку/подпись.</div>') +
'<div class="sbu-add-row">' +
'<select class="sbu-in sbu-in-sm" id="sbu-newtype">' + typeOpts + '</select>' +
'<button class="sbu-add" data-add="object">' + ICON.plus + ' Объект</button>' +
'</div>';
return section('objects', 'Объекты', body, this._open.objects, this.st.objects.length);
};
/* Редактор одного объекта: поля зависят от типа. */
Builder.prototype.objectEditor = function (o, i) {
var selected = (this._selObjId === o._uid);
var fields = OBJ_FIELDS[o.type] || [];
var inner = fields.map(function (f) {
if (f.kind === 'check') {
return '<label class="sbu-of-check"><input type="checkbox" data-of="' + f.key + '"' + (o[f.key] ? ' checked' : '') + '/> ' + esc(f.label) + '</label>';
}
if (f.kind === 'color') {
return miniField(f.label, '<input class="sbu-in sbu-in-color" type="text" data-of="' + f.key + '" value="' + esc(o[f.key] == null ? '' : o[f.key]) + '" placeholder="#06D6E0" />');
}
if (f.kind === 'text') {
return miniField(f.label, '<input class="sbu-in" data-of="' + f.key + '" value="' + esc(o[f.key] == null ? '' : o[f.key]) + '" placeholder="' + esc(f.ph || '') + '" />');
}
// expr — число или выражение, с проверкой
var v = (o[f.key] == null ? '' : o[f.key]);
var err = exprError(v);
return '<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">' + esc(f.label) +
'<button class="sbu-fx" data-fx="' + f.key + '" title="Палитра функций/параметров">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-of="' + f.key + '" value="' + esc(v) + '" placeholder="' + esc(f.ph || 'число или выражение') + '" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>';
}).join('');
// ── label с LaTeX-превью ──
var latexPrev = '';
if (o.type === 'label' && o.text) {
latexPrev = '<div class="sbu-latex" data-latex="' + esc(o.text) + '"></div>';
}
return '<div class="sbu-obj' + (selected ? ' sel' : '') + '" data-oi="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">' + (TYPE_LABEL[o.type] || o.type) + '</span>' +
'<input class="sbu-in sbu-in-id" data-of="id" value="' + esc(o.id == null ? '' : o.id) + '" placeholder="id" title="Идентификатор (для ссылок obj.x/obj.y)" />' +
'<button class="sbu-icon-btn sbu-place" data-place="' + o._uid + '" title="Поставить/двигать на сцене">' + ICON.target + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-odel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-obj-fields">' + inner + latexPrev + '</div>' +
'</div>';
};
/* ── Графики + Физика ── */
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 '<div class="sbu-plot" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-of' + (exprErr ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">f(' + esc(o.var || 'x') + ')<button class="sbu-fx" data-pltfx="expr:' + i + '">fx</button></label>' +
'<input class="sbu-in sbu-in-expr" data-plf="expr" value="' + esc(o.expr == null ? '' : o.expr) + '" placeholder="sin(x)" />' +
(exprErr ? '<span class="sbu-of-err">' + esc(exprErr) + '</span>' : '') +
'</div>' +
'<div class="sbu-row4">' +
miniField('перем.', '<input class="sbu-in" data-plf="var" value="' + esc(o.var == null ? 'x' : o.var) + '" placeholder="x" />') +
miniField('от', '<input class="sbu-in" data-plf="range_a" value="' + esc(o.range_a == null ? '' : o.range_a) + '" placeholder="xmin" />') +
miniField('до', '<input class="sbu-in" data-plf="range_b" value="' + esc(o.range_b == null ? '' : o.range_b) + '" placeholder="xmax" />') +
miniField('цвет', '<input class="sbu-in sbu-in-color" data-plf="color" value="' + esc(o.color == null ? '' : o.color) + '" placeholder="#F15BB5" />') +
'</div>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + (o.trace ? ' checked' : '') + '/> След по времени (trace)</label>' +
(rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc(rangeErr) + '</span>' : '') +
'</div>';
}).join('');
var plotsBody = (plotRows || '<div class="sbu-empty-sm">Нет графиков.</div>') +
'<button class="sbu-add" data-add="plot">' + ICON.plus + ' График</button>';
// physics
var ph = this.st.physics;
var bodyHint = '<div class="sbu-sub" style="margin-top:6px">Сделать объект физ-телом: добавьте ему поле «масса» (' + ICON.cog + ' в объекте). Тип point/circle.</div>';
var physBody =
'<label class="sbu-of-check sbu-phys-toggle"><input type="checkbox" data-phys="enabled"' + (ph.enabled ? ' checked' : '') + '/> Включить физику</label>' +
'<div class="sbu-phys-fields"' + (ph.enabled ? '' : ' style="opacity:.45;pointer-events:none"') + '>' +
'<div class="sbu-row2">' +
miniField('гравитация x', '<input class="sbu-in" type="number" data-phf="gx" value="' + esc(ph.gx) + '" />') +
miniField('гравитация y', '<input class="sbu-in" type="number" data-phf="gy" value="' + esc(ph.gy) + '" />') +
'</div>' +
'<div class="sbu-row2">' +
miniField('трение', '<input class="sbu-in" type="number" step="0.1" data-phf="friction" value="' + esc(ph.friction) + '" />') +
miniField('упругость 0..1', '<input class="sbu-in" type="number" step="0.05" min="0" max="1" data-phf="restitution" value="' + esc(ph.restitution) + '" />') +
'</div>' +
// walls
'<div class="sbu-sub">Стены</div>' +
ph.walls.map(function (w, i) {
var sideOpts = ['', 'bottom', 'top', 'left', 'right'].map(function (s) {
return '<option value="' + s + '"' + (w.side === s ? ' selected' : '') + '>' + (s === '' ? '— отрезок —' : WALL_LABEL[s]) + '</option>';
}).join('');
return '<div class="sbu-wall" data-wi="' + i + '">' +
'<select class="sbu-in sbu-in-sm" data-wf="side">' + sideOpts + '</select>' +
(w.side ? '' :
'<div class="sbu-row4">' +
miniField('x1', '<input class="sbu-in" data-wf="x1" value="' + esc(w.x1 == null ? '' : w.x1) + '" />') +
miniField('y1', '<input class="sbu-in" data-wf="y1" value="' + esc(w.y1 == null ? '' : w.y1) + '" />') +
miniField('x2', '<input class="sbu-in" data-wf="x2" value="' + esc(w.x2 == null ? '' : w.x2) + '" />') +
miniField('y2', '<input class="sbu-in" data-wf="y2" value="' + esc(w.y2 == null ? '' : w.y2) + '" />') +
'</div>') +
'<button class="sbu-icon-btn sbu-del" data-wdel="' + i + '" title="Удалить стену">' + ICON.trash + '</button>' +
'</div>';
}).join('') +
'<button class="sbu-add sbu-add-sm" data-add="wall">' + ICON.plus + ' Стена</button>' +
// springs
'<div class="sbu-sub">Пружины</div>' +
ph.springs.map(function (s, i) {
return '<div class="sbu-spring" data-spi="' + i + '">' +
'<div class="sbu-row2">' +
miniField('конец A', '<input class="sbu-in" data-spf="a" value="' + esc(s.a == null ? '' : s.a) + '" placeholder="id или x,y" />') +
miniField('конец B', '<input class="sbu-in" data-spf="b" value="' + esc(s.b == null ? '' : s.b) + '" placeholder="id или x,y" />') +
'</div>' +
'<div class="sbu-row4">' +
miniField('k', '<input class="sbu-in" type="number" data-spf="k" value="' + esc(s.k == null ? '' : s.k) + '" />') +
miniField('длина', '<input class="sbu-in" type="number" data-spf="length" value="' + esc(s.length == null ? '' : s.length) + '" />') +
miniField('демпф.', '<input class="sbu-in" type="number" data-spf="damping" value="' + esc(s.damping == null ? '' : s.damping) + '" />') +
'<button class="sbu-icon-btn sbu-del" data-spdel="' + i + '" title="Удалить пружину">' + ICON.trash + '</button>' +
'</div>' +
'</div>';
}).join('') +
'<button class="sbu-add sbu-add-sm" data-add="spring">' + ICON.plus + ' Пружина</button>' +
bodyHint +
'</div>';
return section('plots', 'Графики', plotsBody, this._open.plots, this.st.plots.length) +
section('physics', 'Физика', physBody, !!ph.enabled);
};
/* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */
Builder.prototype.wirePanels = function () {
var self = this;
var p = this.panelHost;
// аккордеоны
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 '<div class="sbu-pal-grp"><div class="sbu-pal-title">' + esc(title) + '</div><div class="sbu-pal-chips">' +
arr.map(function (n) { return '<button class="sbu-pal-chip" data-ins="' + esc(n) + '" data-kind="' + kind + '">' + esc(n) + '</button>'; }).join('') +
'</div></div>';
}
var content =
'<div class="sbu-pal">' +
chips('Параметры', params, 'var') +
chips('Объекты (id.x / id.y)', objRefs, 'var') +
chips('Время / размеры', ['t', 'w', 'h'], 'var') +
chips('Константы', names.consts, 'const') +
chips('Функции', names.fns, 'fn') +
'</div>';
var m = global.LS.modal({
title: 'Палитра выражений', size: 'md', content: content,
actions: [{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }]
});
m.body.querySelectorAll('[data-ins]').forEach(function (b) {
b.addEventListener('click', function () {
var ins = b.getAttribute('data-ins');
var kind = b.getAttribute('data-kind');
var add = (kind === 'fn') ? ins + '()' : ins;
insertAtCursor(input, add);
// обновить состояние из input
input.dispatchEvent(new Event('input', { bubbles: true }));
});
});
};
/* ════════════════════════ ХЕЛПЕРЫ ════════════════════════ */
function field(label, inner) {
return '<label class="sbu-field"><span class="sbu-field-lbl">' + esc(label) + '</span>' + inner + '</label>';
}
function miniField(label, inner) {
return '<label class="sbu-mini"><span class="sbu-mini-lbl">' + esc(label) + '</span>' + inner + '</label>';
}
function checkbox(grp, key, label, checked) {
return '<label class="sbu-chk"><input type="checkbox" data-grp="' + grp + '" data-k="' + key + '"' + (checked ? ' checked' : '') + '/> ' + esc(label) + '</label>';
}
/* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */
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: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
reset: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
save: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
send: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
plus: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
trash: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
chev: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>',
target: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
cog: '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
template: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>',
unpublish: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>'
};
/* ── Встроенные шаблоны стартовых спек (Фаза 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);