'use strict'; /** * BrownianSim v2 — Brownian Motion simulation. * v2: age-gradient trail, MSD history chart, hover tooltip on big particle, * resetOrigin() method. */ class BrownianSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; this.big = { x: 0, y: 0, vx: 0, vy: 0, r: 22 }; this.small = []; this.N = 120; this.T = 1.0; this.trail = []; this._origin = { x: 0, y: 0 }; this._steps = 0; this._raf = null; this.onUpdate = null; this._dpr = 1; this._fxLastT = 0; // v2 this._msdHistory = []; // [{step, msd}] this._hover = false; canvas.addEventListener('mousemove', e => this._onMouseMove(e)); canvas.addEventListener('mouseleave', () => { this._hover = false; }); } _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), }; } _onMouseMove(e) { const { x, y } = this._cp(e); this._hover = Math.hypot(x - this.big.x, y - this.big.y) < this.big.r + 22; } // ── 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.big = { x: W / 2, y: H / 2, vx: 0, vy: 0, r: 22 }; this._origin = { x: W / 2, y: H / 2 }; this.trail = [{ x: W / 2, y: H / 2 }]; this._steps = 0; this._msdHistory = []; const small = []; let att = 0; while (small.length < this.N && att < this.N * 20) { att++; const r = 4; const x = r + Math.random() * (W - 2 * r); const y = r + Math.random() * (H - 2 * r); if (Math.hypot(x - W / 2, y - H / 2) < this.big.r + r + 8) continue; const a = Math.random() * Math.PI * 2, s = this.T * 4.5; small.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r }); } this.small = small; } resetOrigin() { this._origin = { x: this.big.x, y: this.big.y }; this._msdHistory = []; } setN(n) { this.N = Math.max(10, Math.min(300, n)); this.reset(); } setT(t) { const f = Math.sqrt(t / this.T); for (const s of this.small) { s.vx *= f; s.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(now) { const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16; this._fxLastT = now; this._step(); this._step(); this._step(); if (window.LabFX) LabFX.particles.update(dt); this.draw(); this._raf = requestAnimationFrame(this._loop.bind(this)); } _step() { const { W, H, big, small } = this; big.x += big.vx; big.y += big.vy; if (big.x < big.r) { big.x = big.r; big.vx = Math.abs(big.vx); } if (big.x > W - big.r) { big.x = W - big.r; big.vx = -Math.abs(big.vx); } if (big.y < big.r) { big.y = big.r; big.vy = Math.abs(big.vy); } if (big.y > H - big.r) { big.y = H - big.r; big.vy = -Math.abs(big.vy); } for (const s of small) { s.x += s.vx; s.y += s.vy; if (s.x < s.r) { s.x = s.r; s.vx = Math.abs(s.vx); } if (s.x > W - s.r) { s.x = W - s.r; s.vx = -Math.abs(s.vx); } if (s.y < s.r) { s.y = s.r; s.vy = Math.abs(s.vy); } if (s.y > H - s.r) { s.y = H - s.r; s.vy = -Math.abs(s.vy); } } // big vs small const m1 = big.r * big.r; for (const s of small) { const dx = s.x - big.x, dy = s.y - big.y; const dist = Math.hypot(dx, dy), md = big.r + s.r; if (dist < md && dist > 0.001) { const nx = dx / dist, ny = dy / dist; const dvn = (big.vx - s.vx) * nx + (big.vy - s.vy) * ny; if (dvn > 0) continue; const m2 = s.r * s.r, imp = (2 * dvn) / (m1 + m2); big.vx -= imp * m2 * nx; big.vy -= imp * m2 * ny; s.vx += imp * m1 * nx; s.vy += imp * m1 * ny; const ov = md - dist, f1 = m2 / (m1 + m2), f2 = m1 / (m1 + m2); big.x -= nx * ov * f1; big.y -= ny * ov * f1; s.x += nx * ov * f2; s.y += ny * ov * f2; } } // small vs small — spatial grid const cs = 10, cols = Math.ceil(W / cs) + 1; const grid = new Map(); for (let i = 0; i < small.length; i++) { const s = small[i]; const k = Math.floor(s.x / cs) + Math.floor(s.y / cs) * cols; if (!grid.has(k)) grid.set(k, []); grid.get(k).push(i); } const checked = new Set(); for (let i = 0; i < small.length; i++) { const s1 = small[i]; const cx = Math.floor(s1.x / cs), cy = Math.floor(s1.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 pk = i * 10000 + j; if (checked.has(pk)) continue; checked.add(pk); const s2 = small[j]; const dx = s2.x - s1.x, dy = s2.y - s1.y; const d = Math.hypot(dx, dy), md = s1.r + s2.r; if (d < md && d > 0.001) { const nx = dx / d, ny = dy / d; const dvn = (s1.vx - s2.vx) * nx + (s1.vy - s2.vy) * ny; if (dvn < 0) continue; s1.vx -= dvn * nx; s1.vy -= dvn * ny; s2.vx += dvn * nx; s2.vy += dvn * ny; const ov = (md - d) / 2; s1.x -= nx * ov; s1.y -= ny * ov; s2.x += nx * ov; s2.y += ny * ov; } } } } // Trail if (this._steps % 2 === 0) { this.trail.push({ x: big.x, y: big.y }); if (this.trail.length > 600) this.trail.shift(); } // MSD history if (this._steps % 6 === 0) { const dx = big.x - this._origin.x, dy = big.y - this._origin.y; this._msdHistory.push({ step: this._steps, msd: dx * dx + dy * dy }); if (this._msdHistory.length > 250) this._msdHistory.shift(); } this._steps++; if (this._steps % 40 === 0 && this.onUpdate) this.onUpdate(this.info()); } info() { const dx = this.big.x - this._origin.x, dy = this.big.y - this._origin.y; return { steps: this._steps, displacement: Math.hypot(dx, dy).toFixed(1), msd: (dx * dx + dy * dy).toFixed(0), speed: Math.hypot(this.big.vx, this.big.vy).toFixed(2), N: this.N, T: this.T, }; } // ── drawing ───────────────────────────────────────────────────────────────── draw() { const { ctx, W, H } = this; const TAU = Math.PI * 2; 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, '#03030C'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); // Dot grid ctx.fillStyle = 'rgba(255,255,255,0.025)'; for (let x = 0; x < W; x += 30) for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.arc(x, y, 1, 0, TAU); ctx.fill(); } // MSD history chart (bottom-left) this._drawMsdChart(ctx, W, H); // Age-gradient trail const trail = this.trail; for (let i = 1; i < trail.length; i++) { const frac = i / trail.length; // young gold (#FFD166), old dark indigo const hue = 220 + (1 - frac) * 20; // 220..240 — indigo blue const sat = 60 + frac * 40; const lit = 20 + frac * 50; ctx.beginPath(); ctx.arc(trail[i].x, trail[i].y, 1.5, 0, TAU); ctx.fillStyle = `hsla(${hue},${sat}%,${lit}%,${frac * 0.75})`; ctx.fill(); } // Newest segment in gold if (trail.length > 2) { const t0 = trail[trail.length - 2], t1 = trail[trail.length - 1]; ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(t0.x, t0.y); ctx.lineTo(t1.x, t1.y); ctx.stroke(); } // Displacement vector const ox = this._origin.x, oy = this._origin.y; const bx = this.big.x, by = this.big.y; const vlen = Math.hypot(bx - ox, by - oy); if (vlen > 2) { ctx.save(); ctx.strokeStyle = 'rgba(255,100,100,0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(bx, by); ctx.stroke(); const ang = Math.atan2(by - oy, bx - ox), hl = 8; ctx.fillStyle = 'rgba(255,100,100,0.6)'; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - hl * Math.cos(ang - 0.4), by - hl * Math.sin(ang - 0.4)); ctx.lineTo(bx - hl * Math.cos(ang + 0.4), by - hl * Math.sin(ang + 0.4)); ctx.closePath(); ctx.fill(); const mx = (ox + bx) / 2, my = (oy + by) / 2; ctx.fillStyle = 'rgba(255,140,140,0.85)'; ctx.font = "10px 'Manrope', sans-serif"; ctx.fillText(`|Δr| = ${vlen.toFixed(1)}`, mx + 6, my - 4); ctx.restore(); } // Origin marker ctx.save(); ctx.strokeStyle = 'rgba(255,100,100,0.35)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.arc(ox, oy, 6, 0, TAU); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); // Small particles ctx.save(); ctx.shadowBlur = 4; ctx.shadowColor = '#4CC9F0'; ctx.fillStyle = '#4CC9F0'; for (const s of this.small) { ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, TAU); ctx.fill(); } ctx.restore(); // Big particle const big = this.big; ctx.save(); ctx.shadowBlur = 32; ctx.shadowColor = 'rgba(255,214,0,0.65)'; const grad = ctx.createRadialGradient( big.x - big.r * 0.3, big.y - big.r * 0.3, 2, big.x, big.y, big.r ); grad.addColorStop(0, '#FFD166'); grad.addColorStop(1, '#9B5DE5'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.fill(); // Hover ring if (this._hover) { ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(big.x, big.y, big.r + 4, 0, TAU); ctx.stroke(); } ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.stroke(); ctx.fillStyle = 'white'; ctx.font = "bold 12px 'Manrope', sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('B', big.x, big.y); ctx.restore(); // Hover tooltip if (this._hover) this._drawBigTooltip(ctx, W, H); if (window.LabFX) LabFX.particles.draw(ctx); } _drawMsdChart(ctx, W, H) { const hist = this._msdHistory; const chartW = 190, chartH = 90; const cx = 14, cy = H - chartH - 14; ctx.save(); ctx.fillStyle = 'rgba(0,0,10,0.72)'; ctx.beginPath(); ctx.roundRect(cx, cy, chartW, chartH, 8); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = "9px 'Manrope', sans-serif"; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('MSD vs Шагов', cx + 8, cy + 7); if (hist.length > 2) { const padL = 8, padR = 10, padT = 20, padB = 8; const pw = chartW - padL - padR; const ph = chartH - padT - padB; const maxMsd = Math.max(...hist.map(h => h.msd), 1); ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < hist.length; i++) { const hx = cx + padL + (i / (hist.length - 1)) * pw; const hy = cy + padT + ph - (hist[i].msd / maxMsd) * ph; if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); } ctx.stroke(); // Theoretical linear MSD ~ D*t line (straight reference) const lastMsd = hist[hist.length - 1].msd; const firstMsd = hist[0].msd; ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(cx + padL, cy + padT + ph - (firstMsd / maxMsd) * ph); ctx.lineTo(cx + padL + pw, cy + padT + ph - (lastMsd / maxMsd) * ph); ctx.stroke(); ctx.setLineDash([]); // Current value const last = hist[hist.length - 1]; ctx.fillStyle = '#9B5DE5'; ctx.font = "bold 10px 'Manrope', sans-serif"; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(last.msd.toFixed(0), cx + chartW - padR - 2, cy + padT + ph - (last.msd / maxMsd) * ph); } else { ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "10px 'Manrope', sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Накапливается…', cx + chartW / 2, cy + chartH / 2); } ctx.restore(); } _drawBigTooltip(ctx, W, H) { const big = this.big; const spd = Math.hypot(big.vx, big.vy); const ke = 0.5 * big.r * big.r * spd * spd; // prop to mass (r²) const dx = big.x - this._origin.x, dy = big.y - this._origin.y; const disp = Math.hypot(dx, dy); const msd = dx * dx + dy * dy; const rows = [ ['|v|', spd.toFixed(2) + ' у.е.'], ['KE', ke.toFixed(0) + ' у.е.'], ['|Δr|', disp.toFixed(1) + ' px'], ['MSD', msd.toFixed(0) + ' px²'], ]; const tw = 142, th = 18 + rows.length * 17 + 8; let tx = big.x + big.r + 12, ty = big.y - th / 2; if (tx + tw > W - 10) tx = big.x - big.r - tw - 12; ty = Math.max(8, Math.min(H - th - 8, ty)); ctx.save(); ctx.fillStyle = 'rgba(6,8,28,0.93)'; ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); ctx.fillStyle = '#FFD166'; 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.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.9)'; ctx.textAlign = 'right'; ctx.fillText(rows[i][1], tx + tw - 10, ry); } ctx.restore(); } } if (typeof module !== 'undefined') module.exports = BrownianSim;