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>
395 lines
15 KiB
JavaScript
395 lines
15 KiB
JavaScript
'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(); });
|
||
}
|
||
}
|