feat(labs): Фаза0 — эконом-режим FX + выбор симуляции из списка в редакторе
План улучшения симуляций — plans/simulations-improvement/README.md. - LabFX: reduced-motion/эконом-режим (prefers-reduced-motion + тумблер localStorage labfx-economy). Тряска отключается, частицы ×0.25 — доступность и экономия на слабых устройствах сразу для всех ~50 симуляций. Кнопка-тумблер в lab.html рядом со звуком. - lesson-editor: блок «Симуляция» — выпадающий список из /api/lab/sims (сгруппирован по предметам) вместо сырого ввода simId; неизвестный id не теряется, помечается «(не найдена)». Закрывает хрупкую вставку в урок. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,28 @@
|
||||
/* ── namespace (cooperative init) ── */
|
||||
global.LabFX = global.LabFX || {};
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
REDUCED MOTION / ECONOMY MODE
|
||||
Уважает системный prefers-reduced-motion + ручной тумблер
|
||||
(localStorage 'labfx-economy'). Когда LabFX.reduced === true:
|
||||
тряска отключается, частицы эмитятся в разы меньше — это и
|
||||
доступность, и экономия на слабых устройствах.
|
||||
───────────────────────────────────────────── */
|
||||
(function () {
|
||||
var mq = null;
|
||||
try { mq = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)'); } catch (e) {}
|
||||
var manual = null;
|
||||
try { var v = localStorage.getItem('labfx-economy'); if (v === '1') manual = true; else if (v === '0') manual = false; } catch (e) {}
|
||||
function compute() { return manual != null ? manual : !!(mq && mq.matches); }
|
||||
global.LabFX.reduced = compute();
|
||||
global.LabFX.setEconomy = function (on) {
|
||||
manual = !!on;
|
||||
try { localStorage.setItem('labfx-economy', on ? '1' : '0'); } catch (e) {}
|
||||
global.LabFX.reduced = compute();
|
||||
};
|
||||
if (mq && mq.addEventListener) mq.addEventListener('change', function () { global.LabFX.reduced = compute(); });
|
||||
})();
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
GLOW — Canvas 2D bloom helper
|
||||
───────────────────────────────────────────── */
|
||||
@@ -59,6 +81,7 @@
|
||||
* @param {number} opts.durMs - duration ms (default 200)
|
||||
*/
|
||||
global.LabFX.shake = function(elementOrCanvas, opts) {
|
||||
if (global.LabFX.reduced) return; // эконом/reduced-motion — без тряски
|
||||
opts = opts || {};
|
||||
var intensity = opts.intensity != null ? opts.intensity : 5;
|
||||
var durMs = opts.durMs != null ? opts.durMs : 200;
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
var size = opts.size != null ? opts.size : 3;
|
||||
var sizeFade = opts.sizeFade != null ? opts.sizeFade : true;
|
||||
|
||||
// Эконом/reduced-motion — декоративных частиц в разы меньше
|
||||
if (global.LabFX.reduced) count = Math.max(1, Math.round(count * 0.25));
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var p = acquire();
|
||||
if (!p) break;
|
||||
|
||||
@@ -362,6 +362,19 @@
|
||||
<line x1="17" y1="9" x2="23" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- economy / reduced-motion toggle -->
|
||||
<button class="zoom-btn" id="labfx-eco-btn" onclick="(function(){var f=window.LabFX;if(!f)return;f.setEconomy(!f.reduced);var on=!!f.reduced;document.getElementById('labfx-eco-btn').setAttribute('aria-pressed',on);document.getElementById('labfx-eco-on').style.display=on?'':'none';document.getElementById('labfx-eco-off').style.display=on?'none':'';})()" title="Эконом-режим: меньше анимаций (для слабых устройств)" style="position:relative" aria-pressed="false">
|
||||
<!-- economy ON (effects reduced) — leaf -->
|
||||
<svg id="labfx-eco-on" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/>
|
||||
<path d="M2 21c0-3 1.85-5.36 5.08-6"/>
|
||||
</svg>
|
||||
<!-- economy OFF (full effects) — zap -->
|
||||
<svg id="labfx-eco-off" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sim bodies вынесены в /labs-bodies.html (content-engine, Phase 2).
|
||||
@@ -441,6 +454,18 @@
|
||||
iconOff.style.display = on ? 'none' : '';
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
})();
|
||||
|
||||
/* Sync economy-mode toggle with LabFX.reduced (prefers-reduced-motion + localStorage) */
|
||||
(function() {
|
||||
var eco = !!(window.LabFX && window.LabFX.reduced);
|
||||
var on = document.getElementById('labfx-eco-on');
|
||||
var off = document.getElementById('labfx-eco-off');
|
||||
var btn = document.getElementById('labfx-eco-btn');
|
||||
if (!on || !off || !btn) return;
|
||||
on.style.display = eco ? '' : 'none';
|
||||
off.style.display = eco ? 'none' : '';
|
||||
btn.setAttribute('aria-pressed', eco ? 'true' : 'false');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2093,14 +2093,48 @@
|
||||
}
|
||||
|
||||
/* ── sim ── */
|
||||
// Каталог симуляций (кэш) — чтобы выбирать из списка, а не вписывать id руками.
|
||||
let _simCatalog = null, _simCatalogLoading = false;
|
||||
const SIM_SUBJ_RU = { phys:'Физика', chem:'Химия', bio:'Биология', math:'Математика', game:'Игры' };
|
||||
function loadSimCatalog(cb) {
|
||||
if (_simCatalog) { cb(_simCatalog); return; }
|
||||
if (_simCatalogLoading) { setTimeout(() => loadSimCatalog(cb), 200); return; }
|
||||
_simCatalogLoading = true;
|
||||
LS.api('/api/lab/sims')
|
||||
.then(r => { _simCatalog = (r && r.sims) || []; _simCatalogLoading = false; cb(_simCatalog); })
|
||||
.catch(() => { _simCatalog = []; _simCatalogLoading = false; cb(_simCatalog); });
|
||||
}
|
||||
function simOptionsHtml(selected) {
|
||||
const list = _simCatalog || [];
|
||||
const bySubj = {};
|
||||
for (const s of list) (bySubj[s.subject] = bySubj[s.subject] || []).push(s);
|
||||
let html = '<option value="">— выберите симуляцию —</option>';
|
||||
const known = new Set(list.map(s => s.id));
|
||||
if (selected && !known.has(selected)) {
|
||||
html += `<option value="${escAttr(selected)}" selected>${escAttr(selected)} (не найдена)</option>`;
|
||||
}
|
||||
for (const subj of Object.keys(bySubj)) {
|
||||
html += `<optgroup label="${escAttr(SIM_SUBJ_RU[subj] || subj)}">`;
|
||||
for (const s of bySubj[subj]) {
|
||||
html += `<option value="${escAttr(s.id)}"${s.id === selected ? ' selected' : ''}>${escAttr(s.title || s.id)}</option>`;
|
||||
}
|
||||
html += '</optgroup>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function renderSimEditor(b) {
|
||||
const d = b.data;
|
||||
const bid = b._id;
|
||||
// подгрузить каталог и заполнить select уже после вставки в DOM
|
||||
loadSimCatalog(() => { const sel = document.getElementById('sim-select-' + bid); if (sel) sel.innerHTML = simOptionsHtml(d.simId || ''); });
|
||||
const initial = _simCatalog
|
||||
? simOptionsHtml(d.simId || '')
|
||||
: `<option value="${escAttr(d.simId || '')}" selected>${d.simId ? escAttr(d.simId) : 'Загрузка…'}</option>`;
|
||||
return `<div>
|
||||
<div class="block-field">
|
||||
<div class="block-row-label">ID симуляции (напр. coulomb, mag, waves)</div>
|
||||
<input class="block-input" type="text" placeholder="coulomb" value="${escAttr(d.simId||'')}"
|
||||
oninput="updateBlockData('${bid}','simId',this.value);updateSimPreview('${bid}',this.value);markDirty()" />
|
||||
<div class="block-row-label">Симуляция</div>
|
||||
<select class="block-input" id="sim-select-${bid}"
|
||||
onchange="updateBlockData('${bid}','simId',this.value);updateSimPreview('${bid}',this.value);markDirty()">${initial}</select>
|
||||
</div>
|
||||
<div class="block-field">
|
||||
<div class="block-row-label">Подпись</div>
|
||||
@@ -2116,8 +2150,10 @@
|
||||
function updateSimPreview(bid, simId) {
|
||||
const el = document.getElementById('sim-preview-' + bid);
|
||||
if (!el) return;
|
||||
let label = simId;
|
||||
if (_simCatalog) { const s = _simCatalog.find(x => x.id === simId); if (s) label = s.title || simId; }
|
||||
el.innerHTML = simId
|
||||
? `<div class="sim-preview">${LS.icon('atom',14)} Симуляция: <strong>${escAttr(simId)}</strong></div>`
|
||||
? `<div class="sim-preview">${LS.icon('atom',14)} Симуляция: <strong>${escAttr(label)}</strong></div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# План улучшения симуляций (лаборатория)
|
||||
|
||||
Утверждён 2026-06-13. Цель — улучшить **текущие** симуляции (~50 шт.), а не наплодить новые.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
База сильная: data-driven реестр (`_registry.js`) с ленивой загрузкой и самовосстановлением
|
||||
(`_loader.js`, `_sim_deps.js`), движок `LabFX` (частицы/звук/анимации), `LSGraphPanel`,
|
||||
`_phys_visuals`, `_chem_visuals`. Каталог в БД (`lab_sims` миграция 042 + `lab_sim_links` 043),
|
||||
синхрон в онлайн-уроке (`controllers/classroom/sim.js`).
|
||||
|
||||
Эталоны: **projectile, pendulum** (RK4), **opticsbench** (Снелл/линзы/дисперсия),
|
||||
**circuit** (MNA-решатель), **trigcircle, titration**, **race** (единственная с задачами+проверкой).
|
||||
|
||||
Состав: физика 13, химия 16, математика 9, биология 2, игра 1.
|
||||
|
||||
## Сквозные пробелы (подтверждены ревью кода, 3 среза)
|
||||
|
||||
1. **Педагогика тонкая** — задания/цели/проверка лишь у ~6-8 из ~50; остальные — песочницы. Теория = голые формулы.
|
||||
2. **Тач сломан в ~80%** — мост `_addTouchSupport` (`lab-init.js:158`) подключён в 2 симуляциях; вёрстка адаптивна, взаимодействие — нет.
|
||||
3. **Доступность ≈ 0** — нет aria/role/alt на canvas, нет клавиатуры, контраст подписей < AA, нет `prefers-reduced-motion`.
|
||||
4. **Нет сохранения состояния и «в Мои материалы»** — нельзя возобновить опыт/сдать результат; PNG-экспорт у 2 симуляций.
|
||||
5. **Синхрон в онлайн-уроке частичный** — `getState/applyState` у ~11, часто 1 под-режим.
|
||||
6. **Тех-долг жизненного цикла** — нет `destroy`, «дробовик» `_pauseAllSims/closeSim` по ~40 глобалам; добавить сим = 6+ мест; утечки; мёртвый `SimUtil` (`_util.js`).
|
||||
7. **Палитра/DPR дублируются** в каждом canvas-файле; нет общего JS-модуля токенов; canvas игнорирует светлую тему.
|
||||
8. **Производительность** — пул частиц обходит все 1500/кадр; нет «эконом-режима»; тяжёлые 3D/двойной маятник.
|
||||
9. **Хрупкая вставка в урок** — `simId` сырым текстом без списка/валидации (`lesson-editor.html`).
|
||||
|
||||
## Фазы
|
||||
|
||||
### Фаза 0 — Фундамент
|
||||
- `SimBase`/миксин жизненного цикла: `fit/play/pause/reset/destroy/getState/applyState` + DPR из одного места (подключать постепенно).
|
||||
- `LabPalette` — общий JS-модуль цветов/констант (`PX_PER_M`), задел под светлую тему canvas.
|
||||
- Заполнить `manifest.stop/destroy` per-sim → убрать «дробовик».
|
||||
- Убрать мёртвый `SimUtil` (или перевести симуляции на него).
|
||||
- lesson-editor: выпадающий список симуляций из `/api/lab/sims` вместо сырого `simId`.
|
||||
- `prefers-reduced-motion` + «эконом-режим» в `LabFX` (центрально). Закрывает 6,7,8(част.),9.
|
||||
|
||||
### Фаза 1 — Педагогический слой (макс. эффект)
|
||||
- Фреймворк заданий (обобщить `race.js`): `tasks:[{prompt, check(state), tolerance, hint}]` + панель ввода ответа/проверки/прогресса.
|
||||
- «Что понять» + deep-link на § учебника.
|
||||
- XP за задания (с дневным лимитом, анти-фарм). Закрывает 1.
|
||||
|
||||
### Фаза 2 — Ценность для ученика
|
||||
- Сохранение/возобновление параметров (поверх `getState`, localStorage + опц. БД).
|
||||
- «В Мои материалы» (скриншот canvas → `student_materials`) + «Скачать PNG» везде.
|
||||
- Общие измерительные инструменты (линейка/транспортир/курсор). Закрывает 4.
|
||||
|
||||
### Фаза 3 — Тач и доступность
|
||||
- Тач везде (`_addTouchSupport` во все canvas-открытия / pointer-events как `geometry.js`); pinch-zoom.
|
||||
- aria/role/alt + текстовая сводка состояния; клавиатура; контраст до AA; `prefers-reduced-motion`. Закрывает 2,3.
|
||||
|
||||
### Фаза 4 — Полный синхрон в онлайн-уроке
|
||||
- `getState/applyState` для всех симуляций и под-режимов. Закрывает 5 (опирается на Фазу 0).
|
||||
|
||||
### Фаза 5 — Производительность и стабильность
|
||||
- Пул частиц: список живых вместо обхода 1500/кадр; общий RAF-планировщик + пауза по `visibilitychange`.
|
||||
- Асинхронная загрузка тел симуляций (сейчас синхронный XHR).
|
||||
- Фронт-тесты движка. Закрывает 8 + отсутствие тестов.
|
||||
|
||||
### Фаза 6 — Контент и охват
|
||||
- Усилить биологию (генетика, экосистемы, ферменты, кровообращение).
|
||||
- Доработать crystal/orbitals (статичные 3D) — интерактив + задания.
|
||||
- Допривязать `lab_sim_links` к §§ учебников.
|
||||
|
||||
## Порядок
|
||||
Старт — Фаза 0. Затем Фаза 1 (педагогика) + Фаза 3 (тач/доступность) параллельно.
|
||||
Быстрые победы: тач массово, список симуляций в редакторе урока, reduced-motion/эконом, убрать `SimUtil`.
|
||||
|
||||
## Прогресс
|
||||
- [ ] Фаза 0
|
||||
- [ ] Фаза 1
|
||||
- [ ] Фаза 2
|
||||
- [ ] Фаза 3
|
||||
- [ ] Фаза 4
|
||||
- [ ] Фаза 5
|
||||
- [ ] Фаза 6
|
||||
Reference in New Issue
Block a user