'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;