refactor(labs): Фаза0 фундамент — убрать мёртвый SimUtil, добавить LabPalette + SimBase

- Удалён _util.js (SimUtil): 0 использований во всех симуляциях (проверено),
  грузился впустую.
- LabPalette (_palette.js): единый источник цветов canvas + PX_PER_M вместо
  хардкода в каждом файле; задел под светлую тему.
- SimBase (_simbase.js): опциональная база жизненного цикла (DPR-fit + RAF
  play/pause/reset/destroy). Существующие симуляции не трогаются; «дробовик»
  остаётся fallback. Адаптация — постепенно, по мере правок (нет фронт-тестов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 10:52:27 +03:00
parent c0442d6803
commit c4ca8bcae7
5 changed files with 105 additions and 193 deletions
+74
View File
@@ -0,0 +1,74 @@
'use strict';
/* SimBase — опциональная база жизненного цикла симуляции (Фаза 0).
* Унифицирует то, что сейчас каждый sim-класс изобретает заново: DPR-fit canvas
* и RAF-петлю play/pause + reset/destroy. Подключение — постепенное (новые и
* рефакторимые симуляции делают `extends SimBase`); существующие не трогаются,
* legacy-«дробовик» _pauseAllSims/closeSim остаётся как fallback, пока все не
* мигрируют. Подкласс реализует step(dt) и/или draw().
*
* class MySim extends SimBase {
* constructor(canvas){ super(canvas); ... }
* step(dt){ ... } // физика (опц.)
* draw(){ ... } // отрисовка
* }
*/
(function (global) {
function SimBase(canvas) {
this.canvas = canvas;
this.ctx = canvas ? canvas.getContext('2d') : null;
this.dpr = 1;
this.w = 0;
this.h = 0;
this._raf = 0;
this._running = false;
this._last = 0;
}
SimBase.prototype.fit = function () {
var c = this.canvas; if (!c) return;
var dpr = Math.min(global.devicePixelRatio || 1, 2);
var r = (c.parentElement || c).getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
this.dpr = dpr; this.w = w; this.h = h;
c.width = w * dpr; c.height = h * dpr;
c.style.width = w + 'px'; c.style.height = h + 'px';
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (typeof this.onResize === 'function') this.onResize(w, h);
};
SimBase.prototype.play = function () {
if (this._running) return;
this._running = true;
this._last = (global.performance || Date).now();
var self = this;
function loop(t) {
if (!self._running) return;
var dt = Math.min((t - self._last) / 1000, 0.05); // кламп против скачков
self._last = t;
if (typeof self.step === 'function') self.step(dt);
if (typeof self.draw === 'function') self.draw();
self._raf = global.requestAnimationFrame(loop);
}
self._raf = global.requestAnimationFrame(loop);
};
SimBase.prototype.pause = function () {
this._running = false;
if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; }
};
SimBase.prototype.isRunning = function () { return this._running; };
SimBase.prototype.reset = function () {
if (typeof this.draw === 'function') this.draw();
};
// Полная остановка + снятие ресурсов. Подкласс переопределяет для удаления
// слушателей/ResizeObserver, затем вызывает super.destroy().
SimBase.prototype.destroy = function () {
this.pause();
};
global.SimBase = SimBase;
})(window);