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>
407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
'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;
|