Files
Learn_System/frontend/js/labs/collision.js
T
Maxim Dolgolyov 6de91f7595 fix(labs): SVG markup rendered as text in 6 simulations
Hardcoded inline <svg class="ic"> markers used as arrow replacements

(left over from emoji removal) were displayed as raw HTML text where

the consumer used textContent or canvas fillText:

- chemsandbox: csbar-v5 (Продукты cell) used textContent → SVG visible.

  Switched to innerHTML for consistency with eq/ionNet cells.

  Quiz question (qEl.textContent) and answer also receiving SVG —

  cleaned via _csClean at source.

- reactions: modeTxt drawn via canvas fillText — replaced SVG with →.

- ionexchange: REACTIONS data + canvas labels — bulk SVG → Unicode arrows.

- newton: action button labels used textContent → switched to innerHTML;

  canvas arrow labels: SVG → Unicode →/↓.

- collision: 'KE сохранена' canvas label — SVG checkmark → ✓.

- projectile: canvas badges + textContent wind label — SVG → Unicode ←/→/↩.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:47:50 +03:00

1131 lines
43 KiB
JavaScript
Raw 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;
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:[] },
{ 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:[] },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
}
/* ═══ 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 dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
this._lastTs = ts;
this._step(dt);
this.draw();
this._emit();
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();
}
/* 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();
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 };
/* 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()); }
/* ═══ 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();
}
}
/* ── 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);
}
}
/* ── 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);
}
}
}
/* ═══ 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 ─────────────────────────────────── */
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(() => {
if (!cSim) {
cSim = new CollisionSim(document.getElementById('coll-canvas'));
cSim.onUpdate = _collUpdateUI;
cSim.onPlayPause = collPlayPause;
}
cSim.fit();
cSim.setSpeed(+document.getElementById('sl-speed').value);
collParam();
cSim.draw();
_collUpdateUI(cSim.stats());
}));
}
function collPlayPause() {
if (!cSim) return;
if (cSim.playing) { cSim.pause(); } else { cSim.play(); }
_collSyncBtn();
}
function _collSyncBtn() {
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 (!cSim) return;
const playing = cSim.playing;
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 = 'Запустить';
}
}
}
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;
document.getElementById('c-m1').textContent = m1 + ' кг';
document.getElementById('c-m2').textContent = m2 + ' кг';
document.getElementById('c-v1').textContent = v1 + ' м/с';
document.getElementById('c-v2').textContent = v2 + ' м/с';
document.getElementById('c-angle').textContent = angle + '°';
document.getElementById('c-e').textContent = e.toFixed(2);
document.getElementById('c-speed').textContent = spd.toFixed(2) + '×';
if (cSim) {
/* speed change doesn't require a reset */
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();
}
}
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) {
// before/after are arrays [{m, vx, vy, ke}, ...]
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) : '—';
document.getElementById('cs-pbefore').textContent = bP !== null ? f2(bP) + ' кг·м/с' : '—';
document.getElementById('cs-pafter').textContent = aP !== null ? f2(aP) + ' кг·м/с' : '—';
document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж' : '—';
document.getElementById('cs-keafter').textContent = aKE !== null ? f2(aKE) + ' Дж' : '—';
document.getElementById('cs-count').textContent = s.colCount;
_collSyncBtn();
}
/* ── magnetic ── */