/** * StatesSim v4 — Aggregate States of Matter (Lennard-Jones MD) * Clean rewrite: stable physics, proper layout, canvas clipping, no boundary artifacts. */ class StatesSim { // ── layout / physics constants ─────────────────────────────────────────── static PAD_B = 112; // px reserved at bottom for charts static PAD_L = 38; // px reserved on left for temperature bar static SIG = 14; // Lennard-Jones σ (px) static EPS = 1.0; // Lennard-Jones ε static DT = 0.16; // time step static CUTOFF = 3.5; // force cutoff in σ units constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.N = 64; this.T = 0.15; this.particles = []; this._raf = null; this._stepCount = 0; this._loop = this._loop.bind(this); this._fxLastT = 0; this._fxTickTimer = 0; // throttle temperature slider tick sound this._wallImpulse = 0; this._pressureSmooth = 0; this._energyHistory = []; this._rdfData = null; this._rdfMaxG = 3; this._rdfTick = 0; this._phaseFlash = 0; this._flashColor = '#4CC9F0'; this._prevPhase = ''; this._phasePulse = 0; this._hover = null; this._showVectors = false; this.onUpdate = null; canvas.addEventListener('mousemove', e => this._onMouse(e)); canvas.addEventListener('mouseleave', () => { this._hover = null; }); } // ── public API ──────────────────────────────────────────────────────────── fit() { this.W = this.canvas.offsetWidth || 400; this.H = this.canvas.offsetHeight || 400; this.canvas.width = this.W * devicePixelRatio; this.canvas.height = this.H * devicePixelRatio; this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); this.reset(); } reset() { this.particles = []; const { N, T } = this; const { SIG, PAD_L, PAD_B } = StatesSim; const spacing = SIG * 1.15; const simW = this.W - PAD_L; const simH = this.H - PAD_B; const cols = Math.ceil(Math.sqrt(N)); const rows = Math.ceil(N / cols); const gridW = (cols - 1) * spacing; const gridH = (rows - 1) * spacing * Math.sqrt(3) / 2; const ox = PAD_L + (simW - gridW) / 2; const oy = (simH - gridH) / 2; let n = 0; for (let r = 0; r < rows && n < N; r++) { const xOff = (r % 2) * spacing * 0.5; for (let c = 0; c < cols && n < N; c++) { this.particles.push({ x: ox + xOff + c * spacing, y: oy + r * spacing * Math.sqrt(3) / 2, vx: (Math.random() - 0.5) * T * 3, vy: (Math.random() - 0.5) * T * 3, ax: 0, ay: 0, }); n++; } } this._stepCount = 0; this._wallImpulse = 0; this._pressureSmooth = 0; this._energyHistory = []; this._rdfData = null; this._rdfMaxG = 3; this._rdfTick = 0; this._phaseFlash = 0; this._prevPhase = ''; this._hover = null; } setT(t) { const old = this.T; this.T = Math.max(0.01, t); if (old > 0) { const f = Math.min(4, Math.sqrt(this.T / old)); for (const p of this.particles) { p.vx *= f; p.vy *= f; } } // throttled tick sound for temperature slider (pitch ∝ T) if (window.LabFX) { const nowMs = performance.now(); if (nowMs - this._fxTickTimer > 120) { this._fxTickTimer = nowMs; const pitch = 0.7 + this.T * 0.9; LabFX.sound.play('tick', { pitch: Math.min(2.5, pitch), volume: 0.12 }); } } } setN(n) { this.N = Math.max(16, Math.min(120, n)); this.reset(); } toggleVectors() { this._showVectors = !this._showVectors; } start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); } stop() { cancelAnimationFrame(this._raf); this._raf = null; } // ── simulation ──────────────────────────────────────────────────────────── _loop(now) { const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16; this._fxLastT = now; for (let i = 0; i < 5; i++) this._stepPhysics(); if (window.LabFX) LabFX.particles.update(dt); this.draw(); this._raf = requestAnimationFrame(this._loop); } _stepPhysics() { const { particles } = this; const { SIG, EPS, DT, CUTOFF, PAD_L, PAD_B } = StatesSim; const dt = DT; const pr = SIG * 0.48; const cut2 = (CUTOFF * SIG) ** 2; const xMin = PAD_L + pr, xMax = this.W - pr; const yMin = pr, yMax = this.H - PAD_B - pr; // Velocity Verlet — step 1 for (const p of particles) { p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt; p.x += p.vx * dt; p.y += p.vy * dt; if (p.x < xMin) { p.x = xMin; p.vx = Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); } else if (p.x > xMax) { p.x = xMax; p.vx = -Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); } if (p.y < yMin) { p.y = yMin; p.vy = Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); } else if (p.y > yMax) { p.y = yMax; p.vy = -Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); } } // Lennard-Jones forces for (const p of particles) { p.ax = 0; p.ay = 0; } for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const pi = particles[i], pj = particles[j]; const dx = pj.x - pi.x, dy = pj.y - pi.y; const r2 = dx * dx + dy * dy; if (r2 >= cut2 || r2 < 0.25) continue; const sr2 = (SIG * SIG) / r2, sr6 = sr2 * sr2 * sr2; const f = Math.max(-40, Math.min(40, 24 * EPS * (2 * sr6 * sr6 - sr6) / r2)); pi.ax += f * dx; pi.ay += f * dy; pj.ax -= f * dx; pj.ay -= f * dy; } } // Velocity Verlet — step 2 for (const p of particles) { p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt; } // Berendsen thermostat this._stepCount++; let ke2 = 0; for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy; const ke = ke2 / (2 * particles.length); if (ke > 1e-8) { const lam = Math.max(0.92, Math.min(1.08, Math.sqrt(1 + (dt / 60) * (this.T / ke - 1)))); for (const p of particles) { p.vx *= lam; p.vy *= lam; } } // smooth pressure this._pressureSmooth = this._pressureSmooth * 0.95 + this._wallImpulse * 0.05; this._wallImpulse = 0; // energy + phase history (every 8 steps) if (this._stepCount % 8 === 0) { const info = this.info(); this._energyHistory.push({ ke: +info.avgKE, pe: +info.avgPE, te: +info.avgKE + +info.avgPE }); if (this._energyHistory.length > 300) this._energyHistory.shift(); const ph = info.phase; if (this._prevPhase && ph !== this._prevPhase) { const fc = { solid: '#4CC9F0', liquid: '#7BF5A4', gas: '#FFB347' }; this._phaseFlash = 1; this._flashColor = fc[ph] || '#ffffff'; } this._prevPhase = ph; } // RDF every 25 steps if (++this._rdfTick % 25 === 0) this._computeRDF(); if (this._stepCount % 25 === 0 && this.onUpdate) this.onUpdate(this.info()); } // ── RDF g(r) ────────────────────────────────────────────────────────────── _computeRDF() { const { particles } = this; const N = particles.length; if (N < 4) return; const { SIG, PAD_L, PAD_B } = StatesSim; const nBins = 32, maxR = 3.8 * SIG, dr = maxR / nBins; const hist = new Float32Array(nBins); for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) { const r = Math.hypot(particles[j].x - particles[i].x, particles[j].y - particles[i].y); if (r < maxR) hist[Math.floor(r / dr)]++; } const area = (this.W - PAD_L) * (this.H - PAD_B); const g = new Float32Array(nBins); for (let i = 0; i < nBins; i++) { const rc = (i + 0.5) * dr; const ideal = N * (N - 1) * Math.PI * rc * dr / area; g[i] = ideal > 1e-10 ? hist[i] / ideal : 0; } if (!this._rdfData) { this._rdfData = g; } else { for (let i = 0; i < nBins; i++) this._rdfData[i] = this._rdfData[i] * 0.65 + g[i] * 0.35; } this._rdfMaxG = Math.max(1.5, ...Array.from(this._rdfData.slice(1))); } // ── info / phase ────────────────────────────────────────────────────────── _phase() { return this.T < 0.2 ? 'solid' : this.T < 0.5 ? 'liquid' : 'gas'; } info() { const { particles, T } = this; const { SIG, EPS, CUTOFF, PAD_L, PAD_B } = StatesSim; let ke2 = 0; for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy; const avgKE = particles.length ? 0.5 * ke2 / particles.length : 0; const cut2 = (CUTOFF * SIG) ** 2; let peTot = 0; for (let i = 0; i < particles.length; i++) for (let j = i + 1; j < particles.length; j++) { const dx = particles[j].x - particles[i].x, dy = particles[j].y - particles[i].y; const r2 = dx * dx + dy * dy; if (r2 < cut2 && r2 > 0.1) { const sr2 = SIG * SIG / r2, sr6 = sr2 * sr2 * sr2; peTot += 4 * EPS * (sr6 * sr6 - sr6); } } const avgPE = particles.length ? peTot / particles.length : 0; const perim = 2 * ((this.W - PAD_L) + (this.H - PAD_B)); const P = this._pressureSmooth / perim * 80; return { phase: this._phase(), T, avgKE: avgKE.toFixed(3), avgPE: avgPE.toFixed(3), P: P.toFixed(1), }; } // ── mouse ───────────────────────────────────────────────────────────────── _onMouse(e) { const r = this.canvas.getBoundingClientRect(); const x = (e.clientX - r.left) * (this.W / r.width); const y = (e.clientY - r.top) * (this.H / r.height); let best = null, bd = 20; for (const p of this.particles) { const d = Math.hypot(p.x - x, p.y - y); if (d < bd) { bd = d; best = p; } } this._hover = best; } // ── draw ────────────────────────────────────────────────────────────────── draw() { const { ctx, W, H, T } = this; const { SIG, PAD_B, PAD_L } = StatesSim; const simH = H - PAD_B; const phase = this._phase(); // full background ctx.fillStyle = '#08091a'; ctx.fillRect(0, 0, W, H); // ── clip everything to simulation area ───────────────────────────────── ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, simH); ctx.clip(); // phase flash if (this._phaseFlash > 0) { this._phaseFlash = Math.max(0, this._phaseFlash - 0.02); const [fr, fg, fb] = this._hex3(this._flashColor); ctx.fillStyle = `rgba(${fr},${fg},${fb},${this._phaseFlash * 0.16})`; ctx.fillRect(0, 0, W, simH); } // pressure wall glow (walls at simulation boundaries) const P = parseFloat(this.info().P); const wi = Math.min(1, P / 25); if (wi > 0.04) { const a = wi * 0.28, gd = 30; const walls = [ { x: PAD_L, y: 0, w: gd, h: simH, d: 'r' }, { x: W - gd, y: 0, w: gd, h: simH, d: 'l' }, { x: 0, y: 0, w: W, h: gd, d: 'd' }, { x: 0, y: simH-gd, w: W, h: gd, d: 'u' }, ]; for (const { x, y, w, h, d } of walls) { let gr; if (d==='r') gr = ctx.createLinearGradient(x, 0, x+w, 0); else if (d==='l') gr = ctx.createLinearGradient(x+w, 0, x, 0); else if (d==='d') gr = ctx.createLinearGradient(0, y, 0, y+h); else gr = ctx.createLinearGradient(0, y+h, 0, y); gr.addColorStop(0, `rgba(139,92,246,${a})`); gr.addColorStop(1, 'rgba(139,92,246,0)'); ctx.fillStyle = gr; ctx.fillRect(x, y, w, h); } } // per-particle speeds const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy)); const maxSpd = Math.max(...speeds, 1e-6); // bonds (solid / liquid) const bondCut = SIG * 1.85; if (phase !== 'gas') { ctx.save(); ctx.strokeStyle = phase === 'solid' ? 'rgba(96,210,250,0.45)' : 'rgba(120,130,255,0.22)'; ctx.lineWidth = phase === 'solid' ? 1.2 : 0.8; ctx.beginPath(); for (let i = 0; i < this.particles.length; i++) { for (let j = i + 1; j < this.particles.length; j++) { const pi = this.particles[i], pj = this.particles[j]; if (Math.hypot(pj.x - pi.x, pj.y - pi.y) < bondCut) { ctx.moveTo(pi.x, pi.y); ctx.lineTo(pj.x, pj.y); } } } ctx.stroke(); ctx.restore(); } // velocity vectors (optional) if (this._showVectors) { ctx.save(); const vScale = SIG * 2 / maxSpd; for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; const len = speeds[i] * vScale; if (len < 1.5) continue; const ang = Math.atan2(p.vy, p.vx); const ex = p.x + Math.cos(ang) * len, ey = p.y + Math.sin(ang) * len; const hue = 240 - (speeds[i] / maxSpd) * 200; ctx.strokeStyle = `hsla(${hue},85%,65%,0.55)`; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke(); const hl = Math.min(7, len * 0.38); ctx.fillStyle = `hsla(${hue},85%,65%,0.55)`; ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - hl * Math.cos(ang - 0.45), ey - hl * Math.sin(ang - 0.45)); ctx.lineTo(ex - hl * Math.cos(ang + 0.45), ey - hl * Math.sin(ang + 0.45)); ctx.closePath(); ctx.fill(); } ctx.restore(); } // particles ctx.save(); for (let i = 0; i < this.particles.length; i++) { const p = this.particles[i]; const t = speeds[i] / maxSpd; const hue = 240 - t * 220; // blue (cold) → green → yellow → red (hot) const col = `hsl(${hue},85%,62%)`; const isH = this._hover === p; const rad = isH ? SIG * 0.62 : SIG * 0.5; ctx.shadowBlur = isH ? 22 : 5 + t * 12; ctx.shadowColor = col; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, Math.PI * 2); ctx.fill(); if (isH) { ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, rad + 4, 0, Math.PI * 2); ctx.stroke(); } } ctx.restore(); // phase badge this._phasePulse += 0.04; this._drawPhaseBadge(ctx, W, phase); // temperature bar this._drawTempBar(ctx, simH, T); ctx.restore(); // end simulation clip // chart separator ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, simH); ctx.lineTo(W, simH); ctx.stroke(); // charts (outside clip) this._drawEnergyChart(ctx, W, H, PAD_B); this._drawRDFChart(ctx, W, H, PAD_B); // hover inspector (may extend into chart area) if (this._hover) this._drawInspector(ctx, this._hover, speeds, maxSpd, W, H); if (window.LabFX) LabFX.particles.draw(ctx); } // ── helpers ─────────────────────────────────────────────────────────────── _hex3(hex) { const h = hex.replace('#', ''); return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; } // ── sub-drawing ─────────────────────────────────────────────────────────── _drawPhaseBadge(ctx, W, phase) { const cfg = { solid: { icon: '❄', label: 'Твёрдое', color: '#4CC9F0', bg: 'rgba(76,201,240,0.12)' }, liquid: { icon: '~', label: 'Жидкость', color: '#7BF5A4', bg: 'rgba(123,245,164,0.12)' }, gas: { icon: '·', label: 'Газ', color: '#FFB347', bg: 'rgba(255,179,71,0.12)' }, }[phase]; const sc = 1 + 0.028 * Math.sin(this._phasePulse); ctx.save(); ctx.font = 'bold 13px sans-serif'; const text = `${cfg.icon} ${cfg.label}`; const tw = ctx.measureText(text).width; const bw = tw + 24, bh = 27, bx = W / 2 - bw / 2, by = 10; ctx.translate(W/2, by+bh/2); ctx.scale(sc,sc); ctx.translate(-W/2, -(by+bh/2)); ctx.fillStyle = cfg.bg; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill(); ctx.strokeStyle = cfg.color + '50'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.stroke(); ctx.fillStyle = cfg.color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, W/2, by+bh/2); ctx.restore(); } _drawTempBar(ctx, simH, T) { const bx = 10, by = 50, bw = 9; const bh = Math.max(50, Math.min(simH - 72, 260)); ctx.save(); // gradient track const grad = ctx.createLinearGradient(0, by, 0, by + bh); grad.addColorStop(0, '#EF476F'); grad.addColorStop(0.4, '#FFD166'); grad.addColorStop(1, '#4CC9F0'); ctx.fillStyle = grad; ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill(); // phase transition markers ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.setLineDash([2,3]); ctx.font = '7px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; for (const [tv, lbl] of [[0.2,'Жидк.'],[0.5,'Газ']]) { const y = by + bh - (tv / 0.7) * bh; if (y > by + 4 && y < by + bh - 4) { ctx.fillStyle = 'rgba(255,255,255,0)'; ctx.beginPath(); ctx.moveTo(bx-3, y); ctx.lineTo(bx+bw+3, y); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText(lbl, bx+bw+5, y); } } ctx.setLineDash([]); // indicator const tNorm = Math.min(1, T / 0.7); const iy = by + bh - tNorm * bh; ctx.fillStyle = '#fff'; ctx.shadowBlur = 8; ctx.shadowColor = '#fff'; ctx.beginPath(); ctx.arc(bx + bw/2, iy, 5, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0; // T value const labelY = iy < by + 18 ? iy + 14 : iy - 14; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = "bold 9px 'Manrope',monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(T.toFixed(2), bx + bw/2, labelY); ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.font = '9px sans-serif'; ctx.fillText('T', bx + bw/2, by - 8); ctx.restore(); } _drawEnergyChart(ctx, W, H, padB) { const hist = this._energyHistory; const cw = Math.min(196, Math.floor((W - 16) * 0.46)); const ch = padB - 18; const cx = 8, cy = H - ch - 8; ctx.save(); ctx.fillStyle = 'rgba(4,6,20,0.82)'; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif"; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('Энергия / частицу', cx + 8, cy + 5); if (hist.length > 3) { const pL=8, pR=6, pT=17, pB=13; const pw = cw-pL-pR, ph = ch-pT-pB; const allV = hist.flatMap(h => [h.ke, h.pe, h.te]); const minV = Math.min(...allV, 0), maxV = Math.max(...allV, 0.001); const rng = maxV - minV || 0.001; const px = i => cx + pL + (i / (hist.length-1)) * pw; const py = v => cy + pT + ph - ((v - minV) / rng) * ph; // zero line const zy = py(0); if (zy > cy + pT && zy < cy + pT + ph) { ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.moveTo(cx+pL, zy); ctx.lineTo(cx+pL+pw, zy); ctx.stroke(); ctx.setLineDash([]); } for (const [key, color, lw, dash] of [ ['pe','#9B5DE5',1.2,false], ['ke','#FFD166',1.2,false], ['te','rgba(255,255,255,0.38)',1,true], ]) { ctx.strokeStyle = color; ctx.lineWidth = lw; if (dash) ctx.setLineDash([3,4]); ctx.beginPath(); hist.forEach((h,i) => { const x = px(i), y = py(h[key]); i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y); }); ctx.stroke(); ctx.setLineDash([]); } // legend [['#FFD166','КЕ'],['#9B5DE5','ПЭ'],['rgba(255,255,255,0.4)','Е']].forEach(([c,l],li) => { const lx = cx + 8 + li * 34; ctx.fillStyle = c; ctx.beginPath(); ctx.arc(lx, cy+ch-7, 3, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = '8px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(l, lx+5, cy+ch-7); }); } else { ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('накапливается…', cx+cw/2, cy+ch/2); } ctx.restore(); } _drawRDFChart(ctx, W, H, padB) { const g = this._rdfData; const cw = Math.min(196, Math.floor((W - 16) * 0.46)); const ch = padB - 18; const cx = W - cw - 8, cy = H - ch - 8; ctx.save(); ctx.fillStyle = 'rgba(4,6,20,0.82)'; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif"; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('g(r) — радиальная функция', cx+8, cy+5); if (g) { const pL=8, pR=6, pT=17, pB=14; const pw = cw-pL-pR, ph = ch-pT-pB; const nBins = g.length, barW = pw/nBins, maxG = this._rdfMaxG; // g=1 reference const refY = cy+pT+ph - (1/maxG)*ph; ctx.strokeStyle = 'rgba(255,209,102,0.38)'; ctx.lineWidth=1; ctx.setLineDash([4,3]); ctx.beginPath(); ctx.moveTo(cx+pL,refY); ctx.lineTo(cx+pL+pw,refY); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(255,209,102,0.35)'; ctx.font='7px sans-serif'; ctx.textAlign='right'; ctx.textBaseline='middle'; ctx.fillText('1', cx+pL-2, refY); for (let i = 0; i < nBins; i++) { const v = Math.min(g[i], maxG), frac = v / maxG; const bh = frac * ph; const bx = cx+pL+i*barW, by = cy+pT+ph-bh; const hue = 220 - frac * 180; ctx.fillStyle = `hsla(${hue},70%,55%,0.82)`; ctx.beginPath(); ctx.roundRect(bx+0.5, by, barW-1, bh, 1); ctx.fill(); } ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font='7px sans-serif'; ctx.textAlign='center'; for (let v=0; v<=3; v++) { ctx.fillText(v, cx+pL+(v/3.8)*pw, cy+pT+ph+8); } ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.textBaseline='bottom'; ctx.fillText('r / σ', cx+pL+pw/2, cy+ch); } else { ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('накапливается…', cx+cw/2, cy+ch/2); } ctx.restore(); } _drawInspector(ctx, p, speeds, maxSpd, W, H) { const { SIG } = StatesSim; const spd = Math.hypot(p.vx, p.vy); const ke = 0.5 * spd * spd; const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI; let coord = 0; for (const q of this.particles) { if (q !== p && Math.hypot(q.x-p.x, q.y-p.y) < SIG*1.5) coord++; } const t = spd / maxSpd, hue = 240 - t * 220; const clr = `hsl(${hue},85%,62%)`; const rows = [ ['|v|', spd.toFixed(3)], ['vx', p.vx.toFixed(2)], ['vy', p.vy.toFixed(2)], ['KE', ke.toFixed(3)], ['угол', ang.toFixed(1)+'°'], ['z', coord+' сос.'], ]; const tw=136, th=rows.length*17+20; let tx = p.x+14, ty = p.y-th/2; if (tx+tw > W-8) tx = p.x-tw-14; ty = Math.max(8, Math.min(H-th-8, ty)); ctx.save(); ctx.fillStyle = 'rgba(5,7,22,0.95)'; ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); ctx.fillStyle = clr; ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8,8,0,0]); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke(); ctx.font = "11px 'Manrope',monospace"; ctx.textBaseline = 'middle'; rows.forEach(([k,v],i) => { const ry = ty+15+i*17; ctx.fillStyle='rgba(255,255,255,0.38)'; ctx.textAlign='left'; ctx.fillText(k, tx+10, ry); ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.textAlign='right'; ctx.fillText(v, tx+tw-10, ry); }); ctx.restore(); } }