Files
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (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>
2026-05-23 13:58:49 +03:00

472 lines
18 KiB
JavaScript
Raw Permalink 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 (pulsing alpha when LabFX available)
const _pulseA = window.LabFX ? (0.10 + LabFX.glow.pulse(performance.now(), 3000) * 0.12) : 0.10;
const _pulseB = window.LabFX ? (0.22 + LabFX.glow.pulse(performance.now(), 3000) * 0.16) : 0.30;
const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0);
grd.addColorStop(0, `rgba(155,93,229,${_pulseA.toFixed(3)})`);
grd.addColorStop(0.5, `rgba(155,93,229,${_pulseB.toFixed(3)})`);
grd.addColorStop(1, `rgba(155,93,229,${_pulseA.toFixed(3)})`);
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);
const drawBell = () => {
// 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();
};
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, drawBell, { color: '#9B5DE5', intensity: 4 });
} else {
drawBell();
}
// μ 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(); });
}
}
/* ─── lab UI init ─────────────────────────────────── */
var ndSim = null;
let _ndPulseRaf = null;
function _openNormalDist() {
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
_simShow('sim-normaldist');
_registerSimState('normaldist', () => ndSim?.getParams(), st => ndSim?.setParams(st));
if (_embedMode) _startStateEmit('normaldist');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!ndSim) {
ndSim = new NormalDistSim(document.getElementById('normaldist-canvas'));
ndSim.onUpdate = _ndUpdateUI;
}
ndSim.fit();
ndSim.draw();
ndSim._emit();
// Pulsing loop for shade area animation
if (!_ndPulseRaf && window.LabFX) {
let _ndLast = performance.now();
const pulse = (now) => {
const dt = (now - _ndLast) / 1000; _ndLast = now;
LabFX.particles.update(dt);
if (ndSim && ndSim.shade !== 'none') ndSim.draw();
_ndPulseRaf = requestAnimationFrame(pulse);
};
_ndPulseRaf = requestAnimationFrame(pulse);
}
}));
}
let _ndSoundTs = 0;
function ndParam(name, val) {
const v = parseFloat(val);
const elId = name === 'mu' ? 'nd-mu-val' : 'nd-sigma-val';
document.getElementById(elId).textContent = v % 1 === 0 ? v : v.toFixed(1);
if (ndSim) ndSim.setParams({ [name]: v });
const now = performance.now();
if (window.LabFX && now - _ndSoundTs > 80) {
_ndSoundTs = now;
const sigma = name === 'sigma' ? v : (ndSim ? ndSim.sigma : 1);
LabFX.sound.play('tick', { pitch: 0.7 + sigma * 0.3, volume: 0.1 });
}
}
function ndShade(mode, btn) {
document.querySelectorAll('.nd-shade-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (ndSim) ndSim.setParams({ shade: mode });
}
function ndPreset(mu, sigma) {
document.getElementById('sl-nd-mu').value = mu; document.getElementById('nd-mu-val').textContent = mu;
document.getElementById('sl-nd-sigma').value = sigma; document.getElementById('nd-sigma-val').textContent = sigma;
if (ndSim) ndSim.setParams({ mu, sigma });
}
function _ndUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('ndbar-v1', info.mu);
v('ndbar-v2', info.sigma);
v('ndbar-v3', info.peak);
v('ndbar-v4', info.area);
}
/* ── graph transform ── */