Files
Learn_System/frontend/js/labs/brownian.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

407 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
// 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() {
this._step(); this._step(); this._step();
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> gold (#FFD166), old <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> dark indigo
const hue = 220 + (1 - frac) * 20; // 220..240 — indigo <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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);
}
_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;