diff --git a/CLAUDE.md b/CLAUDE.md index 4be8185..fc1df3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,3 +172,16 @@ git push origin master - **Новые хелперы модульного уровня** (рядом с `_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)` (нативный `` + текст + опц.очистка), `rangeCtl` (слайдер 0..1 для opacity), `selectCtl` (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — `Builder.styleBlock(o)`, набор полей решает `STYLE_FOR[type]` ({opacity,line,point,glow,grad}). +- **Цвет: текст — источник истины, не нативный пикер.** Нативный `` умеет только `#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`. diff --git a/frontend/js/sim-builder.js b/frontend/js/sim-builder.js index bf1a138..b4c7e8f 100644 --- a/frontend/js/sim-builder.js +++ b/frontend/js/sim-builder.js @@ -132,14 +132,11 @@ var objs = Array.isArray(spec.objects) ? spec.objects : []; st.objects = []; st.plots = []; objs.forEach(function (o) { - var clone = Object.assign({ _uid: uid('o') }, o); if (o.type === 'plot') { - // Восстановить UI-поля диапазона из spec range[a,b], иначе при пересохранении - // normalizePlotForSpec не увидит range_a/range_b и диапазон молча потеряется. - if (Array.isArray(o.range)) { clone.range_a = o.range[0]; clone.range_b = o.range[1]; } - delete clone.range; - st.plots.push(clone); - } else st.objects.push(clone); + st.plots.push(loadPlot(o)); + } else { + st.objects.push(Object.assign({ _uid: uid('o') }, o)); + } }); // physics var ph = spec.physics || {}; @@ -162,10 +159,10 @@ Builder.prototype.buildSpec = function () { var st = this.st; var objects = []; - // обычные объекты - st.objects.forEach(function (o) { objects.push(stripObj(o)); }); + // обычные объекты (скрытые hidden:true не попадают в спеку — движок не знает о hidden) + st.objects.forEach(function (o) { if (o.hidden) return; objects.push(stripObj(o)); }); // plot-объекты - st.plots.forEach(function (o) { objects.push(stripObj(o)); }); + st.plots.forEach(function (o) { if (o.hidden) return; objects.push(stripObj(o)); }); var spec = { specVersion: SPEC_VERSION, @@ -199,13 +196,26 @@ return spec; }; - /* ── Удаление UI-метаданных (_uid и пустых полей) из объекта спеки ── */ + /* ── Удаление UI-метаданных (_uid, пустых и дефолтных стилевых полей) из объекта спеки. + Дефолты стиля не сериализуем — спека минимальна и round-trip стабилен (loadFromSim + восстанавливает их обратно из дефолтов контролов). hidden никогда не идёт в спеку. ── */ + function isDefaultStyle(k, v) { + if (k === 'hidden') return true; // UI-флаг, не для движка + if (k === 'glow' && v === false) return true; + if (k === 'trail' && v === false) return true; + if (k === 'closed' && v === false) return true; + if (k === 'lineStyle' && v === 'solid') return true; + if (k === 'pointStyle' && v === 'filled') return true; + if (k === 'opacity' && (v === 1 || v === '1')) return true; + return false; + } function stripObj(o) { var out = {}; Object.keys(o).forEach(function (k) { if (k === '_uid') return; var v = o[k]; if (v === '' || v === undefined || v === null) return; + if (isDefaultStyle(k, v)) return; out[k] = v; }); return out; @@ -467,17 +477,27 @@ var total = st.objects.length + st.plots.length; if (total > LIMITS.objects) errs.push('Слишком много объектов (макс ' + LIMITS.objects + ').'); - // выражения (объекты + графики) + // выражения объектов var self = this; - st.objects.concat(st.plots).forEach(function (o, i) { + function checkExpr(v, where) { + if (typeof v !== 'string' || v === '') return; + if (v.length > LIMITS.exprLen) errs.push(where + ': выражение длиннее ' + LIMITS.exprLen + ' симв.'); + var c = global.SimExpr ? global.SimExpr.compile(v) : { error: null }; + if (c.error) errs.push(where + ': ' + c.error); + } + st.objects.forEach(function (o, i) { exprFieldsOf(o).forEach(function (f) { - var v = o[f]; - if (typeof v !== 'string' || v === '') return; - if (v.length > LIMITS.exprLen) errs.push('Объект #' + (i + 1) + ': выражение «' + f + '» длиннее ' + LIMITS.exprLen + ' симв.'); - var c = global.SimExpr ? global.SimExpr.compile(v) : { error: null }; - if (c.error) errs.push('Объект #' + (i + 1) + ' (' + o.type + '), поле «' + f + '»: ' + c.error); + checkExpr(o[f], 'Объект #' + (i + 1) + ' (' + o.type + '), поле «' + f + '»'); }); }); + // выражения графиков: кривые + границы диапазона + st.plots.forEach(function (o, i) { + (Array.isArray(o.curves) ? o.curves : []).forEach(function (cv, ci) { + checkExpr(cv.expr, 'График #' + (i + 1) + ', кривая ' + (ci + 1)); + }); + checkExpr(typeof o.range_a === 'string' ? o.range_a : '', 'График #' + (i + 1) + ', «от»'); + checkExpr(typeof o.range_b === 'string' ? o.range_b : '', 'График #' + (i + 1) + ', «до»'); + }); // physics if (st.physics.enabled) { @@ -643,13 +663,17 @@ /* Редактор одного объекта: поля зависят от типа. */ Builder.prototype.objectEditor = function (o, i) { var selected = (this._selObjId === o._uid); + var hidden = !!o.hidden; + var n = this.st.objects.length; var fields = OBJ_FIELDS[o.type] || []; var inner = fields.map(function (f) { if (f.kind === 'check') { return ''; } if (f.kind === 'color') { - return miniField(f.label, ''); + // fill/trailColor — очищаемые («нет заливки»); основной color — нет + var clearable = (f.key === 'fill' || f.key === 'fillColor' || f.key === 'trailColor'); + return colorCtl(f.label, 'data-of="' + f.key + '"', o[f.key], clearable); } if (f.kind === 'text') { return miniField(f.label, ''); @@ -665,51 +689,135 @@ (err ? '' + esc(err) + '' : '') + ''; }).join(''); + // ── блок «Стиль» (P4): opacity/lineStyle/pointStyle/glow/gradient ── + var style = STYLE_FOR[o.type] ? this.styleBlock(o) : ''; // ── label с LaTeX-превью ── var latexPrev = ''; if (o.type === 'label' && o.text) { latexPrev = '
'; } - return '
' + + return '
' + '
' + '' + (TYPE_LABEL[o.type] || o.type) + '' + '' + + '' + + '' + + '' + + '' + '' + '' + '
' + '
' + inner + latexPrev + '
' + + style + '
'; }; + /* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + glow + градиент. + Применимость полей зависит от типа (STYLE_FOR[type] = {opacity,line,point,glow,grad}). */ + Builder.prototype.styleBlock = function (o) { + var cfg = STYLE_FOR[o.type]; + var ctrls = []; + if (cfg.opacity) ctrls.push(rangeCtl('непрозр.', 'data-of="opacity"', o.opacity, 0, 1, 0.05)); + if (cfg.line) ctrls.push(selectCtl('линия', 'data-of="lineStyle"', o.lineStyle || 'solid', LINE_STYLE_OPTS)); + if (cfg.point) ctrls.push(selectCtl('стиль точки', 'data-of="pointStyle"', o.pointStyle || 'filled', POINT_STYLE_OPTS)); + var grad = ''; + if (cfg.grad) { + var g = Array.isArray(o.gradient) ? o.gradient : []; + var on = (g.length >= 2); + grad = + '' + + ''; + } + var glow = cfg.glow + ? '' + : ''; + return '
' + + '
Стиль
' + + (ctrls.length ? '
' + ctrls.join('') + '
' : '') + + glow + grad + + '
'; + }; + + /* Редактор одного графика (plot): plot-уровневые поля (var/range/trace/fill/marker/legend) + + список кривых (curveEditor). */ + Builder.prototype.plotEditor = function (o, i) { + var hidden = !!o.hidden; + var n = this.st.plots.length; + var rangeErr = (o.range_a !== '' && o.range_a != null) ? exprError(o.range_a) : (exprError(o.range_b) || ''); + var curves = Array.isArray(o.curves) ? o.curves : []; + var curveHtml = curves.map(function (cv, ci) { return curveEditor(cv, i, ci, (curves.length > 1)); }).join(''); + return '
' + + '
' + + 'График' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + curveHtml + '
' + + '' + + '
' + + miniField('перем.', '') + + miniField('от', '') + + miniField('до', '') + + miniField('точек', '') + + '
' + + '
' + + selectCtl('маркеры', 'data-plf="plotMarker"', o.plotMarker || 'none', MARKER_OPTS) + + '' + + '
' + + '
' + + '' + + '' + + '
' + + (rangeErr ? 'диапазон: ' + esc(rangeErr) + '' : '') + + '
'; + }; + + /* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */ + function curveEditor(cv, pi, ci, removable) { + var exprErr = exprError(cv.expr); + return '
' + + '
' + + '' + + '' + + (exprErr ? '' + esc(exprErr) + '' : '') + + '
' + + '
' + + colorCtl('цвет', 'data-cvf="color"', cv.color, true) + + miniField('подпись', '') + + '
' + + '
' + + miniField('толщ.', '') + + selectCtl('линия', 'data-cvf="lineStyle"', cv.lineStyle || 'solid', LINE_STYLE_OPTS) + + selectCtl('маркер', 'data-cvf="marker"', cv.marker || 'none', MARKER_OPTS) + + '
' + + '
' + + rangeCtl('непрозр.', 'data-cvf="opacity"', cv.opacity, 0, 1, 0.05) + + '' + + (cv.fill ? colorCtl('цвет зал.', 'data-cvf="fillColor"', cv.fillColor, true) : '') + + '
' + + '
'; + } + /* ── Графики + Физика ── */ Builder.prototype.sectionPlotsPhysics = function () { var self = this; - // plots - var plotRows = this.st.plots.map(function (o, i) { - var exprErr = exprError(o.expr); - var rangeErr = (o.range_a !== '' && o.range_a != null) ? exprError(o.range_a) : (exprError(o.range_b) || ''); - return '
' + - '
' + - 'График' + - '' + - '
' + - '
' + - '' + - '' + - (exprErr ? '' + esc(exprErr) + '' : '') + - '
' + - '
' + - miniField('перем.', '') + - miniField('от', '') + - miniField('до', '') + - miniField('цвет', '') + - '
' + - '' + - (rangeErr ? 'диапазон: ' + esc(rangeErr) + '' : '') + - '
'; - }).join(''); + // plots — каждый график: список кривых + plot-уровневые поля + var plotRows = this.st.plots.map(function (o, i) { return self.plotEditor(o, i); }).join(''); - var plotsBody = (plotRows || '
Нет графиков.
') + + var plotsBody = (plotRows || '
Нет графиков. Добавьте график функции — можно несколько кривых.
') + ''; // physics @@ -838,12 +946,38 @@ el.addEventListener(evt, function () { var k = el.getAttribute('data-of'); if (el.type === 'checkbox') self.st.objects[i][k] = el.checked; - else self.st.objects[i][k] = el.value; + else if (el.type === 'range') { + self.st.objects[i][k] = (el.value === '' ? '' : parseFloat(el.value)); + var vb = el.closest('.sbu-range-mini'); var lbl = vb && vb.querySelector('.sbu-range-val'); + if (lbl) lbl.textContent = el.value; + } else self.st.objects[i][k] = el.value; // обновить inline-ошибку выражения и LaTeX-превью без полного рендера self.updateFieldFeedback(el, self.st.objects[i]); self.scheduleRemount(false); }); }); + // нативный color-picker -> синхрон с текстовым полем рядом (текст = источник истины) + self.wireColorControls(row, function () { self.scheduleRemount(false); }); + // градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient + var gOn = row.querySelector('[data-grad-on]'); + if (gOn) gOn.addEventListener('change', function () { + var obj = self.st.objects[i]; + var gr = row.querySelector('.sbu-grad-row'); + if (gOn.checked) { + if (gr) gr.style.display = ''; + var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]'); + obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ]; + } else { delete obj.gradient; if (gr) gr.style.display = 'none'; } + self.scheduleRemount(false); + }); + row.querySelectorAll('[data-grad]').forEach(function (el) { + el.addEventListener('input', function () { + var obj = self.st.objects[i]; + var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]'); + obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ]; + self.scheduleRemount(false); + }); + }); }); p.querySelectorAll('[data-odel]').forEach(function (b) { b.addEventListener('click', function () { @@ -854,6 +988,44 @@ self.renderPanels(); self.scheduleRemount(false); }); }); + // z-order: вверх (раньше в массиве = под низом отрисовки) / вниз + p.querySelectorAll('[data-oup]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-oup'), 10); + if (i > 0) { var a = self.st.objects; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } + self.renderPanels(); self.scheduleRemount(false); + }); + }); + p.querySelectorAll('[data-odown]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-odown'), 10); + var a = self.st.objects; + if (i < a.length - 1) { var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } + self.renderPanels(); self.scheduleRemount(false); + }); + }); + // видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем) + p.querySelectorAll('[data-ohide]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-ohide'), 10); + var o = self.st.objects[i]; if (!o) return; + if (o.hidden) delete o.hidden; else o.hidden = true; + self.renderPanels(); self.scheduleRemount(false); + }); + }); + // дублировать объект (новый _uid + новая ссылка; вставить сразу после) + p.querySelectorAll('[data-odup]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-odup'), 10); + if (self.st.objects.length + self.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; } + var o = self.st.objects[i]; if (!o) return; + var clone = JSON.parse(JSON.stringify(o)); + clone._uid = uid('o'); + if (clone.id) clone.id = clone.id + '_copy'; + self.st.objects.splice(i + 1, 0, clone); + self.renderPanels(); self.scheduleRemount(false); + }); + }); p.querySelectorAll('[data-place]').forEach(function (b) { b.addEventListener('click', function () { var uidv = b.getAttribute('data-place'); @@ -874,6 +1046,7 @@ // plots p.querySelectorAll('.sbu-plot').forEach(function (row) { var i = parseInt(row.getAttribute('data-plti'), 10); + // plot-уровневые поля (var/range/samples/trace/legend/plotFill/plotMarker) row.querySelectorAll('[data-plf]').forEach(function (el) { var evt = el.type === 'checkbox' ? 'change' : 'input'; el.addEventListener(evt, function () { @@ -884,6 +1057,51 @@ self.scheduleRemount(false); }); }); + // кривые + row.querySelectorAll('.sbu-curve').forEach(function (cr) { + var ci = parseInt(cr.getAttribute('data-ci'), 10); + cr.querySelectorAll('[data-cvf]').forEach(function (el) { + var evt = (el.type === 'checkbox') ? 'change' : 'input'; + el.addEventListener(evt, function () { + var k = el.getAttribute('data-cvf'); + var cv = self.st.plots[i].curves[ci]; if (!cv) return; + if (el.type === 'checkbox') { + cv[k] = el.checked; + if (k === 'fill') { self.renderPanels(); self.scheduleRemount(false); return; } // показать/скрыть «цвет зал.» + } else if (el.type === 'range') { + cv[k] = (el.value === '' ? '' : parseFloat(el.value)); + var vb = el.closest('.sbu-range-mini'); var lbl = vb && vb.querySelector('.sbu-range-val'); + if (lbl) lbl.textContent = el.value; + } else cv[k] = el.value; + self.updateFieldFeedback(el, null); + self.scheduleRemount(false); + }); + }); + self.wireColorControls(cr); + // fx-палитра для выражения кривой + var fx = cr.querySelector('[data-cvfx]'); + if (fx) fx.addEventListener('click', function () { + self.openPalette(cr.querySelector('[data-cvf="expr"]')); + }); + // удалить кривую + var cdel = cr.querySelector('[data-curvedel]'); + if (cdel) cdel.addEventListener('click', function () { + var arr = self.st.plots[i].curves; + if (arr.length > 1) arr.splice(ci, 1); + self.renderPanels(); self.scheduleRemount(false); + }); + }); + // plot-level color-контролы (на будущее; кривые имеют свои) + self.wireColorControls(row); + }); + p.querySelectorAll('[data-curveadd]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-curveadd'), 10); + var plt = self.st.plots[i]; if (!plt) return; + plt.curves = Array.isArray(plt.curves) ? plt.curves : []; + plt.curves.push(defaultCurve('', '')); + self.renderPanels(); self.scheduleRemount(false); + }); }); p.querySelectorAll('[data-pltdel]').forEach(function (b) { b.addEventListener('click', function () { @@ -891,10 +1109,27 @@ self.renderPanels(); self.scheduleRemount(false); }); }); - p.querySelectorAll('[data-pltfx]').forEach(function (b) { + p.querySelectorAll('[data-pltup]').forEach(function (b) { b.addEventListener('click', function () { - var input = b.closest('.sbu-of').querySelector('[data-plf]'); - self.openPalette(input); + var i = parseInt(b.getAttribute('data-pltup'), 10); + if (i > 0) { var a = self.st.plots; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } + self.renderPanels(); self.scheduleRemount(false); + }); + }); + p.querySelectorAll('[data-pltdown]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-pltdown'), 10); + var a = self.st.plots; + if (i < a.length - 1) { var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } + self.renderPanels(); self.scheduleRemount(false); + }); + }); + p.querySelectorAll('[data-plthide]').forEach(function (b) { + b.addEventListener('click', function () { + var i = parseInt(b.getAttribute('data-plthide'), 10); + var o = self.st.plots[i]; if (!o) return; + if (o.hidden) delete o.hidden; else o.hidden = true; + self.renderPanels(); self.scheduleRemount(false); }); }); @@ -954,6 +1189,33 @@ this.renderLatexPreviews(); }; + /* Синхронизация color-контролов внутри row: нативный пикер -> текст (и dispatch input, + чтобы сработал основной обработчик data-of/data-plf/data-cvf); текст -> пикер; + кнопка очистки -> пустое значение («нет заливки»). onChange — доп. ремонт (для grad). */ + Builder.prototype.wireColorControls = function (row, onChange) { + row.querySelectorAll('.sbu-color-wrap').forEach(function (wrap) { + var pick = wrap.querySelector('[data-color-pick]'); + var txt = wrap.querySelector('input.sbu-in-color'); + var clr = wrap.querySelector('[data-color-clear]'); + if (pick && txt) { + pick.addEventListener('input', function () { + txt.value = pick.value; + txt.dispatchEvent(new Event('input', { bubbles: true })); + }); + txt.addEventListener('input', function () { + var h = toHexColor(txt.value); + if (h !== '#000000' || /^#0{3,6}$/i.test(String(txt.value).trim())) pick.value = h; + }); + } + if (clr && txt) { + clr.addEventListener('click', function () { + txt.value = ''; + txt.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + }); + }; + /* Привести checkbox data-grp атрибуты в соответствие (vp/time) — генерим в checkbox() с data-grp/data-k. Но проще: переиспользуем общий обработчик. */ @@ -968,7 +1230,11 @@ this.st.objects.push(defaultObject(type)); } else if (what === 'plot') { if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; } - this.st.plots.push({ _uid: uid('plt'), type: 'plot', expr: 'sin(x)', var: 'x', range_a: '', range_b: '', color: '#F15BB5', trace: false }); + this.st.plots.push({ + _uid: uid('plt'), type: 'plot', 'var': 'x', range_a: '', range_b: '', samples: '', + trace: false, legend: true, plotFill: false, plotMarker: 'none', + curves: [defaultCurve('sin(x)', '#F15BB5')] + }); } else if (what === 'wall') { if (this.st.physics.walls.length >= LIMITS.walls) { global.LS.toast('Достигнут лимит стен', 'warn'); return; } this.st.physics.walls.push({ _uid: uid('w'), side: 'bottom', x1: '', y1: '', x2: '', y2: '' }); @@ -980,15 +1246,103 @@ this.scheduleRemount(false); }; - /* Перед сборкой spec plot-объект нужно «материализовать»: range + убрать UI-поля. */ + /* дефолтная кривая plot (UI-модель). */ + function defaultCurve(expr, color) { + return { + _uid: uid('cv'), expr: (expr == null ? '' : expr), color: (color || ''), + label: '', width: '', lineStyle: 'solid', opacity: '', fill: '', fillColor: '', marker: 'none' + }; + } + + /* Загрузка spec-plot -> UI-модель: список curves[] + plot-уровневые поля. + Поддерживает легаси (одиночный expr/exprs[]) и P3-формат (curves[]). */ + function loadPlot(o) { + var ui = { _uid: uid('plt'), type: 'plot' }; + ui['var'] = o['var'] || 'x'; + if (Array.isArray(o.range)) { ui.range_a = o.range[0]; ui.range_b = o.range[1]; } + else { ui.range_a = ''; ui.range_b = ''; } + ui.trace = !!o.trace; + ui.samples = (o.samples != null ? o.samples : ''); + ui.plotFill = (o.fill === true) ? true : (typeof o.fill === 'string' ? o.fill : false); + ui.plotMarker = (o.marker === 'dot' || o.marker === 'ring') ? o.marker : 'none'; + ui.legend = (o.legend === false) ? false : true; + if (o.hidden) ui.hidden = true; + // источник кривых: curves[] -> exprs[] -> expr (легаси) + var defs = []; + if (Array.isArray(o.curves) && o.curves.length) { + defs = o.curves.map(function (cv) { return (cv && typeof cv === 'object') ? cv : { expr: cv }; }); + } else if (Array.isArray(o.exprs) && o.exprs.length) { + defs = o.exprs.map(function (ex) { return { expr: ex }; }); + } else { + defs = [{ expr: o.expr != null ? o.expr : '', color: o.color }]; + } + // plot-уровневые стили (легаси width/lineStyle/opacity) наследуются кривой, если у неё не задано + ui.curves = defs.map(function (cv) { + cv = cv || {}; + var c = defaultCurve(cv.expr, cv.color || ''); + if (cv.label != null) c.label = String(cv.label); + var w = (cv.width != null && cv.width !== '') ? cv.width : o.width; + if (w != null && w !== '' && isFinite(parseFloat(w))) c.width = w; + var ls = (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle + : ((o.lineStyle === 'dashed' || o.lineStyle === 'dotted') ? o.lineStyle : ''); + if (ls) c.lineStyle = ls; + var op = (cv.opacity != null && cv.opacity !== '') ? cv.opacity : o.opacity; + if (op != null && op !== '' && isFinite(parseFloat(op))) c.opacity = op; + if (cv.fill === true) c.fill = true; + else if (typeof cv.fill === 'string' && cv.fill) { c.fill = true; c.fillColor = cv.fill; } + if (cv.marker === 'dot' || cv.marker === 'ring') c.marker = cv.marker; + return c; + }); + if (!ui.curves.length) ui.curves = [defaultCurve('', '')]; + return ui; + } + + /* Перед сборкой spec plot-объект нужно «материализовать»: range + curves + убрать UI-поля. + Если ровно одна «простая» кривая (только expr + опц. color) и нет plot-уровневых стилей — + эмитим легаси-форму (expr/color) для обратной совместимости и стабильного round-trip. */ function normalizePlotForSpec(o) { - var out = { type: 'plot', expr: o.expr == null ? '' : o.expr, var: o.var || 'x' }; - if (o.color) out.color = o.color; - if (o.trace) out.trace = true; + var out = { type: 'plot', var: o['var'] || 'x' }; var a = o.range_a, b = o.range_b; if (!((a === '' || a == null) && (b === '' || b == null))) { out.range = [parseRangeVal(a), parseRangeVal(b)]; } + if (o.trace) out.trace = true; + if (o.samples !== '' && o.samples != null && isFinite(parseFloat(o.samples))) out.samples = parseFloat(o.samples); + if (o.plotFill === true) out.fill = true; + else if (typeof o.plotFill === 'string' && o.plotFill) out.fill = o.plotFill; + if (o.plotMarker === 'dot' || o.plotMarker === 'ring') out.marker = o.plotMarker; + + var curves = Array.isArray(o.curves) ? o.curves : []; + var built = curves.map(stripCurve).filter(function (c) { return c.expr !== '' && c.expr != null; }); + + // легенда: явно выключаем, если стоит false (по умолчанию движок включает при наличии label) + if (o.legend === false) out.legend = false; + + var single = (built.length === 1) ? built[0] : null; + var simpleSingle = single && !single.label && single.width == null && (!single.lineStyle || single.lineStyle === 'solid') && + single.opacity == null && single.fill == null && (!single.marker || single.marker === 'none'); + if (simpleSingle && out.fill == null && out.marker == null) { + // легаси-форма: одиночное выражение + цвет + out.expr = single.expr; + if (single.color) out.color = single.color; + } else if (built.length) { + out.curves = built; + } else { + out.expr = ''; // пустой график (валидация поймает) + } + return out; + } + + /* кривую (UI) -> минимальный объект кривой спеки (без _uid/дефолтов). */ + function stripCurve(cv) { + var out = { expr: (cv.expr == null ? '' : cv.expr) }; + if (cv.color) out.color = cv.color; + if (cv.label) out.label = cv.label; + if (cv.width !== '' && cv.width != null && isFinite(parseFloat(cv.width))) out.width = parseFloat(cv.width); + if (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') out.lineStyle = cv.lineStyle; + if (cv.opacity !== '' && cv.opacity != null && isFinite(parseFloat(cv.opacity))) out.opacity = parseFloat(cv.opacity); + if (cv.fill === true) out.fill = (cv.fillColor && String(cv.fillColor).trim()) ? cv.fillColor : true; + if (cv.marker === 'dot' || cv.marker === 'ring') out.marker = cv.marker; return out; } function parseRangeVal(v) { @@ -1110,6 +1464,58 @@ return ''; } + /* ── Контролы стиля (P4) ────────────────────────────────────────────────── + Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая). + attr — строка вида 'data-of="color"' (атрибут привязки события). + Цвет: нативный (sync) + текст (точное значение / rgba/named) + + кнопка очистки (для fill/trailColor «нет заливки»). Текст — источник истины. */ + + // привести произвольный цвет к #rrggbb для нативного пикера (иначе #000000) + function toHexColor(v) { + var s = String(v == null ? '' : v).trim(); + if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); + if (/^#[0-9a-fA-F]{3}$/.test(s)) { + return ('#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase(); + } + return '#000000'; + } + // colorAttr — 'data-of="color"' и т.п.; clearable — показывать кнопку «нет» + function colorCtl(label, colorAttr, value, clearable) { + var v = (value == null ? '' : value); + var hex = toHexColor(v); + var clr = clearable + ? '' + : ''; + return ''; + } + // слайдер 0..1 (opacity) с числовым отображением + function rangeCtl(label, attr, value, mn, mx, st) { + var num = (value == null || value === '') ? '' : value; + var sliderVal = (num === '') ? mx : num; + return ''; + } + // select по списку [{v,l}] + function selectCtl(label, attr, value, opts) { + var o = opts.map(function (op) { + return ''; + }).join(''); + return ''; + } + var LINE_STYLE_OPTS = [{ v: 'solid', l: 'сплошная' }, { v: 'dashed', l: 'штрих' }, { v: 'dotted', l: 'точки' }]; + var POINT_STYLE_OPTS = [{ v: 'filled', l: 'заполн.' }, { v: 'hollow', l: 'контур' }, { v: 'ring', l: 'кольцо' }, { v: 'cross', l: 'крест' }]; + var MARKER_OPTS = [{ v: 'none', l: 'нет' }, { v: 'dot', l: 'точка' }, { v: 'ring', l: 'кольцо' }]; + /* Ошибка компиляции выражения (строка) или '' если число/пусто/валидно. */ function exprError(v) { if (v === '' || v == null) return ''; @@ -1182,6 +1588,19 @@ readout: [{ key: 'label', label: 'подпись', kind: 'text', ph: 'R' }, { key: 'expr', label: 'выражение', kind: 'expr' }, { key: 'unit', label: 'ед.', kind: 'text' }, { key: 'precision', label: 'знаков', kind: 'expr' }, { key: 'x', label: 'x (опц.)', kind: 'expr' }, { key: 'y', label: 'y (опц.)', kind: 'expr' }, { key: 'color', label: 'цвет', kind: 'color' }] }; + /* Какие style-контролы (P4) показывать у типа. + opacity/glow — почти у всех рисуемых; line (lineStyle) — у линий/контуров; + point (pointStyle) — только point; grad (gradient-заливка) — у circle/rect (есть заливка). */ + var STYLE_FOR = { + point: { opacity: true, glow: true, point: true }, + segment: { opacity: true, glow: true, line: true }, + vector: { opacity: true, glow: true, line: true }, + circle: { opacity: true, glow: true, line: true, grad: true }, + rect: { opacity: true, glow: true, line: true, grad: true }, + polyline: { opacity: true, glow: true, line: true }, + path: { opacity: true, glow: true, line: true } + }; + var TYPE_LABEL = { point: 'Точка', segment: 'Отрезок', vector: 'Вектор', circle: 'Окружность', rect: 'Прямоугольник', polyline: 'Ломаная', path: 'Путь', label: 'Подпись', @@ -1202,7 +1621,13 @@ target: '', cog: '', template: '', - unpublish: '' + unpublish: '', + up: '', + down: '', + copy: '', + eye: '', + eyeOff: '', + clearX: '' }; /* ── Встроенные шаблоны стартовых спек (Фаза 6) ────────────────────────── diff --git a/frontend/sim-builder.html b/frontend/sim-builder.html index 12272c6..2811423 100644 --- a/frontend/sim-builder.html +++ b/frontend/sim-builder.html @@ -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-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-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; } + /* ── блок «Стиль» объекта (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-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; } @@ -113,6 +145,11 @@ .sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); } .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; } + } diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index 527e1a3..2b78fd3 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -1,6 +1,39 @@ # Feature Context: Конструктор симуляций (SimForge) ## Current State +- **РАУНД УЛУЧШЕНИЙ (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`: нативный `` + текст + (источник истины, держит 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 — не дублировались. @@ -235,6 +268,11 @@ - Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`. ## 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 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора) - Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ. Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master. diff --git a/plans/sim-builder/IMPROVEMENTS.md b/plans/sim-builder/IMPROVEMENTS.md index 0e2d681..d82ab52 100644 --- a/plans/sim-builder/IMPROVEMENTS.md +++ b/plans/sim-builder/IMPROVEMENTS.md @@ -93,9 +93,39 @@ - **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker + label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker, тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`. -- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color- +- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color- пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые состояния, мобайл. Файлы: `frontend/sim-builder.html`, `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`: нативный `` + + текстовое поле (источник истины, поддерживает 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`. Идентичность спеки между билдами уже гарантирована. - [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle), snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`. @@ -105,5 +135,5 @@ | P1 Working field | Done | ✅ PASS | ✅ | | P2 Object graphics | Done | ✅ PASS | ✅ | | P3 Charts | Done | ✅ PASS | ✅ | -| P4 Builder UI | ⬜ | ⬜ | ⬜ | +| P4 Builder UI | Done | ✅ PASS | ✅ | | P5 Direct manip + history | ⬜ | ⬜ | ⬜ |