Files
Learn_System/frontend/js/labs/probability.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

571 lines
19 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';
/* ══════════════════════════════════════════════════════════════
ProbabilitySim — probability & law of large numbers
coin flip · single die · two-dice sum
histogram + convergence chart + animated visuals
══════════════════════════════════════════════════════════════ */
class ProbabilitySim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* parameters */
this.mode = 'coin'; // 'coin' | 'dice' | 'dice2'
this.trials = 100; // target total
this.speed = 5; // trials per frame
/* state */
this.results = []; // outcome per trial
this.distribution = {}; // outcome <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> count
this._convHist = []; // running freq for convergence chart
this._trackKey = null; // key tracked for convergence
/* animation */
this.playing = false;
this._raf = null;
this._animT = 0; // animation phase for coin/dice visual
this._lastOutcome = null;
this._shakeT = 0;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── presets ──────────────────────────────────── */
static PRESETS = {
coin_100: { mode: 'coin', trials: 100, speed: 2 },
coin_1000: { mode: 'coin', trials: 1000, speed: 10 },
dice_100: { mode: 'dice', trials: 100, speed: 2 },
dice2_500: { mode: 'dice2', trials: 500, speed: 5 },
};
preset(name) {
const p = ProbabilitySim.PRESETS[name];
if (p) { this.setParams(p); this.reset(); }
}
/* ── public API ──────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
if (speed !== undefined) this.speed = Math.max(1, Math.min(50, +speed));
this._setupMode();
this.draw();
this._emit();
}
reset() {
this.pause();
this.results = [];
this.distribution = {};
this._convHist = [];
this._animT = 0;
this._lastOutcome = null;
this._shakeT = 0;
this._setupMode();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const n = this.results.length;
const dist = { ...this.distribution };
const theo = this._theoretical();
let chiSq = 0, maxDev = 0;
for (const k of Object.keys(theo)) {
const obs = (dist[k] || 0) / (n || 1);
const exp = theo[k];
const dev = Math.abs(obs - exp);
if (dev > maxDev) maxDev = dev;
if (n > 0) chiSq += ((dist[k] || 0) - n * exp) ** 2 / (n * exp || 1);
}
return {
mode: this.mode,
totalTrials: n,
distribution: dist,
chiSquare: +chiSq.toFixed(4),
maxDeviation: +maxDev.toFixed(6),
};
}
/* ── internals ───────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_setupMode() {
const keys = this._outcomeKeys();
for (const k of keys) {
if (!(k in this.distribution)) this.distribution[k] = 0;
}
this._trackKey = keys[0]; // convergence tracks first outcome
}
_outcomeKeys() {
if (this.mode === 'coin') return ['О', 'Р'];
if (this.mode === 'dice') return ['1','2','3','4','5','6'];
// dice2: sums 2..12
const keys = [];
for (let i = 2; i <= 12; i++) keys.push(String(i));
return keys;
}
_theoretical() {
const t = {};
if (this.mode === 'coin') {
t['О'] = 0.5; t['Р'] = 0.5;
} else if (this.mode === 'dice') {
for (let i = 1; i <= 6; i++) t[String(i)] = 1 / 6;
} else {
// dice2: two dice sum probabilities
const ways = [0,0,1,2,3,4,5,6,5,4,3,2,1]; // index 0-12, sum 2-12
for (let s = 2; s <= 12; s++) t[String(s)] = ways[s] / 36;
}
return t;
}
_rollOnce() {
if (this.mode === 'coin') return Math.random() < 0.5 ? 'О' : 'Р';
if (this.mode === 'dice') return String(Math.floor(Math.random() * 6) + 1);
const d1 = Math.floor(Math.random() * 6) + 1;
const d2 = Math.floor(Math.random() * 6) + 1;
return String(d1 + d2);
}
_addTrial() {
if (this.results.length >= this.trials) return false;
const outcome = this._rollOnce();
this.results.push(outcome);
this.distribution[outcome] = (this.distribution[outcome] || 0) + 1;
this._lastOutcome = outcome;
// convergence: running frequency of tracked key
const n = this.results.length;
const freq = (this.distribution[this._trackKey] || 0) / n;
this._convHist.push(freq);
if (this._convHist.length > 500) this._convHist.shift();
return true;
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(() => {
let added = 0;
for (let i = 0; i < this.speed; i++) {
if (!this._addTrial()) break;
added++;
}
this._animT += 0.15;
if (added > 0) this._shakeT = 1;
else this._shakeT *= 0.9;
this.draw();
this._emit();
if (this.results.length >= this.trials) {
this.pause();
return;
}
this._tick();
});
}
/* ── drawing ─────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const vizH = H * 0.28;
const histH = H * 0.48;
const convH = H * 0.24;
this._drawVisual(ctx, 0, 0, W, vizH);
this._drawHistogram(ctx, 0, vizH, W, histH);
this._drawConvergence(ctx, 0, vizH + histH, W, convH);
this._drawStats(ctx, W, H);
}
/* ── top visual: coin or dice ──────────────── */
_drawVisual(ctx, x0, y0, w, h) {
const cx = x0 + w / 2, cy = y0 + h / 2;
if (this.mode === 'coin') {
this._drawCoin(ctx, cx, cy, Math.min(w, h) * 0.32);
} else if (this.mode === 'dice') {
this._drawDie(ctx, cx, cy, Math.min(w, h) * 0.34, this._lastOutcome ? +this._lastOutcome : 1);
} else {
// dice2: two dice side by side
const sz = Math.min(w, h) * 0.28;
const gap = sz * 0.3;
const last = this._lastOutcome ? +this._lastOutcome : 7;
const d1 = Math.min(6, Math.max(1, Math.ceil(last / 2)));
const d2 = last - d1;
this._drawDie(ctx, cx - sz / 2 - gap, cy, sz, Math.max(1, Math.min(6, d1)));
this._drawDie(ctx, cx + sz / 2 + gap, cy, sz, Math.max(1, Math.min(6, d2)));
}
// trial counter
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`Испытание ${this.results.length} / ${this.trials}`, x0 + w / 2, y0 + h - 6);
}
_drawCoin(ctx, cx, cy, r) {
const phase = this._animT % (Math.PI * 2);
const squeeze = Math.abs(Math.cos(phase));
const showHeads = Math.cos(phase) >= 0;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(0.05, squeeze), 1);
// shadow
ctx.fillStyle = 'rgba(155,93,229,0.15)';
ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 1.1, r * 0.18, 0, 0, Math.PI * 2); ctx.fill();
// coin body
const grad = ctx.createRadialGradient(-r * 0.2, -r * 0.2, 0, 0, 0, r);
if (showHeads) {
grad.addColorStop(0, '#FFD166');
grad.addColorStop(1, '#D4950A');
} else {
grad.addColorStop(0, '#9B5DE5');
grad.addColorStop(1, '#6B2FA0');
}
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
// rim
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.stroke();
// label
ctx.fillStyle = showHeads ? '#5A3000' : '#E0D0FF';
ctx.font = `bold ${Math.round(r * 0.6)}px 'Manrope', system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(showHeads ? 'О' : 'Р', 0, 2);
ctx.restore();
}
_drawDie(ctx, cx, cy, size, value) {
const half = size / 2;
const shake = this._shakeT * 2;
const sx = shake * (Math.random() - 0.5);
const sy = shake * (Math.random() - 0.5);
ctx.save();
ctx.translate(cx + sx, cy + sy);
// shadow
ctx.fillStyle = 'rgba(6,214,224,0.08)';
ctx.beginPath(); ctx.roundRect(-half + 4, -half + 6, size, size, size * 0.18); ctx.fill();
// body
const grad = ctx.createLinearGradient(-half, -half, half, half);
grad.addColorStop(0, '#1E1E3A');
grad.addColorStop(1, '#12122A');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.roundRect(-half, -half, size, size, size * 0.18); ctx.fill();
// border
ctx.strokeStyle = 'rgba(155,93,229,0.4)';
ctx.lineWidth = 1.5;
ctx.stroke();
// dots
const dotR = size * 0.08;
const off = size * 0.26;
const dots = {
1: [[0, 0]],
2: [[-off, -off], [off, off]],
3: [[-off, -off], [0, 0], [off, off]],
4: [[-off, -off], [off, -off], [-off, off], [off, off]],
5: [[-off, -off], [off, -off], [0, 0], [-off, off], [off, off]],
6: [[-off, -off], [off, -off], [-off, 0], [off, 0], [-off, off], [off, off]],
};
const positions = dots[Math.max(1, Math.min(6, value))] || dots[1];
for (const [dx, dy] of positions) {
const dg = ctx.createRadialGradient(dx, dy, 0, dx, dy, dotR);
dg.addColorStop(0, '#FFFFFF');
dg.addColorStop(1, '#C0C0E0');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ── histogram ─────────────────────────────── */
_drawHistogram(ctx, x0, y0, w, h) {
const keys = this._outcomeKeys();
const theo = this._theoretical();
const n = this.results.length || 1;
const pad = { l: 48, r: 16, t: 20, b: 34 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// panel bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 4, w - 16, h - 8, 8); ctx.fill();
// y-axis: relative frequency
let maxFreq = 0;
for (const k of keys) {
const f = (this.distribution[k] || 0) / n;
if (f > maxFreq) maxFreq = f;
}
for (const k of keys) {
const t = theo[k];
if (t > maxFreq) maxFreq = t;
}
maxFreq = Math.max(maxFreq * 1.15, 0.05);
// grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const gy = py + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const v = (maxFreq * i / 4 * 100).toFixed(0) + '%';
ctx.fillText(v, px - 6, py + ph * (1 - i / 4));
}
// bars
const barCount = keys.length;
const gap = Math.max(2, pw * 0.03);
const barW = (pw - gap * (barCount + 1)) / barCount;
const colors = ['#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166',
'#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166','#EF476F'];
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const freq = (this.distribution[k] || 0) / n;
const bh = (freq / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
const by = py + ph - bh;
// bar gradient
const bg = ctx.createLinearGradient(bx, by, bx, py + ph);
bg.addColorStop(0, colors[i % colors.length]);
bg.addColorStop(1, colors[i % colors.length] + '66');
ctx.fillStyle = bg;
ctx.beginPath(); ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]); ctx.fill();
// glow at top
if (bh > 4) {
ctx.fillStyle = colors[i % colors.length] + '33';
ctx.beginPath(); ctx.roundRect(bx - 2, by - 2, barW + 4, 6, 3); ctx.fill();
}
// count + percentage label above bar
const count = this.distribution[k] || 0;
const pct = (freq * 100).toFixed(1);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = "bold 9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
if (bh > 16) {
ctx.fillText(count, bx + barW / 2, by - 2);
} else {
ctx.fillText(count, bx + barW / 2, py + ph - bh - 2);
}
// percentage inside bar
if (bh > 28) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textBaseline = 'top';
ctx.fillText(pct + '%', bx + barW / 2, by + 4);
}
// x label
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(k, bx + barW / 2, py + ph + 6);
}
// theoretical probability dashed lines
ctx.setLineDash([5, 4]);
ctx.lineWidth = 1.5;
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const tp = theo[k];
const ly = py + ph - (tp / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
ctx.strokeStyle = colors[i % colors.length] + '88';
ctx.beginPath();
ctx.moveTo(bx - 2, ly);
ctx.lineTo(bx + barW + 2, ly);
ctx.stroke();
}
ctx.setLineDash([]);
// legend
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.setLineDash([5, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, y0 + h - 8); ctx.lineTo(px + 18, y0 + h - 8); ctx.stroke();
ctx.setLineDash([]);
ctx.fillText('— теор. вероятность', px + 22, y0 + h - 4);
}
/* ── convergence chart ─────────────────────── */
_drawConvergence(ctx, x0, y0, w, h) {
const pad = { l: 48, r: 16, t: 14, b: 20 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 2, w - 16, h - 4, 8); ctx.fill();
// title
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const trackLabel = this._trackKey;
ctx.fillText(`Сходимость частоты «${trackLabel}»`, px, y0 + 3);
// theoretical value
const theo = this._theoretical();
const tp = theo[this._trackKey] || 0;
// y range
const yMin = Math.max(0, tp - 0.35);
const yMax = Math.min(1, tp + 0.35);
const yRange = yMax - yMin || 0.01;
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 3; i++) {
const gy = py + ph * (i / 3);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 3; i++) {
const v = yMax - (i / 3) * yRange;
ctx.fillText(v.toFixed(2), px - 5, py + ph * (i / 3));
}
// theoretical dashed line
const theoY = py + ph * (1 - (tp - yMin) / yRange);
ctx.setLineDash([6, 4]);
ctx.strokeStyle = '#FFD166';
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(px, theoY); ctx.lineTo(px + pw, theoY); ctx.stroke();
ctx.setLineDash([]);
// label for theoretical
ctx.fillStyle = '#FFD166';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText('p=' + tp.toFixed(4), px + pw, theoY - 3);
// convergence line
const data = this._convHist;
if (data.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = '#06D6E0';
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const lx = px + (i / (data.length - 1)) * pw;
const ly = py + ph * (1 - (data[i] - yMin) / yRange);
const cly = Math.max(py, Math.min(py + ph, ly));
i === 0 ? ctx.moveTo(lx, cly) : ctx.lineTo(lx, cly);
}
ctx.stroke();
// x label
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('номер испытания →', px + pw / 2, y0 + h - 14);
}
/* ── stats overlay ─────────────────────────── */
_drawStats(ctx, W) {
const info = this.info();
const px = 12, py = 10, pw = 170, ph = 72;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 15;
const modeLabel = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' }[this.mode] || this.mode;
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`Режим: ${modeLabel}`, px + 10, py + 8);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`N = ${info.totalTrials}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`χ² = ${info.chiSquare}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = '#FFD166';
ctx.fillText(`max Δ = ${info.maxDeviation.toFixed(4)}`, px + 10, py + 8 + lh * 3);
}
}
if (typeof module !== 'undefined') module.exports = ProbabilitySim;