From c4ca8bcae71e05ff9da615af69d062e127ab01cc Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 10:52:27 +0300 Subject: [PATCH] =?UTF-8?q?refactor(labs):=20=D0=A4=D0=B0=D0=B7=D0=B00=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=B4=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=E2=80=94=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BC=D1=91?= =?UTF-8?q?=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20SimUtil,=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20LabPalette=20+=20SimBase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалён _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 --- frontend/js/labs/_palette.js | 28 ++++ frontend/js/labs/_simbase.js | 74 +++++++++ frontend/js/labs/_util.js | 191 ------------------------ frontend/lab.html | 3 +- plans/simulations-improvement/README.md | 2 +- 5 files changed, 105 insertions(+), 193 deletions(-) create mode 100644 frontend/js/labs/_palette.js create mode 100644 frontend/js/labs/_simbase.js delete mode 100644 frontend/js/labs/_util.js 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