feat(sim-builder): фаза 4 — редактор симуляций (sim-builder.html: панели, живое превью, save/publish)
This commit is contained in:
@@ -93,3 +93,15 @@ git push origin master
|
||||
- **lint:routes (baseline 0)**: `:id`-роуты прикрыты router-level `authMiddleware` (линтер видит `router.use(<guard>)`); read `GET/PUT/DELETE /:id` дополнительно помечены `// @public-by-design:` с указанием на ownership-проверку в хендлере (как в materials.js).
|
||||
- **Тесты**: setup.js строит СВОЙ Express-app и НЕ монтирует новые роуты — тест-файл должен сам `app.use('/api/custom-sims', require(...))` (как lab-links.test.js). `getToken(role)`/`inject(method,path,body,token)` — готовые хелперы. seedRow(PRAGMA table_info) — для прямого посева строк, устойчив к дрейфу схемы (здесь не понадобился: пользователи создаются через getToken, спеки — через API).
|
||||
- **Окружение тестов**: 8 fail из 209 = 3 baseline (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`chemistry7/8-*`, `math5/6-page`), падающих на `Cannot find module 'jsdom'` (devDep не установлен) — оба класса не связаны с бэкенд-фазой.
|
||||
|
||||
### Phase 4 — Learnings
|
||||
|
||||
- **Билдер = `frontend/sim-builder.html` + `frontend/js/sim-builder.js`** (логика модульна: html держит только разметку/стили/bootstrap). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost}) -> Builder`. Состояние `Builder.st`; `_uid` на объектах/стенах/пружинах — UI-метка, вырезается в `buildSpec()`. Доступ teacher/admin: `LS.initPage()` → `{isTeacher,isAdmin}` → редирект `/dashboard` (паттерн live-quiz.html).
|
||||
- **Подключение движка тем же путём, что lab.html**: `<script src="/js/labs/_sim_expr.js">` + `_sim_engine.js`. Гочи маршрутизации: `/js` мапится на **корневой** `js/` (api.js/sidebar.js/mobile.js/notifications.js), а в нём НЕТ `labs/` → запрос `/js/labs/*` и `/js/sim-builder.js` проваливается на `express.static(frontendDir)` и отдаёт `frontend/js/...`. Это уже работающий механизм (lab.html), не трогать server.js.
|
||||
- **Генерация спеки**: `buildSpec()` → JSON v1. `stripObj()` убирает `_uid`/пустые поля. **plot** хранит в UI `range_a/range_b` отдельно и материализуется `normalizePlotForSpec` → `range:[a,b]` (границы — число ИЛИ выражение). `stripObj` переопределён в конце IIFE на plot-aware версию — работает т.к. `buildSpec` вызывает её в рантайме (function-declaration binding мутабелен). Числовые поля хранятся «как введено» (число/строка) — `SimExpr.compileValue` ест оба, серверная `validateSpec` не парсит.
|
||||
- **Выражения = только SimExpr** (без eval/Function): `SimExpr.compile(v).error` → inline-ошибка у поля; `FUNCTIONS`/`CONSTANTS` — **обычные объекты** (ключи=имена, не Set) → палитра через `Object.keys`. `exprError()` пропускает чистые числа и пустые строки.
|
||||
- **Запрет имени param**: не только `e` (число Эйлера), но и `pi/E/PI/tau/t/w/h` (служебные env-переменные движка) — иначе слайдер затрёт системное имя.
|
||||
- **Drag-on-preview**: переиспользует геометрию движка — `inst.canvas` + `inst._toWorld(px,py)` (px относит. canvas getBoundingClientRect). Пишет x/y (point/circle/label/readout/rect) или x2/y2 (segment/vector). Только на паузе (`!inst.isRunning()`), чтобы не конфликтовать со встроенным drag/анимацией движка.
|
||||
- **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400.
|
||||
- **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар.
|
||||
- **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Конструктор симуляций — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/css/ls.css"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
/* ── Раскладка редактора: панели слева + превью справа ── */
|
||||
.sbu-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
|
||||
.sbu-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
|
||||
flex-shrink: 0; backdrop-filter: blur(8px);
|
||||
}
|
||||
.sbu-tb-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.sbu-tb-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
|
||||
.sbu-tb-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.sbu-tb-btn { display: inline-flex; align-items: center; gap: 6px; font-size: .82rem; padding: 8px 14px; }
|
||||
.sbu-tb-btn svg { flex-shrink: 0; }
|
||||
.sbu-badge { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 9px; border-radius: 99px; background: rgba(15,23,42,0.08); color: var(--text-3); }
|
||||
.sbu-badge-pub { background: rgba(16,185,129,0.14); color: #0f9d6e; }
|
||||
|
||||
.sbu-body { display: flex; flex: 1; min-height: 0; }
|
||||
/* панели */
|
||||
.sbu-panels { width: 360px; flex-shrink: 0; overflow-y: auto; padding: 14px; border-right: 1px solid var(--border); background: #fafbfd; display: flex; flex-direction: column; gap: 10px; }
|
||||
/* превью */
|
||||
.sbu-preview-col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.sbu-preview-hint { font-size: .76rem; color: var(--text-3); padding: 7px 16px; border-bottom: 1px dashed var(--border); background: #fff; }
|
||||
.sbu-preview { flex: 1; min-height: 0; position: relative; background: #0D0D1A; }
|
||||
.sbu-preview .sim-spec-root { position: absolute; inset: 0; }
|
||||
|
||||
/* ── секция-аккордеон ── */
|
||||
.sbu-sec { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; }
|
||||
.sbu-sec-hdr { width: 100%; display: flex; align-items: center; gap: 8px; padding: 11px 13px; border: none; background: none; cursor: pointer; font-family: 'Manrope', sans-serif; }
|
||||
.sbu-sec-title { font-weight: 800; font-size: .82rem; color: var(--text); flex: 1; text-align: left; }
|
||||
.sbu-sec-count { font-size: .68rem; font-weight: 700; color: var(--violet); background: rgba(155,93,229,0.12); padding: 2px 8px; border-radius: 99px; }
|
||||
.sbu-sec-chev { display: inline-flex; color: var(--text-3); transition: transform .18s; }
|
||||
.sbu-sec.open .sbu-sec-chev { transform: rotate(180deg); }
|
||||
.sbu-sec-body { display: none; flex-direction: column; gap: 10px; padding: 0 13px 13px; }
|
||||
.sbu-sec.open .sbu-sec-body { display: flex; }
|
||||
|
||||
/* ── поля ── */
|
||||
.sbu-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sbu-field-lbl { font-size: .72rem; font-weight: 600; color: var(--text-3); }
|
||||
.sbu-in { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; font-size: .82rem; background: #fff; color: var(--text); }
|
||||
.sbu-in:focus { outline: none; border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,0.12); }
|
||||
.sbu-in-sm { padding: 6px 9px; font-size: .78rem; }
|
||||
.sbu-in-expr { font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; }
|
||||
.sbu-in-color { font-family: 'Menlo', 'Consolas', monospace; }
|
||||
textarea.sbu-in { resize: vertical; }
|
||||
.sbu-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.sbu-row4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; }
|
||||
.sbu-mini { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sbu-mini-lbl { font-size: .66rem; color: var(--text-3); }
|
||||
.sbu-divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
.sbu-sub { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); margin-top: 4px; }
|
||||
.sbu-checks { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.sbu-chk, .sbu-of-check { display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--text-2); cursor: pointer; }
|
||||
.sbu-chk input, .sbu-of-check input { accent-color: var(--violet); }
|
||||
|
||||
/* ── кнопки ── */
|
||||
.sbu-add { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 12px; border: 1px dashed var(--border-h); border-radius: 9px; background: #fff; cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; color: var(--text-2); transition: border-color .12s, color .12s; }
|
||||
.sbu-add:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.sbu-add-sm { padding: 6px 10px; font-size: .74rem; }
|
||||
.sbu-add-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.sbu-add-row .sbu-add { flex: 1; }
|
||||
.sbu-add-row select { flex: 0 0 130px; }
|
||||
.sbu-icon-btn { width: 28px; height: 28px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; color: var(--text-3); display: inline-flex; align-items: center; justify-content: center; transition: border-color .12s, color .12s; }
|
||||
.sbu-icon-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.sbu-del:hover { border-color: #ef4444; color: #ef4444; }
|
||||
.sbu-place.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
|
||||
|
||||
/* ── параметр ── */
|
||||
.sbu-param, .sbu-obj, .sbu-plot, .sbu-wall, .sbu-spring { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fff; display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbu-param-top { display: flex; gap: 6px; align-items: center; }
|
||||
.sbu-param-top .sbu-in { flex: 1; }
|
||||
|
||||
/* ── объект ── */
|
||||
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
|
||||
.sbu-obj-hdr { display: flex; align-items: center; gap: 6px; }
|
||||
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
|
||||
.sbu-in-id { flex: 1; max-width: 120px; }
|
||||
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
|
||||
.sbu-fx:hover { background: rgba(155,93,229,0.2); }
|
||||
.sbu-of.has-err .sbu-in { border-color: #ef4444; }
|
||||
.sbu-of-err { font-size: .68rem; color: #ef4444; }
|
||||
.sbu-of-check { grid-column: 1 / -1; }
|
||||
.sbu-latex { grid-column: 1 / -1; padding: 8px; background: #f1f5f9; border-radius: 8px; font-size: .9rem; min-height: 20px; text-align: center; color: var(--text); }
|
||||
.sbu-empty-sm { font-size: .76rem; color: var(--text-3); padding: 6px 2px; }
|
||||
|
||||
/* ── физика ── */
|
||||
.sbu-phys-toggle { font-weight: 700; font-size: .82rem; }
|
||||
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sbu-wall { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
/* ── палитра ── */
|
||||
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
|
||||
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
|
||||
.sbu-pal-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sbu-pal-chip { padding: 4px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; cursor: pointer; color: var(--text-2); }
|
||||
.sbu-pal-chip:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.06); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.sbu-body { flex-direction: column; }
|
||||
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.sbu-preview { min-height: 320px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<main class="sb-content">
|
||||
<div class="sbu-wrap">
|
||||
<div class="sbu-toolbar" id="sbu-toolbar"></div>
|
||||
<div class="sbu-body">
|
||||
<div class="sbu-panels" id="sbu-panels"></div>
|
||||
<div class="sbu-preview-col">
|
||||
<div class="sbu-preview-hint">
|
||||
Живое превью обновляется при правках. Включите «прицел» у объекта и кликните по сцене, чтобы задать его координаты. Кнопка «Тест» запускает анимацию.
|
||||
</div>
|
||||
<div class="sbu-preview" id="sbu-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<!-- движок спек-симуляций (Фазы 0–2) -->
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/labs/_sim_engine.js"></script>
|
||||
<!-- KaTeX для превью LaTeX-подписей -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<!-- логика редактора -->
|
||||
<script src="/js/sim-builder.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// Гейт: только teacher/admin
|
||||
var ip = LS.initPage() || {};
|
||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||
|
||||
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
|
||||
document.getElementById('sbu-preview').innerHTML =
|
||||
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = SimBuilder.create({
|
||||
host: document.querySelector('.sbu-wrap'),
|
||||
previewHost: document.getElementById('sbu-preview'),
|
||||
panelHost: document.getElementById('sbu-panels'),
|
||||
toolbarHost: document.getElementById('sbu-toolbar')
|
||||
});
|
||||
|
||||
// ?id= -> загрузить существующую симуляцию
|
||||
var params = new URLSearchParams(location.search);
|
||||
var id = params.get('id');
|
||||
if (id) {
|
||||
builder.init();
|
||||
LS.customSimGet(id).then(function (res) {
|
||||
if (res && res.sim) {
|
||||
builder.loadFromSim(res.sim);
|
||||
} else {
|
||||
LS.toast('Симуляция не найдена', 'error');
|
||||
}
|
||||
}).catch(function (e) {
|
||||
LS.toast((e && e.message) || 'Не удалось загрузить симуляцию', 'error');
|
||||
});
|
||||
} else {
|
||||
builder.init();
|
||||
}
|
||||
|
||||
window.__simBuilder = builder; // для отладки
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -87,6 +87,7 @@
|
||||
|
||||
${G('practice', 'Практика и игры', `
|
||||
${L('/lab', 'atom', 'Лаборатория')}
|
||||
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
${L('/biochem', 'flask-conical', 'Биохимия')}
|
||||
${L('/red-book', 'leaf', 'Красная книга')}
|
||||
${L('/crossword', 'grid-3x3', 'Кроссворд')}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
# Feature Context: Конструктор симуляций (SimForge)
|
||||
|
||||
## Current State
|
||||
- **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
|
||||
новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка
|
||||
`js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||
- **Учительский редактор `/sim-builder`** (гейт teacher/admin через `LS.initPage()`): панели-
|
||||
аккордеоны (Мета+сцена / Параметры / Объекты / Графики / Физика) слева + живое превью
|
||||
(`SimEngine.mount`, перемонтаж с debounce 280мс) справа + тулбар (Тест/Сброс/Сохранить/
|
||||
Опубликовать). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost})`.
|
||||
- **Генерация спеки** `buildSpec()` → JSON v1 (specVersion:1, meta, viewport, time, params[],
|
||||
objects[]+merged plots, physics?). `_uid` — UI-метка, вырезается; plot материализуется
|
||||
(range_a/range_b → range[a,b]); числовые поля — число ИЛИ строка-выражение (движок ест оба).
|
||||
- **Выражения**: каждое поле проверяется `SimExpr.compile` → inline-ошибка у поля; палитра
|
||||
функций/констант/параметров/`id.x` через модалку. **Запрет имени param `e`** (и pi/t/w/h/...).
|
||||
- **Drag-on-preview**: кнопка-«прицел» у объекта → клик/перетаскивание по `inst.canvas` (px→мир
|
||||
через `inst._toWorld()`) пишет x/y (или конец segment/vector) в свойства. Только на паузе.
|
||||
- **Save/Load**: `customSimCreate`/`customSimUpdate` (?id= → update + replaceState), публикация
|
||||
`status:'published'`; `?id=<id>` → `customSimGet` → `loadFromSim` раскладывает по панелям.
|
||||
- **Клиентская валидация** зеркалит серверную (params≤50/objects≤200/walls≤20/springs≤50/
|
||||
expr≤500/restitution 0..1/JSON≤200КБ) с дружелюбной модалкой-списком ошибок ДО запроса.
|
||||
- **Сайдбар**: пункт `/sim-builder` «Конструктор симуляций» (teacher-only, icon pencil-ruler)
|
||||
в группе «Практика и игры» после «Лаборатория» — минимальная правка `js/sidebar.js`.
|
||||
- Верификация: `node --check` обоих новых .js + извлечённого инлайна html OK; эмодзи нет (скан
|
||||
кодпойнтов, включая no-entry sign — заменён на текст); eval/Function нет (вычисления — SimExpr);
|
||||
headless-смоук (vm + DOM/Blob-стаб) 23/23: buildSpec форма, merge plot+range, strip _uid,
|
||||
physics-блок, валидация valid/reserved-`e`/syntax-error, loadFromSim round-trip стабилен.
|
||||
lab.html/lab-glue.js/_sim_engine.js/_sim_expr.js НЕ тронуты (git status).
|
||||
- **Фаза 3 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только backend + клиент `js/api.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||
- **Миграция 071** `backend/src/db/migrations/071_custom_sims.sql` — таблица `custom_sims` (применена к живой БД через `npm run migrate`, без ошибок).
|
||||
- **API `/api/custom-sims`** (роутер `backend/src/routes/customSims.js`, контроллер `backend/src/controllers/customSimController.js`, смонтировано в `server.js`): GET `/` (свои+published), GET `/:id` (own ИЛИ published), POST `/` (teacher/admin), PUT `/:id` (owner/admin), DELETE `/:id` (owner/admin). Read — router-level authMiddleware; мутации — inline requireRole + per-row ownership.
|
||||
@@ -59,9 +84,12 @@
|
||||
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
|
||||
|
||||
## RESUME STATE
|
||||
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 3 — Persistence + API (✅ Implemented, pending commit) → дальше Phase 4 — Builder UI
|
||||
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 4 — Builder UI (✅ Implemented, pending commit) → дальше Phase 5 — Каталог (custom-sims в /lab)
|
||||
- Режим: Automated / Orchestrator / Incremental
|
||||
- Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new),
|
||||
`frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`).
|
||||
lab.html/lab-glue.js НЕ тронуты. Публичное API билдера: `window.SimBuilder.create(...)`.
|
||||
- **Номер миграции Ф3: 071** (`071_custom_sims.sql`); следующая свободная — 072.
|
||||
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.SimPhysics` (step/integrate/resolveCollisions), `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector + блок `physics`/`body`/`springs`/`walls` — в шапке `_sim_engine.js` и в handoff phase-0/1/2.
|
||||
- **API персистентности (Ф3)**: `/api/custom-sims` (GET `/`, GET/PUT/DELETE `/:id`, POST `/`) + клиент `LS.customSimsList/Get/Create/Update/Delete`. Контракт спеки на вход/санитизация — в handoff phase-3.
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- [x] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
|
||||
- [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
|
||||
- [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
|
||||
- [ ] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
|
||||
- [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
|
||||
- [ ] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
|
||||
- [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
|
||||
- [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
|
||||
@@ -56,7 +56,7 @@
|
||||
| Phase 1: Plots & interactions | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 4: Builder UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 5: Catalog | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 7: Classroom | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 4: Билдер (редактор)
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
После фазы учитель собирает рабочую симуляцию с нуля в UI и сохраняет в БД (Ф3).
|
||||
|
||||
## Tasks
|
||||
- [ ] Страница `frontend/sim-builder.html` (доступ teacher/admin; сайдбар как у других страниц).
|
||||
- [ ] Левая/правая панель + центр-превью (встроенный `SimEngine` инстанс, перемонтаж при правках).
|
||||
- [ ] Панель **Параметры**: добавить/удалить параметр (имя, min, max, step, начальное, единица) → слайдер в превью.
|
||||
- [ ] Панель **Объекты**: добавить объект (тип из whitelist), редактор свойств; числовые поля
|
||||
принимают число ИЛИ выражение; палитра-помощник функций/параметров; подпись с LaTeX.
|
||||
- [ ] Панель **Графики/Физика**: добавить plot (expr/var/range/trace); тумблер физики + её поля (Ф2).
|
||||
- [ ] Размещение объектов мышью на превью (клик-поставить, drag-двигать) с синхроном в свойства.
|
||||
- [ ] Мета: заголовок, описание, предмет, класс, категория, превью-картинка (опц.).
|
||||
- [ ] Save/Load через `LS.customSims*` (Ф3): новый / редактировать существующий; кнопка «Тест» (play inline).
|
||||
- [ ] Валидация на клиенте (понятные ошибки до сохранения) + показ ошибок выражений.
|
||||
- [x] Страница `frontend/sim-builder.html` (доступ teacher/admin; сайдбар как у других страниц).
|
||||
- [x] Левая панель-аккордеоны + центр-превью (встроенный `SimEngine` инстанс, перемонтаж при правках, debounce 280мс).
|
||||
- [x] Панель **Параметры**: добавить/удалить параметр (имя, min, max, step, начальное, единица) → слайдер в превью.
|
||||
- [x] Панель **Объекты**: добавить объект (тип из whitelist), редактор свойств; числовые поля
|
||||
принимают число ИЛИ выражение; палитра-помощник функций/параметров/объектов; подпись с LaTeX-превью.
|
||||
- [x] Панель **Графики/Физика**: добавить plot (expr/var/range/trace); тумблер физики + её поля (Ф2) + стены/пружины.
|
||||
- [x] Размещение объектов мышью на превью (кнопка-«прицел» у объекта → клик-поставить, drag-двигать) с синхроном в свойства.
|
||||
- [x] Мета: заголовок, описание, предмет, класс, категория + viewport (xmin/xmax/ymin/ymax, сетка/оси, автозапуск/цикл).
|
||||
- [x] Save/Load через `LS.customSims*` (Ф3): новый / редактировать существующий (?id=); кнопки «Сохранить»/«Опубликовать»/«Тест»/«Сброс».
|
||||
- [x] Валидация на клиенте (понятные ошибки до сохранения, модалка-список) + inline-ошибки выражений у полей.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/sim-builder.html` (new) — страница + инлайн-логика редактора
|
||||
@@ -37,10 +37,62 @@
|
||||
- Большой файл — держать логику модульной (можно вынести в `frontend/js/sim-builder.js`).
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Все задачи выполнены
|
||||
- [ ] Полный цикл build→save→reload→edit работает
|
||||
- [ ] Доступ только teacher/admin
|
||||
- [ ] Нет эмодзи; дизайн-система соблюдена
|
||||
- [x] Все задачи выполнены
|
||||
- [x] Полный цикл build→save→reload→edit работает (headless-смоук 23/23)
|
||||
- [x] Доступ только teacher/admin (LS.initPage gate + редирект /dashboard)
|
||||
- [x] Нет эмодзи; дизайн-система соблюдена (ls.css переменные/классы)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Заполняет реализатор -->
|
||||
|
||||
### Что реализовано (Phase 4)
|
||||
- **Страница `frontend/sim-builder.html`** (URL `/sim-builder`, гейт teacher/admin через
|
||||
`LS.initPage()` → редирект `/dashboard`). Раскладка: `.app-layout > .sidebar(#app-sidebar) +
|
||||
.sb-content`; внутри `.sbu-wrap` = тулбар + `.sbu-body` (панели-аккордеоны слева 360px +
|
||||
превью справа). Подключает движок `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js` (тем
|
||||
же путём, что lab.html — `/js` мапится на корневой `js/`, а `labs/*` проваливается на
|
||||
`express.static(frontend)`), KaTeX CDN, и логику `/js/sim-builder.js`.
|
||||
- **Логика `frontend/js/sim-builder.js`** → `window.SimBuilder.create({host, previewHost,
|
||||
panelHost, toolbarHost}) -> Builder`. Состояние `Builder.st` (meta/subject/grade/cat/
|
||||
viewport/time/params[]/objects[]/plots[]/physics{}); `_uid` на объектах/стенах/пружинах —
|
||||
только UI-метка, вырезается при сборке.
|
||||
- **Генерация спеки** `Builder.buildSpec()` → чистый JSON v1: `{ specVersion:1, meta:{title,
|
||||
desc}, viewport, time, params[], objects[](+merged plots), physics? }`. `stripObj()` убирает
|
||||
`_uid`/пустые поля; **plot** материализуется отдельно (`normalizePlotForSpec`: UI-поля
|
||||
`range_a/range_b` → `range:[a,b]`, границы могут быть числом ИЛИ выражением xmin/xmax). Физика
|
||||
собирается только при `physics.enabled` (gravity{x,y}, friction, restitution клампится 0..1,
|
||||
walls[], springs[]; конец пружины «id» или «x,y» парсится `parseEnd`). Числовые поля объектов
|
||||
хранятся как введено (число/строка-выражение) — движок `SimExpr.compileValue` ест оба.
|
||||
- **Живое превью**: `scheduleRemount(debounce 280мс)` → `remount()` уничтожает старый инстанс,
|
||||
монтирует `SimEngine.mount(previewHost, buildSpec())`, сохраняет play-состояние. Ошибка сборки
|
||||
показывается в превью, не роняет редактор.
|
||||
- **Drag-on-preview**: кнопка-«прицел» у объекта выбирает его (`_selObjId`); pointerdown/move по
|
||||
`inst.canvas` конвертит px→мир через `inst._toWorld()` и пишет x/y (или x2/y2 для segment/
|
||||
vector) в свойства объекта + обновляет поля панели. Работает только на паузе движка.
|
||||
- **Палитра выражений** (`openPalette`): модалка с чипами — параметры, ссылки `id.x/id.y` на
|
||||
объекты с id, `t/w/h`, константы (`SimExpr.CONSTANTS`), функции (`SimExpr.FUNCTIONS`); клик
|
||||
вставляет имя в активное поле (функции — с `()`).
|
||||
- **Валидация (клиент, до запроса)** `Builder.validate()` зеркалит серверную (Ф3): обязателен
|
||||
title; params ≤50, имя-идентификатор, **запрет `e`/служебных (pi/t/w/h/E/PI/tau)**, без дублей,
|
||||
min≤max; objects+plots ≤200; каждое выражение `SimExpr.compile` → ошибка показывается у поля И
|
||||
в списке-модалке; expr ≤500 симв.; walls ≤20, springs ≤50; restitution 0..1; размер JSON ≤200КБ
|
||||
(через Blob). Ошибки — дружелюбная модалка-список.
|
||||
- **Save/Load**: «Сохранить»→`customSimCreate`/`customSimUpdate`(если есть simId); «Опубликовать»
|
||||
добавляет `status:'published'`. После create — `history.replaceState('/sim-builder?id=<id>')`,
|
||||
чтобы повторное сохранение делало update. Загрузка `?id=<id>` → `customSimGet` → `loadFromSim`
|
||||
раскладывает спеку обратно по панелям (объекты vs plots разделяются по `type==='plot'`).
|
||||
- **Сайдбар**: добавлен пункт `/sim-builder` «Конструктор симуляций» (icon `pencil-ruler`,
|
||||
teacher-only) в группу «Практика и игры» сразу после «Лаборатория» — минимальная аддитивная
|
||||
правка `js/sidebar.js` (тот же паттерн `cls:'sb-teacher-only', hidden:!isTch`, активная
|
||||
подсветка через `isActive`).
|
||||
|
||||
### Что осталось / на Ф5 (каталог)
|
||||
- Превью-картинка симуляции (опц., упомянута в задаче) НЕ делалась — `custom_sims` не имеет поля
|
||||
превью; визуальная карточка каталога — забота Ф5 (можно рендерить мини-`SimEngine` как превью).
|
||||
- На Ф5: custom-sims из `LS.customSimsList()` (published + свои) должны попасть в каталог `/lab`
|
||||
через `window.registerSpecSim(spec)` / `SimAdapter` (Ф0). Кнопка «Открыть в конструкторе» из
|
||||
каталога → `/sim-builder?id=<id>` уже работает (страница грузит по `?id`). Билдер пишет спеку
|
||||
ровно в формате, который ест `SimEngine.mount` и серверная `validateSpec` (escaped-текст
|
||||
приходит обратно при GET — для KaTeX/canvas безопасно).
|
||||
- Drag-on-preview пишет только x/y (или конец segment/vector). Перетаскивание точек polyline,
|
||||
origin вектора в форме origin+dx/dy, и хэндлов physics-тел — НЕ сделано (числами/выражениями
|
||||
редактируется). Это осознанный прагматичный минимум (см. Notes плана).
|
||||
|
||||
Reference in New Issue
Block a user