feat(sim-builder): улучшение P4 — UI билдера: color-пикеры, контролы стиля, редактор кривых, z-order/дубль/видимость

This commit is contained in:
Maxim Dolgolyov
2026-06-13 14:46:14 +03:00
parent 69e219ae8c
commit b6f854fc77
5 changed files with 602 additions and 59 deletions
+13
View File
@@ -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)` (нативный `<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`.
+480 -55
View File
@@ -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 '<label class="sbu-of-check"><input type="checkbox" data-of="' + f.key + '"' + (o[f.key] ? ' checked' : '') + '/> ' + esc(f.label) + '</label>';
}
if (f.kind === 'color') {
return miniField(f.label, '<input class="sbu-in sbu-in-color" type="text" data-of="' + f.key + '" value="' + esc(o[f.key] == null ? '' : o[f.key]) + '" placeholder="#06D6E0" />');
// 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, '<input class="sbu-in" data-of="' + f.key + '" value="' + esc(o[f.key] == null ? '' : o[f.key]) + '" placeholder="' + esc(f.ph || '') + '" />');
@@ -665,51 +689,135 @@
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>';
}).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 = '<div class="sbu-latex" data-latex="' + esc(o.text) + '"></div>';
}
return '<div class="sbu-obj' + (selected ? ' sel' : '') + '" data-oi="' + i + '">' +
return '<div class="sbu-obj' + (selected ? ' sel' : '') + (hidden ? ' is-hidden' : '') + '" data-oi="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">' + (TYPE_LABEL[o.type] || o.type) + '</span>' +
'<input class="sbu-in sbu-in-id" data-of="id" value="' + esc(o.id == null ? '' : o.id) + '" placeholder="id" title="Идентификатор (для ссылок obj.x/obj.y)" />' +
'<button class="sbu-icon-btn sbu-zord" data-oup="' + i + '" title="Выше"' + (i === 0 ? ' disabled' : '') + '>' + ICON.up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-odown="' + i + '" title="Ниже"' + (i === n - 1 ? ' disabled' : '') + '>' + ICON.down + '</button>' +
'<button class="sbu-icon-btn' + (hidden ? ' active' : '') + '" data-ohide="' + i + '" title="' + (hidden ? 'Показать' : 'Скрыть') + '">' + (hidden ? ICON.eyeOff : ICON.eye) + '</button>' +
'<button class="sbu-icon-btn sbu-dup" data-odup="' + i + '" title="Дублировать">' + ICON.copy + '</button>' +
'<button class="sbu-icon-btn sbu-place" data-place="' + o._uid + '" title="Поставить/двигать на сцене">' + ICON.target + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-odel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-obj-fields">' + inner + latexPrev + '</div>' +
style +
'</div>';
};
/* Блок «Стиль» объекта: непрозрачность + стиль линии/точки + 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 =
'<label class="sbu-of-check"><input type="checkbox" data-grad-on' + (on ? ' checked' : '') + '/> Градиент-заливка</label>' +
'<div class="sbu-grad-row"' + (on ? '' : ' style="display:none"') + '>' +
colorCtl('от', 'data-grad="0"', g[0] || toHexColor(o.color) , false) +
colorCtl('до', 'data-grad="1"', g[1] || '#1b1b2e', false) +
'</div>';
}
var glow = cfg.glow
? '<label class="sbu-of-check"><input type="checkbox" data-of="glow"' + (o.glow ? ' checked' : '') + '/> Свечение (glow)</label>'
: '';
return '<div class="sbu-obj-style">' +
'<div class="sbu-sub">Стиль</div>' +
(ctrls.length ? '<div class="sbu-style-row">' + ctrls.join('') + '</div>' : '') +
glow + grad +
'</div>';
};
/* Редактор одного графика (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 '<div class="sbu-plot' + (hidden ? ' is-hidden' : '') + '" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<span style="flex:1"></span>' +
'<button class="sbu-icon-btn sbu-zord" data-pltup="' + i + '" title="Выше"' + (i === 0 ? ' disabled' : '') + '>' + ICON.up + '</button>' +
'<button class="sbu-icon-btn sbu-zord" data-pltdown="' + i + '" title="Ниже"' + (i === n - 1 ? ' disabled' : '') + '>' + ICON.down + '</button>' +
'<button class="sbu-icon-btn' + (hidden ? ' active' : '') + '" data-plthide="' + i + '" title="' + (hidden ? 'Показать' : 'Скрыть') + '">' + (hidden ? ICON.eyeOff : ICON.eye) + '</button>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-curves">' + curveHtml + '</div>' +
'<button class="sbu-add sbu-add-sm" data-curveadd="' + i + '">' + ICON.plus + ' Кривая</button>' +
'<div class="sbu-row4">' +
miniField('перем.', '<input class="sbu-in" data-plf="var" value="' + esc(o['var'] == null ? 'x' : o['var']) + '" placeholder="x" />') +
miniField('от', '<input class="sbu-in" data-plf="range_a" value="' + esc(o.range_a == null ? '' : o.range_a) + '" placeholder="xmin" />') +
miniField('до', '<input class="sbu-in" data-plf="range_b" value="' + esc(o.range_b == null ? '' : o.range_b) + '" placeholder="xmax" />') +
miniField('точек', '<input class="sbu-in" type="number" data-plf="samples" value="' + esc(o.samples == null ? '' : o.samples) + '" placeholder="200" />') +
'</div>' +
'<div class="sbu-style-row">' +
selectCtl('маркеры', 'data-plf="plotMarker"', o.plotMarker || 'none', MARKER_OPTS) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка под всеми</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-plf="plotFill"' + (o.plotFill ? ' checked' : '') + '/> вкл</label></label>' +
'</div>' +
'<div class="sbu-checks">' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + (o.trace ? ' checked' : '') + '/> След по времени (trace)</label>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="legend"' + (o.legend !== false ? ' checked' : '') + '/> Легенда</label>' +
'</div>' +
(rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc(rangeErr) + '</span>' : '') +
'</div>';
};
/* Редактор одной кривой графика. data-cvf — поле кривой; data-cvfx — fx-палитра. */
function curveEditor(cv, pi, ci, removable) {
var exprErr = exprError(cv.expr);
return '<div class="sbu-curve" data-pi="' + pi + '" data-ci="' + ci + '">' +
'<div class="sbu-of' + (exprErr ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">выражение' +
'<span style="display:flex;gap:4px;align-items:center">' +
'<button class="sbu-fx" data-cvfx>fx</button>' +
(removable ? '<button class="sbu-icon-btn sbu-del sbu-curve-del" data-curvedel title="Удалить кривую">' + ICON.trash + '</button>' : '') +
'</span>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-cvf="expr" value="' + esc(cv.expr == null ? '' : cv.expr) + '" placeholder="sin(x)" />' +
(exprErr ? '<span class="sbu-of-err">' + esc(exprErr) + '</span>' : '') +
'</div>' +
'<div class="sbu-row2">' +
colorCtl('цвет', 'data-cvf="color"', cv.color, true) +
miniField('подпись', '<input class="sbu-in sbu-in-sm" data-cvf="label" value="' + esc(cv.label == null ? '' : cv.label) + '" placeholder="легенда" />') +
'</div>' +
'<div class="sbu-style-row">' +
miniField('толщ.', '<input class="sbu-in sbu-in-sm" type="number" step="0.5" data-cvf="width" value="' + esc(cv.width == null ? '' : cv.width) + '" placeholder="2" />') +
selectCtl('линия', 'data-cvf="lineStyle"', cv.lineStyle || 'solid', LINE_STYLE_OPTS) +
selectCtl('маркер', 'data-cvf="marker"', cv.marker || 'none', MARKER_OPTS) +
'</div>' +
'<div class="sbu-style-row">' +
rangeCtl('непрозр.', 'data-cvf="opacity"', cv.opacity, 0, 1, 0.05) +
'<label class="sbu-mini"><span class="sbu-mini-lbl">заливка</span>' +
'<label class="sbu-of-check" style="height:32px"><input type="checkbox" data-cvf="fill"' + (cv.fill ? ' checked' : '') + '/> вкл</label></label>' +
(cv.fill ? colorCtl('цвет зал.', 'data-cvf="fillColor"', cv.fillColor, true) : '<span></span>') +
'</div>' +
'</div>';
}
/* ── Графики + Физика ── */
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 '<div class="sbu-plot" data-plti="' + i + '">' +
'<div class="sbu-obj-hdr">' +
'<span class="sbu-obj-type">График</span>' +
'<button class="sbu-icon-btn sbu-del" data-pltdel="' + i + '" title="Удалить">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-of' + (exprErr ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">f(' + esc(o.var || 'x') + ')<button class="sbu-fx" data-pltfx="expr:' + i + '">fx</button></label>' +
'<input class="sbu-in sbu-in-expr" data-plf="expr" value="' + esc(o.expr == null ? '' : o.expr) + '" placeholder="sin(x)" />' +
(exprErr ? '<span class="sbu-of-err">' + esc(exprErr) + '</span>' : '') +
'</div>' +
'<div class="sbu-row4">' +
miniField('перем.', '<input class="sbu-in" data-plf="var" value="' + esc(o.var == null ? 'x' : o.var) + '" placeholder="x" />') +
miniField('от', '<input class="sbu-in" data-plf="range_a" value="' + esc(o.range_a == null ? '' : o.range_a) + '" placeholder="xmin" />') +
miniField('до', '<input class="sbu-in" data-plf="range_b" value="' + esc(o.range_b == null ? '' : o.range_b) + '" placeholder="xmax" />') +
miniField('цвет', '<input class="sbu-in sbu-in-color" data-plf="color" value="' + esc(o.color == null ? '' : o.color) + '" placeholder="#F15BB5" />') +
'</div>' +
'<label class="sbu-of-check"><input type="checkbox" data-plf="trace"' + (o.trace ? ' checked' : '') + '/> След по времени (trace)</label>' +
(rangeErr ? '<span class="sbu-of-err">диапазон: ' + esc(rangeErr) + '</span>' : '') +
'</div>';
}).join('');
// plots — каждый график: список кривых + plot-уровневые поля
var plotRows = this.st.plots.map(function (o, i) { return self.plotEditor(o, i); }).join('');
var plotsBody = (plotRows || '<div class="sbu-empty-sm">Нет графиков.</div>') +
var plotsBody = (plotRows || '<div class="sbu-empty-sm">Нет графиков. Добавьте график функции — можно несколько кривых.</div>') +
'<button class="sbu-add" data-add="plot">' + ICON.plus + ' График</button>';
// 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 '<label class="sbu-chk"><input type="checkbox" data-grp="' + grp + '" data-k="' + key + '"' + (checked ? ' checked' : '') + '/> ' + esc(label) + '</label>';
}
/* ── Контролы стиля (P4) ──────────────────────────────────────────────────
Все генерят input-ы с data-of (для объектов) или data-plf/data-cvf (plot/кривая).
attr — строка вида 'data-of="color"' (атрибут привязки события).
Цвет: нативный <input type=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
? '<button type="button" class="sbu-color-clr" data-color-clear title="Нет заливки">' + ICON.clearX + '</button>'
: '';
return '<label class="sbu-mini sbu-color-mini">' +
'<span class="sbu-mini-lbl">' + esc(label) + '</span>' +
'<span class="sbu-color-wrap">' +
'<input type="color" class="sbu-color-pick" data-color-pick value="' + esc(hex) + '" title="Выбрать цвет" />' +
'<input class="sbu-in sbu-in-sm sbu-in-color" ' + colorAttr + ' value="' + esc(v) + '" placeholder="#06D6E0" />' +
clr +
'</span>' +
'</label>';
}
// слайдер 0..1 (opacity) с числовым отображением
function rangeCtl(label, attr, value, mn, mx, st) {
var num = (value == null || value === '') ? '' : value;
var sliderVal = (num === '') ? mx : num;
return '<label class="sbu-mini sbu-range-mini">' +
'<span class="sbu-mini-lbl">' + esc(label) + ' <b class="sbu-range-val">' + esc(num === '' ? mx : num) + '</b></span>' +
'<input type="range" class="sbu-range" ' + attr + ' min="' + mn + '" max="' + mx + '" step="' + st + '" value="' + esc(sliderVal) + '" />' +
'</label>';
}
// select по списку [{v,l}]
function selectCtl(label, attr, value, opts) {
var o = opts.map(function (op) {
return '<option value="' + esc(op.v) + '"' + (String(value || '') === String(op.v) ? ' selected' : '') + '>' + esc(op.l) + '</option>';
}).join('');
return '<label class="sbu-mini"><span class="sbu-mini-lbl">' + esc(label) + '</span>' +
'<select class="sbu-in sbu-in-sm" ' + attr + '>' + o + '</select></label>';
}
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: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
cog: '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
template: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>',
unpublish: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>'
unpublish: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>',
up: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="18 15 12 9 6 15"/></svg>',
down: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>',
copy: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
eye: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>',
eyeOff: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
clearX: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
};
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────
+39 -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-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; }
}
</style>
</head>
<body>
+38
View File
@@ -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`: нативный `<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 — не дублировались.
@@ -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.
+32 -2
View File
@@ -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`: нативный `<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`. Идентичность спеки между билдами уже гарантирована.
- [ ] **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 | ⬜ | ⬜ | ⬜ |