Files
Learn_System/frontend/js/labs/gas.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

463 lines
16 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.
/**
* GasSim v2 — Ideal Gas simulation (PV=nRT, Maxwell-Boltzmann distribution)
* v2: hover inspector, velocity vectors, movable piston, v_mp/v_rms markers.
*/
class GasSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0;
this.H = 0;
this.particles = [];
this.N = 80;
this.T = 1.0;
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._raf = null;
this._updateTick = 0;
this.onUpdate = null;
this._loop = this._loop.bind(this);
// v2
this._showVectors = false;
this._pistonFrac = 1.0; // fraction of W — right wall position
this._hover = null; // hovered particle
this._pistonDrag = false;
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hover = null; this._pistonDrag = false; });
canvas.addEventListener('mousedown', e => this._onMouseDown(e));
canvas.addEventListener('mouseup', () => { this._pistonDrag = false; });
}
// ── canvas coordinate helper ────────────────────────────────────────────────
_cp(e) {
const r = this.canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * (this.W / r.width),
y: (e.clientY - r.top) * (this.H / r.height),
};
}
_onMouseDown(e) {
const { x } = this._cp(e);
const px = this.W * this._pistonFrac;
if (Math.abs(x - px) < 16) this._pistonDrag = true;
}
_onMouseMove(e) {
const { x, y } = this._cp(e);
if (this._pistonDrag) {
this.setPiston(x / this.W);
return;
}
// nearest particle within 28px
let best = null, bestD = 28;
for (const p of this.particles) {
const d = Math.hypot(p.x - x, p.y - y);
if (d < bestD) { bestD = d; best = p; }
}
this._hover = best;
}
// ── public API ──────────────────────────────────────────────────────────────
fit() {
this.W = this.canvas.offsetWidth;
this.H = this.canvas.offsetHeight;
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 px = this.W * this._pistonFrac;
for (let i = 0; i < this.N; i++) {
const a = Math.random() * Math.PI * 2;
const s = this._maxwellSpeed();
this.particles.push({
x: 20 + Math.random() * (px - 40),
y: 20 + Math.random() * (this.H - 40),
vx: s * Math.cos(a),
vy: s * Math.sin(a),
r: 5,
});
}
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._updateTick = 0;
this._hover = null;
}
setN(n) { this.N = n; this.reset(); }
setT(t) {
const oldT = this.T;
if (oldT <= 0) { this.T = t; this.reset(); return; }
const f = Math.sqrt(t / oldT);
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
this.T = t;
}
setPiston(frac) {
this._pistonFrac = Math.max(0.3, Math.min(1.0, frac));
const px = this.W * this._pistonFrac;
for (const p of this.particles) {
if (p.x + p.r > px) { p.x = px - p.r; if (p.vx > 0) p.vx = -p.vx; }
}
}
toggleVectors() { this._showVectors = !this._showVectors; }
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); }
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
// ── simulation ──────────────────────────────────────────────────────────────
_loop() {
this._step();
this._step();
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
_maxwellSpeed() {
const u1 = Math.max(1e-10, Math.random());
const sigma = this.T * 60;
return Math.abs(Math.sqrt(-2 * Math.log(u1)) * Math.cos(Math.PI * 2 * Math.random()) * sigma + sigma);
}
_step() {
const { W, H, particles } = this;
const px = W * this._pistonFrac;
for (const p of particles) { p.x += p.vx; p.y += p.vy; }
for (const p of particles) {
if (p.x < p.r) {
p.x = p.r; p.vx = Math.abs(p.vx);
this._wallImpulse += 2 * Math.abs(p.vx);
} else if (p.x > px - p.r) {
p.x = px - p.r; p.vx = -Math.abs(p.vx);
this._wallImpulse += 2 * Math.abs(p.vx);
}
if (p.y < p.r) {
p.y = p.r; p.vy = Math.abs(p.vy);
this._wallImpulse += 2 * Math.abs(p.vy);
} else if (p.y > H - p.r) {
p.y = H - p.r; p.vy = -Math.abs(p.vy);
this._wallImpulse += 2 * Math.abs(p.vy);
}
}
// Spatial grid collision
const cell = 14, cols = Math.ceil(W / cell), rows = Math.ceil(H / cell);
const grid = new Map();
const key = (cx, cy) => cy * cols + cx;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const k = key(Math.floor(p.x / cell), Math.floor(p.y / cell));
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const checked = new Set();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const cx = Math.floor(p.x / cell);
const cy = Math.floor(p.y / cell);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
const cell2 = grid.get(key(nx, ny));
if (!cell2) continue;
for (const j of cell2) {
if (j <= i) continue;
const pk = i * 100000 + j;
if (checked.has(pk)) continue;
checked.add(pk);
const q = particles[j];
const ddx = q.x - p.x, ddy = q.y - p.y;
const d2 = ddx * ddx + ddy * ddy;
const md = p.r + q.r;
if (d2 < md * md && d2 > 0) {
const d = Math.sqrt(d2), nx2 = ddx / d, ny2 = ddy / d;
const dvn = (q.vx - p.vx) * nx2 + (q.vy - p.vy) * ny2;
if (dvn >= 0) continue;
p.vx += dvn * nx2; p.vy += dvn * ny2;
q.vx -= dvn * nx2; q.vy -= dvn * ny2;
const ov = (md - d) / 2;
p.x -= ov * nx2; p.y -= ov * ny2;
q.x += ov * nx2; q.y += ov * ny2;
}
}
}
}
this._pressureSmooth = this._pressureSmooth * 0.92 + this._wallImpulse * 0.08;
this._wallImpulse = 0;
if (++this._updateTick % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
}
info() {
const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy));
const avgSpeed = speeds.length ? speeds.reduce((a, b) => a + b) / speeds.length : 0;
const pf = this._pistonFrac;
const P = this._pressureSmooth / (2 * (this.W * pf + this.H)) * 100;
const V = (this.W * pf * this.H) / 10000;
return {
N: this.N, T: this.T,
P: P.toFixed(1), V: V.toFixed(1), PV: (P * V).toFixed(1),
avgSpeed: avgSpeed.toFixed(0),
speedData: this._speedHistogram(speeds),
};
}
_speedHistogram(speeds) {
const maxSpeed = this.T * 200;
const numBins = 12;
const binWidth = maxSpeed / numBins;
const bins = new Array(numBins).fill(0);
for (const s of speeds) {
const idx = Math.floor(s / binWidth);
if (idx >= 0 && idx < numBins) bins[idx]++;
}
return { bins, max: Math.max(...bins, 1), binWidth };
}
_mbCurve(v) {
const sigma = this.T * 60;
return (v / (sigma * sigma)) * Math.exp(-v * v / (2 * sigma * sigma));
}
// ── drawing ─────────────────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
const pistonX = W * this._pistonFrac;
// Background
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7);
bg.addColorStop(0, '#080818'); bg.addColorStop(1, '#030308');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= W; x += 20) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = 0; y <= H; y += 20) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
// Dead zone beyond piston
if (this._pistonFrac < 0.99) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(pistonX, 0, W - pistonX, H);
}
// Pressure wall glow
const P = parseFloat(this.info().P);
const wi = Math.min(1, P / 50);
if (wi > 0) {
const a = wi * 0.3, gd = 30;
const glows = [
[ctx.createLinearGradient(0, 0, gd, 0), 0, 0, gd, H],
[ctx.createLinearGradient(pistonX, 0, pistonX - gd, 0), pistonX - gd, 0, gd, H],
[ctx.createLinearGradient(0, 0, 0, gd), 0, 0, W, gd],
[ctx.createLinearGradient(0, H, 0, H - gd), 0, H - gd, W, gd],
];
for (const [g, rx, ry, rw, rh] of glows) {
g.addColorStop(0, `rgba(155,93,229,${a})`);
g.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = g; ctx.fillRect(rx, ry, rw, rh);
}
}
// Velocity vectors
if (this._showVectors) {
ctx.save();
for (const p of this.particles) {
const scale = 3;
const ex = p.x + p.vx * scale, ey = p.y + p.vy * scale;
const ang = Math.atan2(p.vy, p.vx);
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke();
const hl = 4;
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hl * Math.cos(ang - 0.4), ey - hl * Math.sin(ang - 0.4));
ctx.lineTo(ex - hl * Math.cos(ang + 0.4), ey - hl * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
}
ctx.restore();
}
// Particles
for (const p of this.particles) {
const spd = Math.hypot(p.vx, p.vy);
const T = this.T;
const color = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F';
const isH = this._hover === p;
ctx.save();
ctx.shadowBlur = isH ? 20 : 8;
ctx.shadowColor = color;
ctx.beginPath(); ctx.arc(p.x, p.y, isH ? p.r + 2 : p.r, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fill();
if (isH) { ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); }
ctx.restore();
}
// Piston
this._drawPiston(ctx, pistonX, H);
// Hover inspector
if (this._hover) this._drawInspector(ctx, this._hover, W, H);
// Histogram
this._drawHistogram(ctx, W, H);
}
_drawPiston(ctx, pistonX, H) {
if (this._pistonFrac >= 0.99) return;
ctx.save();
const pw = 8;
ctx.shadowBlur = 16; ctx.shadowColor = 'rgba(255,209,102,0.5)';
const g = ctx.createLinearGradient(pistonX - pw, 0, pistonX + pw, 0);
g.addColorStop(0, 'rgba(255,209,102,0.4)');
g.addColorStop(0.5, 'rgba(255,209,102,0.9)');
g.addColorStop(1, 'rgba(255,209,102,0.3)');
ctx.fillStyle = g; ctx.fillRect(pistonX - pw / 2, 0, pw, H);
// Handle
const hh = 44, hw = 18, hx = pistonX - hw / 2, hy = H / 2 - hh / 2;
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,209,102,0.88)';
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1.5;
for (let i = 0; i < 3; i++) {
const gy = hy + 10 + i * 10;
ctx.beginPath(); ctx.moveTo(hx + 4, gy); ctx.lineTo(hx + hw - 4, gy); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,209,102,0.7)';
ctx.font = "bold 9px 'Manrope', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('⇌', pistonX, hy - 12);
ctx.restore();
}
_drawInspector(ctx, p, W, H) {
const spd = Math.hypot(p.vx, p.vy);
const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
const ke = 0.5 * spd * spd;
const T = this.T;
const clr = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F';
const rows = [
['|v|', spd.toFixed(1) + ' у.е.'],
['vx', p.vx.toFixed(1)],
['vy', p.vy.toFixed(1)],
['KE', ke.toFixed(0) + ' у.е.'],
['угол', ang.toFixed(1) + '°'],
];
const tw = 132, th = 18 + rows.length * 17 + 8;
let tx = p.x + 14, ty = p.y - th / 2;
if (tx + tw > W - 10) tx = p.x - tw - 14;
ty = Math.max(8, Math.min(H - th - 8, ty));
ctx.save();
ctx.fillStyle = 'rgba(6,8,28,0.92)';
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.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = "11px 'Manrope', monospace"; ctx.textBaseline = 'middle';
for (let i = 0; i < rows.length; i++) {
const ry = ty + 18 + i * 17;
ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.textAlign = 'left';
ctx.fillText(rows[i][0], tx + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.92)'; ctx.textAlign = 'right';
ctx.fillText(rows[i][1], tx + tw - 10, ry);
}
ctx.restore();
}
_drawHistogram(ctx, W, H) {
const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy));
const hist = this._speedHistogram(speeds);
const hw = 204, hh = 102;
const hx = W - hw - 12, hy = H - hh - 12;
const pad = { l: 8, r: 8, t: 20, b: 18 };
const barW = (hw - pad.l - pad.r) / hist.bins.length;
const barAreaH = hh - pad.t - pad.b;
const maxV = this.T * 200;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.58)';
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 6); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Распределение скоростей', hx + hw / 2, hy + 11);
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '8px sans-serif';
ctx.fillText('v (у.е.)', hx + hw / 2, hy + hh - 2);
// Bars
for (let i = 0; i < hist.bins.length; i++) {
const ratio = hist.bins[i] / hist.max;
const bh = ratio * barAreaH;
const bx = hx + pad.l + i * barW;
const by = hy + pad.t + barAreaH - bh;
ctx.fillStyle = 'rgba(155,93,229,0.75)';
ctx.beginPath(); ctx.roundRect(bx + 0.5, by, barW - 1, bh, 2); ctx.fill();
}
// MB theoretical curve
ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]); ctx.beginPath();
let first = true;
for (let i = 0; i <= 80; i++) {
const v = (i / 80) * maxV;
const sc = this._mbCurve(v) * speeds.length * hist.binWidth / hist.max;
const cx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r);
const cy2 = hy + pad.t + barAreaH - sc * barAreaH;
if (first) { ctx.moveTo(cx2, cy2); first = false; }
else ctx.lineTo(cx2, cy2);
}
ctx.stroke(); ctx.setLineDash([]);
// Characteristic speed lines
const sigma = this.T * 60;
const v_mp = sigma; // v most probable (mode)
const v_rms = sigma * Math.sqrt(2); // v_rms in 2D = sqrt(2) * sigma
const vline = (v, color, label) => {
if (v > maxV) return;
const vx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r);
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(vx2, hy + pad.t); ctx.lineTo(vx2, hy + pad.t + barAreaH); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color; ctx.font = '7px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(label, vx2, hy + pad.t - 3);
};
vline(v_mp, 'rgba(76,201,240,0.9)', 'v_mp');
vline(v_rms, 'rgba(239,71,111,0.9)', 'v_rms');
ctx.restore();
}
}