feat(sim-builder): фаза 6 — раздача классу, клон, шаблоны, привязка к программе (custom_sims)

This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:06:30 +03:00
parent 1bee332ae1
commit cbb6edf372
10 changed files with 803 additions and 30 deletions
+108 -7
View File
@@ -715,6 +715,21 @@ const SIMS = [
var _EDIT_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>';
var _DEL_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
var _SHARE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
var _CLONE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
var _PUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var _UNPUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M2 2l20 20"/><path d="M12 2a15.3 15.3 0 0 1 4 10c0 1.3-.2 2.6-.5 3.8M6.5 6.5A15.3 15.3 0 0 0 12 22a15.3 15.3 0 0 0 3.3-5"/><path d="M2 12h7m6 0h7"/></svg>';
function _isTeacherUser() {
try { return typeof user !== 'undefined' && user && (user.role === 'teacher' || user.role === 'admin'); }
catch (e) { return false; }
}
function _btn(act, id, html, extra, title) {
return '<button type="button" data-act="' + act + '" data-id="' + _esc(id) + '" ' +
(title ? 'title="' + _esc(title) + '" ' : '') +
'style="display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;' + (extra || '') + '">' +
html + '</button>';
}
function _cardHtml(m) {
var owner = _isOwner(m);
@@ -726,14 +741,26 @@ const SIMS = [
else if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.06);color:var(--text-3);border:1px solid rgba(255,255,255,.14)">Черновик</span>';
var actions = '';
if (owner) {
var STYLE_PRI = 'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)';
var STYLE_GHOST = 'background:rgba(255,255,255,.05);color:var(--text-2);border:1px solid rgba(255,255,255,.16)';
var STYLE_DEL = 'background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)';
var pubBtn = published
? _btn('unpublish', m.id, _UNPUB_ICON, STYLE_GHOST, 'Снять с публикации')
: _btn('publish', m.id, _PUB_ICON, STYLE_GHOST, 'Опубликовать');
actions =
'<div style="display:flex;gap:8px;margin-top:12px">' +
'<button type="button" data-act="edit" data-id="' + _esc(m.id) + '" ' +
'style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)">' +
_EDIT_ICON + 'Редактировать</button>' +
'<button type="button" data-act="del" data-id="' + _esc(m.id) + '" ' +
'style="display:inline-flex;align-items:center;justify-content:center;padding:7px 11px;border-radius:10px;cursor:pointer;background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)" title="Удалить">' +
_DEL_ICON + '</button>' +
_btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) +
_btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') +
'</div>' +
'<div style="display:flex;gap:8px;margin-top:8px">' +
_btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') +
pubBtn +
'</div>';
} else if (published && _isTeacherUser()) {
actions =
'<div style="display:flex;gap:8px;margin-top:12px">' +
_btn('clone', m.id, _CLONE_ICON + 'Клонировать к себе',
'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)') +
'</div>';
}
var preview = '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
@@ -792,6 +819,14 @@ const SIMS = [
location.href = '/sim-builder?id=' + encodeURIComponent(id);
} else if (act === 'del') {
del(id);
} else if (act === 'share') {
shareToClass(id);
} else if (act === 'clone') {
clone(id);
} else if (act === 'publish') {
setStatus(id, 'published');
} else if (act === 'unpublish') {
setStatus(id, 'draft');
}
return;
}
@@ -818,6 +853,69 @@ const SIMS = [
});
}
// Опубликовать / снять с публикации (владельцу). PUT status.
function setStatus(dbid, status) {
if (!window.LS || !LS.customSimUpdate) return;
LS.customSimUpdate(dbid, { status: status }).then(function () {
if (_meta[dbid]) _meta[dbid].status = status;
renderSection(_catFilter);
if (LS.toast) LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Не удалось изменить статус', 'error');
});
}
// Клонировать чужую (published) симуляцию к себе как черновик и открыть в билдере.
function clone(dbid) {
if (!window.LS || !LS.customSimClone) return;
LS.customSimClone(dbid).then(function (res) {
var newId = res && res.id;
if (newId) {
if (LS.toast) LS.toast('Скопировано в ваши черновики', 'success');
location.href = '/sim-builder?id=' + encodeURIComponent(newId);
}
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Не удалось клонировать', 'error');
});
}
// Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует
// и шлёт уведомление ученикам со ссылкой /lab?sim=custom:<id>).
function shareToClass(dbid) {
if (!window.LS || !LS.customSimShare || !LS.getClasses || !LS.modal) return;
LS.getClasses().then(function (classes) {
if (!Array.isArray(classes) || !classes.length) {
if (LS.toast) 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="cs-share-class" style="width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:9px;font:inherit;background:var(--surface);color:var(--text)">' + opts + '</select>' +
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
'</div>';
var m = LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
{ label: 'Отмена', onClick: function () { m.close(); } },
{ label: 'Раздать', primary: true, onClick: function () {
var sel = m.body.querySelector('#cs-share-class');
var classId = sel ? Number(sel.value) : NaN;
LS.customSimShare(dbid, { classId: classId }).then(function (r) {
m.close();
if (_meta[dbid]) _meta[dbid].status = 'published';
renderSection(_catFilter);
if (LS.toast) LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
}).catch(function (e) {
if (LS.toast) LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
});
} }
] });
}).catch(function () {
if (LS.toast) LS.toast('Не удалось загрузить классы', 'error');
});
}
// Загрузить список custom-sims, зарегистрировать ленивые манифесты, нарисовать секцию.
function init() {
if (_initPromise) return _initPromise;
@@ -844,6 +942,9 @@ const SIMS = [
resolveId: resolveId,
renderSection: renderSection,
ensureSpec: ensureSpec,
del: del
del: del,
share: shareToClass,
clone: clone,
setStatus: setStatus
};
})();
+169 -2
View File
@@ -320,16 +320,26 @@
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>' +
'<button class="btn-primary sbu-tb-btn" data-a="publish">' + ICON.send + ' Опубликовать</button>' +
shareBtn +
pubBtn +
'</div>';
t.querySelectorAll('[data-a]').forEach(function (b) {
b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); });
@@ -341,6 +351,96 @@
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();
});
});
};
/* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */
@@ -1100,9 +1200,76 @@
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>'
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;