feat(sim-builder): улучшение P1 — рабочее поле: фикс смещения (контролы оверлеем), сетка/оси с делениями, zoom/pan
This commit is contained in:
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
@@ -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,
|
||||
раздача/клон/шаблоны/привязка, доска онлайн-урока с синхроном классу.
|
||||
|
||||
@@ -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 | ⬜ | ⬜ | ⬜ |
|
||||
Reference in New Issue
Block a user