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:
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user