Files
Learn_System/frontend/js/labs/states.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

636 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
}