6afe928c0d
ФУНДАМЕНТ (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>
631 lines
21 KiB
JavaScript
631 lines
21 KiB
JavaScript
'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 ── */
|
||
|