Files
Learn_System/frontend/js/labs/normaldist.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
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>
2026-04-13 18:04:59 +03:00

395 lines
15 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';
/**
* NormalDistSim v2 — интерактивное нормальное распределение
* μ, σ · правило 68-95-99.7 · Z-score · закрашивание области
* Чистый рерайт: без SVG-строк в info(), лучшая визуализация.
*/
class NormalDistSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.mu = 0;
this.sigma = 1;
this.shade = '1s'; // 'none' | '1s' | '2s' | '3s' | 'custom'
this.zLow = -1;
this.zHigh = 1;
this.hx = null;
this.onUpdate = null;
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
// ── 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 { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
if (shade !== undefined) this.shade = shade;
if (zLow !== undefined) this.zLow = +zLow;
if (zHigh !== undefined) this.zHigh = +zHigh;
this.draw(); this._emit();
}
info() {
const { mu, sigma, shade } = this;
let areaLabel = '\u2014', areaPct = 0;
if (shade === '1s') { areaPct = 68.27; areaLabel = '\u03bc \u00b1 1\u03c3 \u2192 68.27%'; }
else if (shade === '2s') { areaPct = 95.45; areaLabel = '\u03bc \u00b1 2\u03c3 \u2192 95.45%'; }
else if (shade === '3s') { areaPct = 99.73; areaLabel = '\u03bc \u00b1 3\u03c3 \u2192 99.73%'; }
else if (shade === 'custom') {
areaPct = (this._phi(this.zHigh) - this._phi(this.zLow)) * 100;
areaLabel = `Z \u2208 [${this.zLow.toFixed(1)}, ${this.zHigh.toFixed(1)}] \u2192 ${areaPct.toFixed(2)}%`;
}
return {
mu: mu.toFixed(1),
sigma: sigma.toFixed(2),
peak: (1 / (sigma * Math.sqrt(2 * Math.PI))).toFixed(4),
area: areaLabel,
areaPct: areaPct.toFixed(2),
};
}
// ── math ─────────────────────────────────────────────────────
_pdf(x) {
const z = (x - this.mu) / this.sigma;
return Math.exp(-0.5 * z * z) / (this.sigma * Math.sqrt(2 * Math.PI));
}
_phi(z) {
const a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
const sign = z < 0 ? -1 : 1;
const t = 1 / (1 + p * Math.abs(z) / Math.SQRT2);
const y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-z*z/2);
return 0.5 * (1 + sign * y);
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
// ── coordinate transforms ─────────────────────────────────────
_pad() { return { PL: 52, PR: 22, PT: 38, PB: 50 }; }
_xToP(x, xMin, xMax, PL, pw) { return PL + (x - xMin) / (xMax - xMin) * pw; }
_yToP(y, yMax, PT, ph) { return PT + ph - (y / yMax) * ph; }
_pToX(px, xMin, xMax, PL, pw){ return xMin + (px - PL) / pw * (xMax - xMin); }
// ── draw ─────────────────────────────────────────────────────
draw() {
const { ctx, W, H, mu, sigma } = this;
if (!W || !H) return;
const { PL, PR, PT, PB } = this._pad();
const pw = W - PL - PR, ph = H - PT - PB;
const xMin = mu - 4.5 * sigma, xMax = mu + 4.5 * sigma;
const yMax = this._pdf(mu) * 1.18;
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
this._drawGrid (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawShade (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawCurve (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawLabels (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawBadge (PL, PT, pw, ph);
if (this.hx !== null) this._drawHover(PL, PT, pw, ph, xMin, xMax, yMax);
}
_drawGrid(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma } = this;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Horizontal grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
const py = PT + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(PL, py); ctx.lineTo(PL + pw, py); ctx.stroke();
}
// Vertical sigma grid lines
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
ctx.strokeStyle = s === 0
? 'rgba(6,214,224,0.22)'
: `rgba(255,255,255,${0.04 + (Math.abs(s) <= 2 ? 0.03 : 0)})`;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
// Axes
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(PL, bottom); ctx.lineTo(PL + pw, bottom); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, bottom); ctx.stroke();
// X-axis labels (sigma notation)
ctx.font = `11px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const lbl = s === 0 ? '\u03bc' : (s > 0 ? `+${s}\u03c3` : `${s}\u03c3`);
ctx.fillText(lbl, px, bottom + 6);
}
// Actual x values below
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(255,255,255,0.18)';
for (let s = -3; s <= 3; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const dec = sigma < 1 ? 1 : 0;
ctx.fillText(x.toFixed(dec), px, bottom + 20);
}
// Y-axis labels
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = `10px ${FN}`;
for (let i = 0; i <= 4; i++) {
const v = (yMax / 4) * i;
const py = PT + ph - (v / yMax) * ph;
ctx.fillText(v.toFixed(2), PL - 6, py);
}
// Axis names
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.font = `10px ${FN}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('x', PL + pw / 2, PT + ph + 36);
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('f(x)', PL + 6, PT);
}
_drawShade(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma, shade } = this;
let lo, hi;
if (shade === '1s') { lo = mu - sigma; hi = mu + sigma; }
else if (shade === '2s') { lo = mu - 2 * sigma; hi = mu + 2 * sigma; }
else if (shade === '3s') { lo = mu - 3 * sigma; hi = mu + 3 * sigma; }
else if (shade === 'custom') { lo = mu + this.zLow * sigma; hi = mu + this.zHigh * sigma; }
else return;
const bottom = PT + ph;
const steps = 240;
const dx = (hi - lo) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Filled area with gradient
const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0);
grd.addColorStop(0, 'rgba(155,93,229,0.10)');
grd.addColorStop(0.5, 'rgba(155,93,229,0.30)');
grd.addColorStop(1, 'rgba(155,93,229,0.10)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.moveTo(xp(lo), bottom);
for (let i = 0; i <= steps; i++) {
const x = lo + i * dx;
ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.lineTo(xp(hi), bottom);
ctx.closePath(); ctx.fill();
// Border dashes
ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
for (const bx of [lo, hi]) {
const px = xp(bx);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
ctx.setLineDash([]);
}
_drawCurve(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx } = this;
const steps = Math.min(pw * 2, 500);
const dx = (xMax - xMin) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Glow layer
ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// Main curve
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// μ marker
const muPx = xp(this.mu);
const bottom = PT + ph;
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(muPx, PT); ctx.lineTo(muPx, bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`\u03bc = ${this.mu.toFixed(1)}`, muPx, PT - 4);
// Peak label
const peakPx = xp(this.mu);
const peakPy = yp(this._pdf(this.mu));
ctx.fillStyle = 'rgba(155,93,229,0.5)';
ctx.font = '9px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
const peakVal = (1 / (this.sigma * Math.sqrt(2 * Math.PI))).toFixed(3);
ctx.fillText('f(μ) = ' + peakVal, peakPx + 6, peakPy - 2);
}
_drawLabels(PL, PT, pw, ph, xMin, xMax, yMax) {
// sigma annotation brackets
const { ctx, mu, sigma, shade } = this;
if (shade === 'none') return;
const nSig = shade === '1s' ? 1 : shade === '2s' ? 2 : shade === '3s' ? 3 : null;
if (!nSig) return;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Annotate ±nσ points with small bracket
const lo = mu - nSig * sigma, hi = mu + nSig * sigma;
const loPx = xp(lo), hiPx = xp(hi);
const midY = bottom + 32;
ctx.save();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(loPx, bottom + 4); ctx.lineTo(loPx, midY);
ctx.lineTo(hiPx, midY); ctx.lineTo(hiPx, bottom + 4);
ctx.stroke();
ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('\u00b1' + nSig + '\u03c3', (loPx + hiPx) / 2, midY + 2);
ctx.restore();
}
_drawBadge(PL, PT, pw, ph) {
const { ctx, shade } = this;
if (shade === 'none') return;
const info = this.info();
const pct = parseFloat(info.areaPct);
if (!pct) return;
const FN = 'Manrope, sans-serif';
ctx.save();
ctx.font = `bold 15px ${FN}`;
const text = pct.toFixed(2) + '%';
const tw = ctx.measureText(text).width;
const bw = tw + 24, bh = 28;
const bx = PL + pw - bw - 4, by = PT + 4;
ctx.fillStyle = 'rgba(155,93,229,0.16)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.stroke();
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, by + bh / 2);
const shadeNames = { '1s': '\u03bc \u00b1 1\u03c3', '2s': '\u03bc \u00b1 2\u03c3', '3s': '\u03bc \u00b1 3\u03c3', custom: 'произвольный Z' };
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.fillText(shadeNames[shade] || '', bx + bw / 2, by + bh + 10);
ctx.restore();
}
_drawHover(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, W } = this;
const x = this.hx;
if (x < xMin || x > xMax) return;
const px = this._xToP(x, xMin, xMax, PL, pw);
const y = this._pdf(x);
const py = this._yToP(y, yMax, PT, ph);
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Vertical crosshair
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
ctx.setLineDash([]);
// Point on curve
ctx.fillStyle = '#FFD166'; ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.stroke();
// Tooltip
const z = (x - this.mu) / this.sigma;
const rows = [
['x', x.toFixed(3)],
['z', z.toFixed(3)],
['f(x)', y.toFixed(5)],
['\u03a6(z)', (this._phi(z) * 100).toFixed(2) + '%'],
];
ctx.font = `11px ${FN}`;
const maxKW = Math.max(...rows.map(([k]) => ctx.measureText(k).width));
const maxVW = Math.max(...rows.map(([, v]) => ctx.measureText(v).width));
const tw = maxKW + maxVW + 26, th = rows.length * 18 + 14;
let tx = px + 14, ty = py - th / 2;
if (tx + tw > W - 8) tx = px - tw - 14;
if (ty < PT + 4) ty = PT + 4;
if (ty + th > bottom) ty = bottom - th;
ctx.fillStyle = 'rgba(10,10,28,0.95)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = ty + 7 + i * 18 + 9;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, tx + 10, ry);
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right'; ctx.fillText(v, tx + tw - 10, ry);
});
}
// ── events ────────────────────────────────────────────────────
_bind() {
const cv = this.canvas;
const getHx = e => {
const r = cv.getBoundingClientRect();
const { PL, PR } = this._pad();
const pw = this.W - PL - PR;
const xMin = this.mu - 4.5 * this.sigma;
const xMax = this.mu + 4.5 * this.sigma;
return this._pToX(e.clientX - r.left, xMin, xMax, PL, pw);
};
cv.addEventListener('mousemove', e => { this.hx = getHx(e); this.draw(); });
cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1) { this.hx = getHx(e.touches[0]); this.draw(); }
}, { passive: false });
cv.addEventListener('touchend', () => { this.hx = null; this.draw(); });
}
}