Files
Learn_System/frontend/js/labs/states.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

621 lines
24 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._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; }
}
}
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() {
for (let i = 0; i < 5; i++) this._stepPhysics();
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);
}
// ── 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();
}
}