feat(sim-builder): фаза 0 — рантайм SimEngine + безопасный движок выражений + адаптер LabRegistry

This commit is contained in:
Maxim Dolgolyov
2026-06-13 11:14:13 +03:00
parent eca68e1a28
commit 4dd92f83a0
8 changed files with 1371 additions and 22 deletions
+112
View File
@@ -0,0 +1,112 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
_sim_demo — рукописная демо-спека «Бросок тела» (projectile) для проверки
рантайма Фазы 0. Регистрируется как id 'customdemo' ТОЛЬКО за флагом — не
светится ученикам в каталоге (карточка не добавляется в SIMS).
Включение для проверки (любой из вариантов):
- URL: /lab?simdemo=1 (или ?sim=customdemo прямой deep-link)
- глоб: window.LAB_SHOW_SPEC_DEMO = true (до загрузки labs-скриптов)
- localStorage.setItem('lab-spec-demo','1')
Проверка: открыть /lab?simdemo=1 -> карточка появится; либо открыть
/lab?sim=customdemo напрямую. Слайдеры угла/скорости меняют траекторию,
play/pause/reset работают. Это ВРЕМЕННЫЙ раздел (удалить после Фазы 4).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
function demoEnabled() {
try {
var qs = (global.location && global.location.search) || '';
if (/[?&]simdemo=1\b/.test(qs)) return true;
if (/[?&]sim=customdemo\b/.test(qs)) return true;
if (global.LAB_SHOW_SPEC_DEMO === true) return true;
if (global.localStorage && global.localStorage.getItem('lab-spec-demo') === '1') return true;
} catch (e) { /* noop */ }
return false;
}
// Спека v1: бросок тела. g фиксирован 10 -> y = v*sin(θ)*t - 5*t^2.
var PROJECTILE_DEMO = {
id: 'customdemo',
cat: 'phys',
meta: { title: 'Демо: бросок тела', desc: 'Спек-симуляция (Фаза 0). Угол и скорость — слайдеры.' },
viewport: { xmin: 0, xmax: 60, ymin: 0, ymax: 30, grid: true, axes: true, bg: '#0D0D1A' },
time: { autoplay: false, loop: true, duration: 8, speed: 1 },
params: [
{ name: 'theta', label: 'Угол θ', min: 0, max: 90, step: 1, value: 45, unit: '°' },
{ name: 'v', label: 'Скорость v', min: 0, max: 30, step: 0.5, value: 20, unit: 'м/с' }
],
objects: [
// снаряд: x = v*cos(θ)*t, y = v*sin(θ)*t - 5 t^2 (но не ниже 0)
{
id: 'ball', type: 'point',
x: 'v*cos(theta*pi/180)*t',
y: 'max(0, v*sin(theta*pi/180)*t - 5*t^2)',
r: 7, color: '#06D6E0', trail: true, trailColor: '#9B5DE5'
},
// вектор начальной скорости из старта
{
type: 'vector', x1: 0, y1: 0,
x2: 'cos(theta*pi/180)*v*0.4',
y2: 'sin(theta*pi/180)*v*0.4',
color: '#FFD166', width: 3
},
// земля
{ type: 'segment', x1: 0, y1: 0, x2: 60, y2: 0, color: 'rgba(255,255,255,0.35)', width: 2 },
// подпись над снарядом
{
type: 'label', latex: true,
x: 'ball.x', y: 'ball.y + 2.5',
text: 'v_0', color: '#06D6E0', size: 15
}
]
};
function tryRegister() {
if (!demoEnabled()) return;
if (typeof global.registerSpecSim !== 'function') {
if (global.console) console.warn('[sim-demo] registerSpecSim недоступен');
return;
}
global.registerSpecSim(PROJECTILE_DEMO);
// Если каталог уже отрисован, добавить карточку демо вручную (минимально-
// инвазивно: только когда флаг включён; не трогаем SIMS/каталожный рендер).
addDemoCardIfNeeded();
}
function addDemoCardIfNeeded() {
var grid = document.getElementById('sim-grid');
if (!grid) return;
if (document.getElementById('sim-card-customdemo')) return;
var m = global.LabRegistry && global.LabRegistry.get('customdemo');
if (!m) return;
var preview = global.LabRegistry.resolvePreview(m);
var card = document.createElement('div');
card.id = 'sim-card-customdemo';
card.className = 'sim-card';
card.setAttribute('onclick', "openSim('customdemo')");
card.innerHTML = preview +
'<div class="sim-body">' +
'<span class="sim-cat ' + (m.cat || 'phys') + '">демо</span>' +
'<div class="sim-title">' + esc(m.title) + '</div>' +
'<div class="sim-desc">' + esc(m.desc || '') + '</div>' +
'</div>';
grid.appendChild(card);
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Зарегистрировать после готовности DOM/реестра. _register-all.js грузится
// последним (defer); этот файл — после него, поэтому LabRegistry уже есть.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryRegister);
} else {
tryRegister();
}
// экспонируем для ручной проверки из консоли
global.LAB_SPEC_DEMO = PROJECTILE_DEMO;
})(typeof window !== 'undefined' ? window : globalThis);