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