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>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+620
View File
@@ -0,0 +1,620 @@
/**
* 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();
}
}