fd29acbbdd
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
572 lines
19 KiB
JavaScript
572 lines
19 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(() => {
|
||
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;
|