5 Commits

11 changed files with 1389 additions and 117 deletions
+27
View File
@@ -158,3 +158,30 @@ git push origin master
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется. - **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15. - **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию. - **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
### SimForge improvements — P3 (Графики/диаграммы) — Learnings
Всё в `frontend/js/labs/_sim_engine.js`. Расширен `_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — в P3 не дублировались.
- **Несколько кривых.** Нормализуются в `prep.curves[]` с приоритетом источника: `curves:[{...}]``exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость). Каждой кривой свой цвет: явный `color` или `DEFAULT_PALETTE[i%8]`. `prep.exprFn` оставлен = первой кривой (нужен trace-режиму `_accumPlotTrace`).
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`(solid|dashed|dotted), `opacity`(0..1), `fill`(true→полупрозр. цвет кривой / строка цвета), `marker`(none|dot|ring). Не заданные наследуют plot-уровень (`width/lineStyle/opacity`). **Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
- **Заливка под кривой** — `_fillUnderCurve(ctx,pts,baseY)`: между кривой и осью `y=0` (baseY клиппится к canvas), посегментно — разрывы у не-finite точек НЕ сливаются в один полигон. `fill:true``_fillAlpha(color,0.18)` (#RGB/#RRGGBB→rgba; прочие форматы как есть, alpha через globalAlpha).
- **Маркеры узлов** — `_drawCurveMarkers` переиспользует `_drawPoint` (dot→filled, ring→hollow), прорежены ~28px по экрану (не сотни точек на 200 сэмплах).
- **Легенда** — `_drawLegend` на canvas (НЕ DOM): тёмная плашка (`roundRect` с фолбэком на `fillRect`) + цветной свотч (strokeStyle цвета кривой) + светлый `fillText`. Верх-право, не наезжает на бар кнопок вида. Авто при наличии `label`; `legend:false` отключает. ⛔ Пользовательский цвет — только canvas-сток; текст легенды — фикс. светлый.
- **Качество кривой** — пропуск не-finite (разрывы через `started=false`), переиспользован equidistant sampling (`samples` 200/макс 2000), `_applyStroke` даёт dash/opacity/glow/round-стыки. Каждая кривая в своём `ctx.save()/restore()`, легенда — на внешнем уровне → состояние не протекает.
- **Новые хелперы модульного уровня** (рядом с `_dashFor`/`_opacity`): `_markerStyle(v)` (none|dot|ring), `_fillAlpha(color,a)` (hex→rgba для заливки).
- **Верификация P3**: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`) 10/10: легаси одиночный expr, exprs[], curves[]+fill+marker+legend, наследование plot-уровня, не-finite (1/x, tan) без throw, legend:false, trace±range, fillUnder+markers с null-разрывами, регресс point/vector/circle/rect — все PASS; **ctx сбалансирован** (depth→0, нет restore-underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0. Temp-смоук удалён.
- **На P4 (билдер)**: дать полям контролы — список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-уровневые fill/marker, тумблер легенды.
### SimForge improvements — P4 (UI билдера + контролы стиля) — Learnings
Всё в `frontend/sim-builder.html` (CSS) + `frontend/js/sim-builder.js` (логика). `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер только генерит спеку, которую движок P2/P3 уже рисует.
- **Контролы стиля = data-driven хелперы** (рядом с `field`/`miniField`): `colorCtl(label,attr,val,clearable)` (нативный `<input type=color>` + текст + опц.очистка), `rangeCtl` (слайдер 0..1 для opacity), `selectCtl` (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — `Builder.styleBlock(o)`, набор полей решает `STYLE_FOR[type]` ({opacity,line,point,glow,grad}).
- **Цвет: текст — источник истины, не нативный пикер.** Нативный `<input type=color>` умеет только `#rrggbb`; rgba()/named он бы потерял. Поэтому пикер лишь ПИШЕТ в текстовое поле и диспатчит `input` (его ловит основной `data-of`/`data-cvf`-обработчик). `Builder.wireColorControls(row)` связывает пикер↔текст↔очистку. `toHexColor(v)` приводит #rgb/#rrggbb#rrggbb (иначе #000000) для нативного пикера. Очистка (fill/trailColor) = пустая строка → `stripObj` выбрасывает → «нет заливки».
- **Round-trip как чинили range в Ф4: дефолты НЕ сериализуем.** `stripObj.isDefaultStyle(k,v)` выбрасывает `hidden`, `glow:false`, `lineStyle:'solid'`, `pointStyle:'filled'`, `opacity:1`, `trail/closed:false`. Спека минимальна, а save→load→save идемпотентен (loadFromSim восстанавливает дефолты из контролов). Селекты хранят дефолтную строку в `st`, но она не уходит в спеку. Проверено vm-смоуком.
- **Plot теперь — кривые.** UI-модель plot = `{var,range_a/b,samples,trace,legend,plotFill,plotMarker,curves:[{_uid,expr,color,label,width,lineStyle,opacity,fill(bool),fillColor,marker}]}`. `plotEditor`+`curveEditor` рисуют, `loadPlot` (spec→UI: `curves[]``exprs[]`→легаси `expr`; легаси plot-level width/lineStyle/opacity наследуются кривой), `normalizePlotForSpec`+`stripCurve` (UI→spec). **Одиночная «простая» кривая (только expr+color, без plot-fill/marker) → легаси `{expr,color}`**, иначе `curves:[...]` — не ломает обратную совместимость. `legend:false` эмитится только при выкл (движок включает легенду авто при label). Валидация: каждая кривая + границы range через `SimExpr.compile`.
- **z-order / видимость / дублирование — чисто в билдере** (движок не трогали): z-order = порядок массива `st.objects`/`st.plots` (кнопки вверх/вниз свапают, крайние disabled). Видимость `hidden:true` — билдерский флаг, `buildSpec` фильтрует hidden из спеки (движок про hidden не знает). Дублирование — `JSON.parse(JSON.stringify(o))` + новый `_uid` + `id+'_copy'`, вставка после оригинала. Аналогично у plot.
- **Новые ICON** (inline SVG `.ic`, ⛔ без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта `flex-wrap` + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец).
- **Верификация P4**: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js.
- **На P5 (прямое манипулирование + история)**: drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в `_sim_engine.js`). Undo/redo: `this.st` сериализуем JSON → стек снапшотов в Builder, restore + `renderPanels`/`scheduleRemount`.
+1 -1
View File
@@ -525,7 +525,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) { function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant']; 'gamification', 'assistant', 'sim_builder'];
const updates = req.body; const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
+13 -7
View File
@@ -6,29 +6,35 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController'); const c = require('../controllers/customSimController');
router.use(authMiddleware); router.use(authMiddleware);
// «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
// Чтение/проигрывание уже сохранённых симуляций остаётся доступным; гейтим только
// авторинг — создание/правку/удаление/раздачу/клон/связи.
const gate = requireFeature('sim_builder');
router.get('/', c.list); router.get('/', c.list);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id', c.get); router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related); router.get('/:id/related', c.related);
router.post('/', requireRole('teacher', 'admin'), c.create); router.post('/', gate, requireRole('teacher', 'admin'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', requireRole('teacher', 'admin'), c.update); router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', requireRole('teacher', 'admin'), c.remove); router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline // Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
// requireRole(teacher,admin) + per-row ownership в хендлере. // requireRole(teacher,admin) + per-row ownership в хендлере.
router.post('/:id/share', requireRole('teacher', 'admin'), c.share); router.post('/:id/share', gate, requireRole('teacher', 'admin'), c.share);
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone); router.post('/:id/clone', gate, requireRole('teacher', 'admin'), c.clone);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink); router.post('/:id/links', gate, requireRole('teacher', 'admin'), c.addLink);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink); router.delete('/:id/links/:linkId', gate, requireRole('teacher', 'admin'), c.removeLink);
module.exports = router; module.exports = router;
+1
View File
@@ -17,6 +17,7 @@
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
]; ];
const FS_FEATURES = [ const FS_FEATURES = [
+190 -12
View File
@@ -137,6 +137,26 @@
return v < 0 ? 0 : (v > 1 ? 1 : v); return v < 0 ? 0 : (v > 1 ? 1 : v);
} }
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
function _markerStyle(v) {
return (v === 'dot' || v === 'ring') ? v : 'none';
}
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
function _fillAlpha(color, a) {
if (typeof color !== 'string') return color;
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (!m) return color;
var h = m[1], r, g, b;
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
} else {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
}
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */ /* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) { function bind(value, dflt) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -635,13 +655,52 @@
bp('x', 0); bp('y', 0); bp('x', 0); bp('y', 0);
} else if (type === 'plot') { } else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x'; prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null; var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null); prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null); prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng; prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0)); prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace; prep.trace = !!o.trace;
// ── P3: несколько кривых на одном plot ──
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
var curveDefs = [];
if (Array.isArray(o.curves) && o.curves.length) {
curveDefs = o.curves.map(function (cv) {
return (cv && typeof cv === 'object') ? cv : { expr: cv };
});
} else if (Array.isArray(o.exprs) && o.exprs.length) {
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
} else {
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
}
var plotMarker = _markerStyle(o.marker);
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
prep.curves = curveDefs.map(function (cv, ci) {
cv = cv || {};
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
return {
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
label: (cv.label != null) ? String(cv.label) : '',
width: num(cv.width, prep.width),
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
: prep.lineStyle,
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
// маркеры узлов: none|dot|ring (наследует plot-уровень)
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
glow: prep.glow,
glowColor: prep.glowColor,
glowBlur: prep.glowBlur
};
});
// легаси: одиночное выражение для trace-режима (накопление по t)
prep.exprFn = prep.curves[0].exprFn;
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
prep.legend = (o.legend === false) ? false : anyLabel;
} else if (type === 'readout') { } else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка) // компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0') var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
@@ -1471,11 +1530,14 @@
} }
}; };
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */ /* ── plot: график f(var) на отрезке range (мир-координаты) ──
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) { SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет) // trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return; if (o.trace && !o.hasRange) return;
var vp = this._vp(); var vp = this._vp();
var W = this._cw, H = this._ch;
var a = o.rangeA.ev(env), b = o.rangeB.ev(env); var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; } if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return; if (a === b) return;
@@ -1485,25 +1547,141 @@
var prev = env[o.varName]; var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName); var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save(); var curves = o.curves || [];
this._applyStroke(ctx, o); var legendItems = [];
ctx.strokeStyle = o.color; // y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
ctx.beginPath(); var zeroPy = this._toPx(0, 0)[1];
var started = false; for (var ci = 0; ci < curves.length; ci++) {
var cv = curves[ci];
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
var pts = [];
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
var xv = a + step * i; var xv = a + step * i;
env[o.varName] = xv; env[o.varName] = xv;
var yv = o.exprFn.ev(env); var yv = cv.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; } if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
var px = this._toPx(xv, yv); var p = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; } pts.push([p[0], p[1], yv]);
else ctx.lineTo(px[0], px[1]); }
ctx.save();
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
if (cv.fill) {
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
ctx.save();
ctx.globalAlpha = cv.opacity;
ctx.fillStyle = fillCol;
ctx.shadowBlur = 0;
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
ctx.restore();
}
// линия кривой (через _applyStroke: dash/opacity/glow/width)
this._applyStroke(ctx, cv);
ctx.strokeStyle = cv.color;
ctx.beginPath();
var started = false;
for (var k = 0; k < pts.length; k++) {
if (!pts[k]) { started = false; continue; }
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
else ctx.lineTo(pts[k][0], pts[k][1]);
} }
ctx.stroke(); ctx.stroke();
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
if (cv.marker && cv.marker !== 'none') {
this._drawCurveMarkers(ctx, pts, cv);
}
ctx.restore(); ctx.restore();
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
}
// восстановить env // восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName]; if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
// легенда (поверх кривых, в углу области plot, на canvas)
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
};
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
var i = 0, n = pts.length;
while (i < n) {
// найти начало непрерывного сегмента
while (i < n && !pts[i]) i++;
var startI = i;
while (i < n && pts[i]) i++;
var endI = i; // [startI, endI)
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
ctx.beginPath();
ctx.moveTo(pts[startI][0], baseY);
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.lineTo(pts[endI - 1][0], baseY);
ctx.closePath();
ctx.fill();
}
};
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
var r = Math.max(2.5, (cv.width || 2) + 1.5);
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
var lastX = -1e9, lastY = -1e9;
for (var k = 0; k < pts.length; k++) {
var p = pts[k];
if (!p) continue;
var dx = p[0] - lastX, dy = p[1] - lastY;
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
this._drawPoint(ctx, marker, p[0], p[1], r);
lastX = p[0]; lastY = p[1];
}
};
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
if (!items.length) return;
ctx.save();
ctx.font = '12px Manrope,system-ui,sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
var pad = 8, rowH = 18, swatch = 11, gap = 7;
// ширина по самой длинной подписи
var maxTxt = 0;
for (var i = 0; i < items.length; i++) {
var w = ctx.measureText(items[i].label).width;
if (w > maxTxt) maxTxt = w;
}
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
var bx = W - boxW - 12, by = 12;
if (bx < 6) bx = 6;
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(13,13,26,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
for (var j = 0; j < items.length; j++) {
var cy = by + pad + 7 + j * rowH;
// цветная метка (линия-свотч)
ctx.strokeStyle = items[j].color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
ctx.stroke();
// текст метки (светлый, без пользовательского цвета в DOM)
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
}
ctx.restore();
}; };
/* ── readout: живое значение выражения как бейдж на оверлее ── */ /* ── readout: живое значение выражения как бейдж на оверлее ── */
File diff suppressed because it is too large Load Diff
+46 -2
View File
@@ -82,10 +82,42 @@
/* ── объект ── */ /* ── объект ── */
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); } .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.is-hidden, .sbu-plot.is-hidden { opacity: .62; }
.sbu-obj.is-hidden .sbu-obj-fields, .sbu-obj.is-hidden .sbu-obj-style { opacity: .7; }
.sbu-obj-hdr { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; } .sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
.sbu-in-id { flex: 1; max-width: 120px; } .sbu-in-id { flex: 1; min-width: 64px; max-width: 110px; }
.sbu-obj-hdr .sbu-icon-btn { width: 26px; height: 26px; }
.sbu-icon-btn:disabled { opacity: .32; cursor: default; pointer-events: none; }
.sbu-icon-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
.sbu-zord { color: var(--text-3); }
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } .sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── блок «Стиль» объекта (P4) ── */
.sbu-obj-style { border-top: 1px dashed var(--border); padding-top: 7px; margin-top: 1px; display: flex; flex-direction: column; gap: 7px; }
.sbu-obj-style .sbu-sub { margin-top: 0; }
.sbu-style-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; align-items: end; }
.sbu-style-row > * { min-width: 0; }
.sbu-grad-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── color-picker контрол (нативный пикер + текст + очистка) ── */
.sbu-color-mini { min-width: 0; }
.sbu-color-wrap { display: flex; align-items: center; gap: 5px; }
.sbu-color-pick { width: 30px; height: 30px; flex-shrink: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
.sbu-color-pick::-webkit-color-swatch-wrapper { padding: 3px; }
.sbu-color-pick::-webkit-color-swatch { border: none; border-radius: 5px; }
.sbu-color-wrap .sbu-in-color { flex: 1; min-width: 0; }
.sbu-color-clr { width: 26px; height: 26px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text-3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.sbu-color-clr:hover { border-color: #ef4444; color: #ef4444; }
/* ── range (opacity) ── */
.sbu-range-mini { min-width: 0; }
.sbu-range-val { color: var(--violet); font-variant-numeric: tabular-nums; }
.sbu-range { width: 100%; accent-color: var(--violet); height: 30px; box-sizing: border-box; }
/* ── кривые графика ── */
.sbu-curves { display: flex; flex-direction: column; gap: 8px; }
.sbu-curve { border: 1px solid var(--border); border-radius: 9px; padding: 8px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-curve-del { width: 24px; height: 24px; }
.sbu-of { display: flex; flex-direction: column; gap: 2px; } .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-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 { 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; }
@@ -113,6 +145,11 @@
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); } .sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
.sbu-preview { min-height: 320px; } .sbu-preview { min-height: 320px; }
} }
@media (max-width: 560px) {
.sbu-obj-fields { grid-template-columns: 1fr; }
.sbu-style-row, .sbu-grad-row { grid-template-columns: 1fr; }
.sbu-row4 { grid-template-columns: 1fr 1fr; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -152,6 +189,13 @@
var ip = LS.initPage() || {}; var ip = LS.initPage() || {};
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; } if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) { if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
document.getElementById('sbu-preview').innerHTML = document.getElementById('sbu-preview').innerHTML =
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>'; '<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
+131 -2
View File
@@ -416,7 +416,7 @@
<div class="tg-nav-title">Содержание</div> <div class="tg-nav-title">Содержание</div>
<div class="tg-progress-wrap"> <div class="tg-progress-wrap">
<div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div> <div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div>
<div class="tg-progress-text" id="tg-prog-text">0 из 13 глав прочитано</div> <div class="tg-progress-text" id="tg-prog-text">0 из 21 глав прочитано</div>
</div> </div>
</div> </div>
<div class="tg-nav-search"> <div class="tg-nav-search">
@@ -1587,7 +1587,7 @@
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
</div> </div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Это вся учительская часть</div>Дальше — главы для администраторов (видны только под ролью admin).</div></div> <div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
</div> </div>
<div class="tg-chapter-nav"> <div class="tg-chapter-nav">
@@ -1595,6 +1595,134 @@
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div> <div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div> <div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div>
</div> </div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-21')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Конструктор симуляций</div></div>
</div>
</div>
</div>
<!-- ═══ CHAPTER 21 — КОНСТРУКТОР СИМУЛЯЦИЙ ═══ -->
<div class="tg-chapter" id="ch-21">
<div class="tg-chapter-header">
<div class="tg-chapter-icon"><i data-lucide="pencil-ruler"></i></div>
<div class="tg-chapter-meta">
<div class="tg-chapter-num">Глава 21</div>
<div class="tg-chapter-title">Конструктор симуляций</div>
</div>
<a href="/sim-builder" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Открыть конструктор</a>
</div>
<div class="tg-section" id="s-21-1">
<div class="tg-section-title">21.1 Что это и где</div>
<p><b>Конструктор симуляций</b> — инструмент, в котором вы сами, без программирования, собираете интерактивную 2D-сцену: параметры-ползунки, объекты (точки, отрезки, векторы, фигуры, подписи), привязанные <b>формулами</b> к параметрам и времени, настоящую физику и графики. Готовую симуляцию можно сохранить, опубликовать в лабораторию, раздать классу и открыть на доске онлайн-урока.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Открыть: в боковом меню пункт <b>«Конструктор симуляций»</b> или адрес <a href="/sim-builder">/sim-builder</a>. Доступно учителю и администратору.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Симуляция — это <b>данные</b>, а не код. Формулы вычисляются безопасным движком (доступны только математические функции), поэтому готовыми сценами безопасно делиться.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="lightbulb"></i></div><div class="tg-box-body"><div class="tg-box-label">Что реально собрать</div>Кинематику (брошенное тело, равноускоренное движение), колебания и волны, графики функций, геометрические чертежи, а с включённой физикой — маятники, пружины и упругие столкновения.</div></div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Если пункта меню нет</div>Администратор мог отключить конструктор: <b>Админка → Функции → «Конструктор симуляций»</b>. При выключенном тумблере страница недоступна, но ранее опубликованные симуляции в лаборатории продолжают работать.</div></div>
</div>
<div class="tg-section" id="s-21-2">
<div class="tg-section-title">21.2 Рабочее поле</div>
<p>В центре — <b>живое превью</b>: всё, что вы добавляете и меняете, сразу видно. Слева — панели настроек, сверху — панель инструментов (Тест, Сброс, Сохранить, Опубликовать, Шаблон, Раздать, отмена/повтор).</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Масштаб</b> — колесо мыши (приближает к курсору).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перемещение вида (панорама)</b> — перетаскивание мышью по пустому месту сцены.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Кнопки в углу сцены</b> — «Вписать» (показать всю область) и «Сбросить вид».</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сетка и оси</b> с числовыми делениями и точкой (0,0) рисуются автоматически; границы области задаются в настройках сцены (xmin/xmax/ymin/ymax).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Контролы запущенной симуляции (ползунки, плей/пауза) — плавающая панель в углу, не закрывает сцену; её можно свернуть.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="play"></i></div><div class="tg-box-body"><div class="tg-box-label">Кнопка «Тест»</div>Запускает анимацию (время <code>t</code> идёт), «Сброс» — возвращает в начало. Пока симуляция на паузе, перетаскивание объектов и ползунки служат для настройки сцены.</div></div>
</div>
<div class="tg-section" id="s-21-3">
<div class="tg-section-title">21.3 Параметры (ползунки)</div>
<p>Параметр — это переменная со ползунком, которой можно управлять прямо в симуляции. На параметры потом ссылаются формулы объектов.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте панель <b>«Параметры»</b> → «Добавить параметр».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте: <b>имя</b> (латиницей, например <code>v</code>, <code>theta</code>), минимум, максимум, шаг, начальное значение и (необязательно) единицу.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В превью появится ползунок — двигайте его, чтобы видеть, как меняется сцена.</div></div>
</div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Зарезервированные имена</div>Нельзя называть параметр <code>t</code> (время), <code>e</code>, <code>pi</code>, <code>w</code>, <code>h</code> — это служебные имена в формулах. Конструктор предупредит и не даст сохранить.</div></div>
</div>
<div class="tg-section" id="s-21-4">
<div class="tg-section-title">21.4 Объекты и формулы</div>
<p>Объекты — это то, что рисуется на сцене. Любое числовое поле объекта (координата, радиус, размер) можно задать <b>числом или формулой</b> от параметров и времени <code>t</code>.</p>
<div class="tg-tools-grid">
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="dot"></i></div><div><div class="tg-tool-name">Точка / Отрезок / Вектор</div><div class="tg-tool-desc">Базовая геометрия со стрелками</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="circle"></i></div><div><div class="tg-tool-name">Круг / Прямоугольник</div><div class="tg-tool-desc">Фигуры с заливкой</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="spline"></i></div><div><div class="tg-tool-name">Ломаная / Кривая</div><div class="tg-tool-desc">Набор точек</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="type"></i></div><div><div class="tg-tool-name">Подпись (LaTeX)</div><div class="tg-tool-desc">Текст и формулы KaTeX</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="line-chart"></i></div><div><div class="tg-tool-name">График</div><div class="tg-tool-desc">Кривая y=f(x), см. 21.7</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="gauge"></i></div><div><div class="tg-tool-name">Индикатор (readout)</div><div class="tg-tool-desc">Живое числовое значение</div></div></div>
</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Панель <b>«Объекты»</b> → выберите тип → «Добавить».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">В полях координат пишите формулы: например для броска тела <code>x = v*cos(theta)*t</code>, <code>y = v*sin(theta)*t - 5*t^2</code>.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Кнопка <b>fx</b> у поля открывает палитру: параметры, время <code>t</code>, функции (sin, cos, sqrt, abs, exp, ln…), константы (pi, e). Клик вставляет имя в формулу.</div></div>
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Объект можно поставить мышью: нажмите значок <b>«прицел»</b> у объекта и кликните по сцене — координаты подставятся (см. 21.5).</div></div>
<div class="tg-step"><div class="tg-step-num">5</div><div class="tg-step-body">У объекта можно задать <b>id</b> и затем ссылаться на его координаты в других формулах: <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="function-square"></i></div><div class="tg-box-body"><div class="tg-box-label">Ошибки в формуле</div>Если выражение записано неверно, поле подсветится и покажет ошибку — симуляция при этом не ломается. Деление на ноль и неопределённости дают 0.</div></div>
</div>
<div class="tg-section" id="s-21-5">
<div class="tg-section-title">21.5 Стиль, порядок и прямое редактирование</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стиль объекта</b>: выбор цвета (палитра), прозрачность, толщина и тип линии (сплошная / штрих / пунктир), стиль точки, свечение, градиентная заливка.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Порядок и операции</b>: кнопки «вверх/вниз» меняют порядок отрисовки (что поверх чего), есть дублирование и тумблер видимости (глаз), удаление.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перетаскивание на сцене</b>: значок «прицел» включает ручки — тяните точку, концы отрезка/вектора, вершины ломаной прямо на превью (на паузе). Поля с формулами при этом не затираются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Привязка к сетке</b> (тумблер в панели инструментов) — координаты при перетаскивании округляются к узлам сетки.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Отмена/повтор</b>: кнопки в панели инструментов и горячие клавиши <code>Ctrl+Z</code> / <code>Ctrl+Y</code> (или <code>Ctrl+Shift+Z</code>).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-6">
<div class="tg-section-title">21.6 Физика</div>
<p>Включите тумблер <b>«Физика»</b> — и часть объектов будет двигаться по законам механики, а не по формуле. Движок сам интегрирует движение.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Общие силы</b>: гравитация (g по X и Y), трение, упругость столкновений (0…1).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Тело</b>: у точки/круга включите «тело» и задайте массу и начальную скорость (vx, vy). Тело падает, сталкивается, отскакивает.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Пружины</b> — между двумя телами или телом и точкой-якорем (жёсткость, длина покоя, демпфирование).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стены</b> — границы области (низ/верх/лево/право) или произвольный отрезок: от них тела отражаются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Тело можно <b>тянуть мышью</b> на паузе; при отпускании в запущенной сцене оно полетит.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="info"></i></div><div class="tg-box-body"><div class="tg-box-label">Формулы и физика вместе</div>Физические тела и формульные объекты сосуществуют на одной сцене: например, подпись или вектор скорости можно привязать к координатам тела через <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-section" id="s-21-7">
<div class="tg-section-title">21.7 Графики и диаграммы</div>
<p>Объект <b>«График»</b> рисует кривую функции прямо на сцене в мировых координатах.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Задайте выражение (например <code>sin(x)</code>), переменную (по умолчанию <code>x</code>), диапазон и число точек.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Несколько кривых</b> на одном графике — у каждой свои цвет, подпись, толщина и стиль линии.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Заливка под кривой</b>, <b>маркеры точек</b> и <b>легенда</b> (по подписям кривых) включаются переключателями.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Режим <b>«след» (trace)</b> — кривая накапливается по времени <code>t</code> (удобно строить график величины в ходе анимации).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-8">
<div class="tg-section-title">21.8 Сохранение, публикация и раздача</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сохранить</b> — симуляция сохраняется как черновик (видна только вам).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Опубликовать</b> — симуляция появляется в лаборатории для всех; «Снять с публикации» возвращает в черновик.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Шаблон</b> — начать не с нуля, а с заготовки (пустая, маятник, график, бросок).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Раздать классу</b> — ученики класса получают уведомление со ссылкой на симуляцию (она автоматически публикуется).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Клонировать</b> — сделать свою копию чужой опубликованной симуляции и доработать её.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="flask-conical"></i></div><div class="tg-box-body"><div class="tg-box-label">Где появляются ваши симуляции</div>В <a href="/lab">лаборатории</a> есть раздел <b>«Мои симуляции»</b> — там ваши черновики и опубликованные, с кнопками «Редактировать» и «Удалить». Прямая ссылка вида <code>/lab?sim=custom:НОМЕР</code> открывает конкретную симуляцию.</div></div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="presentation"></i></div><div class="tg-box-body"><div class="tg-box-label">На онлайн-уроке</div>Свою симуляцию можно открыть на доске урока — значения ползунков синхронизируются у учеников, а поверх можно рисовать аннотации.</div></div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово</div>Это вся учительская часть руководства. Дальше — главы для администраторов (видны только под ролью admin).</div></div>
</div>
<div class="tg-chapter-nav">
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-20')">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Ещё модули платформы</div></div>
</div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right"> <div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div> <div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div> <div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
@@ -1928,6 +2056,7 @@
{ id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] }, { id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] },
{ id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] }, { id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] },
{ id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] }, { id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] },
{ id:'ch-21', label:'Конструктор симуляций', icon:'pencil-ruler', sections:['s-21-1','s-21-2','s-21-3','s-21-4','s-21-5','s-21-6','s-21-7','s-21-8'], sLabels:['Что это и где','Рабочее поле','Параметры','Объекты и формулы','Стиль и порядок','Физика','Графики','Сохранение и раздача'] },
]; ];
const ADMIN_CHAPTERS = [ const ADMIN_CHAPTERS = [
+1
View File
@@ -860,6 +860,7 @@ async function hideDisabledFeatures() {
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'], live_quiz: ['/live-quiz'],
classroom: ['/classroom'], classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'], exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'], textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
}; };
+85 -3
View File
@@ -1,6 +1,85 @@
# Feature Context: Конструктор симуляций (SimForge) # Feature Context: Конструктор симуляций (SimForge)
## Current State ## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) ЗАВЕРШЁН — P5 «Прямое манипулирование + история» РЕАЛИЗОВАН**
(рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файл: ТОЛЬКО `frontend/js/sim-builder.js`.
`_sim_engine.js` НЕ тронут — `_toWorld`/`_toPx`/`_niceStep` уже публичны на инстансе движка, хука не
потребовалось (в IMPROVEMENTS.md P5 предполагались правки движка — не понадобились).
- **Прямое манипулирование** (`bindPreviewDrag` переписан): «ручки» через `handlesOf(obj)` для ВСЕХ
позиционируемых типов — point/circle/label/readout/rect (одна ручка x,y), segment/vector (origin x1,y1 +
end x2,y2 ИЛИ origin+dx/dy), polyline/path (по ручке на числовую вершину `points`). Хит-тест `pickHandle`
(14px, через `_toPx`); режимы pointerdown: `handle`/`place` (единств. ручка — клик ставит)/`body`
(несколько ручек — относительный сдвиг)/`none`. Поля-выражения `blocked` (не затираются). `refreshObjFields`
расширен на x1/y1/x2/y2/dx/dy/points.
- **Snap-к-сетке**: тумблер в тулбаре (`_snap`, `toggleSnap`, иконка `ICON.grid`, активность — инлайн
`SNAP_ACTIVE_CSS`); округление к `_niceStep(34)` (минорный шаг сетки; fallback 0.5). Выравнивание к чужим
координатам не делалось (бонус; snap достаточно — отмечено как частичное).
- **Undo/Redo**: стек `JSON.stringify(this.st)` (глубина 50), `pushHistory` (до мутации, без дублей, сброс
redo), `snapField` (один снапшот на сессию правки поля через focusin/`_fieldSnapTaken`). Структурные
операции — снапшот сразу; drag — один на сессию (no-op откатывается). Кнопки undo/redo (SVG `.ic`) +
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, игнорит фокус в полях). `loadFromSim` обнуляет
историю; `_restoreSnapshot` → renderPanels + scheduleRemount.
- Верификация: `node --check` OK; эмодзи/eval — 0; vm-смоук 38/38 PASS (drag всех типов + body-move; snap;
защита выражений; undo/redo drag+add; лимит стека; round-trip идемпотентен). buildSpec/валидация не тронуты.
git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит параллельной сессии
«goal/game», мной НЕ редактировался).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
`_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
- **Контролы стиля объекта** (блок «Стиль», `STYLE_FOR[type]`): слайдер непрозр.(`opacity` 0..1),
select `lineStyle`(solid/dashed/dotted), `pointStyle`(только point), тумблер `glow`, тумблер градиент-
заливки(circle/rect → `gradient:[c0,c1]`). Цвета — `colorCtl`: нативный `<input type=color>` + текст
(источник истины, держит rgba/named) + очистка для fill/trailColor. Синхрон — `wireColorControls`,
`toHexColor``#rrggbb`. Per-объект уже были width/color в OBJ_FIELDS — переведены на color-пикеры.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель `{var,range_a/b,samples,trace,legend,
plotFill,plotMarker,curves:[{expr,color,label,width,lineStyle,opacity,fill,fillColor,marker}]}`. Список
кривых (add/del, минимум 1), на кривую все P3-поля + fx-палитра, plot-уровневые fill/marker/легенда.
`loadPlot` (spec→UI: curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity → в кривую),
`normalizePlotForSpec`+`stripCurve` (UI→spec). Одиночная простая кривая → легаси `{expr,color}`, иначе
`curves:[...]`. `legend:false` эмитится только при выкл.
- **Список объектов/графиков**: z-order вверх/вниз (порядок массива = порядок отрисовки), видимость
(`hidden:true` — чисто билдерский флаг, фильтруется в `buildSpec`, движок не знает), дублировать
(deep-clone+новый `_uid`, `id+'_copy'`), удалить. Иконки — новые inline SVG `.ic` (up/down/copy/eye/eyeOff/clearX).
- **Минимизация спеки + стабильный round-trip**: `stripObj.isDefaultStyle` выбрасывает дефолты
(glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false) и `hidden`. Save→load→
save идемпотентен (loadFromSim восстанавливает дефолты из контролов).
- **Дизайн/мобайл**: новые CSS-классы в ls.css-стиле (`.sbu-obj-style`/`.sbu-style-row`/`.sbu-color-*`/
`.sbu-range`/`.sbu-curve(s)`/`.is-hidden`/`.sbu-grad-row`); заголовок объекта flex-wrap + 26px-кнопки;
медиа ≤920px (раскладка) + новый ≤560px (поля/стили в один столбец). Пустые состояния дополнены.
- **Безопасность**: выражения только через `SimExpr.compile`; цвета попадают лишь в спеку (canvas-стоки
движка), DOM-style с польз.цветом не используется; eval/new Function — нет.
- Верификация: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи нет (скан кодпойнтов
обоих файлов — 0); eval/new Function — 0; headless vm-смоук (DOM/SimExpr-стаб) 27+12 PASS: стили объекта в
спеке, round-trip объектов идемпотентен ×2, plot с 2 кривыми (label/marker/lineStyle/opacity/fill-цвет/
range/samples) + round-trip ×2, легаси-одиночная кривая → легаси-форма + round-trip, hidden исключает из
спеки, z-order=порядок массива, дефолты-стрип; +шаблонные легаси-plot save→load→save стабильны (2 PASS).
Temp удалены. git status: тронуты только sim-builder.html и sim-builder.js.
- **Следующее (P5):** прямое манипулирование на сцене (drag всех типов + snap-к-сетке + выравнивание) и
undo/redo. Потребуются правки `_sim_engine.js` (хит-тесты/ручки) + `sim-builder.js` (стек снапшотов `this.st`).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P3 «Графики/диаграммы» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Расширен
`_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — не дублировались.
- **Несколько кривых**: нормализуются в `prep.curves[]`, приоритет источника `curves:[{...}]`
`exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость сохранена). Каждой кривой
свой цвет (явный `color` или `DEFAULT_PALETTE[i%8]`). `prep.exprFn` = первой кривой (для trace-режима).
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`, `opacity`,
`fill`(true→полупрозр. цвет / строка), `marker`(none|dot|ring). Не заданные наследуют plot-уровень.
**Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
- **Заливка под кривой** `_fillUnderCurve` (между кривой и y=0, посегментно — разрывы у не-finite не
сливаются; baseY клиппится к canvas). **Маркеры** `_drawCurveMarkers` (переиспользует `_drawPoint`,
прорежены ~28px). **Легенда** `_drawLegend` на canvas (тёмная плашка + свотч + светлый текст, верх-право,
авто при `label`, `legend:false` отключает). Новые модульные хелперы `_markerStyle`/`_fillAlpha`.
- **Безопасность**: цвета только в canvas-стоки (strokeStyle/fillStyle/fillText фикс-цвет легенды);
DOM-style с пользовательским цветом не используется; eval нет. Каждая кривая в своём save/restore,
легенда на внешнем уровне.
- Верификация: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ
`_sim_expr`+`_sim_engine`) 10/10: легаси/exprs[]/curves+fill+marker+legend/наследование/не-finite
(1/x,tan)/legend:false/trace±range/fillUnder+markers с null/регресс point-vector-circle-rect — все PASS;
ctx сбалансирован (depth→0, нет underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0.
Temp-смоук удалён. git status: тронут только `_sim_engine.js`.
- **Следующее (P4):** UI билдера + контролы стиля (`sim-builder.html`/`sim-builder.js`) — дать новым полям
plot контролы: список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-fill/
marker, тумблер легенды; плюс per-объект color/opacity/width/dash, z-order, дублирование, мобайл.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не - **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок → закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
эффект и в билдере, и в /lab, и на доске. эффект и в билдере, и в /lab, и на доске.
@@ -25,9 +104,7 @@
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист. масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
в комментарии стр.15. git status: тронут только `_sim_engine.js`. в комментарии стр.15. git status: тронут только `_sim_engine.js`.
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под - **Следующее (P3):** РЕАЛИЗОВАНО (см. блок P3 выше) — несколько кривых, заливка, маркеры, легенда.
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
`_drawPoint` готовы к переиспользованию.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено; - **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске. `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
@@ -213,6 +290,11 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`. - Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE ## RESUME STATE
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md):** P1+P2+P3 закоммичены; **P4 «UI билдера + контролы стиля»
РЕАЛИЗОВАН** (рабочее дерево, не закоммичено — ждёт ревьюера/оркестратора). Файлы: только
`frontend/sim-builder.html` + `frontend/js/sim-builder.js`. Дальше — независимый ревью P4, затем P5
(прямое манипулирование на сцене для всех типов + snap/выравнивание + undo/redo; правки `_sim_engine.js`
+ `sim-builder.js`). Контракт стилей/кривых из P2/P3-handoff полностью покрыт контролами билдера.
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора) - Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ. - Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master. Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
+92 -9
View File
@@ -68,20 +68,103 @@
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
готовы к переиспользованию. готовы к переиспользованию.
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых, - [x] **P3 — Графики/диаграммы (визуал charts).** Для plot: несколько кривых, заливка под кривой,
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер маркеры точек, легенда; аккуратный стиль диаграмм (оси/сетка/подписи — уже из P1). Файл: `_sim_engine.js`.
поля plot).
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color- **Handoff (P3 → P4): новые поля plot-объекта** (контракт для контролов билдера в P4). Все читаются в
`_prepareObjects` (ветка `type==='plot'`), рендерятся ТОЛЬКО на canvas (без DOM-style/eval). Старый
одиночный `expr`/`var`/`range`/`samples`/`trace` работает как раньше (обратная совместимость):
- **Несколько кривых.** Источник (приоритет): `curves:[{...}]``exprs:['sin(x)','x^2']``expr`
(легаси). Нормализуются в `prep.curves[]`. Каждой кривой свой цвет: явный `color` или
`DEFAULT_PALETTE[i%8]`. `prep.exprFn` = первая кривая (для trace-режима).
- **Поля кривой** (`curves[i]`): `expr` (строка), `color`, `label` (строка → легенда), `width`,
`lineStyle` (`solid|dashed|dotted`), `opacity` (0..1), `fill` (`true` → полупрозр. цвет кривой / строка
цвета), `marker` (`none|dot|ring`). Не заданные наследуются от plot-уровня (`width/lineStyle/opacity`)
или дефолтов.
- **Plot-уровневые** `fill` и `marker` — дефолт для всех кривых (если у кривой не задано).
- **Заливка под кривой** — между кривой и осью `y=0`, посегментно (разрывы у не-finite точек не сливаются),
`_fillUnderCurve`. Прозрачность через `_fillAlpha(color, 0.18)` для `fill:true`.
- **Маркеры узлов**`_drawCurveMarkers` (переиспользует `_drawPoint`), прорежены ~28px по экрану
(не рисуем сотни точек). `dot` → filled, `ring` → hollow.
- **Легенда**`_drawLegend` (на canvas: тёмная плашка + цветной свотч + светлый текст), верх-право,
не наезжает на бар кнопок вида. Включается авто при наличии `label`; `legend:false` отключает.
- **Качество кривой** — пропуск не-finite (разрывы), переиспользован существующий equidistant sampling
(`samples`, деф. 200, макс 2000), `_applyStroke` (dash/opacity/glow/lineJoin/cap).
- **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker +
label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker,
тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`.
- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`. состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`. **Handoff (P4 → P5):**
- **Контролы стиля объекта** (блок «Стиль» в каждом редакторе, `STYLE_FOR[type]` решает набор):
`rangeCtl` непрозр. (слайдер 0..1 → `opacity`), `selectCtl` линия (`lineStyle` solid/dashed/dotted),
стиль точки (`pointStyle`, только point), тумблер `glow`, тумблер «Градиент-заливка» (circle/rect →
`gradient:[c0,c1]`, две пары color-инпутов). Цвета — новый `colorCtl`: нативный `<input type=color>`
+ текстовое поле (источник истины, поддерживает rgba/named) + кнопка очистки для fill/trailColor
(«нет заливки»). Синхрон пикер↔текст — `Builder.wireColorControls(row)` (текст диспатчит `input`,
основной `data-of`/`data-cvf` обработчик ловит). `toHexColor` приводит к `#rrggbb` для нативного пикера.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель plot = `{var, range_a/b, samples,
trace, legend, plotFill, plotMarker, curves:[...]}`. Кривая = `{_uid, expr, color, label, width,
lineStyle, opacity, fill(bool), fillColor, marker}`. Список кривых (добавить `[data-curveadd]` /
удалить `[data-curvedel]`, минимум 1), на кривую — expr+fx, color, label, width, lineStyle, marker,
opacity, fill+цвет. Plot-уровневые `plotFill`/`plotMarker`/легенда. `loadPlot` нормализует
spec→UI (curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity наследуются кривой),
`normalizePlotForSpec`+`stripCurve` собирают обратно: **одиночная «простая» кривая (только expr+color,
нет plot-fill/marker) → легаси-форма** `{expr,color}`; иначе `curves:[...]`. `legend:false` эмитится
только при выключенной легенде.
- **Список объектов**: в шапке каждого — z-order вверх/вниз (`[data-oup]`/`[data-odown]`, порядок в
массиве = порядок отрисовки; крайние disabled), видимость (`[data-ohide]``o.hidden=true`),
дублировать (`[data-odup]`, deep-clone + новый `_uid`, `id+'_copy'`), удалить. Аналогично у plot.
- **hidden — чисто на стороне билдера** (движок не трогали): `buildSpec` фильтрует объекты/plot с
`hidden`; `stripObj.isDefaultStyle` гарантирует, что `hidden`/дефолты стиля (glow:false, lineStyle:
'solid', pointStyle:'filled', opacity:1, trail/closed:false) НЕ попадают в спеку → спека минимальна,
round-trip save→load→save идемпотентен (проверено vm-смоуком 27+12+2 PASS).
- **На P5 (прямое манипулирование + история):** в билдере сейчас есть только drag x/y point/circle/label/
readout/rect и конца segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке; undo/redo в билдере. Файл: `frontend/js/sim-builder.js` (движок НЕ тронут — `_toWorld`/
`_toPx`/`_niceStep` уже публичны на инстансе, хука не потребовалось).
**Итог / Handoff (P5 — финал раунда):**
- **Прямое манипулирование (`bindPreviewDrag`, переписан).** «Ручки» объекта строит `handlesOf(obj)`:
точка/окружность/подпись/показатель/прямоугольник → одна ручка `pos`(x,y); отрезок/вектор → две
ручки `origin`(x1,y1)+`end`(x2,y2 ИЛИ origin+dx/dy — определяется по наличию полей); ломаная/путь →
по ручке на каждую числовую вершину `points`. Каждая ручка несёт `set(x,y)` и флаг `blocked`. Хит-тест
`pickHandle` (допуск 14px через `inst._toPx`) выбирает ближайшую ручку. Режимы pointerdown:
`handle` (попали в ручку — двигаем её), `place` (единственная ручка, клик ставит точку — сохранён
исходный смысл «клик ставит»), `body` (несколько ручек — двигаем всё тело относительным сдвигом от
стартовой мир-точки), `none` (нет двигаемых ручек). Поля-ВЫРАЖЕНИЯ не трогаются: `numField` вернёт
`null` для нечислового значения → ручка `blocked` (не двигается, не сериализуется молча).
- **Snap-к-сетке.** Тумблер в тулбаре (иконка `ICON.grid`, флаг `this._snap`, переключатель `toggleSnap`,
активное состояние — инлайн-стиль `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При включённом
drag округляет мир-координаты к шагу `inst._niceStep(34)` (минорный шаг сетки движка; fallback 0.5).
Выключенный — `round2`.
- **Выравнивание** — реализован минимум (snap-к-сетке движка). Прилипание к координатам других объектов
НЕ делалось (бонус; достаточно snap для зачёта). Зафиксировано как частичное.
- **Undo/Redo.** Стек снапшотов `JSON.stringify(this.st)` (глубина `_undoMax=50`). `pushHistory` снимает
снапшот ПЕРЕД мутацией (без дублей верхушки; сбрасывает redo). `snapField` — один снапшот на сессию
правки поля (focusin сбрасывает флаг `_fieldSnapTaken`, первый input/change снимает) → Ctrl+Z откатывает
значение целиком, а не посимвольно. Структурные операции (add/delete/z-order/duplicate/hide/toggle,
включая plot/curve/wall/spring и физ-тумблер) — снапшот сразу. Drag — один снапшот на сессию (пустые
no-op-снапшоты откатываются в `end()`). Кнопки undo/redo в тулбаре (SVG `.ic`), горячие клавиши
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, вешается один раз, игнорит фокус в полях ввода).
`loadFromSim` обнуляет историю. `_restoreSnapshot``renderPanels`+`scheduleRemount`.
- **Совместимость.** `buildSpec`/round-trip/валидация не тронуты; идемпотентность спеки сохранена.
`refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. Проверено vm-смоуком: 38/38 PASS
(drag point/circle/segment-оба-конца/vector-dx,dy/polyline-vertex + body-move polyline/segment; snap к
0.5; выражение не затирается; undo/redo drag и add; стек ограничен; round-trip идемпотентен; no-op
drag не плодит историю). `node --check` OK, эмодзи/eval нет.
## Progress ## Progress
| Phase | Status | Review | Committed | | Phase | Status | Review | Committed |
|-------|--------|--------|-----------| |-------|--------|--------|-----------|
| P1 Working field | Done | ✅ PASS | ✅ | | P1 Working field | Done | ✅ PASS | ✅ |
| P2 Object graphics | Done | ✅ PASS | ✅ | | P2 Object graphics | Done | ✅ PASS | ✅ |
| P3 Charts | ⬜ | ⬜ | | | P3 Charts | Done | ✅ PASS | |
| P4 Builder UI | ⬜ | ⬜ | | | P4 Builder UI | Done | ✅ PASS | |
| P5 Direct manip + history | ⬜ | ⬜ | | | P5 Direct manip + history | Done | ✅ PASS | |