'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(); }); } }