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>
466 lines
17 KiB
JavaScript
466 lines
17 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* DiffusionSim v2 — Diffusion simulation (two gases mixing).
|
||
* v2: entropy timeline on history chart, pore mode (gap in partition), density heatmap.
|
||
*/
|
||
class DiffusionSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
this.particles = [];
|
||
this.N = 60;
|
||
this.T = 1.0;
|
||
this.partitionOn = true;
|
||
this._history = []; // {step, fracA_left, entropy}
|
||
this._steps = 0;
|
||
this._raf = null;
|
||
this.onUpdate = null;
|
||
this._dpr = 1;
|
||
|
||
// v2
|
||
this._poreMode = false; // partition has a gap in the center
|
||
this._poreH = 40; // gap height in pixels
|
||
this._heatmap = null; // cached density heatmap
|
||
this._hmTick = 0;
|
||
}
|
||
|
||
// ── public API ──────────────────────────────────────────────────────────────
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
this._dpr = dpr;
|
||
const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight;
|
||
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
|
||
this.W = w; this.H = h;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.reset();
|
||
}
|
||
|
||
reset() {
|
||
const { W, H } = this;
|
||
this.partitionOn = true;
|
||
this._poreMode = false;
|
||
this._steps = 0;
|
||
this._history = [{ step: 0, fracA_left: 1.0, entropy: 0 }];
|
||
this._heatmap = null;
|
||
|
||
const particles = [];
|
||
const r = 5;
|
||
|
||
let attA = 0;
|
||
while (particles.filter(p => p.type === 'A').length < this.N && attA < this.N * 30) {
|
||
attA++;
|
||
const x = r + Math.random() * (W / 2 - 2 * r);
|
||
const y = r + Math.random() * (H - 2 * r);
|
||
const a = Math.random() * Math.PI * 2, s = this.T * 3.5;
|
||
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'A' });
|
||
}
|
||
|
||
let attB = 0;
|
||
while (particles.filter(p => p.type === 'B').length < this.N && attB < this.N * 30) {
|
||
attB++;
|
||
const x = W / 2 + r + Math.random() * (W / 2 - 2 * r);
|
||
const y = r + Math.random() * (H - 2 * r);
|
||
const a = Math.random() * Math.PI * 2, s = this.T * 3.5;
|
||
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'B' });
|
||
}
|
||
|
||
this.particles = particles;
|
||
}
|
||
|
||
togglePartition() {
|
||
if (this._poreMode) {
|
||
// If pore is on, full toggle removes pore first
|
||
this._poreMode = false;
|
||
this.partitionOn = true;
|
||
} else {
|
||
this.partitionOn = !this.partitionOn;
|
||
}
|
||
}
|
||
|
||
togglePore() {
|
||
if (!this.partitionOn && !this._poreMode) {
|
||
// Partition is fully off — re-enable with pore
|
||
this.partitionOn = true;
|
||
this._poreMode = true;
|
||
} else if (this.partitionOn && !this._poreMode) {
|
||
this._poreMode = true; // add pore to full partition
|
||
} else if (this._poreMode) {
|
||
this._poreMode = false; // remove pore, keep partition
|
||
}
|
||
}
|
||
|
||
setN(n) { this.N = Math.max(10, Math.min(200, n)); this.reset(); }
|
||
|
||
setT(t) {
|
||
const f = Math.sqrt(t / this.T);
|
||
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
|
||
this.T = t;
|
||
}
|
||
|
||
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop.bind(this)); }
|
||
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
|
||
|
||
// ── simulation ──────────────────────────────────────────────────────────────
|
||
_loop() {
|
||
this._step(); this._step();
|
||
this.draw();
|
||
this._raf = requestAnimationFrame(this._loop.bind(this));
|
||
}
|
||
|
||
_step() {
|
||
const { W, H, particles } = this;
|
||
|
||
for (const p of particles) {
|
||
p.x += p.vx; p.y += p.vy;
|
||
|
||
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
|
||
if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); }
|
||
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
|
||
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
|
||
|
||
// Partition logic
|
||
if (this.partitionOn) {
|
||
const mid = W / 2, hw = 3;
|
||
const inPore = this._poreMode
|
||
&& p.y > H / 2 - this._poreH / 2
|
||
&& p.y < H / 2 + this._poreH / 2;
|
||
|
||
if (!inPore) {
|
||
if (p.vx > 0 && p.x + p.r > mid - hw && p.x < mid) {
|
||
p.x = mid - hw - p.r; p.vx = -Math.abs(p.vx);
|
||
} else if (p.vx < 0 && p.x - p.r < mid + hw && p.x > mid) {
|
||
p.x = mid + hw + p.r; p.vx = Math.abs(p.vx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Spatial grid collisions
|
||
const cs = 14, cols = Math.ceil(W / cs) + 1;
|
||
const grid = new Map();
|
||
for (let i = 0; i < particles.length; i++) {
|
||
const p = particles[i];
|
||
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
|
||
if (!grid.has(k)) grid.set(k, []);
|
||
grid.get(k).push(i);
|
||
}
|
||
|
||
for (let i = 0; i < particles.length; i++) {
|
||
const p1 = particles[i];
|
||
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
|
||
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
|
||
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
|
||
if (!cell) continue;
|
||
for (const j of cell) {
|
||
if (j <= i) continue;
|
||
const p2 = particles[j];
|
||
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
||
const d = Math.hypot(dx, dy), md = p1.r + p2.r;
|
||
if (d < md && d > 0.001) {
|
||
const nx = dx / d, ny = dy / d;
|
||
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
|
||
if (dvn < 0) continue;
|
||
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
|
||
p2.vx += dvn * nx; p2.vy += dvn * ny;
|
||
const ov = (md - d) / 2;
|
||
p1.x -= nx * ov; p1.y -= ny * ov;
|
||
p2.x += nx * ov; p2.y += ny * ov;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// History (with entropy)
|
||
if (this._steps % 60 === 0) {
|
||
const left = particles.filter(p => p.x < W / 2);
|
||
const fracA_left = left.length > 0
|
||
? left.filter(p => p.type === 'A').length / left.length
|
||
: 0;
|
||
const f = fracA_left;
|
||
const entropy = -(f * Math.log(f + 1e-9) + (1 - f) * Math.log(1 - f + 1e-9));
|
||
this._history.push({ step: this._steps, fracA_left, entropy });
|
||
if (this._history.length > 200) this._history.shift();
|
||
}
|
||
|
||
// Heatmap update (every 30 steps)
|
||
if (this._steps % 30 === 0) this._updateHeatmap();
|
||
|
||
this._steps++;
|
||
if (this._steps % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
|
||
}
|
||
|
||
_updateHeatmap() {
|
||
const { W, H, particles } = this;
|
||
const cols = 20, rows = 14;
|
||
const cw = W / cols, ch = H / rows;
|
||
const grid = [];
|
||
for (let r = 0; r < rows; r++) {
|
||
grid[r] = [];
|
||
for (let c = 0; c < cols; c++) grid[r][c] = { A: 0, B: 0 };
|
||
}
|
||
for (const p of particles) {
|
||
const c = Math.min(cols - 1, Math.floor(p.x / cw));
|
||
const r = Math.min(rows - 1, Math.floor(p.y / ch));
|
||
grid[r][c][p.type]++;
|
||
}
|
||
const maxCount = Math.max(...grid.flat().map(c => c.A + c.B), 1);
|
||
this._heatmap = { grid, cols, rows, cw, ch, maxCount };
|
||
}
|
||
|
||
info() {
|
||
const { particles, W, N } = this;
|
||
const leftA = particles.filter(p => p.x < W / 2 && p.type === 'A').length;
|
||
const leftB = particles.filter(p => p.x < W / 2 && p.type === 'B').length;
|
||
const rightA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length;
|
||
const rightB = particles.filter(p => p.x >= W / 2 && p.type === 'B').length;
|
||
const mixed = (leftB + rightA) / (2 * N);
|
||
const fracAL = leftA / ((leftA + leftB) || 1);
|
||
const entropy = -(fracAL * Math.log(fracAL + 1e-9) + (1 - fracAL) * Math.log(1 - fracAL + 1e-9));
|
||
return {
|
||
leftA, leftB, rightA, rightB,
|
||
mixed: (mixed * 100).toFixed(0),
|
||
entropy: entropy.toFixed(3),
|
||
partitionOn: this.partitionOn,
|
||
poreMode: this._poreMode,
|
||
steps: this._steps,
|
||
};
|
||
}
|
||
|
||
// ── drawing ─────────────────────────────────────────────────────────────────
|
||
draw() {
|
||
const { ctx, W, H } = this;
|
||
const TAU = Math.PI * 2;
|
||
|
||
ctx.fillStyle = '#080818'; ctx.fillRect(0, 0, W, H);
|
||
|
||
// Background tints
|
||
ctx.fillStyle = 'rgba(6,214,224,0.04)'; ctx.fillRect(0, 0, W / 2, H);
|
||
ctx.fillStyle = 'rgba(241,91,181,0.04)'; ctx.fillRect(W / 2, 0, W / 2, H);
|
||
|
||
// Density heatmap (subtle)
|
||
this._drawHeatmap(ctx);
|
||
|
||
// Partition
|
||
if (this.partitionOn) this._drawPartition(ctx, W, H);
|
||
|
||
// Particles
|
||
ctx.save();
|
||
for (const p of this.particles) {
|
||
const color = p.type === 'A' ? '#06D6E0' : '#F15BB5';
|
||
ctx.shadowColor = color; ctx.shadowBlur = 6;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
|
||
// Concentration bar (right side)
|
||
this._drawConcBar(ctx, W, H);
|
||
|
||
// History chart with entropy (bottom)
|
||
this._drawHistoryChart(ctx, W, H);
|
||
|
||
// Stats overlay (top-left)
|
||
this._drawStats(ctx);
|
||
}
|
||
|
||
_drawHeatmap(ctx) {
|
||
const hm = this._heatmap;
|
||
if (!hm) return;
|
||
for (let r = 0; r < hm.rows; r++) for (let c = 0; c < hm.cols; c++) {
|
||
const cell = hm.grid[r][c];
|
||
const total = cell.A + cell.B;
|
||
if (total === 0) continue;
|
||
const frac = total / hm.maxCount;
|
||
// Color based on A vs B ratio
|
||
const fracA = cell.A / total;
|
||
// Mix cyan and pink by composition
|
||
const rr = Math.round(6 + (241 - 6) * (1 - fracA));
|
||
const rg = Math.round(214 + (91 - 214) * (1 - fracA));
|
||
const rb = Math.round(224 + (181 - 224) * (1 - fracA));
|
||
ctx.fillStyle = `rgba(${rr},${rg},${rb},${frac * 0.08})`;
|
||
ctx.fillRect(c * hm.cw, r * hm.ch, hm.cw, hm.ch);
|
||
}
|
||
}
|
||
|
||
_drawPartition(ctx, W, H) {
|
||
const mid = W / 2, pw = 6;
|
||
const poreOn = this._poreMode;
|
||
const poreY1 = H / 2 - this._poreH / 2;
|
||
const poreY2 = H / 2 + this._poreH / 2;
|
||
|
||
ctx.save();
|
||
ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(255,255,255,0.5)';
|
||
|
||
const grad = ctx.createLinearGradient(mid - pw / 2, 0, mid + pw / 2, 0);
|
||
grad.addColorStop(0, 'rgba(255,255,255,0.15)');
|
||
grad.addColorStop(1, 'rgba(255,255,255,0.05)');
|
||
ctx.fillStyle = grad;
|
||
|
||
if (!poreOn) {
|
||
ctx.fillRect(mid - pw / 2, 0, pw, H);
|
||
} else {
|
||
// Two segments (above and below pore)
|
||
ctx.fillRect(mid - pw / 2, 0, pw, poreY1);
|
||
ctx.fillRect(mid - pw / 2, poreY2, pw, H - poreY2);
|
||
|
||
// Pore opening highlight
|
||
ctx.shadowBlur = 0;
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1;
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY1); ctx.lineTo(mid + pw / 2, poreY1); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY2); ctx.lineTo(mid + pw / 2, poreY2); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Pore gap arrows (showing flow direction)
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||
ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('⇌', mid, H / 2);
|
||
}
|
||
|
||
if (!poreOn) {
|
||
// Door handle
|
||
const hx = mid - 10, hy = H / 2 - 14, hw = 20, hh = 28;
|
||
ctx.shadowBlur = 0;
|
||
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||
ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('||', mid, H / 2);
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawConcBar(ctx, W, H) {
|
||
const barX = W - 20, barHalf = H / 2;
|
||
const { particles } = this;
|
||
const lA = particles.filter(p => p.x < W / 2 && p.type === 'A').length;
|
||
const lT = particles.filter(p => p.x < W / 2).length || 1;
|
||
const rA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length;
|
||
const rT = particles.filter(p => p.x >= W / 2).length || 1;
|
||
const fAL = lA / lT, fAR = rA / rT;
|
||
|
||
ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, 0, 20, barHalf * fAL);
|
||
ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf * fAL, 20, barHalf * (1 - fAL));
|
||
ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, barHalf, 20, barHalf * fAR);
|
||
ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf + barHalf * fAR, 20, barHalf * (1 - fAR));
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillRect(barX, barHalf - 1, 20, 2);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||
ctx.strokeRect(barX, 0, 20, H);
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = "9px 'Manrope', sans-serif";
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.translate(barX + 10, H / 2); ctx.rotate(-Math.PI / 2);
|
||
ctx.fillText('Концентрация', 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawHistoryChart(ctx, W, H) {
|
||
const graphH = 100, graphY = H - graphH, graphW = W - 24;
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(0,0,10,0.76)';
|
||
ctx.beginPath(); ctx.roundRect(0, graphY, graphW, graphH, 8); ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "10px 'Manrope', sans-serif";
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
ctx.fillText('Доля A в левой половине', 10, graphY + 6);
|
||
|
||
// Y-axis labels
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "9px 'Manrope', sans-serif";
|
||
ctx.fillText('1.0', 4, graphY + 18);
|
||
ctx.fillText('0.0', 4, graphY + graphH - 10);
|
||
|
||
const refY = graphY + graphH * 0.5 - 2;
|
||
ctx.setLineDash([4, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(24, refY); ctx.lineTo(graphW - 10, refY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = "9px 'Manrope', sans-serif";
|
||
ctx.textAlign = 'left'; ctx.fillText('равновесие', 28, refY - 10);
|
||
|
||
const hist = this._history;
|
||
if (hist.length > 1) {
|
||
const plotX0 = 28, plotW = graphW - 38;
|
||
const plotY0 = graphY + 18, plotH2 = graphH - 28;
|
||
|
||
// Concentration line (cyan)
|
||
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < hist.length; i++) {
|
||
const hx = plotX0 + (i / (hist.length - 1)) * plotW;
|
||
const hy = plotY0 + plotH2 * (1 - hist[i].fracA_left);
|
||
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Entropy line (orange, dashed, scaled to 0..ln(2) ≈ 0.693)
|
||
const maxEnt = Math.log(2);
|
||
ctx.strokeStyle = '#FFB347'; ctx.lineWidth = 1.2;
|
||
ctx.setLineDash([4, 3]);
|
||
ctx.beginPath();
|
||
for (let i = 0; i < hist.length; i++) {
|
||
const hx = plotX0 + (i / (hist.length - 1)) * plotW;
|
||
const hy = plotY0 + plotH2 * (1 - hist[i].entropy / maxEnt);
|
||
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
|
||
}
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Legend
|
||
ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 50, graphY + 8, 3, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "8px sans-serif"; ctx.textBaseline = 'middle';
|
||
ctx.textAlign = 'left'; ctx.fillText('X(A)', plotX0 + plotW - 44, graphY + 8);
|
||
|
||
ctx.fillStyle = '#FFB347'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 22, graphY + 8, 3, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillText('S', plotX0 + plotW - 16, graphY + 8);
|
||
|
||
// Current value
|
||
const last = hist[hist.length - 1];
|
||
const endX = plotX0 + plotW;
|
||
const endY = plotY0 + plotH2 * (1 - last.fracA_left);
|
||
ctx.fillStyle = '#06D6E0'; ctx.font = "bold 10px 'Manrope', sans-serif";
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||
ctx.fillText((last.fracA_left * 100).toFixed(0) + '%', endX - 2, endY);
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawStats(ctx) {
|
||
const info = this.info();
|
||
const pad = 10, panelW = 180, panelH = 90, px = 14, py = 14;
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(0,0,10,0.72)';
|
||
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 8); ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
const lineH = 18;
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.font = "11px 'Manrope', sans-serif";
|
||
|
||
ctx.fillStyle = '#06D6E0';
|
||
ctx.fillText(`Лево: A=${info.leftA} B=${info.leftB}`, px + pad, py + pad);
|
||
|
||
ctx.fillStyle = '#F15BB5';
|
||
ctx.fillText(`Право: A=${info.rightA} B=${info.rightB}`, px + pad, py + pad + lineH);
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||
ctx.fillText(`Смешивание: ${info.mixed}%`, px + pad, py + pad + lineH * 2);
|
||
|
||
const stateLabel = !info.partitionOn ? 'Снята' : info.poreMode ? 'С порой' : 'Вкл';
|
||
const stateColor = !info.partitionOn ? '#F15BB5' : info.poreMode ? '#FFB347' : '#06D6E0';
|
||
ctx.fillStyle = stateColor;
|
||
ctx.fillText(`Раздел: ${stateLabel}`, px + pad, py + pad + lineH * 3);
|
||
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
if (typeof module !== 'undefined') module.exports = DiffusionSim;
|