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>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 13:58:49 +03:00
parent 8b3159b529
commit 6afe928c0d
50 changed files with 2748 additions and 215 deletions
+84 -10
View File
@@ -181,6 +181,11 @@ class EMFieldSim {
this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
this._invalidateAll();
this.draw();
if (window.LabFX) {
LabFX.sound.play('spark', { pitch: 1.1 });
const col = q > 0 ? '#EF476F' : '#4CC9F0';
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
}
if (this.onUpdate) this.onUpdate(this.info());
}
@@ -191,6 +196,11 @@ class EMFieldSim {
this.sources.push({ kind, id: this._nextId++, x, y, I });
this._invalidateAll();
this.draw();
if (window.LabFX) {
LabFX.sound.play('spark', { pitch: 1.1 });
const col = I > 0 ? '#06D6E0' : '#F15BB5';
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
}
if (this.onUpdate) this.onUpdate(this.info());
}
@@ -318,6 +328,14 @@ class EMFieldSim {
if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; }
if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; }
if (window.LabFX) {
LabFX.particles.update(dt);
const v2 = Math.hypot(rod.vx, rod.vy);
if (v2 > 30) {
LabFX.particles.emit({ ctx: this.ctx, x: rod.x1, y: rod.y1, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
LabFX.particles.emit({ ctx: this.ctx, x: rod.x2, y: rod.y2, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
}
}
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
rod._raf = requestAnimationFrame(() => this._tickRod());
@@ -389,8 +407,11 @@ class EMFieldSim {
_tickParticle() {
if (!this.particleOn || !this._particle) return;
const now = performance.now();
const rawDt = Math.min((now - this._pLast) * 0.001, 0.05); // seconds
const dt = Math.min((now - this._pLast) * 0.06, 2.5);
this._pLast = now;
if (!this._pFrame) this._pFrame = 0;
this._pFrame++;
const p = this._particle;
@@ -433,6 +454,12 @@ class EMFieldSim {
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > 350) p.trail.shift();
if (window.LabFX && this._pFrame % 2 === 0) {
const trailCol = p.q > 0 ? '#FFD166' : '#4CC9F0';
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 1, color: trailCol, life: 400, shape: 'dot', size: 2, glow: true });
}
if (window.LabFX) LabFX.particles.update(rawDt);
this.draw();
this._pRaf = requestAnimationFrame(() => this._tickParticle());
}
@@ -792,6 +819,11 @@ class EMFieldSim {
if (this._gauss._dragging) {
this._gauss.x = p.x; this._gauss.y = p.y;
const now2 = performance.now();
if (!this._gaussHapticT || now2 - this._gaussHapticT > 100) {
this._gaussHapticT = now2;
if (window.LabFX) LabFX.haptic(5);
}
this.draw(); return;
}
@@ -970,6 +1002,28 @@ class EMFieldSim {
/* sources */
this._drawSources(ctx);
/* high-field lightning FX */
if (window.LabFX && this.sources.length >= 2) {
const now3 = performance.now();
if (!this._lightningT) this._lightningT = 0;
if (now3 - this._lightningT > 500) {
// sample max field at center
const cx = this.W / 2, cy = this.H / 2;
const em = this._eField(cx, cy), bm = this._bField(cx, cy);
const maxField = Math.max(em.mag, bm.mag);
if (maxField > 30000) {
this._lightningT = now3;
const i1 = Math.floor(Math.random() * this.sources.length);
let i2 = Math.floor(Math.random() * this.sources.length);
if (i2 === i1) i2 = (i1 + 1) % this.sources.length;
const s1 = this.sources[i1], s2 = this.sources[i2];
const lx = (s1.x + s2.x) / 2, ly = (s1.y + s2.y) / 2;
LabFX.particles.emit({ ctx: this.ctx, x: lx, y: ly, count: 5, color: '#FFFFFF', speed: 30, spread: Math.PI * 2, life: 80, shape: 'spark', glow: true });
LabFX.sound.play('spark', { volume: 0.2 });
}
}
}
/* cursor readout */
if (this._mousePos) {
if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx);
@@ -977,6 +1031,8 @@ class EMFieldSim {
}
if (this.sources.length === 0) this._drawHint(ctx);
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── grid ── */
@@ -1125,7 +1181,9 @@ class EMFieldSim {
/* ── E vectors ── */
_drawVectorsE(ctx) {
const GRID = 45;
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
ctx.save();
ctx.globalAlpha = _pulse;
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
@@ -1195,10 +1253,17 @@ class EMFieldSim {
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.strokeStyle = grad;
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
const drawStroke = () => {
ctx.strokeStyle = grad;
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
};
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, drawStroke, { color: '#06D6E0', intensity: 6 });
} else {
drawStroke();
}
}
}
ctx.restore();
@@ -1266,11 +1331,19 @@ class EMFieldSim {
pts.push({ x, y });
}
if (pts.length < 3) continue;
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
ctx.stroke();
const drawBLine = () => {
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
ctx.stroke();
};
if (window.LabFX) {
const bGlowCol = src.I > 0 ? '#06D6E0' : '#9B5DE5';
LabFX.glow.drawGlow(ctx, drawBLine, { color: bGlowCol, intensity: 6 });
} else {
drawBLine();
}
this._drawBArrows(ctx, pts, col);
}
}
@@ -1298,6 +1371,7 @@ class EMFieldSim {
/* ── B vector field ── */
_drawVectorsB(ctx) {
const step = 42;
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
ctx.save();
for (let px = step*0.5; px < this.W; px += step) {
for (let py = step*0.5; py < this.H; py += step) {
@@ -1306,7 +1380,7 @@ class EMFieldSim {
const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4);
const len = 8 + t * 14;
const nx = bx / mag, ny = by / mag;
const alp = 0.28 + t * 0.6;
const alp = (0.28 + t * 0.6) * _pulse;
ctx.save();
ctx.translate(px, py); ctx.rotate(Math.atan2(ny, nx));
ctx.globalAlpha = alp;