Files
Maxim Dolgolyov 7a323f8fe0 feat(labs): универсальные инструменты для физических симуляций (Раунд 2)
ФУНДАМЕНТ — frontend/js/labs/_phys_visuals.js (новый файл, 871 строк):
- LSPhysFX.FORCE_COLORS: стандарт 10 цветов (mg красный, N циан, F_тр оранжевый,
  T фиолетовый, F_упр зелёный, F_сопр серый, F_прил жёлтый, импульс, v, a)
- drawVector / drawForceArrow / drawSpring / drawRope / drawSurface
- drawCoordSystem / drawScale / drawClock / drawAngleArc / drawPivot
- drawEnergyBars (KE/PE/W_тр/E_упр стеком с авто-масштабированием)
- LSTimeControl class (pause/play/0.1×-5×/scrubber для одного DOF)
- LSMotionTrail class (gradient line с alpha fade)
- LSBuildTimeControlUI helper для DOM-UI бара

ГРАФИКИ — frontend/js/labs/_graph_panel.js (новый файл, 415 строк):
- LSGraphPanel: 3 stacked time-series плота, авто-scale, freeze/export PNG
- LSGraphPanelUI: overlay-виджет 320×248 в углу canvas, body-selector,
  кнопки Сброс/Стоп/PNG download

FBD (свободные силовые диаграммы) интегрированы в:
- projectile.js: mg + drag + wind + elastic (bounce)
- pendulum.js: mg + T (math), mg + F_упр (spring vert/horiz)
- collision.js: стрелки скорости каждого шара + flash импульса
- newton.js: все сцены законов I-III + новые Атвуд/наклон/скатывание
- forcesandbox.js: gravity/N/friction/spring/applied на каждом теле

ENERGY BARS интегрированы в 5 сим с расчётами:
- projectile: ΔE_drag = F_d·v·dt (cumulative)
- pendulum: для math/spring/double/physical с учётом γ-затухания
- collision: KE loss при каждом столкновении
- newton: все 8 сцен включая Атвуд + наклон + скатывание (с моментом инерции)
- forcesandbox: + E_упр от пружин

GRAPHS PANEL — в 5 сим:
- pendulum: θ/ω/E (режим-aware)
- collision: |v₁|, |v₂|, v_цм
- newton: x/v/a (зависит от закона)
- forcesandbox: x/|v|/|a| выбранного тела
- hydrostatics: depth/vy/submergedFrac (только Архимед)

TIME CONTROL + MOTION TRAILS в 5 сим:
- pendulum (полный scrubber для math), collision, newton, forcesandbox (speed+pause)
- projectile (layered speed+pause, свой trail сохранён)
- LSMotionTrail на bob/балах/блоках с alpha gradient

Заменено рисование пружин на LSPhysFX.drawSpring везде.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:37:48 +03:00

