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:
+84
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user