'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 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 ── */