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>
463 lines
16 KiB
JavaScript
463 lines
16 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
}
|