Files
Learn_System/frontend/js/labs/probability.js
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

631 lines
21 KiB
JavaScript
Raw Permalink 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(now => {
const dt = (now - (this._lastTickTs || now)) / 1000;
this._lastTickTs = now;
if (window.LabFX) LabFX.particles.update(dt);
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;
if (window.LabFX && now - (this._lastBounceSoundTs || 0) > 120) {
this._lastBounceSoundTs = now;
LabFX.sound.play('bounce', { pitch: 1.0 + Math.random() * 0.3 });
}
} else this._shakeT *= 0.9;
this.draw();
this._emit();
if (this.results.length >= this.trials) {
this.pause();
if (window.LabFX) {
LabFX.sound.play('chime');
const ctx = this.ctx;
const W = this.W, H = this.H;
LabFX.particles.emit({ ctx, x: W / 2, y: H * 0.4, count: 40, color: '#9B5DE5', shape: 'spark', spread: Math.PI * 2, life: 1400, speed: 120, gravity: 200, glow: true });
}
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);
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── 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(); }
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
}
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 ── */