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