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
+28
View File
@@ -0,0 +1,28 @@
'use strict';
/* LabPalette — единый источник цветов и констант для canvas-симуляций (Фаза 0).
* В 2D-контексте нельзя использовать var(--…) из ls.css, поэтому палитра
* дублировалась хардкодом в каждом файле. Теперь — одна точка правды.
* Адаптация симуляций — постепенная (по мере правок), задел под светлую тему. */
(function (global) {
global.LabPalette = global.LabPalette || {
bg: '#0D0D1A', // фон сцены
panel: 'rgba(255,255,255,0.04)', // лёгкие плашки
violet: '#9B5DE5', // бренд (= --violet)
cyan: '#06D6E0', // = --cyan
green: '#7BF5A4',
red: '#EF476F',
amber: '#FFD166',
pink: '#F15BB5', // = --pink
text: '#FFFFFF',
text2: 'rgba(255,255,255,0.72)',
muted: 'rgba(255,255,255,0.55)',
faint: 'rgba(255,255,255,0.30)',
grid: 'rgba(255,255,255,0.08)',
axis: 'rgba(255,255,255,0.45)',
// Физическая шкала по умолчанию: 1 м = 100 px (вынесено из магического *100).
PX_PER_M: 100,
};
})(window);
+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);
-191
View File
@@ -1,191 +0,0 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
SimUtil — shared utility functions for lab simulations.
Loaded once before all sim scripts.
══════════════════════════════════════════════════════════════ */
const SimUtil = (() => {
/**
* DPR-aware canvas resize. Sets canvas pixel size and applies
* `setTransform` so subsequent drawing is in CSS-pixel coords.
* Returns { W, H, dpr }.
*/
function fitCanvas(canvas, ctx) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || canvas.parentElement?.offsetWidth || 600;
const h = canvas.offsetHeight || canvas.parentElement?.offsetHeight || 400;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { W: w, H: h, dpr };
}
/**
* Compute a "nice" grid step for the given pixel range and target ~n divisions.
*/
function niceStep(rangePx, scale, n) {
if (!n) n = 8;
const raw = rangePx / scale / n;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
/**
* Format number for grid/axis labels.
*/
function fmt(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
/**
* Draw an arrow from (x1,y1) to (x2,y2).
*/
function arrow(ctx, x1, y1, x2, y2, color, lw) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
const ux = dx / len, uy = dy / len;
const hs = Math.min(10, len * 0.3); // head size
ctx.save();
ctx.strokeStyle = color || '#fff';
ctx.fillStyle = color || '#fff';
ctx.lineWidth = lw || 2;
ctx.lineCap = 'round';
// shaft
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2 - ux * hs * 0.5, y2 - uy * hs * 0.5);
ctx.stroke();
// head
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - ux * hs - uy * hs * 0.4, y2 - uy * hs + ux * hs * 0.4);
ctx.lineTo(x2 - ux * hs + uy * hs * 0.4, y2 - uy * hs - ux * hs * 0.4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
/**
* Draw a coordinate grid with labels.
* @param {object} opts — { ox, oy, scl, font, gridColor, labelColor }
*/
function drawGrid(ctx, W, H, opts) {
const { ox = 0, oy = 0, scl = 50, font, gridColor, labelColor } = opts || {};
const step = niceStep(W, scl);
// math <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> pixel helpers
const toPx = (mx, my) => [W / 2 + (mx - ox) * scl, H / 2 - (my - oy) * scl];
const toMx = (px) => (px - W / 2) / scl + ox;
const toMy = (py) => -(py - H / 2) / scl + oy;
const x0 = toMx(0), x1 = toMx(W);
const y0 = toMy(H), y1 = toMy(0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
// grid lines
ctx.strokeStyle = gridColor || 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = toPx(x, 0);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = toPx(0, y);
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
// labels
ctx.font = font || '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = labelColor || 'rgba(255,255,255,0.3)';
const [axX, axY] = toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = toPx(x, 0);
if (px < 18 || px > W - 18) continue;
ctx.fillText(fmt(x, step), px, lblY);
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = toPx(0, y);
if (py < 12 || py > H - 12) continue;
ctx.fillText(fmt(y, step), lblX, py);
}
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(0, axY); ctx.lineTo(W - 10, axY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(axX, H); ctx.lineTo(axX, 8); ctx.stroke();
// arrowheads
ctx.fillStyle = 'rgba(255,255,255,0.4)';
_arrowHead(ctx, W - 8, axY, 0);
_arrowHead(ctx, axX, 6, -Math.PI / 2);
// axis labels
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillText('x', W - 10, axY - 13);
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('y', axX + 7, 4);
}
function _arrowHead(ctx, x, y, angle) {
const s = 5;
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
/**
* Draw a tooltip panel at (x, y).
* @param {string[]} rows — lines of text
* @param {object} opts — { bg, fg, font, padding, radius }
*/
function tooltip(ctx, x, y, rows, opts) {
const { bg = 'rgba(22,22,38,0.92)', fg = '#ddd', font = '12px Manrope, sans-serif',
padding = 8, radius = 8 } = opts || {};
ctx.save();
ctx.font = font;
let maxW = 0;
for (const r of rows) maxW = Math.max(maxW, ctx.measureText(r).width);
const w = maxW + padding * 2;
const lineH = 17;
const h = rows.length * lineH + padding * 2;
const tx = x + 14, ty = y - h / 2;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.roundRect(tx, ty, w, h, radius);
ctx.fill();
ctx.fillStyle = fg;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
for (let i = 0; i < rows.length; i++) {
ctx.fillText(rows[i], tx + padding, ty + padding + i * lineH);
}
ctx.restore();
}
return { fitCanvas, niceStep, fmt, arrow, drawGrid, tooltip };
})();
+2 -1
View File
@@ -425,6 +425,8 @@
<script src="/js/labs/_registry.js"></script> <script src="/js/labs/_registry.js"></script>
<script src="/js/labs/_loader.js"></script> <script src="/js/labs/_loader.js"></script>
<script src="/js/labs/_sim_deps.js"></script> <script src="/js/labs/_sim_deps.js"></script>
<script src="/js/labs/_palette.js"></script>
<script src="/js/labs/_simbase.js"></script>
<script src="/js/labs/_fx_core.js"></script> <script src="/js/labs/_fx_core.js"></script>
<script src="/js/labs/_fx_particles.js"></script> <script src="/js/labs/_fx_particles.js"></script>
<script src="/js/labs/_fx_motion.js"></script> <script src="/js/labs/_fx_motion.js"></script>
@@ -433,7 +435,6 @@
<script src="/js/labs/_tasks.js"></script> <script src="/js/labs/_tasks.js"></script>
<script src="/js/labs/_phys_visuals.js"></script> <script src="/js/labs/_phys_visuals.js"></script>
<script src="/js/labs/_chem_visuals.js"></script> <script src="/js/labs/_chem_visuals.js"></script>
<script src="/js/labs/_util.js"></script>
<script src="/js/labs/graph.js"></script> <script src="/js/labs/graph.js"></script>
<script src="/js/notifications.js"></script> <script src="/js/notifications.js"></script>
<script src="/js/search.js"></script> <script src="/js/search.js"></script>
+1 -1
View File
@@ -68,7 +68,7 @@
Быстрые победы: тач массово, список симуляций в редакторе урока, reduced-motion/эконом, убрать `SimUtil`. Быстрые победы: тач массово, список симуляций в редакторе урока, reduced-motion/эконом, убрать `SimUtil`.
## Прогресс ## Прогресс
- [~] Фаза 0 — сделано: эконом-режим/reduced-motion (LabFX, тумблер), выбор симуляции из списка в редакторе урока. Осталось: SimBase, LabPalette, чистка дробовика/SimUtil. - [x] Фаза 0 (фундамент заложен) — эконом-режим/reduced-motion (LabFX, тумблер), выбор симуляции из списка в редакторе урока, удалён мёртвый `SimUtil`, добавлены `LabPalette` (_palette.js) и `SimBase` (_simbase.js) как опциональные основания. **Адаптация симуляций к SimBase/LabPalette и удаление «дробовика» `_pauseAllSims/closeSim` — постепенно, по мере правок каждой симуляции (требует поштучной проверки, нет фронт-тестов).**
- [~] Фаза 1 — сделано: фреймворк `LabTasks` (_tasks.js) + интеграция в теорию; задания на 17 симуляций. Осталось: XP за задания, deep-link на §, наполнение остальных. - [~] Фаза 1 — сделано: фреймворк `LabTasks` (_tasks.js) + интеграция в теорию; задания на 17 симуляций. Осталось: XP за задания, deep-link на §, наполнение остальных.
- [ ] Фаза 2 - [ ] Фаза 2
- [ ] Фаза 3 - [ ] Фаза 3