2608 lines
103 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════
CollisionSim — 2D elastic/inelastic ball collision
Conservation of momentum & energy demo
═══════════════════════════════════════════════ */
class CollisionSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
/* physics params */
this.m1 = 4;
this.m2 = 4;
this.v1 = 8;
this.v2 = 8;
this.angle = 0;
this.e = 1;
this.speed = 1; // sim speed multiplier (0.1 4)
/* runtime */
this.playing = false;
this._raf = null;
this._lastTs = null;
this._cooldown = 0;
this._colCount = 0;
/* balls */
this._b = [];
/* visual effects */
this._sparks = [];
this._rings = [];
this._dust = []; // tiny debris cloud
this._impactPt = null;
this._squish = [null, null]; // per-ball squish {ts, nx, ny}
this._launchTs = null; // when play() was called
/* stats */
this._snapBefore = null;
this._snapAfter = null;
/* perfectly-inelastic merge state */
this._merged = false;
this._mergeR = 0;
this._mergeNormal = null; // {nx, ny} at moment of merge
this.onUpdate = null;
this.onPlayPause = null; // canvas click callback
/* centre-of-mass trail */
this._cmTrail = [];
/* pre-collision ghost velocity arrows */
this._ghostArrows = [];
/* hover inspector */
this._hoverBall = null;
/* FBD toggle */
this._fbdOn = false;
/* ── Energy bars widget ── */
this._energyOn = false;
this._frictionWork = 0; // cumulative KE lost in inelastic collisions
this._energyScale = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
/* ── TimeControl + MotionTrails ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this._tcTrailColors = ['#06D6E0', '#F15BB5'];
this.showTCTrails = false;
canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); });
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hoverBall = null; this.draw(); });
new ResizeObserver(() => { this.fit(); this._initBalls(); this.draw(); })
.observe(canvas.parentElement);
}
/* ═══ public API ═══ */
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
if (p.v1 !== undefined) this.v1 = +p.v1;
if (p.v2 !== undefined) this.v2 = +p.v2;
if (p.angle !== undefined) this.angle = +p.angle;
if (p.e !== undefined) this.e = +p.e;
this.reset();
}
setSpeed(s) {
this.speed = Math.max(0.1, Math.min(4, +s));
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._launchTs = performance.now();
this._spawnLaunchFx();
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._sparks = [];
this._rings = [];
this._dust = [];
this._squish = [null, null];
this._impactPt = null;
this._launchTs = null;
this._merged = false;
this._mergeR = 0;
this._mergeNormal = null;
this._cmTrail = [];
this._ghostArrows = [];
this._initBalls();
this.draw();
this._emit();
}
stats() {
if (this._b.length < 2) return { v1:0, v2:0, ke:0, p:0, colCount:0, before:null, after:null };
const [b1, b2] = this._b;
const v1 = Math.hypot(b1.vx, b1.vy), v2 = Math.hypot(b2.vx, b2.vy);
const ke = 0.5 * b1.m * v1 * v1 + 0.5 * b2.m * v2 * v2;
const px = b1.m * b1.vx + b2.m * b2.vx;
const py = b1.m * b1.vy + b2.m * b2.vy;
const p = Math.hypot(px, py);
return { v1, v2, ke, p, colCount: this._colCount,
before: this._snapBefore, after: this._snapAfter };
}
/* ═══ init ═══ */
_r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const r1 = this._r(this.m1), r2 = this._r(this.m2);
const gap = Math.max(r1 + r2 + 70, W * 0.30);
const cx = W / 2, cy = H / 2;
const rad = (this.angle * Math.PI) / 180;
const dy2 = Math.tan(rad) * (gap / 2);
this._b = [
{ id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy,
vx:this.v1, vy:0, angle: 0, angVel: 0,
color:'#9B5DE5', rgb:'155,93,229', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#9B5DE5', width:2.5, maxLen:120 }) : null },
{ id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2,
vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0,
color:'#06D6E0', rgb:'6,214,224', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#06D6E0', width:2.5, maxLen:120 }) : null },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._frictionWork = 0;
this._energyScale = 0;
}
/* ═══ launch visual burst ═══ */
_spawnLaunchFx() {
const now = performance.now();
for (const b of this._b) {
/* expanding ring per ball */
this._rings.push({ x:b.x, y:b.y, ts:now, kind:'launch',
life:700, maxR:b.r * 4, col:b.rgb });
/* radial spark burst */
for (let k = 0; k < 14; k++) {
this._sparks.push({
kind:'launch',
ang: (k / 14) * Math.PI * 2,
spd: 25 + Math.random() * 35,
x: b.x, y: b.y, ts: now,
col: b.rgb, life: 550,
});
}
}
}
/* ═══ tick / step ═══ */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
if (window.LabFX) LabFX.particles.update(rawDt);
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.speed);
if (dt === 0) { this.draw(); this._emit(); if (this.playing) this._tick(); return; }
} else {
dt = rawDt * this.speed;
}
this._tSim = (this._tSim || 0) + dt;
/* tick ball motion trails */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.tick(); }
}
this._step(dt);
this.draw();
this._emit();
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
const [b1, b2] = this._b;
this._graphUI.push(this._tSim || 0, [b1 ? Math.hypot(b1.vx, b1.vy) : 0, b2 ? Math.hypot(b2.vx, b2.vy) : 0, 0]);
}
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
const [b1, b2] = this._b;
if (this._cooldown > 0) this._cooldown--;
/* trails */
for (const b of this._b) {
b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) });
if (b.trail.length > 90) b.trail.shift();
if (this.showTCTrails && b._trail2) b._trail2.push(b.x, b.y);
}
/* centre-of-mass trail */
const M = b1.m + b2.m;
const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
this._cmTrail.push({ x: cmx, y: cmy });
if (this._cmTrail.length > 180) this._cmTrail.shift();
/* integrate */
for (const b of this._b) {
b.x += b.vx * dt; b.y += b.vy * dt;
b.angle += (b.angVel || 0) * dt;
if (b.angVel) b.angVel *= 0.997; // rotational air drag
}
/* wall bounces — when merged, use combined radius and bounce both */
if (this._merged) {
const r = this._mergeR;
const fx = [
b1.x - r < 0 ? [b => { b.x = r; b.vx = Math.abs(b.vx); }, 'L'] : null,
b1.x + r > W ? [b => { b.x = W - r; b.vx = -Math.abs(b.vx); }, 'R'] : null,
b1.y - r < 0 ? [b => { b.y = r; b.vy = Math.abs(b.vy); }, 'T'] : null,
b1.y + r > H ? [b => { b.y = H - r; b.vy = -Math.abs(b.vy); }, 'B'] : null,
].filter(Boolean);
for (const [fn, side] of fx) {
fn(b1); fn(b2); this._wallFx(b1, side);
}
} else {
const eW = Math.max(0.5, this.e); // wall restitution (at least 0.5)
const wallMu = 0.18; // wall surface friction <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> spin
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; const vn = Math.abs(b.vx); b.vx = vn * eW; b.angVel -= b.vy * wallMu / b.r; this._wallFx(b, 'L'); }
if (b.x + b.r > W) { b.x = W - b.r; const vn = Math.abs(b.vx); b.vx = -vn * eW; b.angVel += b.vy * wallMu / b.r; this._wallFx(b, 'R'); }
if (b.y - b.r < 0) { b.y = b.r; const vn = Math.abs(b.vy); b.vy = vn * eW; b.angVel += b.vx * wallMu / b.r; this._wallFx(b, 'T'); }
if (b.y + b.r > H) { b.y = H - b.r; const vn = Math.abs(b.vy); b.vy = -vn * eW; b.angVel -= b.vx * wallMu / b.r; this._wallFx(b, 'B'); }
}
}
/* ballball collision — skip when already merged */
if (this._cooldown === 0 && !this._merged) {
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;
if (dvn > 0) {
if (this._colCount === 0) this._snapBefore = this._snapshot();
/* save pre-collision ghost arrows */
const _gnow = performance.now();
this._ghostArrows = this._b.map(b => ({
x: b.x, y: b.y, vx: b.vx, vy: b.vy, rgb: b.rgb, ts: _gnow,
}));
const isPerfectlyInelastic = this.e < 0.02;
if (isPerfectlyInelastic) {
/* ── MERGE: conservation of momentum, balls stick ── */
const M = b1.m + b2.m;
const mvx = (b1.m * b1.vx + b2.m * b2.vx) / M;
const mvy = (b1.m * b1.vy + b2.m * b2.vy) / M;
b1.vx = b2.vx = mvx;
b1.vy = b2.vy = mvy;
this._merged = true;
this._mergeR = Math.sqrt(b1.r * b1.r + b2.r * b2.r);
this._mergeNormal = { nx, ny };
} else {
const J = (1 + this.e) * dvn / (1 / b1.m + 1 / b2.m);
b1.vx -= J * nx / b1.m; b1.vy -= J * ny / b1.m;
b2.vx += J * nx / b2.m; b2.vy += J * ny / b2.m;
/* tangential friction <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> spin */
const tx = -ny, ty = nx;
const vRelT = (b1.vx - b2.vx) * tx + (b1.vy - b2.vy) * ty;
if (Math.abs(vRelT) > 0.5) {
const frMu = 0.32;
const jtMax = frMu * Math.abs(J);
const jt = -Math.sign(vRelT) * Math.min(jtMax, Math.abs(vRelT) / (1/b1.m + 1/b2.m));
b1.vx += jt * tx / b1.m; b1.vy += jt * ty / b1.m;
b2.vx -= jt * tx / b2.m; b2.vy -= jt * ty / b2.m;
const I1 = 0.4 * b1.m * b1.r * b1.r;
const I2 = 0.4 * b2.m * b2.r * b2.r;
b1.angVel -= jt * b1.r / I1;
b2.angVel += jt * b2.r / I2;
}
}
this._snapAfter = this._snapshot();
/* track inelastic energy loss for energy bars */
if (this._energyOn && this._snapBefore && this._snapAfter) {
const sumKE = arr => arr.reduce((s, b) => s + b.ke, 0);
const dKE = sumKE(this._snapBefore) - sumKE(this._snapAfter);
if (dKE > 0) this._frictionWork += dKE;
}
this._colCount++;
this._cooldown = 8;
const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
this._spawnCollisionFx(ix, iy, nx, ny, dvn);
/* squish (even on merge — visible one frame) */
this._squish[0] = { ts: performance.now(), nx, ny, dv: dvn };
this._squish[1] = { ts: performance.now(), nx: -nx, ny: -ny, dv: dvn };
}
/* overlap resolution */
const ov = min - dist;
b1.x -= nx * ov / 2; b1.y -= ny * ov / 2;
b2.x += nx * ov / 2; b2.y += ny * ov / 2;
}
}
/* if merged, lock both balls to centre-of-mass */
if (this._merged) {
const M = b1.m + b2.m;
const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
b1.x = b2.x = cmx;
b1.y = b2.y = cmy;
b2.vx = b1.vx;
b2.vy = b1.vy;
/* sync rotation of merged body */
const avgAng = (b1.angVel * b1.m + b2.angVel * b2.m) / M;
b1.angVel = b2.angVel = avgAng;
b1.angle += avgAng * dt; b2.angle = b1.angle;
}
/* expire effects */
const now = performance.now();
this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900));
this._sparks = this._sparks.filter(sp => (now - sp.ts) < (sp.life || 800));
this._dust = this._dust.filter(d => (now - d.ts) < 1400);
for (let i = 0; i < 2; i++) {
if (this._squish[i] && (now - this._squish[i].ts) > 300) this._squish[i] = null;
}
}
_wallFx(b, side) {
const now = performance.now();
const count = 6;
const baseAng = { L: 0, R: Math.PI, T: Math.PI / 2, B: -Math.PI / 2 }[side] ?? 0;
for (let k = 0; k < count; k++) {
this._sparks.push({
kind: 'wall',
ang: baseAng + (Math.random() - 0.5) * Math.PI,
spd: 10 + Math.random() * 20,
x: b.x, y: b.y, ts: now,
col: b.rgb, life: 400,
});
}
}
_spawnCollisionFx(ix, iy, nx, ny, dvn) {
const now = performance.now();
const intensity = Math.min(1, dvn / 22);
this._impactPt = { x: ix, y: iy, ts: now, intensity };
/* LabFX: collision effects */
if (window.LabFX) {
LabFX.sound.play('bounce');
LabFX.particles.emit({
ctx: this.ctx, x: ix, y: iy,
count: 12, color: '#FFF', speed: 90,
spread: Math.PI * 2, life: 400,
glow: true, shape: 'spark', size: 2,
});
LabFX.shake(this.c, { intensity: 3, durMs: 150 });
}
/* 4 expanding rings (different radii, colors, lifetimes) */
const ringDefs = [
{ life:1400, col:'255,255,255', maxR:160 },
{ life:1000, col:'155,93,229', maxR: 90 },
{ life: 750, col:'6,214,224', maxR: 65 },
{ life: 500, col:'241,91,181', maxR: 42 },
];
for (const rd of ringDefs) {
this._rings.push({ x:ix, y:iy, ts:now, kind:'collision',
life:rd.life, col:rd.col, maxR:rd.maxR });
}
/* 40 sparks — multi-color, gravity arc */
const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
for (let k = 0; k < 40; k++) {
this._sparks.push({
kind: 'collision',
ang: (k / 40) * Math.PI * 2 + (Math.random() - 0.5) * 0.35,
spd: (28 + Math.random() * 95) * (0.55 + intensity * 0.45),
len: 0.3 + Math.random() * 0.7,
x: ix, y: iy, ts: now,
col: pal[Math.floor(Math.random() * pal.length)],
grav: 35 + Math.random() * 65,
life: 900,
});
}
/* dust cloud — tiny slow particles */
for (let k = 0; k < 20; k++) {
this._dust.push({
ang: Math.random() * Math.PI * 2,
spd: 4 + Math.random() * 12,
x: ix + (Math.random() - 0.5) * 6,
y: iy + (Math.random() - 0.5) * 6,
r: 1 + Math.random() * 2.5,
ts: now,
grav: 8 + Math.random() * 18,
});
}
}
/* ═══ snapshot ═══ */
_snapshot() {
return this._b.map(b => {
const spd = Math.hypot(b.vx, b.vy);
return { m: b.m, vx: b.vx, vy: b.vy, spd, ke: 0.5 * b.m * spd * spd };
});
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
toggleGraphs(canvasOuter) {
if (!window.LSGraphPanelUI) return false;
this._graphsOn = !this._graphsOn;
if (this._graphsOn) {
this._tSim = 0;
this._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['v1', 'v2', 'cm'],
labels: ['|v1|', '|v2|', 'v_цм'],
units: ['м/с', 'м/с', 'м/с'],
colors: ['#9B5DE5', '#06D6E0', '#FFD166'],
toggleBtnId: 'btn-coll-graphs',
title: 'Скорости'
});
this._graphUI.isOn = true;
this._graphUI._build();
} else {
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
}
return this._graphsOn;
}
/* ═══ RENDER ═══ */
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H || this._b.length < 2) return;
const ctx = this.ctx;
const now = performance.now();
/* ── 1. Background ── */
/* radial dark-violet base */
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.hypot(W, H) / 1.7);
bg.addColorStop(0, '#130e22');
bg.addColorStop(1, '#080812');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
/* impact bg flash */
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 220;
if (el < 1) {
const fa = Math.pow(1 - el, 2) * (this._impactPt.intensity || 0.5) * 0.22;
const fg = ctx.createRadialGradient(
this._impactPt.x, this._impactPt.y, 0,
this._impactPt.x, this._impactPt.y, Math.hypot(W, H));
fg.addColorStop(0, `rgba(255,255,255,${fa})`);
fg.addColorStop(0.4, `rgba(155,93,229,${fa * 0.5})`);
fg.addColorStop(1, 'transparent');
ctx.fillStyle = fg;
ctx.fillRect(0, 0, W, H);
}
}
/* ── 2. Grid ── */
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = 60; x < W; x += 60) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 60; y < H; y += 60) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
/* ── 3. Arena border (pulses on impact) ── */
let borderAlpha = 0.18;
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 500;
if (el < 1) borderAlpha = 0.18 + (1 - el) * 0.55;
}
ctx.strokeStyle = `rgba(155,93,229,${borderAlpha})`;
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, W - 4, H - 4);
/* ── 4. Center dashes ── */
ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1;
ctx.setLineDash([6, 7]);
ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
ctx.setLineDash([]);
/* ── 5. Idle pulsing halos ── */
if (!this.playing && this._colCount === 0) {
const pulse = 0.5 + 0.5 * Math.sin(now / 420);
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
for (let ring = 0; ring < 2; ring++) {
const ph = pulse * (ring === 0 ? 1 : 1 - pulse);
const hg = ctx.createRadialGradient(b.x, b.y, b.r * (1 + ring * 0.7),
b.x, b.y, b.r * (3.2 + ring));
hg.addColorStop(0, `rgba(${r},${g},${bl},${0.28 * ph})`);
hg.addColorStop(0.5, `rgba(${r},${g},${bl},${0.08 * ph})`);
hg.addColorStop(1, 'transparent');
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r * (3.2 + ring), 0, Math.PI * 2);
ctx.fill();
}
}
/* dashed approach lines */
for (const b of this._b) {
const spd = Math.hypot(b.vx, b.vy);
if (spd < 0.01) continue;
const nx = b.vx / spd, ny = b.vy / spd;
const opa = 0.12 + 0.1 * Math.sin(now / 420);
ctx.strokeStyle = `rgba(255,255,255,${opa})`;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x + nx * 60, b.y + ny * 60); ctx.stroke();
ctx.setLineDash([]);
}
}
/* ── 6. Launch burst (first 600 ms after play()) ── */
if (this._launchTs) {
const le = Math.min(1, (now - this._launchTs) / 600);
if (le < 1) {
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
/* expanding ring */
const lR = le * b.r * 5.5;
const lAlph = (1 - le) * 0.75;
ctx.strokeStyle = `rgba(${r},${g},${bl},${lAlph})`;
ctx.lineWidth = 3 * (1 - le);
ctx.beginPath(); ctx.arc(b.x, b.y, lR, 0, Math.PI * 2); ctx.stroke();
/* 8 radial spokes */
for (let k = 0; k < 8; k++) {
const ang = (k / 8) * Math.PI * 2;
const inner = b.r + 3;
const outer = b.r + 3 + le * 48;
ctx.strokeStyle = `rgba(${r},${g},${bl},${(1 - le) * 0.65})`;
ctx.lineWidth = 1.8 * (1 - le * 0.6);
ctx.beginPath();
ctx.moveTo(b.x + Math.cos(ang) * inner, b.y + Math.sin(ang) * inner);
ctx.lineTo(b.x + Math.cos(ang) * outer, b.y + Math.sin(ang) * outer);
ctx.stroke();
}
}
}
}
/* ── 7. Trails (speed-colored: blue<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>yellow<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>red) ── */
const _maxSpd = Math.max(this.v1, this.v2, 0.1) * 1.6;
for (const b of this._b) {
for (let i = 1; i < b.trail.length; i++) {
const frac = i / b.trail.length;
const spd = b.trail[i].spd || 0;
const tr = frac * Math.min(8, 1.5 + spd * 0.18);
/* hue: 220 (blue) <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> 60 (yellow) <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> 0 (red) */
const t = Math.min(1, spd / _maxSpd);
const hue = 220 - t * 220;
const sat = 80 + t * 20;
ctx.fillStyle = `hsla(${hue},${sat}%,65%,${frac * 0.6})`;
ctx.beginPath();
ctx.arc(b.trail[i].x, b.trail[i].y, tr, 0, Math.PI * 2);
ctx.fill();
}
}
/* ── 7a. MotionTrail overlay ── */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.draw(ctx); }
}
/* ── 7b. Centre-of-mass trail ── */
for (let i = 1; i < this._cmTrail.length; i++) {
const frac = i / this._cmTrail.length;
const p = this._cmTrail[i];
ctx.fillStyle = `rgba(255,255,255,${frac * 0.35})`;
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5 + frac, 0, Math.PI * 2); ctx.fill();
}
if (this._cmTrail.length > 0) {
const cm = this._cmTrail[this._cmTrail.length - 1];
ctx.strokeStyle = 'rgba(255,255,255,.65)'; ctx.lineWidth = 1.5;
const cs = 7;
ctx.beginPath();
ctx.moveTo(cm.x - cs, cm.y); ctx.lineTo(cm.x + cs, cm.y);
ctx.moveTo(cm.x, cm.y - cs); ctx.lineTo(cm.x, cm.y + cs);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cm.x, cm.y, 5, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,.35)';
ctx.font = '8px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('CM', cm.x, cm.y + 8);
}
/* ── 8. Impact rings ── */
for (const ring of this._rings) {
const el = (now - ring.ts) / ring.life;
if (el >= 1) continue;
const maxR = ring.maxR || 110;
const ra = Math.pow(1 - el, ring.kind === 'launch' ? 1.2 : 1.6);
ctx.strokeStyle = `rgba(${ring.col},${ra * 0.7})`;
ctx.lineWidth = (ring.kind === 'launch' ? 1.5 : 2.8) * (1 - el * 0.75);
ctx.beginPath(); ctx.arc(ring.x, ring.y, el * maxR, 0, Math.PI * 2); ctx.stroke();
}
/* ── 9. Sparks ── */
ctx.lineCap = 'round';
for (const sp of this._sparks) {
const el = (now - sp.ts) / sp.life;
if (el >= 1) continue;
const sa = Math.pow(1 - el, sp.kind === 'launch' ? 1.1 : 1.4);
const dist = sp.spd * el;
const grav = sp.grav ? sp.grav * el * el : 0;
const ex = sp.x + Math.cos(sp.ang) * dist;
const ey = sp.y + Math.sin(sp.ang) * dist + grav;
const sx = sp.x + Math.cos(sp.ang) * dist * 0.4;
const sy = sp.y + Math.sin(sp.ang) * dist * 0.4 + grav * 0.16;
ctx.strokeStyle = `rgba(${sp.col},${sa * 0.92})`;
ctx.lineWidth = (sp.len || 1) * (sp.kind === 'launch' ? 1.2 : 2) * (1 - el * 0.4);
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
/* bright tip */
if (el < 0.45) {
ctx.fillStyle = `rgba(255,255,255,${sa * 0.75})`;
ctx.beginPath(); ctx.arc(ex, ey, 1.4, 0, Math.PI * 2); ctx.fill();
}
}
ctx.lineCap = 'butt';
/* ── 10. Dust cloud ── */
for (const d of this._dust) {
const el = (now - d.ts) / 1400;
if (el >= 1) continue;
const da = Math.pow(1 - el, 2) * 0.55;
const dist = d.spd * el;
const grav = d.grav * el * el;
ctx.fillStyle = `rgba(200,180,255,${da})`;
ctx.beginPath();
ctx.arc(d.x + Math.cos(d.ang) * dist,
d.y + Math.sin(d.ang) * dist + grav,
d.r * (1 + el * 0.5), 0, Math.PI * 2);
ctx.fill();
}
/* ── 11. Central impact flash ── */
if (this._impactPt) {
const el = (now - this._impactPt.ts) / 320;
if (el < 1) {
const fa = Math.pow(1 - el, 2.2);
const fgR = 90 * (1 + el * 0.6);
const fg = ctx.createRadialGradient(
this._impactPt.x, this._impactPt.y, 0,
this._impactPt.x, this._impactPt.y, fgR);
fg.addColorStop(0, `rgba(255,255,255,${fa})`);
fg.addColorStop(0.22, `rgba(255,215,80,${fa * 0.55})`);
fg.addColorStop(0.6, `rgba(155,93,229,${fa * 0.18})`);
fg.addColorStop(1, 'transparent');
ctx.fillStyle = fg;
ctx.beginPath(); ctx.arc(this._impactPt.x, this._impactPt.y, fgR, 0, Math.PI * 2);
ctx.fill();
}
}
/* ── 12. Velocity arrows ── */
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
const spd = Math.hypot(b.vx, b.vy);
if (spd < 0.05) continue;
const pLen = Math.min(68, spd * b.m * 0.75 + 8);
const nx = b.vx / spd, ny = b.vy / spd;
const ox = nx * (b.r + 7), oy = ny * (b.r + 7);
_colArrow(ctx,
b.x + ox, b.y + oy,
b.x + ox + nx*pLen, b.y + oy + ny*pLen,
b.color, 2.5);
ctx.save();
ctx.shadowColor = b.color; ctx.shadowBlur = 5;
ctx.fillStyle = `rgba(${r},${g},${bl},.9)`;
ctx.font = 'bold 10px Manrope';
ctx.textAlign = nx > 0 ? 'left' : 'right';
ctx.textBaseline = 'middle';
ctx.fillText(spd.toFixed(1) + ' м/с',
b.x + ox + nx * (pLen + 8),
b.y + oy + ny * (pLen + 8));
ctx.restore();
}
/* ── 12b. Ghost velocity arrows (pre-collision, fade out 1.5 s) ── */
if (this._ghostArrows.length > 0) {
const ghostAge = (now - this._ghostArrows[0].ts) / 1500;
if (ghostAge < 1) {
const alpha = Math.pow(1 - ghostAge, 1.5) * 0.5;
for (const ga of this._ghostArrows) {
const spd = Math.hypot(ga.vx, ga.vy);
if (spd < 0.1) continue;
const pLen = Math.min(64, spd * 2.5 + 8);
const nx = ga.vx / spd, ny = ga.vy / spd;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = `rgba(${ga.rgb},.9)`;
ctx.lineWidth = 2; ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(ga.x, ga.y);
ctx.lineTo(ga.x + nx * pLen, ga.y + ny * pLen);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
} else {
this._ghostArrows = [];
}
}
/* ── 12c. ΔKE loss badge near impact ── */
if (this._snapBefore && this._snapAfter && this._impactPt) {
const keBefore = this._snapBefore.reduce((s, b) => s + b.ke, 0);
const keAfter = this._snapAfter.reduce((s, b) => s + b.ke, 0);
const lossPct = keBefore > 0.1 ? Math.round((1 - keAfter / keBefore) * 100) : 0;
if (lossPct > 0) {
const ix = this._impactPt.x, iy = this._impactPt.y - 42;
const label = 'ΔKE ' + lossPct + '%';
ctx.font = 'bold 10px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(239,71,111,.18)';
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,.4)'; ctx.lineWidth = 1;
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, ix, iy);
} else if (lossPct === 0 && keBefore > 0.1) {
const ix = this._impactPt.x, iy = this._impactPt.y - 42;
const label = 'KE сохранена ✓';
ctx.font = 'bold 10px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(123,245,164,.15)';
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(123,245,164,.4)'; ctx.lineWidth = 1;
_roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, ix, iy);
}
}
/* ── 12d. Merged-body special rendering ── */
if (this._merged && this._mergeNormal) {
const mn = this._mergeNormal;
const b1_ = this._b[0], b2_ = this._b[1];
const cx = b1_.x, cy = b1_.y; // both at same CM pos
const off = (b1_.r + b2_.r) * 0.52; // visual separation
/* connection beam — shimmering gradient bar */
const bx1 = cx - mn.nx * off, by1 = cy - mn.ny * off;
const bx2 = cx + mn.nx * off, by2 = cy + mn.ny * off;
const pulse = 0.55 + 0.45 * Math.sin(now / 200);
const beamG = ctx.createLinearGradient(bx1, by1, bx2, by2);
beamG.addColorStop(0, `rgba(155,93,229,${pulse * 0.9})`);
beamG.addColorStop(0.5, `rgba(255,255,255,${pulse * 0.55})`);
beamG.addColorStop(1, `rgba(6,214,224,${pulse * 0.9})`);
ctx.save();
ctx.strokeStyle = beamG;
ctx.lineWidth = Math.max(b1_.r, b2_.r) * 0.7;
ctx.lineCap = 'round';
ctx.shadowColor = '#fff'; ctx.shadowBlur = 18 * pulse;
ctx.globalAlpha = 0.7;
ctx.beginPath(); ctx.moveTo(bx1, by1); ctx.lineTo(bx2, by2); ctx.stroke();
ctx.restore();
/* outer merged-body glow */
const gloBig = ctx.createRadialGradient(cx, cy, this._mergeR * 0.3,
cx, cy, this._mergeR * 3.2);
gloBig.addColorStop(0, `rgba(255,220,100,${0.2 * pulse})`);
gloBig.addColorStop(0.4, `rgba(155,93,229,${0.1 * pulse})`);
gloBig.addColorStop(1, 'transparent');
ctx.save(); ctx.fillStyle = gloBig;
ctx.beginPath(); ctx.arc(cx, cy, this._mergeR * 3.2, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
/* ── 13. Balls (with squish deform) ── */
for (let i = 0; i < this._b.length; i++) {
const b = this._b[i];
const [r, g, bl] = b.rgb.split(',').map(Number);
const sq = this._squish[i];
/* visual offset when merged — draw at slightly separated positions */
let drawX = b.x, drawY = b.y;
if (this._merged && this._mergeNormal) {
const mn = this._mergeNormal;
const off = (this._b[0].r + this._b[1].r) * 0.52;
const sign = i === 0 ? -1 : 1;
drawX = b.x + sign * mn.nx * off;
drawY = b.y + sign * mn.ny * off;
}
/* outer glow */
const glo = ctx.createRadialGradient(drawX, drawY, b.r * 0.2, drawX, drawY, b.r * 3.4);
glo.addColorStop(0, `rgba(${r},${g},${bl},.55)`);
glo.addColorStop(0.35,`rgba(${r},${g},${bl},.16)`);
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo;
ctx.beginPath(); ctx.arc(drawX, drawY, b.r * 3.4, 0, Math.PI * 2); ctx.fill();
/* squish transform */
let sqEl = 0;
let sqAngle = 0;
if (sq) {
sqEl = Math.min(1, (now - sq.ts) / 300);
sqAngle = Math.atan2(sq.ny, sq.nx);
}
ctx.save();
ctx.translate(drawX, drawY);
if (sqEl > 0 && sqEl < 1) {
const squeeze = 1 - 0.38 * Math.sin(sqEl * Math.PI);
ctx.rotate(sqAngle);
ctx.scale(1 + (1 - squeeze) * 0.5, squeeze);
ctx.rotate(-sqAngle);
}
/* body */
const bodyG = ctx.createRadialGradient(
-b.r * 0.33, -b.r * 0.33, b.r * 0.06,
0, 0, b.r);
bodyG.addColorStop(0, '#ffffff');
bodyG.addColorStop(0.18, b.color);
bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
ctx.fillStyle = bodyG;
ctx.beginPath(); ctx.arc(0, 0, b.r, 0, Math.PI * 2); ctx.fill();
/* rim */
ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1.5;
ctx.stroke();
/* rotation indicator */
const bAng = b.angle || 0;
ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(bAng) * b.r * 0.78, Math.sin(bAng) * b.r * 0.78);
ctx.stroke();
ctx.beginPath();
ctx.arc(Math.cos(bAng) * b.r * 0.58, Math.sin(bAng) * b.r * 0.58, 2.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,.28)'; ctx.fill();
/* primary highlight */
ctx.fillStyle = 'rgba(255,255,255,.25)';
ctx.beginPath();
ctx.ellipse(-b.r * 0.3, -b.r * 0.3, b.r * 0.35, b.r * 0.2, -0.6, 0, Math.PI * 2);
ctx.fill();
/* secondary glint */
ctx.fillStyle = 'rgba(255,255,255,.1)';
ctx.beginPath();
ctx.ellipse(b.r * 0.22, b.r * 0.28, b.r * 0.14, b.r * 0.07, 0.9, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
/* mass label (not squished) */
ctx.fillStyle = 'rgba(255,255,255,.95)';
ctx.font = `bold ${Math.max(10, Math.round(b.r * 0.60))}px Manrope`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.m + ' кг', drawX, drawY);
}
/* ── 14. Total momentum vector from CoM ── */
const [b1, b2] = this._b;
const px = b1.m * b1.vx + b2.m * b2.vx;
const py = b1.m * b1.vy + b2.m * b2.vy;
const pMag = Math.hypot(px, py);
if (pMag > 0.1) {
const cmx = (b1.m * b1.x + b2.m * b2.x) / (b1.m + b2.m);
const cmy = (b1.m * b1.y + b2.m * b2.y) / (b1.m + b2.m);
const pLen = Math.min(68, pMag * 1.5);
const pnx = px / pMag, pny = py / pMag;
ctx.save(); ctx.globalAlpha = 0.42;
_colArrow(ctx, cmx, cmy, cmx + pnx * pLen, cmy + pny * pLen, '#F15BB5', 1.5);
ctx.restore();
ctx.fillStyle = 'rgba(241,91,181,.5)';
ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('p⃗ = ' + pMag.toFixed(1), cmx + pnx * (pLen / 2 + 12), cmy + pny * (pLen / 2));
}
/* ── 15. Collision count badge ── */
if (this._colCount > 0) {
/* badge bg pill */
const txt = 'Столкновений: ' + this._colCount;
ctx.font = 'bold 11px Manrope';
const tw = ctx.measureText(txt).width;
const bx = W - 14 - tw, by = 8;
ctx.fillStyle = 'rgba(255,200,50,.15)';
_roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
ctx.fill();
ctx.strokeStyle = 'rgba(255,200,50,.3)'; ctx.lineWidth = 1;
_roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
ctx.stroke();
ctx.fillStyle = 'rgba(255,200,50,.95)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(txt, bx, by);
}
/* ── 15b. СЛИПАНИЕ badge ── */
if (this._merged) {
const pulse2 = 0.65 + 0.35 * Math.sin(now / 380);
const txt2 = 'СЛИПАНИЕ';
ctx.font = 'bold 12px Manrope';
const tw2 = ctx.measureText(txt2).width;
ctx.fillStyle = `rgba(255,220,100,${0.22 * pulse2})`;
_roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.fill();
ctx.strokeStyle = `rgba(255,220,100,${0.55 * pulse2})`; ctx.lineWidth = 1.5;
_roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.stroke();
ctx.fillStyle = `rgba(255,220,100,${pulse2})`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(txt2, W/2, 19);
}
/* ── 16. Speed badge (top-left when speed ≠ 1×) ── */
if (Math.abs(this.speed - 1) > 0.05) {
const label = this.speed.toFixed(2) + '×';
ctx.font = 'bold 11px Manrope';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(6,214,224,.12)';
_roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
_roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.stroke();
ctx.fillStyle = 'rgba(6,214,224,.9)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(label, 15, 10);
}
/* ── 17. Hover ball tooltip ── */
if (this._hoverBall) {
this._drawBallTooltip(ctx, this._hoverBall, W, H);
}
/* ── 18. FBD velocity / impulse overlays ── */
if (this._fbdOn && window.LSPhysFX) {
for (const b of this._b) {
const spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (spd > 0.5) {
const vLen = Math.min(60, spd * 3);
LSPhysFX.drawForceArrow(ctx, b.x, b.y - b.r - 8,
(b.vx / spd) * vLen, (b.vy / spd) * vLen,
'velocity', 'v=' + spd.toFixed(1));
}
}
/* impulse flash at impact */
if (this._impactPt) {
const iEl = (performance.now() - this._impactPt.ts) / 300;
if (iEl < 1) {
const ip = this._impactPt;
LSPhysFX.drawForceArrow(ctx, ip.x, ip.y, 0, -40 * (1 - iEl), 'impulse', 'J');
}
}
}
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsColl(ctx, W, H);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Energy bars: collision (1D) ── */
_drawEnergyBarsColl(ctx, W, H) {
var ke = 0;
for (var i = 0; i < this._b.length; i++) {
var b = this._b[i];
var spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
ke += 0.5 * b.m * spd * spd;
}
var tot = ke + this._frictionWork;
if (tot > this._energyScale) this._energyScale = tot;
var PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
{ ke: ke, friction: this._frictionWork, total: this._energyScale }, {});
}
/* ── hover inspector ── */
_onMouseMove(e) {
const r = this.c.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.c.width / r.width);
const my = (e.clientY - r.top) * (this.c.height / r.height);
let found = null;
for (const b of this._b) {
if (Math.hypot(mx - b.x, my - b.y) < b.r + 18) { found = b; break; }
}
if (found !== this._hoverBall) { this._hoverBall = found; this.draw(); }
}
_drawBallTooltip(ctx, b, W, H) {
const [r, g, bl] = b.rgb.split(',').map(Number);
const spd = Math.hypot(b.vx, b.vy);
const ke = 0.5 * b.m * spd * spd;
const p = b.m * spd;
const ang = Math.atan2(b.vy, b.vx) * 180 / Math.PI;
const rows = [
{ label: 'Масса m', val: b.m + ' кг', color: b.color },
{ label: '|v|', val: spd.toFixed(2) + ' м/с', color: '#ffffff' },
{ label: 'vx', val: b.vx.toFixed(2) + ' м/с', color: '#06D6E0' },
{ label: 'vy', val: b.vy.toFixed(2) + ' м/с', color: '#9B5DE5' },
{ label: 'KE', val: ke.toFixed(1) + ' Дж', color: '#FFD166' },
{ label: 'p = mv', val: p.toFixed(1) + ' кг·м/с', color: '#F15BB5' },
{ label: 'угол', val: ang.toFixed(1) + '°', color: 'rgba(255,255,255,.55)' },
{ label: 'ω', val: (b.angVel || 0).toFixed(1) + ' рад/с', color: '#F15BB5' },
];
const padX = 10, padY = 8, lineH = 17;
const tw = 148, th = padY * 2 + rows.length * lineH;
let tx = b.x + b.r + 14, ty = b.y - th / 2;
if (tx + tw > W - 8) tx = b.x - b.r - tw - 14;
if (ty < 8) ty = 8;
if (ty + th > H - 8) ty = H - th - 8;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.65)'; ctx.shadowBlur = 14;
ctx.fillStyle = 'rgba(8,8,18,.93)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
ctx.restore();
ctx.strokeStyle = `rgba(${r},${g},${bl},.5)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();
ctx.strokeStyle = `rgba(${r},${g},${bl},.8)`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(tx + 9, ty + 1); ctx.lineTo(tx + tw - 9, ty + 1); ctx.stroke();
ctx.font = '10px Manrope, sans-serif';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ry = ty + padY + i * lineH + lineH / 2;
if (i > 0) {
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(row.label, tx + padX, ry);
ctx.fillStyle = row.color; ctx.textAlign = 'right';
ctx.fillText(row.val, tx + tw - padX, ry);
}
}
}
/* ═══════════════════════════════════════════════
Collision2DSim — 2D angle collision (mode 2D)
═══════════════════════════════════════════════ */
class Collision2DSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.m1 = 4; this.m2 = 4;
this.v1 = 8; this.v2 = 8;
this.a1 = 0; this.a2 = 180; // angles in degrees
this.e = 1;
this.speed = 1;
this.cmFrame = false; // centre-of-mass reference frame toggle
this.playing = false;
this._raf = null;
this._lastTs = null;
this._b = [];
this._sparks = [];
this._rings = [];
this._colCount = 0;
this._cooldown = 0;
this._impactPt = null;
this._snapBefore = null;
this._snapAfter = null;
this.onUpdate = null;
this.onPlayPause = null;
this._activeMode = false;
canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
getParams() { return { m1:this.m1, m2:this.m2, v1:this.v1, v2:this.v2, a1:this.a1, a2:this.a2, e:this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
if (p.v1 !== undefined) this.v1 = +p.v1;
if (p.v2 !== undefined) this.v2 = +p.v2;
if (p.a1 !== undefined) this.a1 = +p.a1;
if (p.a2 !== undefined) this.a2 = +p.a2;
if (p.e !== undefined) this.e = +p.e;
this.reset();
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
_r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const r1 = this._r(this.m1), r2 = this._r(this.m2);
const cx = W / 2, cy = H / 2;
const gap = Math.max(r1 + r2 + 80, W * 0.35);
const rad1 = this.a1 * Math.PI / 180;
const rad2 = this.a2 * Math.PI / 180;
this._b = [
{ id:1, m:this.m1, r:r1,
x: cx - gap/2 * Math.cos(rad1), y: cy - gap/2 * Math.sin(rad1),
vx: this.v1 * Math.cos(rad1), vy: this.v1 * Math.sin(rad1),
color:'#9B5DE5', rgb:'155,93,229', trail:[] },
{ id:2, m:this.m2, r:r2,
x: cx - gap/2 * Math.cos(rad2), y: cy - gap/2 * Math.sin(rad2),
vx: this.v2 * Math.cos(rad2), vy: this.v2 * Math.sin(rad2),
color:'#06D6E0', rgb:'6,214,224', trail:[] },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._sparks = [];
this._rings = [];
this._impactPt = null;
}
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this._sparks = []; this._rings = []; this._impactPt = null;
this._initBalls(); this.draw(); this._emit();
}
stats() {
if (this._b.length < 2) return { colCount:0, before:null, after:null };
return { colCount: this._colCount, before: this._snapBefore, after: this._snapAfter };
}
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
this._emit();
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
if (this._cooldown > 0) this._cooldown--;
/* trails */
for (const b of this._b) {
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 80) b.trail.shift();
}
/* integrate */
for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }
/* wall bounces */
const eW = Math.max(0.5, this.e);
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'L'); }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'R'); }
if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'T'); }
if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'B'); }
}
/* ball-ball collision */
if (this._cooldown === 0) {
const [b1, b2] = this._b;
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
// relative velocity along normal
const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;
if (dvn > 0) {
if (this._colCount === 0) this._snapBefore = this._snapshot();
/* 2D elastic/inelastic: decompose into normal + tangential */
// v_n1, v_n2 (scalars along n̂)
const v1n = b1.vx * nx + b1.vy * ny;
const v2n = b2.vx * nx + b2.vy * ny;
// tangential components (unchanged)
const v1tx = b1.vx - v1n * nx, v1ty = b1.vy - v1n * ny;
const v2tx = b2.vx - v2n * nx, v2ty = b2.vy - v2n * ny;
// new normal velocities (1D formula with e)
const M = b1.m + b2.m;
const v1nNew = ((b1.m - b2.m * this.e) * v1n + b2.m * (1 + this.e) * v2n) / M;
const v2nNew = ((b2.m - b1.m * this.e) * v2n + b1.m * (1 + this.e) * v1n) / M;
b1.vx = v1tx + v1nNew * nx; b1.vy = v1ty + v1nNew * ny;
b2.vx = v2tx + v2nNew * nx; b2.vy = v2ty + v2nNew * ny;
this._snapAfter = this._snapshot();
this._colCount++;
this._cooldown = 8;
const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
this._impactPt = { x:ix, y:iy, ts:performance.now() };
_c2dSpawnFx(this, ix, iy);
}
/* overlap fix */
const ov = min - dist;
b1.x -= nx * ov / 2; b1.y -= ny * ov / 2;
b2.x += nx * ov / 2; b2.y += ny * ov / 2;
}
}
const now = performance.now();
this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900));
this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life || 800));
}
_snapshot() {
return this._b.map(b => {
const spd = Math.hypot(b.vx, b.vy);
return { m:b.m, vx:b.vx, vy:b.vy, spd, ke: 0.5 * b.m * spd * spd };
});
}
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H || this._b.length < 2) return;
const ctx = this.ctx;
const now = performance.now();
/* bg */
const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
bg.addColorStop(0,'#130e22'); bg.addColorStop(1,'#080812');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
for (let x = 60; x < W; x += 60) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
for (let y = 60; y < H; y += 60) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
ctx.strokeStyle = 'rgba(155,93,229,.18)'; ctx.lineWidth = 2;
ctx.strokeRect(2,2,W-4,H-4);
/* rings */
for (const ring of this._rings) {
const el = (now - ring.ts) / ring.life;
if (el >= 1) continue;
ctx.strokeStyle = `rgba(${ring.col},${Math.pow(1-el,1.6) * 0.7})`;
ctx.lineWidth = 2.8 * (1 - el * 0.75);
ctx.beginPath(); ctx.arc(ring.x, ring.y, el * (ring.maxR || 90), 0, Math.PI*2); ctx.stroke();
}
/* sparks */
ctx.lineCap = 'round';
for (const sp of this._sparks) {
const el = (now - sp.ts) / sp.life;
if (el >= 1) continue;
const dist = sp.spd * el;
const grav = (sp.grav || 0) * el * el;
const ex = sp.x + Math.cos(sp.ang) * dist;
const ey = sp.y + Math.sin(sp.ang) * dist + grav;
ctx.strokeStyle = `rgba(${sp.col},${Math.pow(1-el,1.4) * 0.9})`;
ctx.lineWidth = 2 * (1 - el * 0.4);
ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(ex, ey); ctx.stroke();
}
ctx.lineCap = 'butt';
/* trails */
for (const b of this._b) {
for (let i = 1; i < b.trail.length; i++) {
const frac = i / b.trail.length;
ctx.fillStyle = `rgba(${b.rgb},${frac * 0.45})`;
ctx.beginPath();
ctx.arc(b.trail[i].x, b.trail[i].y, frac * 4, 0, Math.PI*2);
ctx.fill();
}
}
/* balls */
for (const b of this._b) {
const [r, g, bl] = b.rgb.split(',').map(Number);
/* glow */
const glo = ctx.createRadialGradient(b.x,b.y, b.r*0.2, b.x,b.y, b.r*3.2);
glo.addColorStop(0, `rgba(${r},${g},${bl},.5)`);
glo.addColorStop(1, 'transparent');
ctx.fillStyle = glo; ctx.beginPath(); ctx.arc(b.x,b.y, b.r*3.2, 0, Math.PI*2); ctx.fill();
/* body */
const bodyG = ctx.createRadialGradient(-b.r*0.33+b.x, -b.r*0.33+b.y, b.r*0.06, b.x, b.y, b.r);
bodyG.addColorStop(0,'#ffffff'); bodyG.addColorStop(0.18, b.color);
bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
ctx.fillStyle = bodyG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke();
/* label */
ctx.fillStyle='rgba(255,255,255,.95)';
ctx.font = `bold ${Math.max(10,Math.round(b.r*0.6))}px Manrope`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(b.m + ' кг', b.x, b.y);
/* velocity arrow */
const spd = Math.hypot(b.vx, b.vy);
if (spd > 0.05) {
const nx = b.vx/spd, ny = b.vy/spd;
const pLen = Math.min(68, spd * b.m * 0.75 + 8);
_colArrow(ctx, b.x+nx*(b.r+7), b.y+ny*(b.r+7),
b.x+nx*(b.r+7+pLen), b.y+ny*(b.r+7+pLen), b.color, 2.5);
}
}
/* HUD — momentum px,py + KE before/after */
_c2dDrawHUD(ctx, this, W, H, now);
/* CM frame label */
if (this.cmFrame) {
ctx.fillStyle = 'rgba(255,200,50,.7)';
ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('Система ЦМ', W/2, 8);
}
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
}
/* ═══ Collision2DSim helpers ═══ */
function _c2dWallFx(sim, b, side) {
const now = performance.now();
const baseAng = { L:0, R:Math.PI, T:Math.PI/2, B:-Math.PI/2 }[side] ?? 0;
for (let k = 0; k < 6; k++) {
sim._sparks.push({ ang: baseAng + (Math.random()-0.5)*Math.PI, spd: 10+Math.random()*20,
x:b.x, y:b.y, ts:now, col:b.rgb, life:400 });
}
}
function _c2dSpawnFx(sim, ix, iy) {
const now = performance.now();
if (window.LabFX) { LabFX.sound.play('bounce'); }
const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
for (let k = 0; k < 30; k++) {
sim._sparks.push({ ang:(k/30)*Math.PI*2+(Math.random()-0.5)*0.4, spd:25+Math.random()*75,
x:ix, y:iy, ts:now, col:pal[k%pal.length], life:800, grav:30+Math.random()*50 });
}
const ringDefs = [
{life:1200,col:'255,255,255',maxR:130},{life:800,col:'155,93,229',maxR:80},{life:600,col:'6,214,224',maxR:55}
];
for (const rd of ringDefs) sim._rings.push({ x:ix, y:iy, ts:now, life:rd.life, col:rd.col, maxR:rd.maxR });
}
function _c2dDrawHUD(ctx, sim, W, H, now) {
const before = sim._snapBefore, after = sim._snapAfter;
const rows = [];
if (before) {
const px = before.reduce((s,b)=>s+b.m*b.vx,0), py = before.reduce((s,b)=>s+b.m*b.vy,0);
const ke = before.reduce((s,b)=>s+b.ke,0);
rows.push({ label:'p_x до:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
rows.push({ label:'p_y до:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
rows.push({ label:'KE до:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
}
if (after) {
const px = after.reduce((s,b)=>s+b.m*b.vx,0), py = after.reduce((s,b)=>s+b.m*b.vy,0);
const ke = after.reduce((s,b)=>s+b.ke,0);
rows.push({ label:'p_x после:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
rows.push({ label:'p_y после:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
rows.push({ label:'KE после:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
}
if (rows.length === 0) return;
const lh = 17, pad = 8, tw = 188, th = pad*2 + rows.length * lh;
const bx = W - tw - 10, by = H - th - 10;
ctx.fillStyle = 'rgba(8,8,18,.88)';
ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.stroke();
ctx.font = '10px Manrope';
for (let i = 0; i < rows.length; i++) {
const ry = by + pad + i * lh + lh/2;
ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(rows[i].label, bx + pad, ry);
ctx.fillStyle = rows[i].c; ctx.textAlign = 'right';
ctx.fillText(rows[i].val, bx + tw - pad, ry);
}
}
/* ═══════════════════════════════════════════════
MultiBodySim — N bodies (mode multi)
═══════════════════════════════════════════════ */
class MultiBodySim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.N = 5;
this.e = 1;
this.speed = 1;
this.cmFrame = false;
this.playing = false;
this._raf = null;
this._lastTs = null;
this._b = [];
this._sparks = [];
this._rings = [];
this._colCount = 0;
this.onUpdate = null;
this.onPlayPause = null;
this._activeMode = false;
canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
_r(m) { return Math.max(14, Math.min(36, 12 + m * 2)); }
_initBalls() {
const W = this.c.width || 700, H = this.c.height || 420;
const COLORS = [
{ color:'#9B5DE5', rgb:'155,93,229' }, { color:'#06D6E0', rgb:'6,214,224' },
{ color:'#F15BB5', rgb:'241,91,181' }, { color:'#FFD166', rgb:'255,209,102' },
{ color:'#06D6A0', rgb:'6,214,160' }, { color:'#EF476F', rgb:'239,71,111' },
{ color:'#118AB2', rgb:'17,138,178' }, { color:'#FFB347', rgb:'255,179,71' },
{ color:'#B5EAD7', rgb:'181,234,215' }, { color:'#C7CEEA', rgb:'199,206,234' },
];
const masses = [3,4,5,6,5,4,3,5,4,6];
this._b = [];
for (let i = 0; i < this.N; i++) {
const m = masses[i % masses.length];
const r = this._r(m);
const col = COLORS[i % COLORS.length];
let x, y, tries = 0;
do {
x = r + Math.random() * (W - 2*r);
y = r + Math.random() * (H - 2*r);
tries++;
} while (tries < 50 && this._b.some(b => Math.hypot(b.x-x, b.y-y) < b.r + r + 5));
const ang = Math.random() * Math.PI * 2;
const spd = 5 + Math.random() * 10;
this._b.push({ id:i, m, r, x, y, vx:Math.cos(ang)*spd, vy:Math.sin(ang)*spd,
color:col.color, rgb:col.rgb, trail:[] });
}
this._colCount = 0;
this._sparks = [];
this._rings = [];
}
shuffle() {
this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats());
}
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null; this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }
stats() {
const totalP = { x:0, y:0 }, totalKE = { v:0 };
for (const b of this._b) {
totalP.x += b.m * b.vx; totalP.y += b.m * b.vy;
totalKE.v += 0.5 * b.m * (b.vx*b.vx + b.vy*b.vy);
}
return { colCount: this._colCount, px: totalP.x, py: totalP.y, ke: totalKE.v };
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs)/1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
if (this.playing) this._tick();
});
}
_step(dt) {
const W = this.c.width, H = this.c.height;
/* apply CM frame: subtract v_cm from display velocities if toggled */
let vcmx = 0, vcmy = 0;
if (this.cmFrame) {
let M = 0;
for (const b of this._b) { M += b.m; vcmx += b.m*b.vx; vcmy += b.m*b.vy; }
vcmx /= M; vcmy /= M;
}
/* trails */
for (const b of this._b) {
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 60) b.trail.shift();
}
/* integrate */
for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }
/* walls */
const eW = Math.max(0.5, this.e);
for (const b of this._b) {
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * eW; }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * eW; }
if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; }
}
/* pair collisions */
const now = performance.now();
for (let i = 0; i < this._b.length; i++) {
for (let j = i+1; j < this._b.length; j++) {
const b1 = this._b[i], b2 = this._b[j];
const dx = b2.x - b1.x, dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx = dx/dist, ny = dy/dist;
const v1n = b1.vx*nx + b1.vy*ny;
const v2n = b2.vx*nx + b2.vy*ny;
const dvn = v1n - v2n;
if (dvn > 0) {
const v1tx = b1.vx - v1n*nx, v1ty = b1.vy - v1n*ny;
const v2tx = b2.vx - v2n*nx, v2ty = b2.vy - v2n*ny;
const M = b1.m + b2.m;
const v1nNew = ((b1.m - b2.m*this.e)*v1n + b2.m*(1+this.e)*v2n)/M;
const v2nNew = ((b2.m - b1.m*this.e)*v2n + b1.m*(1+this.e)*v1n)/M;
b1.vx = v1tx + v1nNew*nx; b1.vy = v1ty + v1nNew*ny;
b2.vx = v2tx + v2nNew*nx; b2.vy = v2ty + v2nNew*ny;
this._colCount++;
const ix = (b1.x+b2.x)/2, iy = (b1.y+b2.y)/2;
if (window.LabFX) LabFX.sound.play('bounce');
for (let k = 0; k < 3; k++) {
this._rings.push({ x:ix, y:iy, ts:now, life:600, col:'255,255,255', maxR:50 });
}
}
const ov = min - dist;
b1.x -= nx*ov/2; b1.y -= ny*ov/2;
b2.x += nx*ov/2; b2.y += ny*ov/2;
}
}
}
this._rings = this._rings.filter(r => (now - r.ts) < (r.life||900));
this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life||800));
}
draw() {
const W = this.c.width, H = this.c.height;
if (!W || !H) return;
const ctx = this.ctx;
const now = performance.now();
/* bg */
const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
bg.addColorStop(0,'#0d0a1a'); bg.addColorStop(1,'#060610');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
for (let x=60;x<W;x+=60){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
for (let y=60;y<H;y+=60){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
ctx.strokeStyle='rgba(155,93,229,.18)'; ctx.lineWidth=2; ctx.strokeRect(2,2,W-4,H-4);
/* rings */
for (const ring of this._rings) {
const el=(now-ring.ts)/ring.life; if(el>=1) continue;
ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.6})`;
ctx.lineWidth=2*(1-el*0.7);
ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||50),0,Math.PI*2); ctx.stroke();
}
/* trails */
for (const b of this._b) {
for (let i=1;i<b.trail.length;i++) {
const frac=i/b.trail.length;
ctx.fillStyle=`rgba(${b.rgb},${frac*0.35})`;
ctx.beginPath(); ctx.arc(b.trail[i].x,b.trail[i].y,frac*3.5,0,Math.PI*2); ctx.fill();
}
}
/* compute vcm if needed */
let vcmx=0, vcmy=0;
if (this.cmFrame) {
let M=0;
for (const b of this._b){M+=b.m;vcmx+=b.m*b.vx;vcmy+=b.m*b.vy;}
vcmx/=M; vcmy/=M;
}
/* balls */
for (const b of this._b) {
const [r,g,bl] = b.rgb.split(',').map(Number);
const glo = ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r*3);
glo.addColorStop(0,`rgba(${r},${g},${bl},.45)`); glo.addColorStop(1,'transparent');
ctx.fillStyle=glo; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*3,0,Math.PI*2); ctx.fill();
const bG=ctx.createRadialGradient(b.x-b.r*0.33,b.y-b.r*0.33,b.r*0.06,b.x,b.y,b.r);
bG.addColorStop(0,'#fff'); bG.addColorStop(0.18,b.color);
bG.addColorStop(1,`rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
ctx.fillStyle=bG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.9)';
ctx.font=`bold ${Math.max(9,Math.round(b.r*0.55))}px Manrope`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(b.m+'кг', b.x, b.y);
/* velocity arrow (in CM frame if toggled) */
const dvx = b.vx - vcmx, dvy = b.vy - vcmy;
const spd = Math.hypot(dvx,dvy);
if (spd > 0.1) {
const nx=dvx/spd, ny=dvy/spd;
const pLen=Math.min(55,spd*1.8+6);
_colArrow(ctx, b.x+nx*(b.r+5), b.y+ny*(b.r+5), b.x+nx*(b.r+5+pLen), b.y+ny*(b.r+5+pLen), b.color, 2);
}
}
/* HUD: total p + KE */
const st = this.stats();
const pMag = Math.hypot(st.px, st.py);
const hudLines = [
{ l:'p суммарный:', v: pMag.toFixed(2)+' кг·м/с', c:'#F15BB5' },
{ l:'KE суммарная:', v: st.ke.toFixed(1)+' Дж', c:'#FFD166' },
{ l:'Столкновений:', v: String(st.colCount), c:'#fff' },
];
const tw=200, lh=18, pad=8, th=pad*2+hudLines.length*lh;
ctx.fillStyle='rgba(8,8,18,.88)';
ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.fill();
ctx.strokeStyle='rgba(241,91,181,.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.stroke();
ctx.font='10px Manrope';
for (let i=0;i<hudLines.length;i++) {
const ry = H-th-8+pad+i*lh+lh/2;
ctx.fillStyle='rgba(255,255,255,.4)'; ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText(hudLines[i].l, 16, ry);
ctx.fillStyle=hudLines[i].c; ctx.textAlign='right';
ctx.fillText(hudLines[i].v, 8+tw-pad, ry);
}
if (this.cmFrame) {
ctx.fillStyle='rgba(255,200,50,.7)';
ctx.font='bold 11px Manrope'; ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('Система ЦМ', W/2, 8);
}
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
}
/* ═══════════════════════════════════════════════
BilliardSim — billiard table (mode billiard)
═══════════════════════════════════════════════ */
class BilliardSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.cueForce = 15;
this.speed = 1;
this.playing = false;
this._raf = null;
this._lastTs = null;
this._b = [];
this._pocketed = [];
this._rings = [];
this._sparks = [];
this._drag = null; // { x,y } — aiming start
this._aimEnd = null; // current mouse position
this._cueShot = false; // cue ball is in motion after shot
this._colCount = 0;
this.onUpdate = null;
this.onPlayPause = null;
this._activeMode = false;
canvas.addEventListener('mousedown', e => { if (this._activeMode) this._onDown(e); });
canvas.addEventListener('mousemove', e => { if (this._activeMode) this._onMove(e); });
canvas.addEventListener('mouseup', e => { if (this._activeMode) this._onUp(e); });
canvas.addEventListener('mouseleave',() => { if (this._activeMode) { this._drag = null; this._aimEnd = null; this.draw(); } });
new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
}
fit() {
const r = this.c.parentElement.getBoundingClientRect();
this.c.width = r.width || 700;
this.c.height = r.height || 420;
}
setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }
/* Table geometry helpers */
_table() {
const W=this.c.width||700, H=this.c.height||420;
const pad = 36;
return { x:pad, y:pad, w:W-2*pad, h:H-2*pad, W, H, pad };
}
_pockets() {
const t = this._table();
return [
{ x:t.x, y:t.y },
{ x:t.x+t.w/2, y:t.y },
{ x:t.x+t.w, y:t.y },
{ x:t.x, y:t.y+t.h },
{ x:t.x+t.w/2, y:t.y+t.h },
{ x:t.x+t.w, y:t.y+t.h },
];
}
/* Build billiard rack */
_initBalls() {
const t = this._table();
const R = 11;
this._b = [];
this._pocketed = [];
this._rings = []; this._sparks = [];
this._drag = null; this._aimEnd = null; this._cueShot = false;
this._colCount = 0;
/* Cue ball */
this._b.push({ id:0, m:5, r:R, x:t.x+t.w*0.28, y:t.y+t.h*0.5,
vx:0, vy:0, color:'#FFFFFF', rgb:'255,255,255',
isCue:true, pocketed:false, trail:[] });
/* Triangle rack: 6 colored balls */
const COLORS_RACK = ['#F15BB5','#FFD166','#06D6A0','#EF476F','#118AB2','#9B5DE5'];
const RGBS = ['241,91,181','255,209,102','6,214,160','239,71,111','17,138,178','155,93,229'];
const apex = { x: t.x + t.w*0.65, y: t.y + t.h*0.5 };
const rows = [[0],[1,2],[3,4,5]];
let idx = 0;
for (let row = 0; row < rows.length; row++) {
for (let col = 0; col < rows[row].length; col++) {
const px = apex.x + row * (R*2 + 1);
const py = apex.y + (col - (rows[row].length-1)/2) * (R*2 + 1);
this._b.push({ id:idx+1, m:5, r:R, x:px, y:py, vx:0, vy:0,
color:COLORS_RACK[idx], rgb:RGBS[idx],
isCue:false, pocketed:false, trail:[] });
idx++;
}
}
}
reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }
stats() { return { colCount:this._colCount, pocketed:this._pocketed.length }; }
play() {
if (this.playing) return;
this.playing = true; this._lastTs = null; this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
_canvasXY(e) {
const r = this.c.getBoundingClientRect();
return { x: (e.clientX - r.left)*(this.c.width/r.width),
y: (e.clientY - r.top)*(this.c.height/r.height) };
}
_cueBall() { return this._b.find(b => b.isCue && !b.pocketed); }
_onDown(e) {
const cue = this._cueBall();
if (!cue) return;
const p = this._canvasXY(e);
if (Math.hypot(p.x - cue.x, p.y - cue.y) < cue.r + 20) {
this._drag = { x:cue.x, y:cue.y };
this._aimEnd = p;
this.draw();
}
}
_onMove(e) {
if (!this._drag) return;
this._aimEnd = this._canvasXY(e);
this.draw();
}
_onUp(e) {
if (!this._drag) return;
const cue = this._cueBall();
if (!cue) { this._drag = null; return; }
const p = this._canvasXY(e);
/* shoot in opposite direction of drag (pull-back mechanic) */
const dx = this._drag.x - p.x, dy = this._drag.y - p.y;
const dist = Math.hypot(dx, dy);
if (dist > 4) {
const forceMag = Math.min(dist * 0.35, this.cueForce * 1.2) * (this.cueForce / 15);
cue.vx = (dx / dist) * forceMag;
cue.vy = (dy / dist) * forceMag;
this._cueShot = true;
if (!this.playing) this.play();
}
this._drag = null; this._aimEnd = null;
this.draw();
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs)/1000, 0.04);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
/* stop when all balls nearly still */
const moving = this._b.some(b => !b.pocketed && Math.hypot(b.vx,b.vy) > 0.3);
if (!moving) { this.pause(); this.draw(); }
else if (this.playing) this._tick();
});
}
_step(dt) {
const t = this._table();
const now = performance.now();
const friction = 0.985; // per frame rolling friction
/* trails */
for (const b of this._b) {
if (b.pocketed) continue;
b.trail.push({ x:b.x, y:b.y });
if (b.trail.length > 50) b.trail.shift();
}
/* integrate + friction */
for (const b of this._b) {
if (b.pocketed) continue;
b.x += b.vx * dt; b.y += b.vy * dt;
b.vx *= friction; b.vy *= friction;
if (Math.hypot(b.vx,b.vy) < 0.05) { b.vx = 0; b.vy = 0; }
}
/* wall bounces (cushion) */
for (const b of this._b) {
if (b.pocketed) continue;
if (b.x - b.r < t.x) { b.x = t.x + b.r; b.vx = Math.abs(b.vx)*0.8; }
if (b.x + b.r > t.x + t.w) { b.x = t.x+t.w - b.r; b.vx = -Math.abs(b.vx)*0.8; }
if (b.y - b.r < t.y) { b.y = t.y + b.r; b.vy = Math.abs(b.vy)*0.8; }
if (b.y + b.r > t.y + t.h) { b.y = t.y+t.h - b.r; b.vy = -Math.abs(b.vy)*0.8; }
}
/* ball-ball collisions */
const active = this._b.filter(b => !b.pocketed);
for (let i = 0; i < active.length; i++) {
for (let j = i+1; j < active.length; j++) {
const b1 = active[i], b2 = active[j];
const dx = b2.x-b1.x, dy = b2.y-b1.y;
const dist = Math.hypot(dx,dy);
const min = b1.r + b2.r;
if (dist < min && dist > 0.01) {
const nx=dx/dist, ny=dy/dist;
const dvn = (b1.vx-b2.vx)*nx + (b1.vy-b2.vy)*ny;
if (dvn > 0) {
const M=b1.m+b2.m;
const J = (1+0.85)*dvn / (1/b1.m + 1/b2.m);
b1.vx -= J*nx/b1.m; b1.vy -= J*ny/b1.m;
b2.vx += J*nx/b2.m; b2.vy += J*ny/b2.m;
this._colCount++;
if (window.LabFX) LabFX.sound.play('bounce');
const ix=(b1.x+b2.x)/2, iy=(b1.y+b2.y)/2;
this._rings.push({x:ix,y:iy,ts:now,life:500,col:'255,255,255',maxR:35});
}
const ov=min-dist;
b1.x-=nx*ov/2; b1.y-=ny*ov/2;
b2.x+=nx*ov/2; b2.y+=ny*ov/2;
}
}
}
/* pocket detection */
const pockets = this._pockets();
const PR = 14; // pocket radius
for (const b of this._b) {
if (b.pocketed) continue;
for (const p of pockets) {
if (Math.hypot(b.x-p.x, b.y-p.y) < PR) {
b.pocketed = true; b.vx=0; b.vy=0;
this._pocketed.push(b.id);
if (window.LabFX) LabFX.sound.play('chime');
this._rings.push({x:p.x,y:p.y,ts:now,life:700,col:'255,220,50',maxR:40});
break;
}
}
}
this._rings = this._rings.filter(r=>(now-r.ts)<(r.life||900));
this._sparks = this._sparks.filter(s=>(now-s.ts)<(s.life||800));
}
draw() {
const W=this.c.width||700, H=this.c.height||420;
const ctx=this.ctx, now=performance.now();
const t=this._table();
/* bg */
ctx.fillStyle='#1a1020'; ctx.fillRect(0,0,W,H);
/* table felt */
ctx.fillStyle='#1a5c2e';
ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,6); ctx.fill();
/* felt texture lines */
ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
for (let x=t.x+30;x<t.x+t.w;x+=30){ctx.beginPath();ctx.moveTo(x,t.y);ctx.lineTo(x,t.y+t.h);ctx.stroke();}
/* cushion border */
ctx.strokeStyle='#4a2c0a'; ctx.lineWidth=t.pad*0.7;
ctx.beginPath(); ctx.roundRect(t.x-t.pad*0.35,t.y-t.pad*0.35,t.w+t.pad*0.7,t.h+t.pad*0.7,8); ctx.stroke();
/* inner cushion line */
ctx.strokeStyle='rgba(255,255,255,.15)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,4); ctx.stroke();
/* centre line */
ctx.strokeStyle='rgba(255,255,255,.12)'; ctx.lineWidth=1; ctx.setLineDash([8,8]);
ctx.beginPath(); ctx.moveTo(t.x+t.w/2,t.y); ctx.lineTo(t.x+t.w/2,t.y+t.h); ctx.stroke();
ctx.setLineDash([]);
/* centre circle */
ctx.strokeStyle='rgba(255,255,255,.1)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(t.x+t.w/2,t.y+t.h/2,t.h*0.18,0,Math.PI*2); ctx.stroke();
/* pockets */
const pockets = this._pockets();
for (const p of pockets) {
ctx.fillStyle='#000';
ctx.beginPath(); ctx.arc(p.x,p.y,14,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.25)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(p.x,p.y,14,0,Math.PI*2); ctx.stroke();
}
/* rings */
for (const ring of this._rings) {
const el=(now-ring.ts)/ring.life; if(el>=1) continue;
ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.8})`;
ctx.lineWidth=2.5*(1-el*0.7);
ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||40),0,Math.PI*2); ctx.stroke();
}
/* trails */
for (const b of this._b) {
if (b.pocketed) continue;
for (let i=1;i<b.trail.length;i++) {
const frac=i/b.trail.length;
ctx.fillStyle=`rgba(${b.rgb},${frac*0.3})`;
ctx.beginPath(); ctx.arc(b.trail[i].x,b.trail[i].y,frac*b.r*0.5,0,Math.PI*2); ctx.fill();
}
}
/* balls */
for (const b of this._b) {
if (b.pocketed) continue;
const [r,g,bl]=b.rgb.split(',').map(Number);
const glo=ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r*2.5);
glo.addColorStop(0,`rgba(${r},${g},${bl},.4)`); glo.addColorStop(1,'transparent');
ctx.fillStyle=glo; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*2.5,0,Math.PI*2); ctx.fill();
const bG=ctx.createRadialGradient(b.x-b.r*0.3,b.y-b.r*0.3,b.r*0.05,b.x,b.y,b.r);
bG.addColorStop(0,'#fff'); bG.addColorStop(0.15,b.color); bG.addColorStop(1,`rgba(${Math.round(r*0.25)},${Math.round(g*0.25)},${Math.round(bl*0.25)},1)`);
ctx.fillStyle=bG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.45)'; ctx.lineWidth=1.2; ctx.stroke();
}
/* aim line when dragging */
if (this._drag && this._aimEnd) {
const cue = this._cueBall();
if (cue) {
const dx=this._drag.x - this._aimEnd.x, dy=this._drag.y - this._aimEnd.y;
const dist=Math.hypot(dx,dy);
if (dist > 4) {
const nx=dx/dist, ny=dy/dist;
/* cue stick */
ctx.strokeStyle='rgba(210,160,80,.9)'; ctx.lineWidth=4; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(cue.x + nx*(cue.r+8), cue.y + ny*(cue.r+8));
ctx.lineTo(cue.x + nx*(cue.r+8+Math.min(dist*1.2,120)), cue.y + ny*(cue.r+8+Math.min(dist*1.2,120)));
ctx.stroke(); ctx.lineCap='butt';
/* aim trajectory dash */
ctx.strokeStyle='rgba(255,255,255,.3)'; ctx.lineWidth=1; ctx.setLineDash([6,6]);
ctx.beginPath();
ctx.moveTo(cue.x - nx*(cue.r+4), cue.y - ny*(cue.r+4));
ctx.lineTo(cue.x - nx*180, cue.y - ny*180);
ctx.stroke(); ctx.setLineDash([]);
/* power indicator */
const power = Math.min(1, dist * 0.35 / (this.cueForce * 1.2) * (15/this.cueForce));
const pw = 80, ph = 8, px2 = 8, py2 = H - 28;
ctx.fillStyle='rgba(8,8,18,.75)';
ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.fill();
ctx.fillStyle=`hsl(${120-power*120},85%,55%)`;
ctx.beginPath(); ctx.roundRect(px2,py2,pw*power,ph,4); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.2)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.6)'; ctx.font='9px Manrope';
ctx.textAlign='left'; ctx.textBaseline='top';
ctx.fillText('Сила', px2, py2-12);
}
}
}
/* HUD */
const active = this._b.filter(b=>!b.pocketed&&!b.isCue).length;
const hudTxt = `Шаров в лузе: ${this._pocketed.length} | Осталось: ${active}`;
ctx.fillStyle='rgba(8,8,18,.75)';
const htw=ctx.measureText(hudTxt).width+20;
ctx.beginPath(); ctx.roundRect(W-htw-8,8,htw,24,6); ctx.fill();
ctx.fillStyle='rgba(255,255,255,.7)'; ctx.font='11px Manrope';
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(hudTxt, W-18, 20);
/* "drag cue" hint */
const cue=this._cueBall();
if (cue && !this.playing && !this._drag) {
ctx.fillStyle='rgba(255,255,255,.4)'; ctx.font='11px Manrope';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('Тяните от биткa для удара', W/2, t.y+t.h+6);
}
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
}
/* ═══ helpers ═══ */
function _colArrow(ctx, x1, y1, x2, y2, color, lw) {
const ang = Math.atan2(y2 - y1, x2 - x1);
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.shadowColor = color; ctx.shadowBlur = 9;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 9 * Math.cos(ang - 0.42), y2 - 9 * Math.sin(ang - 0.42));
ctx.lineTo(x2 - 9 * Math.cos(ang + 0.42), y2 - 9 * Math.sin(ang + 0.42));
ctx.closePath(); ctx.fill();
ctx.restore();
}
function _roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.arcTo(x+w, y, x+w, y+r, r);
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x+w, y+h, x+w-r, y+h, r);
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y+h, x, y+h-r, r);
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x+r, y, r);
ctx.closePath();
}
/* ─── lab UI init ─────────────────────────────────── */
/* active collision simulation instances (cSim declared in lab-init.js) */
var cSim2D = null; /* 2D mode (Collision2DSim) */
var cSimMB = null; /* multi-body mode (MultiBodySim) */
var cSimBL = null; /* billiard mode (BilliardSim) */
var _collMode = '1d'; /* '1d' | '2d' | 'multi' | 'billiard' */
/* Return whichever sim is currently active */
function _activeSim() {
if (_collMode === '1d') return cSim;
if (_collMode === '2d') return cSim2D;
if (_collMode === 'multi') return cSimMB;
if (_collMode === 'billiard') return cSimBL;
return null;
}
function _openCollision() {
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
_simShow('sim-coll');
_simShow('ctrl-coll');
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
if (_embedMode) _startStateEmit('collision');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('coll-canvas');
if (!cSim) {
cSim = new CollisionSim(canvas);
cSim.onUpdate = _collUpdateUI;
cSim.onPlayPause = collPlayPause;
}
if (!cSim2D) {
cSim2D = new Collision2DSim(canvas);
cSim2D.onUpdate = _collUpdateUI;
cSim2D.onPlayPause = collPlayPause;
}
if (!cSimMB) {
cSimMB = new MultiBodySim(canvas);
cSimMB.onUpdate = _collUpdateUI;
cSimMB.onPlayPause = collPlayPause;
}
if (!cSimBL) {
cSimBL = new BilliardSim(canvas);
cSimBL.onUpdate = _collUpdateUI;
cSimBL.onPlayPause = collPlayPause;
}
/* inject mode-selector + mode panels if not yet present */
if (!document.getElementById('coll-mode-bar')) {
_collInjectModeUI();
}
_collMode = '1d';
_collApplyMode();
}));
}
/* Build the mode selector row + mode-specific panels */
function _collInjectModeUI() {
const simBody = document.getElementById('sim-coll');
const bodyWrap = simBody.querySelector('.sim-body-wrap');
const projPanel = bodyWrap ? bodyWrap.querySelector('.proj-panel') : null;
if (!projPanel) return;
/* ── mode selector bar (injected above params) ── */
const modeBar = document.createElement('div');
modeBar.id = 'coll-mode-bar';
modeBar.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px';
modeBar.innerHTML =
'<button class="proj-preset-chip coll-mode-btn active" data-mode="1d" onclick="collSetMode(\'1d\',this)">1D</button>' +
'<button class="proj-preset-chip coll-mode-btn" data-mode="2d" onclick="collSetMode(\'2d\',this)">2D угол</button>' +
'<button class="proj-preset-chip coll-mode-btn" data-mode="multi" onclick="collSetMode(\'multi\',this)">Multi-ball</button>' +
'<button class="proj-preset-chip coll-mode-btn" data-mode="billiard" onclick="collSetMode(\'billiard\',this)">Бильярд</button>';
projPanel.insertBefore(modeBar, projPanel.firstChild);
/* ── CM-frame toggle (universal) ── */
const cmRow = document.createElement('div');
cmRow.id = 'coll-cm-row';
cmRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:6px 0;';
cmRow.innerHTML =
'<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:0.8rem;color:rgba(255,255,255,.7)">' +
'<input type="checkbox" id="coll-cm-toggle" onchange="collCMFrame(this.checked)" style="accent-color:#FFD166">' +
'Система ЦМ</label>';
projPanel.insertBefore(cmRow, projPanel.firstChild.nextSibling);
/* ── 2D-specific panel ── */
const p2d = document.createElement('div');
p2d.id = 'coll-panel-2d';
p2d.style.display = 'none';
p2d.innerHTML =
'<div class="gp-section-title">2D — углы движения</div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">Угол v₁</span><span class="param-val" id="c2d-a1">0°</span></div>' +
'<input type="range" class="param-slider" id="sl-2da1" min="0" max="359" value="0" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">Угол v₂</span><span class="param-val" id="c2d-a2">180°</span></div>' +
'<input type="range" class="param-slider" id="sl-2da2" min="0" max="359" value="180" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">Масса m₁</span><span class="param-val" id="c2d-m1">4 кг</span></div>' +
'<input type="range" class="param-slider" id="sl-2dm1" min="1" max="20" value="4" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">Масса m₂</span><span class="param-val" id="c2d-m2">4 кг</span></div>' +
'<input type="range" class="param-slider" id="sl-2dm2" min="1" max="20" value="4" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">|v₁|</span><span class="param-val" id="c2d-v1">8 м/с</span></div>' +
'<input type="range" class="param-slider" id="sl-2dv1" min="0" max="30" value="8" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">|v₂|</span><span class="param-val" id="c2d-v2">8 м/с</span></div>' +
'<input type="range" class="param-slider" id="sl-2dv2" min="0" max="30" value="8" oninput="coll2DParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name">Упругость e</span><span class="param-val" id="c2d-e">1.00</span></div>' +
'<input type="range" class="param-slider" id="sl-2de" min="0" max="1" step="0.01" value="1" oninput="coll2DParam()"></div>';
projPanel.appendChild(p2d);
/* ── Multi-body panel ── */
const pmb = document.createElement('div');
pmb.id = 'coll-panel-multi';
pmb.style.display = 'none';
pmb.innerHTML =
'<div class="gp-section-title">Multi-ball</div>' +
'<div class="param-block"><div class="param-header"><span class="param-name">Шаров N</span><span class="param-val" id="cmb-n">5</span></div>' +
'<input type="range" class="param-slider" id="sl-mb-n" min="2" max="10" value="5" oninput="collMBParam()"></div>' +
'<div class="param-block"><div class="param-header"><span class="param-name">Упругость e</span><span class="param-val" id="cmb-e">1.00</span></div>' +
'<input type="range" class="param-slider" id="sl-mb-e" min="0" max="1" step="0.01" value="1" oninput="collMBParam()"></div>' +
'<div style="margin-top:8px">' +
'<button class="proj-preset-chip" onclick="cSimMB&&cSimMB.shuffle()">Перемешать</button>' +
'</div>';
projPanel.appendChild(pmb);
/* ── Billiard panel ── */
const pbl = document.createElement('div');
pbl.id = 'coll-panel-billiard';
pbl.style.display = 'none';
pbl.innerHTML =
'<div class="gp-section-title">Бильярд</div>' +
'<div class="param-block"><div class="param-header"><span class="param-name">Сила удара</span><span class="param-val" id="cbl-force">15</span></div>' +
'<input type="range" class="param-slider" id="sl-bl-force" min="5" max="30" value="15" oninput="collBLParam()"></div>' +
'<div style="margin-top:8px">' +
'<button class="proj-preset-chip" onclick="cSimBL&&cSimBL.reset()">Reset rack</button>' +
'</div>';
projPanel.appendChild(pbl);
/* first fit + draw */
cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value);
collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
/* ── Inject TimeControl bar ── */
_collInjectTCBar();
}
function _collInjectTCBar() {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-coll');
if (!wrap || wrap.querySelector('.tc-bar')) return;
/* Build a shared TC that controls whichever sim is active */
var proxyTC = {
scale: 1, paused: false,
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
setScale: function(s) {
this.scale = +s;
/* propagate to all sims */
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.setScale(proxyTC.scale); });
},
togglePause: function() {
this.paused = !this.paused;
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.paused = proxyTC.paused; });
return this.paused;
},
};
/* Trail toggle */
var trailBtn = document.createElement('button');
trailBtn.className = 'zoom-btn';
trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
trailBtn.title = 'Следы движения';
trailBtn.addEventListener('click', function() {
var on = !((cSim && cSim.showTCTrails));
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s) s.showTCTrails = on; });
trailBtn.style.background = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
trailBtn.style.color = on ? '#06D6E0' : '#ccc';
trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
});
/* dummy sim adapter for LSBuildTimeControlUI */
var simProxy = { draw: function() { var a = cSim || cSim2D || cSimMB || cSimBL; if (a) a.draw(); } };
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
var sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
tcBar.appendChild(sep);
tcBar.appendChild(trailBtn);
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
/* Switch active mode */
function collSetMode(mode, btn) {
/* pause all */
if (cSim) cSim.pause();
if (cSim2D) cSim2D.pause();
if (cSimMB) cSimMB.pause();
if (cSimBL) cSimBL.pause();
_collMode = mode;
/* update tab buttons */
document.querySelectorAll('.coll-mode-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
/* show/hide mode-specific panels */
const show = id => { const el=document.getElementById(id); if(el) el.style.display=''; };
const hide = id => { const el=document.getElementById(id); if(el) el.style.display='none'; };
const p1d = document.getElementById('coll-params-1d'); /* original param blocks */
const presets1d = document.getElementById('coll-presets-1d');
/* toggle visibility of original 1D controls */
const orig1d = ['sl-m1','sl-m2','sl-cv1','sl-cv2','sl-cangle','sl-e'].map(id => {
const el = document.getElementById(id);
return el ? el.closest('.param-block') : null;
}).filter(Boolean);
orig1d.forEach(el => el.style.display = (mode === '1d') ? '' : 'none');
/* hide presets section if not 1d (find by traversal) */
const presetsDiv = document.querySelector('#sim-coll .gp-section-title + div[style*="flex-wrap"]');
if (presetsDiv) presetsDiv.style.display = (mode === '1d') ? '' : 'none';
const presetsTitle = presetsDiv ? presetsDiv.previousElementSibling : null;
if (presetsTitle) presetsTitle.style.display = (mode === '1d') ? '' : 'none';
/* mode-specific panels */
hide('coll-panel-2d'); hide('coll-panel-multi'); hide('coll-panel-billiard');
if (mode === '2d') show('coll-panel-2d');
if (mode === 'multi') show('coll-panel-multi');
if (mode === 'billiard') show('coll-panel-billiard');
/* show/hide launch+reset buttons (not needed for billiard) */
const launchWrap = document.querySelector('#sim-coll .proj-launch-btn')?.parentElement;
if (launchWrap) launchWrap.style.display = (mode === 'billiard') ? 'none' : '';
/* CM toggle only for 1d/2d/multi */
const cmRow = document.getElementById('coll-cm-row');
if (cmRow) cmRow.style.display = (mode === 'billiard') ? 'none' : '';
_collApplyMode();
}
function _collApplyMode() {
const canvas = document.getElementById('coll-canvas');
if (!canvas) return;
const mode = _collMode;
/* Mark which sim is active so ResizeObserver callbacks skip inactive sims */
if (cSim) cSim._activeMode = (mode === '1d');
if (cSim2D) cSim2D._activeMode = (mode === '2d');
if (cSimMB) cSimMB._activeMode = (mode === 'multi');
if (cSimBL) cSimBL._activeMode = (mode === 'billiard');
if (mode === '1d') {
cSim.fit(); collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
} else if (mode === '2d') {
cSim2D.fit(); coll2DParam(); cSim2D.draw(); _collUpdateUI(cSim2D.stats());
} else if (mode === 'multi') {
cSimMB.fit(); cSimMB._initBalls(); collMBParam(); cSimMB.draw(); _collUpdateUI(cSimMB.stats());
} else if (mode === 'billiard') {
cSimBL.fit(); cSimBL._initBalls(); cSimBL.draw(); _collUpdateUI(cSimBL.stats());
}
_collSyncBtn();
}
/* CM-frame toggle (universal) */
function collCMFrame(on) {
if (cSim2D) cSim2D.cmFrame = on;
if (cSimMB) cSimMB.cmFrame = on;
const act = _activeSim();
if (act && !act.playing) act.draw();
}
function collPlayPause() {
const sim = _activeSim();
if (!sim) return;
if (sim.playing) { sim.pause(); } else { sim.play(); }
_collSyncBtn();
}
function _collSyncBtn() {
const sim = _activeSim();
const playing = sim ? sim.playing : false;
const tb = document.getElementById('coll-play-btn');
const lb = document.getElementById('coll-launch-main');
const lbl = document.getElementById('coll-launch-label');
const lic = document.getElementById('coll-launch-icon');
if (tb) {
tb.innerHTML = playing
? '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'
: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
tb.title = playing ? 'Пауза' : 'Запустить';
tb.classList.toggle('active', playing);
}
if (lb && lbl && lic) {
lb.classList.toggle('paused', playing);
lb.classList.remove('done');
if (playing) {
lic.innerHTML = '<rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/>';
lbl.textContent = 'Пауза';
} else {
lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
lbl.textContent = 'Запустить';
}
}
}
/* ── 1D param handler (existing) ── */
function collParam() {
const m1 = +document.getElementById('sl-m1').value;
const m2 = +document.getElementById('sl-m2').value;
const v1 = +document.getElementById('sl-cv1').value;
const v2 = +document.getElementById('sl-cv2').value;
const angle = +document.getElementById('sl-cangle').value;
const e = +document.getElementById('sl-e').value;
const spd = +document.getElementById('sl-speed').value;
const el = id => document.getElementById(id);
if (el('c-m1')) el('c-m1').textContent = m1 + ' кг';
if (el('c-m2')) el('c-m2').textContent = m2 + ' кг';
if (el('c-v1')) el('c-v1').textContent = v1 + ' м/с';
if (el('c-v2')) el('c-v2').textContent = v2 + ' м/с';
if (el('c-angle')) el('c-angle').textContent = angle + '°';
if (el('c-e')) el('c-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
if (cSim) {
const speedChanged = Math.abs(cSim.speed - spd) > 0.001;
if (speedChanged) cSim.setSpeed(spd);
const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 ||
cSim.v1 !== v1 || cSim.v2 !== v2 ||
cSim.angle !== angle || cSim.e !== e;
if (physChanged) cSim.setParams({ m1, m2, v1, v2, angle, e });
_collSyncBtn();
}
}
/* ── 2D param handler ── */
function coll2DParam() {
if (!cSim2D) return;
const el = id => document.getElementById(id);
const a1 = +(el('sl-2da1')?.value ?? 0);
const a2 = +(el('sl-2da2')?.value ?? 180);
const m1 = +(el('sl-2dm1')?.value ?? 4);
const m2 = +(el('sl-2dm2')?.value ?? 4);
const v1 = +(el('sl-2dv1')?.value ?? 8);
const v2 = +(el('sl-2dv2')?.value ?? 8);
const e = +(el('sl-2de')?.value ?? 1);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('c2d-a1')) el('c2d-a1').textContent = a1 + '°';
if (el('c2d-a2')) el('c2d-a2').textContent = a2 + '°';
if (el('c2d-m1')) el('c2d-m1').textContent = m1 + ' кг';
if (el('c2d-m2')) el('c2d-m2').textContent = m2 + ' кг';
if (el('c2d-v1')) el('c2d-v1').textContent = v1 + ' м/с';
if (el('c2d-v2')) el('c2d-v2').textContent = v2 + ' м/с';
if (el('c2d-e')) el('c2d-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSim2D.setSpeed(spd);
const changed = cSim2D.a1!==a1||cSim2D.a2!==a2||cSim2D.m1!==m1||cSim2D.m2!==m2||
cSim2D.v1!==v1||cSim2D.v2!==v2||cSim2D.e!==e;
if (changed) cSim2D.setParams({ a1, a2, m1, m2, v1, v2, e });
_collSyncBtn();
}
/* ── Multi-body param handler ── */
function collMBParam() {
if (!cSimMB) return;
const el = id => document.getElementById(id);
const N = +(el('sl-mb-n')?.value ?? 5);
const e = +(el('sl-mb-e')?.value ?? 1);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('cmb-n')) el('cmb-n').textContent = N;
if (el('cmb-e')) el('cmb-e').textContent = e.toFixed(2);
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSimMB.setSpeed(spd);
const changed = cSimMB.N !== N || cSimMB.e !== e;
if (changed) { cSimMB.N = N; cSimMB.e = e; cSimMB.reset(); }
_collSyncBtn();
}
/* ── Billiard param handler ── */
function collBLParam() {
if (!cSimBL) return;
const el = id => document.getElementById(id);
const force = +(el('sl-bl-force')?.value ?? 15);
const spd = +(el('sl-speed')?.value ?? 1);
if (el('cbl-force')) el('cbl-force').textContent = force;
if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
cSimBL.cueForce = force;
cSimBL.setSpeed(spd);
}
function collPreset(m1, m2, v1, v2, angle, e) {
document.getElementById('sl-m1').value = m1;
document.getElementById('sl-m2').value = m2;
document.getElementById('sl-cv1').value = v1;
document.getElementById('sl-cv2').value = v2;
document.getElementById('sl-cangle').value = angle;
document.getElementById('sl-e').value = e;
collParam();
}
function _collUpdateUI(s) {
if (_collMode === 'billiard') {
/* billiard: show pocketed count */
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = '—';
if (el('cs-pafter')) el('cs-pafter').textContent = '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = '—';
if (el('cs-keafter')) el('cs-keafter').textContent = '—';
if (el('cs-count')) el('cs-count').textContent = 'Луз: ' + (s.pocketed || 0);
_collSyncBtn();
return;
}
if (_collMode === 'multi') {
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = s.px ? s.px.toFixed(2)+' кг·м/с' : '—';
if (el('cs-pafter')) el('cs-pafter').textContent = s.py ? s.py.toFixed(2)+' кг·м/с' : '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = s.ke ? s.ke.toFixed(1)+' Дж' : '—';
if (el('cs-keafter')) el('cs-keafter').textContent = '—';
if (el('cs-count')) el('cs-count').textContent = s.colCount || 0;
_collSyncBtn();
return;
}
/* 1D / 2D: same before/after display */
function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; }
function snapP(arr) {
if (!arr) return null;
return Math.hypot(arr.reduce((t, b) => t + b.m * b.vx, 0),
arr.reduce((t, b) => t + b.m * b.vy, 0));
}
const bKE = snapKE(s.before), bP = snapP(s.before);
const aKE = snapKE(s.after), aP = snapP(s.after);
const f2 = v => v !== null ? v.toFixed(2) : '—';
const el = id => document.getElementById(id);
if (el('cs-pbefore')) el('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
if (el('cs-pafter')) el('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
if (el('cs-kebefore')) el('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
if (el('cs-keafter')) el('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
if (el('cs-count')) el('cs-count').textContent = s.colCount || 0;
_collSyncBtn();
}
/* ── Energy toggle: collision ── */
function collToggleEnergy() {
const as = _activeSim && _activeSim();
if (!as) return;
as._energyOn = !as._energyOn;
const on = as._energyOn;
const btn = document.getElementById('coll-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { as._frictionWork = 0; as._energyScale = 0; }
as.draw();
}
/* ── magnetic ── */
function collToggleGraphs() {
const as = _activeSim && _activeSim();
if (!as || typeof as.toggleGraphs !== 'function') return;
const canvasOuter = document.querySelector('#sim-coll .proj-canvas-outer');
if (!canvasOuter) return;
const on = as.toggleGraphs(canvasOuter);
const btn = document.getElementById('btn-coll-graphs');
if (btn) btn.classList.toggle('active', on);
}