diff --git a/frontend/js/labs/_palette.js b/frontend/js/labs/_palette.js
new file mode 100644
index 0000000..3723c62
--- /dev/null
+++ b/frontend/js/labs/_palette.js
@@ -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);
diff --git a/frontend/js/labs/_simbase.js b/frontend/js/labs/_simbase.js
new file mode 100644
index 0000000..ba51d29
--- /dev/null
+++ b/frontend/js/labs/_simbase.js
@@ -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);
diff --git a/frontend/js/labs/_util.js b/frontend/js/labs/_util.js
deleted file mode 100644
index 7b48b4a..0000000
--- a/frontend/js/labs/_util.js
+++ /dev/null
@@ -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 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 };
-})();
diff --git a/frontend/lab.html b/frontend/lab.html
index 792e624..b740a1e 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -425,6 +425,8 @@
+
+
@@ -433,7 +435,6 @@
-
diff --git a/plans/simulations-improvement/README.md b/plans/simulations-improvement/README.md
index a8151e6..ac3b9d7 100644
--- a/plans/simulations-improvement/README.md
+++ b/plans/simulations-improvement/README.md
@@ -68,7 +68,7 @@
Быстрые победы: тач массово, список симуляций в редакторе урока, 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 на §, наполнение остальных.
- [ ] Фаза 2
- [ ] Фаза 3