feat(sim-builder): улучшение P1 — рабочее поле: фикс смещения (контролы оверлеем), сетка/оси с делениями, zoom/pan

This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:55:50 +03:00
parent d8717d0fbd
commit 4be3fbde50
4 changed files with 499 additions and 111 deletions
+13
View File
@@ -133,3 +133,16 @@ git push origin master
- **simId с двоеточием ломал бэкенд-валидацию.** `simOpen` валидировал `^[a-z0-9_-]{1,40}$` — двоеточие в `custom:5` не проходило. Добавлена ветка `^custom:(\d+)$` + проверка доступа (own|published|admin → иначе 404/403). Доступ дублируется на `GET /custom-sims/:id` (ensureSpec в iframe) — две линии обороны, чужой draft не утечёт.
- **Закрытие = `frame.src='about:blank'` сносит весь iframe-документ** (SimEngine, rAF, listeners, `_simStateRegistry`) — явный `destroy()` в классруме не нужен, чисто по построению. Смена sim — тот же сброс src + новый load.
- **classroom.html (8240 строк) — искать через vex по DOM-id** (`cr-sim-picker-grid`, `cr-sim-frame`), затем точечный Read. ast-index НЕ индексирует inline-`<script>` в HTML (символы `crOpenSimPicker` и т.п. → пусто); vex тоже не парсит тела inline-функций. Для тел функций в HTML — Grep tool (документированный escape-hatch ast-index.md: «ONLY when ast-index returns empty»). Проверка инлайна: извлечь `<script>` без src в temp .js → `node --check` → удалить.
### SimForge improvements — P1 (Рабочее поле) — Learnings
Раунд полировки сверх фаз 0–7. План: `plans/sim-builder/IMPROVEMENTS.md`. Всё в `frontend/js/labs/_sim_engine.js` (один движок → эффект и в билдере, и в /lab, и на доске).
- **Первопричина «съехало вправо»**: `_build` раскладывал `root` как `display:flex` с фикс-панелью `width:260px` СЛЕВА + `stage` справа → у пустой/новой sim панель всё равно занимала 260px, сцена смещалась. **Фикс — раскладка, НЕ `_fit`** (`_fit` был корректен): `root`(relative) → `stage`(`position:absolute;inset:0`, canvas+labels на всю площадь) + контролы как **плавающая overlay-панель** (`position:absolute;left/top:10px;z-index:5;pointer-events:auto`, сворачивается `_togglePanel`, есть только при наличии `params`) + бар кнопок вида (`right/bottom:10px`). Пустое место сцены под панелью доступно для pan (`pointer-events:auto` только на карточке). sim-builder.html НЕ потребовался — старый CSS `.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже растягивает новый full-bleed root.
- **Transform-модель (zoom/pan)**: `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit по viewport) и ЭФФЕКТИВНЫЙ `_scale/_offX/_offY` (его используют `_toPx/_toWorld` — сигнатуры без изменений). `_zoom` — пользовательский множитель к базе; `_viewLocked` — был ли zoom/pan (тогда ресайз СОХРАНЯЕТ мир-центр+zoom, не сбрасывает вид). Публичное API вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport). Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке — мир-точка под курсором инвариантна; кламп `_zoom` 0.1..50×), `_setupZoomPan()` (колесо `{passive:false}` + pan на pointer events), `_visibleWorld(W,H)` (видимые мир-границы для сетки/осей с учётом zoom/pan).
- **Pan vs drag-ручек — приоритет хит-теста**: хит-тест ручек/тел вынесен из замыкания `_setupDrag` в общий метод `_pickHandleAt(lx,ly)`. Drag-листенеры регистрируются ПЕРВЫМИ (если `_hasHandles`), pan — после; `_onPanDown` стартует pan, только если `!_dragging && !_pickHandleAt(...)` → ручка/тело всегда побеждает. Курсор сцены `grab` (пустое место паним), `grabbing` при pan.
- **Сетка адаптивна к zoom**: `_niceStep(targetPx)` завязан на `_scale` (мир→px), шаги 1/2/5·10^n; `_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`); линии округляются к `.5px` (резкость, без «ступенек»). `_drawAxes` — оси X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений (светлый текст + тень на тёмном фоне, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
- **destroy** снимает wheel-листенер + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver — утечек нет.
- Иконки кнопок (`_chevIcon/_fitIcon/_resetViewIcon`) — inline SVG `.ic`-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились.
- **Верификация P1**: `node --check` OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, грузятся через `require`) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по `_toPx`), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек.
- **На P2 (графика объектов)**: расширять `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot` и чтение стилей в `_prepareObjects` (там уже читаются color/fill/width).
+401 -111
View File
@@ -303,6 +303,12 @@
this._cw = 0; this._ch = 0;
this._destroyed = false;
this._ro = null;
// ── вид (zoom/pan) ──
this._scale = 1; this._offX = 0; this._offY = 0; // эффективный transform
this._baseScale = 1; this._baseOffX = 0; this._baseOffY = 0; // базовый fit
this._zoom = 1; // пользовательский множитель к базовому масштабу
this._viewLocked = false; // true после зума/пана — ресайз не сбрасывает вид
this._panning = null; // активный pan { lastX, lastY }
this._dragging = null; // текущая перетаскиваемая ручка (drag)
this._readoutSlot = 0; // счётчик автопозиционируемых readout-бейджей
// ── физика (Фаза 2) ──
@@ -330,18 +336,45 @@
// корень
var root = document.createElement('div');
root.className = 'sim-spec-root';
root.style.cssText = 'flex:1;min-height:0;display:flex;width:100%;height:100%;background:' +
(this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
root.style.cssText = 'flex:1;min-height:0;position:relative;display:block;width:100%;height:100%;' +
'overflow:hidden;background:' + (this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
this.el = root;
// ── панель контролов слева ──
// ── сцена на ВСЮ площадь хоста (canvas + оверлей подписей) ──
// (раньше сцена делила строку flex с фикс-панелью 260px и визуально съезжала
// вправо у пустых симуляций — теперь сцена занимает весь root, центрирована.)
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'position:absolute;inset:0;overflow:hidden';
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
stage.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
var labels = document.createElement('div');
labels.className = 'sim-spec-labels';
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
stage.appendChild(labels);
this._labelLayer = labels;
root.appendChild(stage);
this._stage = stage;
// ── плавающая панель контролов (overlay поверх сцены, сворачиваемая) ──
// pointer-events только на самой карточке: пустое место сцены под ней доступно
// для pan/drag. Если параметров нет — панель минимальна (только play/reset).
var panel = document.createElement('div');
panel.className = 'sim-spec-panel';
panel.style.cssText = 'width:260px;flex-shrink:0;background:rgba(13,13,26,0.92);' +
'border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column;' +
'gap:10px;padding:16px 14px;overflow-y:auto';
panel.style.cssText = 'position:absolute;left:10px;top:10px;z-index:5;max-width:248px;' +
'max-height:calc(100% - 20px);display:flex;flex-direction:column;gap:10px;' +
'background:rgba(13,13,26,0.82);border:1px solid rgba(255,255,255,0.10);border-radius:14px;' +
'padding:10px 12px;overflow-y:auto;backdrop-filter:blur(6px);pointer-events:auto;' +
'box-shadow:0 8px 28px rgba(0,0,0,0.35)';
this._panel = panel;
// заголовок + кнопки play/pause/reset
// заголовок: play/pause/reset + кнопка сворачивания
var ctrlRow = document.createElement('div');
ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center';
var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза');
@@ -351,10 +384,25 @@
btnReset.addEventListener('click', function () { self.reset(); });
ctrlRow.appendChild(btnPlay);
ctrlRow.appendChild(btnReset);
// тело панели (слайдеры) — сворачивается
var pBody = document.createElement('div');
pBody.style.cssText = 'display:flex;flex-direction:column;gap:10px';
this._panelBody = pBody;
var params = Array.isArray(spec.params) ? spec.params : [];
// кнопка сворачивания — только если есть что сворачивать (есть параметры)
if (params.length) {
var btnCollapse = this._btn(this._chevIcon(true), 'Свернуть панель');
this._btnCollapse = btnCollapse;
btnCollapse.style.marginLeft = 'auto';
btnCollapse.addEventListener('click', function () { self._togglePanel(); });
ctrlRow.appendChild(btnCollapse);
}
panel.appendChild(ctrlRow);
// слайдеры параметров
var params = Array.isArray(spec.params) ? spec.params : [];
params.forEach(function (p) {
if (!p || !p.name) return;
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
@@ -366,7 +414,7 @@
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
var lblRow = document.createElement('div');
lblRow.style.cssText = 'display:flex;justify-content:space-between;font-size:.74rem;color:rgba(255,255,255,.6)';
lblRow.style.cssText = 'display:flex;justify-content:space-between;gap:8px;font-size:.74rem;color:rgba(255,255,255,.6)';
var lblName = document.createElement('span');
lblName.textContent = p.label || p.name;
var lblVal = document.createElement('span');
@@ -392,29 +440,16 @@
wrap.appendChild(lblRow);
wrap.appendChild(slider);
panel.appendChild(wrap);
pBody.appendChild(wrap);
self._sliders[p.name] = slider;
});
if (params.length) panel.appendChild(pBody);
// ── сцена справа (canvas + оверлей подписей) ──
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'flex:1;min-width:0;position:relative;overflow:hidden';
stage.appendChild(panel);
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
stage.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// ── кнопки управления видом («Вписать» / «Сбросить вид») в углу сцены ──
this._buildViewControls(stage);
var labels = document.createElement('div');
labels.className = 'sim-spec-labels';
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
stage.appendChild(labels);
this._labelLayer = labels;
root.appendChild(panel);
root.appendChild(stage);
this.host.appendChild(root);
// подготовить объекты (компиляция привязок один раз)
@@ -428,6 +463,8 @@
// drag-интеракции (мышь + тач через pointer events)
this._setupDrag();
// zoom (колесо) + pan (drag пустого места)
this._setupZoomPan();
// первичная подгонка после layout
requestAnimationFrame(function () {
@@ -438,6 +475,29 @@
});
};
/* свернуть/развернуть тело панели параметров */
SimEngineInstance.prototype._togglePanel = function () {
if (!this._panelBody) return;
this._panelCollapsed = !this._panelCollapsed;
this._panelBody.style.display = this._panelCollapsed ? 'none' : 'flex';
if (this._btnCollapse) this._btnCollapse.innerHTML = this._chevIcon(!this._panelCollapsed);
};
/* кнопки вида в правом-нижнем углу сцены: «Вписать» и «Сбросить вид» */
SimEngineInstance.prototype._buildViewControls = function (stage) {
var self = this;
var bar = document.createElement('div');
bar.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:5;display:flex;gap:6px;pointer-events:auto';
var btnFit = this._btn(this._fitIcon(), 'Вписать в окно');
var btnResetView = this._btn(this._resetViewIcon(), 'Сбросить вид');
btnFit.addEventListener('click', function () { self.fitView(); });
btnResetView.addEventListener('click', function () { self.resetView(); });
bar.appendChild(btnFit);
bar.appendChild(btnResetView);
stage.appendChild(bar);
this._viewBar = bar;
};
/* кнопка контрола (inline SVG, без эмодзи) */
SimEngineInstance.prototype._btn = function (innerSvg, title) {
var b = document.createElement('button');
@@ -458,6 +518,20 @@
SimEngineInstance.prototype._resetIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
};
// шеврон: up=панель развёрнута (клик свернёт), down=панель свёрнута (клик развернёт)
SimEngineInstance.prototype._chevIcon = function (expanded) {
return expanded
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 15 12 9 18 15"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>';
};
// «вписать»: рамка-кадрирование
SimEngineInstance.prototype._fitIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M4 8V5a1 1 0 0 1 1-1h3"/><path d="M16 4h3a1 1 0 0 1 1 1v3"/><path d="M20 16v3a1 1 0 0 1-1 1h-3"/><path d="M8 20H5a1 1 0 0 1-1-1v-3"/></svg>';
};
// «сбросить вид»: круговая стрелка с точкой-прицелом
SimEngineInstance.prototype._resetViewIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3"/><path d="M12 18v3"/><path d="M3 12h3"/><path d="M18 12h3"/></svg>';
};
SimEngineInstance.prototype._syncPlayBtn = function () {
if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running);
};
@@ -755,7 +829,11 @@
return env;
};
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── */
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ──
Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с
наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу;
при активном пользовательском виде (_viewLocked) ресайз НЕ сбрасывает зум/пан —
мир-центр и пользовательский масштаб сохраняются. */
SimEngineInstance.prototype._fit = function () {
var c = this.canvas; if (!c) return;
var dpr = Math.min(global.devicePixelRatio || 1, 2);
@@ -763,11 +841,17 @@
var r = stage.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
// запомнить мир-точку под центром canvas (для сохранения вида при ресайзе)
var hadView = this._viewLocked && this._cw && this._ch && this._scale;
var worldCx, worldCy;
if (hadView) { var wc = this._toWorld(this._cw / 2, this._ch / 2); worldCx = wc[0]; worldCy = wc[1]; }
this._dpr = dpr; this._cw = w; this._ch = h;
c.width = w * dpr; c.height = h * dpr;
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// вычислить масштаб «вписать viewport, равные оси»
// базовый масштаб «вписать viewport, равные оси»
var vp = this._vp();
var vw = vp.xmax - vp.xmin || 1;
var vh = vp.ymax - vp.ymin || 1;
@@ -775,12 +859,25 @@
var sx = (w - pad * 2) / vw;
var sy = (h - pad * 2) / vh;
var s = Math.min(sx, sy);
this._scale = s;
// центр мира в центре canvas
var cxWorld = (vp.xmin + vp.xmax) / 2;
var cyWorld = (vp.ymin + vp.ymax) / 2;
this._offX = w / 2 - cxWorld * s;
this._offY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
this._baseScale = s;
this._baseOffX = w / 2 - cxWorld * s;
this._baseOffY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
if (hadView) {
// сохранить пользовательский зум-коэффициент и мир-центр после ресайза
var z = this._zoom || 1;
this._scale = s * z;
this._offX = w / 2 - worldCx * this._scale;
this._offY = h / 2 + worldCy * this._scale;
} else {
// нет активного пользовательского вида — эффективный = базовый
this._zoom = 1;
this._scale = this._baseScale;
this._offX = this._baseOffX;
this._offY = this._baseOffY;
}
};
SimEngineInstance.prototype._toPx = function (mx, my) {
@@ -793,6 +890,16 @@
return [(px - this._offX) / s, (this._offY - py) / s];
};
/* «Вписать»: вернуться к базовому fit-виду по viewport (как при mount). */
SimEngineInstance.prototype.fitView = function () {
this._viewLocked = false;
this._zoom = 1;
this._fit();
this._renderFrame();
};
/* «Сбросить вид» — то же, что «Вписать» (база = центрированный viewport). */
SimEngineInstance.prototype.resetView = function () { this.fitView(); };
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
Перетаскиваемы: (1) объекты с prep.drag — ручки, пишут мир-коорд. в параметр;
(2) физ-тела (prep.body, не fixed) — тащишь напрямую: задаёт позицию тела, при
@@ -806,47 +913,53 @@
return false;
};
var HIT_PX = 16; // допуск хит-теста ручек/тел в экранных пикселях
/* локальные координаты pointer-события относительно canvas */
SimEngineInstance.prototype._localXY = function (ev) {
var r = this.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
};
/* хит-тест: ближайшая ручка/тело под (lx,ly) в пределах допуска, иначе null.
Общий для drag (приоритет ручек) и zoom/pan (pan только когда вернулся null). */
SimEngineInstance.prototype._pickHandleAt = function (lx, ly) {
var env = this._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = this._bodyById[o.id];
if (!body) continue;
ox = body.x; oy = body.y; hit = true;
// у тела допуск = max(HIT_PX, его экранный радиус)
} else if (o.drag && o.b.x && o.b.y) {
ox = o.b.x.ev(env); oy = o.b.y.ev(env); hit = true;
}
if (!hit) continue;
var p = this._toPx(ox, oy);
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
var tol = bestD;
if (o.body) {
var rb = this._bodyById[o.id];
var rpx = rb ? rb.radius * (this._scale || 1) : HIT_PX;
var t = Math.max(HIT_PX, rpx); t = t * t;
if (d <= t && d <= bestD) { bestD = d; best = o; }
continue;
}
if (d <= tol) { bestD = d; best = o; }
}
return best;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
var HIT_PX = 16; // допуск хит-теста в пикселях
function localXY(ev) {
var r = self.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
}
function pickHandle(lx, ly) {
// хит-тест в экранных пикселях, ближайшая ручка/тело в пределах допуска
var env = self._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < self._objs.length; i++) {
var o = self._objs[i];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = self._bodyById[o.id];
if (!body) continue;
ox = body.x; oy = body.y; hit = true;
// у тела допуск = max(HIT_PX, его экранный радиус)
} else if (o.drag && o.b.x && o.b.y) {
ox = o.b.x.ev(env); oy = o.b.y.ev(env); hit = true;
}
if (!hit) continue;
var p = self._toPx(ox, oy);
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
var tol = bestD;
if (o.body) {
var rb = self._bodyById[o.id];
var rpx = rb ? rb.radius * (self._scale || 1) : HIT_PX;
var t = Math.max(HIT_PX, rpx); t = t * t;
if (d <= t && d <= bestD) { bestD = d; best = o; }
continue;
}
if (d <= tol) { bestD = d; best = o; }
}
return best;
}
function localXY(ev) { return self._localXY(ev); }
function pickHandle(lx, ly) { return self._pickHandleAt(lx, ly); }
this._onPointerDown = function (ev) {
if (ev.button != null && ev.button !== 0) return; // только левая кнопка/тач
@@ -872,9 +985,9 @@
if (self._dragging) {
ev.preventDefault();
self._applyDrag(self._dragging, xy[0], xy[1]);
} else {
// hover-курсор над ручкой/телом
self.canvas.style.cursor = pickHandle(xy[0], xy[1]) ? 'grab' : 'default';
} else if (!self._panning) {
// hover-курсор: 'grab' и над ручкой/телом, и над пустым местом (pan)
self.canvas.style.cursor = 'grab';
}
};
this._onPointerUp = function (ev) {
@@ -898,6 +1011,77 @@
c.addEventListener('pointercancel', this._onPointerUp);
};
/* ════════════════════ Zoom (колесо к курсору) + Pan (drag пустого места) ════
Колесо масштабирует относительно позиции курсора: мир-точка под курсором
остаётся под курсором. Pan — перетаскивание ПУСТОГО места (приоритет у ручек/
тел: если pickHandle нашёл объект или drag уже активен — pan не стартует).
Множитель зума ограничен [MIN_ZOOM, MAX_ZOOM] относительно базового fit. */
SimEngineInstance.prototype._setupZoomPan = function () {
if (!this.canvas) return;
var self = this;
var c = this.canvas;
c.style.touchAction = 'none';
c.style.cursor = 'grab'; // пустое место сцены можно панить
// ── колесо: зум к курсору ──
this._onWheel = function (ev) {
ev.preventDefault();
var xy = self._localXY(ev);
var factor = Math.pow(1.0015, -ev.deltaY); // плавно; вверх (deltaY<0) — приблизить
self._zoomAt(xy[0], xy[1], factor);
};
c.addEventListener('wheel', this._onWheel, { passive: false });
// ── pan: pointer на пустом месте ──
this._onPanDown = function (ev) {
if (ev.button != null && ev.button !== 0) return;
if (self._dragging) return; // ручка/тело уже захвачены
var xy = self._localXY(ev);
if (self._pickHandleAt(xy[0], xy[1])) return; // попали в ручку/тело — не паним
self._panning = { x: xy[0], y: xy[1], pid: ev.pointerId };
ev.preventDefault();
try { c.setPointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'grabbing';
};
this._onPanMove = function (ev) {
if (!self._panning) return;
ev.preventDefault();
var xy = self._localXY(ev);
var dx = xy[0] - self._panning.x, dy = xy[1] - self._panning.y;
self._panning.x = xy[0]; self._panning.y = xy[1];
self._offX += dx; self._offY += dy;
self._viewLocked = true;
if (!self._running) self._renderFrame();
};
this._onPanUp = function (ev) {
if (!self._panning) return;
self._panning = null;
try { c.releasePointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'default';
};
c.addEventListener('pointerdown', this._onPanDown);
c.addEventListener('pointermove', this._onPanMove);
c.addEventListener('pointerup', this._onPanUp);
c.addEventListener('pointercancel', this._onPanUp);
};
/* зум вокруг экранной точки (lx,ly): мир-точка под курсором не сдвигается. */
SimEngineInstance.prototype._zoomAt = function (lx, ly, factor) {
var MIN_ZOOM = 0.1, MAX_ZOOM = 50;
var base = this._baseScale || this._scale || 1;
var newZoom = _clamp((this._zoom || 1) * factor, MIN_ZOOM, MAX_ZOOM);
if (newZoom === this._zoom) return;
// мир под курсором до зума
var w = this._toWorld(lx, ly);
this._zoom = newZoom;
this._scale = base * newZoom;
// сдвинуть offset так, чтобы (w) снова оказался под (lx,ly)
this._offX = lx - w[0] * this._scale;
this._offY = ly + w[1] * this._scale; // Y инвертирована
this._viewLocked = true;
if (!this._running) this._renderFrame();
};
/* кламп скорости броска (мир/с), чтобы рывок мыши не запускал тело в космос */
SimEngineInstance._clampThrow = function (v) {
var MAX = 40;
@@ -1250,47 +1434,125 @@
this._labelLayer.appendChild(el);
};
/* ── сетка/оси (мат. координаты, Y вверх) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var step = this._niceStep(vp);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
var x;
var x0 = Math.ceil(vp.xmin / step) * step;
for (x = x0; x <= vp.xmax + 1e-9; x += step) {
var pxv = this._toPx(x, 0)[0];
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
var y0 = Math.ceil(vp.ymin / step) * step, y;
for (y = y0; y <= vp.ymax + 1e-9; y += step) {
var pyv = this._toPx(0, y)[1];
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
ctx.restore();
/* видимые мир-границы текущего вьюпорта экрана (учёт zoom/pan).
Возвращает {xmin,xmax,ymin,ymax} в мировых координатах для всей области canvas. */
SimEngineInstance.prototype._visibleWorld = function (W, H) {
var a = this._toWorld(0, 0); // верх-лево экрана
var b = this._toWorld(W, H); // низ-право экрана
return {
xmin: Math.min(a[0], b[0]), xmax: Math.max(a[0], b[0]),
ymin: Math.min(a[1], b[1]), ymax: Math.max(a[1], b[1])
};
};
SimEngineInstance.prototype._niceStep = function (vp) {
var span = Math.max(vp.xmax - vp.xmin, vp.ymax - vp.ymin);
var raw = span / 10;
var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
/* «красивый» шаг сетки (1/2/5·10^n), чтобы минорная линия была ~targetPx пикселей.
Завязан на текущий масштаб _scale (мир→px), поэтому адаптивен к zoom. */
SimEngineInstance.prototype._niceStep = function (targetPx) {
var s = this._scale || 1;
var worldPerTarget = targetPx / s; // сколько мир-единиц в targetPx
var p = Math.pow(10, Math.floor(Math.log10(worldPerTarget || 1)));
var arr = [1, 2, 5, 10];
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= raw) return arr[i] * p;
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= worldPerTarget) return arr[i] * p;
return p * 10;
};
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var o = this._toPx(0, 0);
/* ── сетка: минорные (бледные/тонкие) + мажорные (каждые 5 минорных) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var minor = this._niceStep(34); // целевой шаг минорной линии ~34px
var major = minor * 5; // мажор каждые 5 минорных
this._gridStep = major; // запомнить для подписей осей
var EPS = minor * 1e-6;
// минорные
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
// ось X (если 0 в диапазоне Y)
if (0 >= vp.ymin && 0 <= vp.ymax) {
ctx.beginPath(); ctx.moveTo(0, o[1]); ctx.lineTo(W, o[1]); ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
this._gridLines(ctx, W, H, vw, minor, EPS);
// мажорные (поверх, ярче)
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
this._gridLines(ctx, W, H, vw, major, EPS);
ctx.restore();
};
/* нарисовать набор линий с шагом step через всю область canvas (вертикали по X,
горизонтали по Y). Координаты округляются к .5px для резкости (без «ступенек»).
Major vs minor различаются ТОЛЬКО step + strokeStyle, который ставит вызывающий. */
SimEngineInstance.prototype._gridLines = function (ctx, W, H, vw, step, EPS) {
if (!(step > 0) || !isFinite(step)) return;
var x, y;
var x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
var pxv = Math.round(this._toPx(x, 0)[0]) + 0.5;
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
// ось Y (если 0 в диапазоне X)
if (0 >= vp.xmin && 0 <= vp.xmax) {
ctx.beginPath(); ctx.moveTo(o[0], 0); ctx.lineTo(o[0], H); ctx.stroke();
var y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
var pyv = Math.round(this._toPx(0, y)[1]) + 0.5;
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
};
/* ── оси X/Y с числовыми подписями делений + маркер origin (0,0) ── */
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var o = this._toPx(0, 0);
var step = this._gridStep || this._niceStep(34) * 5;
var EPS = step * 1e-6;
// позиции осей: на 0, либо прижаты к краю canvas, если 0 вне видимой области
var axisYpx = _clamp(o[1], 0, H); // экранная Y линии оси X
var axisXpx = _clamp(o[0], 0, W); // экранная X линии оси Y
var xAtEdge = (o[1] < 0 || o[1] > H);
var yAtEdge = (o[0] < 0 || o[0] > W);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.42)';
ctx.lineWidth = 1.4;
// ось X
ctx.beginPath(); ctx.moveTo(0, Math.round(axisYpx) + 0.5); ctx.lineTo(W, Math.round(axisYpx) + 0.5); ctx.stroke();
// ось Y
ctx.beginPath(); ctx.moveTo(Math.round(axisXpx) + 0.5, 0); ctx.lineTo(Math.round(axisXpx) + 0.5, H); ctx.stroke();
// ── подписи делений (на тёмном фоне: светлый текст с тенью) ──
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.shadowColor = 'rgba(0,0,0,0.85)';
ctx.shadowBlur = 3;
var dec = _stepDecimals(step);
// подписи по X (под осью X)
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
var labY = _clamp(axisYpx + 4, 2, H - 14);
var x, x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
if (Math.abs(x) < EPS) continue; // origin подпишем отдельно
var px = this._toPx(x, 0)[0];
if (px < 14 || px > W - 4) continue;
ctx.fillText(_axisNum(x, dec), px, labY);
}
// подписи по Y (слева от оси Y)
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
var labX = _clamp(axisXpx - 6, 22, W - 2);
var y, y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
if (Math.abs(y) < EPS) continue;
var py = this._toPx(0, y)[1];
if (py < 8 || py > H - 8) continue;
ctx.fillText(_axisNum(y, dec), labX, py);
}
ctx.shadowBlur = 0;
// ── маркер origin (0,0): точка + подпись «0», только если 0 в видимой области ──
if (!xAtEdge && !yAtEdge) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(o[0], o[1], 2.5, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.shadowColor = 'rgba(0,0,0,0.85)'; ctx.shadowBlur = 3;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.fillText('0', o[0] - 5, o[1] + 4);
ctx.shadowBlur = 0;
}
ctx.restore();
};
@@ -1366,15 +1628,27 @@
this._destroyed = true;
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
// снять drag-слушатели
if (this.canvas && this._onPointerDown) {
if (this.canvas) {
var c = this.canvas;
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
if (this._onPointerDown) {
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
// снять zoom/pan-слушатели
if (this._onWheel) c.removeEventListener('wheel', this._onWheel);
if (this._onPanDown) {
c.removeEventListener('pointerdown', this._onPanDown);
c.removeEventListener('pointermove', this._onPanMove);
c.removeEventListener('pointerup', this._onPanUp);
c.removeEventListener('pointercancel', this._onPanUp);
}
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._onWheel = this._onPanDown = this._onPanMove = this._onPanUp = null;
this._dragging = null;
this._panning = null;
this._dragBody = null;
this._phys = null;
this._bodyById = {};
@@ -1392,6 +1666,22 @@
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(prec);
}
/* сколько знаков после запятой нужно для шага сетки (1/2/5·10^n) */
function _stepDecimals(step) {
if (!(step > 0) || !isFinite(step)) return 0;
var d = Math.ceil(-Math.log10(step));
return d > 0 ? Math.min(d, 6) : 0;
}
/* подпись деления оси: компактно, без лишних нулей; крупные/мелкие — экспонента */
function _axisNum(v, dec) {
if (!isFinite(v)) return '';
var a = Math.abs(v);
if (a !== 0 && (a >= 1e5 || a < 1e-4)) return v.toExponential(0).replace('e+', 'e');
var s = v.toFixed(dec);
// убрать хвостовые нули после точки
if (s.indexOf('.') >= 0) s = s.replace(/\.?0+$/, '');
return s;
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+22
View File
@@ -1,6 +1,28 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
- **Fix смещения вправо:** `_build` больше не делит строку flex с фикс-панелью 260px. Теперь
`root`(relative) → `stage`(absolute inset:0, canvas+labels на всю площадь) + плавающая `panel`
(absolute left/top:10px, z-index:5, pointer-events:auto, сворачивается `_togglePanel`, есть только при params)
+ бар кнопок вида (right/bottom:10px). Сцена центрирована во всю ширину хоста; пустая спека не съезжает.
- **Сетка:** minor(~34px)/major(×5), адаптивна к zoom (`_niceStep(targetPx)` завязан на `_scale`, шаги
1/2/5·10^n), рисуется через всю видимую область (`_visibleWorld`), линии на .5px (резкость, без «ступенек»).
- **Оси:** X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений
(светлый текст + тень на тёмном фоне, `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
- **Zoom/Pan:** колесо → `_zoomAt(lx,ly,factor)` (мир-точка под курсором инвариантна, зум-кламп 0.1..50×);
pan = drag пустого места (`_setupZoomPan`), приоритет ручек/тел через общий `_pickHandleAt` (pan стартует,
только если хит-тест вернул null). Кнопки вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный
viewport, SVG `.ic` в углу сцены). `_viewLocked` сохраняет вид при ресайзе. DPR-резкость сохранена.
- **destroy** снимает wheel+pan-листенеры и ResizeObserver. Верификация: `node --check` OK; headless-смоук
(DOM/canvas-стаб + реальные `_sim_expr.js`+`_sim_engine.js`) 40/40 (центрирование пустой спеки, zoom-инвариант
курсора+кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют
zoom/pan, fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy чист);
эмодзи нет (только `→` в комментариях, как в существующем коде), eval/Function нет.
- **Следующее (P2):** качество графики объектов (`_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/
`_prepareObjects` в `_sim_engine.js`).
- **ВСЕ ФАЗЫ (0–7) РЕАЛИЗОВАНЫ** (в рабочем дереве, не закоммичено — коммит за оркестратором).
Фича «Конструктор симуляций» функционально полна: рантайм+физика, БД+API, билдер, каталог в /lab,
раздача/клон/шаблоны/привязка, доска онлайн-урока с синхроном классу.
+63
View File
@@ -0,0 +1,63 @@
# SimForge — раунд улучшений (визуал / графика / рабочее поле)
**Branch:** `feature/sim-builder` · **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental
**Started:** 2026-06-13
Полировка конструктора симуляций по всем направлениям. Каждая фаза — реализатор + независимый ревьюер,
коммит поимённо. ⛔ Эмодзи нет (SVG .ic); ast-index/Read; общая ветка с параллельной сессией — править
свои файлы движка/билдера, чужое (materials/quota) не трогать.
## Контекст бага «съехало вправо»
`_sim_engine.js._build` рисует фикс-панель контролов `width:260px` СЛЕВА + сцену справа. У пустой/новой
симуляции панель всё равно 260px → сцена и сетка визуально смещены вправо (правые ~70% хоста). `_fit`
(DPR, центрирование по stage) корректен. Фикс — в раскладке (Фаза 1).
## Фазы
- [x] **P1 — Рабочее поле (fix смещения + основа сцены).** Контролы из фикс-260px-колонки → плавающая/
нижняя ненавязчивая панель (collapsible); canvas-сцена центрирована и во всю ширину. Сетка major/minor +
числовые подписи осей + маркер origin. Zoom (колесо к курсору) + pan (drag пустого места) + кнопки
fit/reset-view. Работает и в билдере, и в /lab, и на доске (один движок). Подтвердить DPR-резкость.
Файлы: `frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался — старый CSS превью
`.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже корректно растягивает новый full-bleed root).
**Handoff (P1 → P2):**
- **Раскладка:** `_build` теперь делает `root` (`position:relative`) → внутри `stage` (`position:absolute;
inset:0`, canvas+labels на всю площадь) + плавающая `panel` (`position:absolute;left/top:10px;z-index:5;
pointer-events:auto`, сворачивается кнопкой `_togglePanel`, есть только при наличии params) + бар кнопок
вида (`right/bottom:10px`). Смещение вправо устранено: панель больше не отжимает сцену.
- **Transform-модель:** `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit) и
ЭФФЕКТИВНЫЙ `_scale/_offX/_offY`. `_zoom` — пользовательский множитель к базе, `_viewLocked` — был ли
zoom/pan (ресайз тогда сохраняет мир-центр и zoom, не сбрасывает вид). `_toPx/_toWorld` — без изменений сигнатур.
- **API вида (новое, публичное):** `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport).
Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке, инвариант мир-точки), `_setupZoomPan()`,
`_pickHandleAt(lx,ly)` (вынесен из `_setupDrag`, общий хит-тест — pan стартует только если вернул null →
приоритет ручек/тел сохранён), `_visibleWorld(W,H)`.
- **Сетка/оси:** `_niceStep(targetPx)` теперь завязан на `_scale` (адаптивен к zoom, шаги 1/2/5·10^n);
`_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`), линии на .5px
(резкость, без «ступенек»); `_drawAxes` рисует оси (прижимаются к краю если 0 вне вида) + числовые подписи
делений (светлый текст + тень для тёмного фона, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
- **destroy:** снимает wheel + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver.
- **На P2:** качество графики ОБЪЕКТОВ (lineJoin/cap, стрелки векторов, dash/opacity/градиент/glow, стили
точек, затухающие трассы) — это `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot`/`_prepareObjects` в том
же файле. Поля стилей объектов уже читаются в `_prepareObjects` (color/fill/width) — расширять там.
- [ ] **P2 — Качество графики объектов.** Скругление/сглаживание линий (lineJoin/cap), красивые стрелки
векторов, стили линий (solid/dashed/dotted), opacity, градиент-заливки, опц. тень/glow, стили точек
(filled/hollow/cross), затухающие трассы; приятная дефолтная палитра. Файл: `_sim_engine.js`.
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
поля plot).
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
## Progress
| Phase | Status | Review | Committed |
|-------|--------|--------|-----------|
| P1 Working field | Done | ✅ PASS | ✅ |
| P2 Object graphics | ⬜ | ⬜ | ⬜ |
| P3 Charts | ⬜ | ⬜ | ⬜ |
| P4 Builder UI | ⬜ | ⬜ | ⬜ |
| P5 Direct manip + history | ⬜ | ⬜ | ⬜ |