7a323f8fe0
ФУНДАМЕНТ — 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>
2608 lines
103 KiB
JavaScript
2608 lines
103 KiB
JavaScript
'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'); }
|
||
}
|
||
}
|
||
|
||
/* ball–ball 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);
|
||
} |