be4d43105e
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>
621 lines
24 KiB
JavaScript
621 lines
24 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
}
|