Files
Learn_System/frontend/js/labs/probability.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

614 lines
21 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;
}
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
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;
/* ─── lab UI init ─────────────────────────────────── */
function _openProbability() {
document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
_simShow('sim-probability');
_registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
if (_embedMode) _startStateEmit('probability');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!probSim) {
probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
probSim.onUpdate = _probUpdateUI;
}
probSim.fit();
probSim.reset();
probSim.play();
}));
}
function probMode(mode, btn) {
document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
}
function probPreset(mode, trials) {
document.querySelectorAll('.prob-mode-btn').forEach(b => {
b.classList.toggle('active', b.textContent.toLowerCase().includes(mode === 'coin' ? 'монет' : mode === 'dice2' ? '2 куб' : 'кубик'));
});
if (probSim) { probSim.setParams({ mode, trials }); probSim.reset(); probSim.play(); }
}
function _probUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('probbar-v1', info.totalTrials);
v('probbar-v2', typeof info.maxDeviation === 'number' ? (info.maxDeviation * 100).toFixed(1) + '%' : '—');
v('probbar-v3', typeof info.chiSquare === 'number' ? info.chiSquare.toFixed(2) : '—');
const modeNames = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' };
v('probbar-v4', modeNames[info.mode] || info.mode);
}
/* ── bohr atom ── */