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
+64 -14
View File
@@ -121,6 +121,17 @@ class ThinLensSim {
}
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
// Lens caustics: emit dust near focal point when image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const imgX = lensX + dPrime;
if (!this._causticFrame) this._causticFrame = 0;
this._causticFrame++;
if (this._causticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
}
_drawLens(ctx, lx, ay, f) {
@@ -193,9 +204,13 @@ class ThinLensSim {
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
const _doGlow = (color, fn) => {
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
else fn();
};
// Ray 1: parallel to axis
{
_doGlow(colors[0], () => {
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
if (hasImage) {
@@ -211,10 +226,10 @@ class ThinLensSim {
ctx.setLineDash([]);
}
}
}
});
// Ray 2: through center
{
_doGlow(colors[1], () => {
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350, farY = ay + slope * 350;
@@ -225,10 +240,10 @@ class ThinLensSim {
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
}
});
// Ray 3: through F
{
_doGlow(colors[2], () => {
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f, slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
@@ -240,7 +255,7 @@ class ThinLensSim {
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
}
});
}
_extendRay(ctx, x1, y1, x2, y2, color) {
@@ -298,7 +313,7 @@ class ThinLensSim {
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@@ -622,6 +637,17 @@ class MirrorSim {
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
if (step >= 0) this._drawStepOverlay(ctx, step);
// Mirror caustics near focal point when real image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
if (!this._mCausticFrame) this._mCausticFrame = 0;
this._mCausticFrame++;
if (this._mCausticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
}
_drawGrid(ctx) {
@@ -718,6 +744,7 @@ class MirrorSim {
_oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
ctx.save(); ctx.globalAlpha = alpha;
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
if (!hasImg) { ctx.restore(); return; }
@@ -1062,7 +1089,7 @@ class MirrorSim {
this.draw(); this._emit();
} else if (!this._photonRaf && !this._playing) { this.draw(); }
});
window.addEventListener('mouseup', () => { this._drag = null; });
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor='grabbing'; return; }
const {px,py}=getPos(e);
@@ -1192,6 +1219,24 @@ class RefractionSim {
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
// TIR one-shot sound
if (window.LabFX) {
if (isTIR && !this._wasTIR) {
LabFX.sound.play('spark', { volume: 0.2 });
}
this._wasTIR = isTIR;
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
if (_isBrew && !this._wasBrewster) {
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
}
this._wasBrewster = _isBrew;
LabFX.particles.update(1 / 60);
LabFX.particles.draw(ctx);
}
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
@@ -1245,11 +1290,15 @@ class RefractionSim {
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
const drawFn = () => {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
else drawFn();
}
_drawArrowhead(ctx, x, y, angle, color) {
@@ -1341,7 +1390,7 @@ class RefractionSim {
this.angle = angleFromMouse(mx, my);
this.draw(); this._emit();
};
const onUp = () => { this._drag = false; };
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@@ -1397,6 +1446,7 @@ function _obApplyState(st) {
/* Switch between modes — mirrors emSwitchMode pattern */
function obSwitchMode(mode, silent) {
if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
_obMode = mode;
/* tab button styling */