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