diff --git a/frontend/css/lab.css b/frontend/css/lab.css
index e049eb1..4ebe575 100644
--- a/frontend/css/lab.css
+++ b/frontend/css/lab.css
@@ -1111,3 +1111,369 @@
background: rgba(155,93,229,0.18);
font-weight: 700;
}
+
+/* ════════════════════════════════════════════════════════════════════════════
+ UX MICRO-INTERACTIONS — appended block
+ All additions are scoped to lab.html UI; individual sim JS files untouched.
+ ════════════════════════════════════════════════════════════════════════════ */
+
+/* ── 1. BUTTON STATES ────────────────────────────────────────────────────────
+ Snappy hover/active/disabled for all interactive controls in the lab shell.
+ Uses cubic-bezier(0.16,1,0.3,1) — fast-out-slow-in for a spring-like feel.
+ ─────────────────────────────────────────────────────────────────────────── */
+.zoom-btn,
+.proj-preset-chip,
+.geo-tool-btn,
+.sb-tool-btn {
+ transition: transform .12s cubic-bezier(0.16,1,0.3,1),
+ filter .12s cubic-bezier(0.16,1,0.3,1),
+ border-color .12s, color .12s, background .12s,
+ box-shadow .12s;
+}
+.zoom-btn:hover,
+.proj-preset-chip:hover,
+.geo-tool-btn:hover,
+.sb-tool-btn:hover {
+ transform: scale(1.025);
+ filter: brightness(1.08);
+}
+.zoom-btn:active,
+.proj-preset-chip:active,
+.geo-tool-btn:active,
+.sb-tool-btn:active {
+ transform: scale(0.96);
+ filter: brightness(0.92);
+}
+.zoom-btn:disabled,
+.proj-preset-chip:disabled,
+.geo-tool-btn:disabled,
+.sb-tool-btn:disabled,
+.zoom-btn[disabled],
+.proj-preset-chip[disabled],
+.geo-tool-btn[disabled],
+.sb-tool-btn[disabled] {
+ opacity: .45;
+ filter: grayscale(.6);
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* .sim-card already has transition; add scale micro-interaction */
+.sim-card:not(.soon):hover { transform: translateY(-2px) scale(1.012); }
+.sim-card:not(.soon):active { transform: scale(0.985); filter: brightness(0.97); }
+
+/* ── 2. SLIDER POLISH ────────────────────────────────────────────────────────
+ Custom track + thumb for all range inputs inside .proj-panel.
+ Track: 4px, filled-gradient from left to thumb position via background-size trick.
+ Thumb: 16px circle, cyan/violet, shadow, scale on hover/active.
+ ─────────────────────────────────────────────────────────────────────────── */
+.proj-panel input[type=range],
+.param-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 4px;
+ background: linear-gradient(
+ to right,
+ var(--violet) 0%,
+ var(--violet) var(--sl-pct, 50%),
+ rgba(255,255,255,.15) var(--sl-pct, 50%),
+ rgba(255,255,255,.15) 100%
+ );
+ outline: none;
+ cursor: pointer;
+ transition: background .12s;
+}
+.proj-panel input[type=range]::-webkit-slider-thumb,
+.param-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px; height: 16px;
+ border-radius: 50%;
+ background: var(--violet);
+ box-shadow: 0 0 0 3px rgba(155,93,229,.25), 0 2px 6px rgba(0,0,0,.35);
+ cursor: pointer;
+ transition: transform .12s cubic-bezier(0.16,1,0.3,1),
+ box-shadow .12s;
+}
+.proj-panel input[type=range]:hover::-webkit-slider-thumb,
+.param-slider:hover::-webkit-slider-thumb {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px rgba(155,93,229,.3), 0 3px 8px rgba(0,0,0,.4);
+}
+.proj-panel input[type=range]:active::-webkit-slider-thumb,
+.param-slider:active::-webkit-slider-thumb {
+ transform: scale(0.95);
+}
+.proj-panel input[type=range]::-moz-range-thumb,
+.param-slider::-moz-range-thumb {
+ width: 16px; height: 16px;
+ border-radius: 50%; border: none;
+ background: var(--violet);
+ box-shadow: 0 0 0 3px rgba(155,93,229,.25), 0 2px 6px rgba(0,0,0,.35);
+ cursor: pointer;
+ transition: transform .12s cubic-bezier(0.16,1,0.3,1);
+}
+.proj-panel input[type=range]:hover::-moz-range-thumb,
+.param-slider:hover::-moz-range-thumb { transform: scale(1.15); }
+.proj-panel input[type=range]:active::-moz-range-thumb,
+.param-slider:active::-moz-range-thumb { transform: scale(0.95); }
+
+/* ── 3. FOCUS RINGS (accessibility) ─────────────────────────────────────────
+ :focus-visible only — mouse clicks don't show the ring.
+ ─────────────────────────────────────────────────────────────────────────── */
+.zoom-btn:focus-visible,
+.proj-preset-chip:focus-visible,
+.geo-tool-btn:focus-visible,
+.sb-tool-btn:focus-visible,
+.sim-card:focus-visible,
+.lab-filter:focus-visible,
+.gp-btn:focus-visible,
+.sim-back:focus-visible,
+.proj-launch-btn:focus-visible,
+.proj-reset-btn:focus-visible,
+.param-slider:focus-visible,
+.proj-panel input[type=range]:focus-visible {
+ outline: 2px solid var(--violet);
+ outline-offset: 2px;
+}
+
+/* ── 4. TOOLTIP SYSTEM (CSS-only, zero JS) ───────────────────────────────────
+ Usage: add class="tt-host" data-tt="Your tooltip text" to any element.
+ Tooltip appears above the element after a 400ms delay on hover.
+ Arrow points downward. Dark overlay with white text, 8px radius.
+ ─────────────────────────────────────────────────────────────────────────── */
+.tt-host {
+ position: relative;
+}
+.tt-host::after {
+ content: attr(data-tt);
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%) translateY(4px);
+ white-space: nowrap;
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ background: rgba(20,20,30,.95);
+ color: #fff;
+ font-family: 'Manrope', sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.4;
+ padding: 5px 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(255,255,255,.1);
+ box-shadow: 0 4px 16px rgba(0,0,0,.4);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity .15s ease, transform .15s ease;
+ transition-delay: 0s;
+ z-index: 9000;
+}
+.tt-host:hover::after {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ transition-delay: 400ms;
+}
+/* arrow */
+.tt-host::before {
+ content: '';
+ position: absolute;
+ bottom: calc(100% + 3px);
+ left: 50%;
+ transform: translateX(-50%) translateY(4px);
+ border: 5px solid transparent;
+ border-top-color: rgba(20,20,30,.95);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity .15s ease, transform .15s ease;
+ transition-delay: 0s;
+ z-index: 9001;
+}
+.tt-host:hover::before {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ transition-delay: 400ms;
+}
+
+/* ── 5. SIM FADE TRANSITION ──────────────────────────────────────────────────
+ .sim-fading is toggled by lab-glue.js around openSim() calls.
+ The sim body wrap fades out (150ms) then new sim fades in (200ms).
+ ─────────────────────────────────────────────────────────────────────────── */
+#lab-sim {
+ transition: opacity .2s ease;
+}
+#lab-sim.sim-fading {
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* ── 6. MARCHING ANTS SELECTION ──────────────────────────────────────────────
+ Opt-in: apply class="sim-selected-ants" to any SVG stroke element.
+ Downstream sims (geometry, circuit, logic) can use this.
+ ─────────────────────────────────────────────────────────────────────────── */
+@keyframes ant-march {
+ to { stroke-dashoffset: -28; }
+}
+.sim-selected-ants {
+ stroke-dasharray: 4 3;
+ animation: ant-march 800ms linear infinite;
+}
+
+/* ── 7. LOADING SKELETON ─────────────────────────────────────────────────────
+ Opt-in: apply .sim-loading-skel to any container while a sim initialises.
+ Shimmer moves left-to-right. Remove the class when the sim is ready.
+ ─────────────────────────────────────────────────────────────────────────── */
+@keyframes skel-shimmer {
+ 0% { background-position: -400px 0; }
+ 100% { background-position: 400px 0; }
+}
+.sim-loading-skel {
+ background: linear-gradient(
+ 90deg,
+ rgba(255,255,255,.04) 25%,
+ rgba(255,255,255,.10) 50%,
+ rgba(255,255,255,.04) 75%
+ );
+ background-size: 800px 100%;
+ animation: skel-shimmer 1.4s ease-in-out infinite;
+ border-radius: 8px;
+ min-height: 40px;
+}
+
+/* ── 8. EMPTY STATE PATTERN ──────────────────────────────────────────────────
+ Usage:
+
+
+
Title
+
Description text
+
+
+ ─────────────────────────────────────────────────────────────────────────── */
+.sim-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--text-3);
+ opacity: .72;
+}
+.sim-empty-icon {
+ width: 48px; height: 48px;
+ stroke: var(--text-3);
+ stroke-width: 1.4;
+ fill: none;
+ opacity: .6;
+ display: block;
+}
+.sim-empty-title {
+ font-family: 'Unbounded', sans-serif;
+ font-size: 0.88rem;
+ font-weight: 800;
+ color: var(--text-2);
+}
+.sim-empty-desc {
+ font-size: 0.82rem;
+ line-height: 1.55;
+ max-width: 280px;
+}
+.sim-empty-cta {
+ margin-top: 4px;
+ padding: 8px 20px;
+ border-radius: 99px;
+ border: 1.5px solid var(--violet);
+ background: transparent;
+ color: var(--violet);
+ font-family: 'Manrope', sans-serif;
+ font-size: 0.82rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background .15s, color .15s;
+}
+.sim-empty-cta:hover {
+ background: var(--violet);
+ color: #fff;
+}
+
+/* ── 9. TOAST UPGRADE — progress bar ────────────────────────────────────────
+ The existing lsToast already slides in from right and stacks.
+ This adds a thin auto-dismiss progress bar at the bottom of each toast.
+ JS in lab-glue.js injects .ls-toast-progress with --toast-dur CSS var.
+ ─────────────────────────────────────────────────────────────────────────── */
+@keyframes toast-progress {
+ from { transform: scaleX(1); }
+ to { transform: scaleX(0); }
+}
+.ls-toast {
+ position: relative;
+ overflow: hidden;
+}
+.ls-toast-bar {
+ position: absolute;
+ bottom: 0; left: 0;
+ height: 3px;
+ width: 100%;
+ background: rgba(255,255,255,.45);
+ transform-origin: left center;
+ animation: toast-progress var(--toast-dur, 3.5s) linear forwards;
+ border-radius: 0 0 14px 14px;
+}
+
+/* ── 10. CURSOR STATES ───────────────────────────────────────────────────────
+ Default canvas cursor for projector-style sims.
+ Sims can toggle .cur-* classes on .proj-canvas-outer or canvas itself.
+ ─────────────────────────────────────────────────────────────────────────── */
+.proj-canvas-outer canvas { cursor: crosshair; }
+
+.cur-grab { cursor: grab !important; }
+.cur-grabbing { cursor: grabbing !important; }
+.cur-cell { cursor: cell !important; }
+.cur-pointer { cursor: pointer !important; }
+.cur-cross { cursor: crosshair !important; }
+.cur-move { cursor: move !important; }
+
+/* Draggable canvas elements within sims */
+[draggable="true"] canvas,
+canvas[data-draggable] { cursor: grab; }
+[draggable="true"] canvas:active,
+canvas[data-draggable]:active { cursor: grabbing; }
+
+/* ── 11. VIEW TRANSITIONS ────────────────────────────────────────────────────
+ If browser supports View Transitions API, openSim is wrapped in
+ document.startViewTransition() in lab-glue.js (see below comment).
+ ::view-transition-* rules control the cross-fade animation.
+ Fallback: manual .sim-fading class (rule 5 above).
+ ─────────────────────────────────────────────────────────────────────────── */
+@supports (view-transition-name: none) {
+ @view-transition { navigation: none; }
+
+ ::view-transition-old(sim-view),
+ ::view-transition-new(sim-view) {
+ animation-duration: 200ms;
+ animation-timing-function: ease;
+ }
+ ::view-transition-old(sim-view) {
+ animation-name: sim-vt-out;
+ }
+ ::view-transition-new(sim-view) {
+ animation-name: sim-vt-in;
+ }
+}
+
+@keyframes sim-vt-out {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes sim-vt-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* Assign view-transition-name so only the sim area morphs */
+#lab-sim {
+ view-transition-name: sim-view;
+}
diff --git a/frontend/js/labs/_fx_core.js b/frontend/js/labs/_fx_core.js
new file mode 100644
index 0000000..0ce0c0a
--- /dev/null
+++ b/frontend/js/labs/_fx_core.js
@@ -0,0 +1,90 @@
+'use strict';
+(function(global) {
+
+ /* ── namespace (cooperative init) ── */
+ global.LabFX = global.LabFX || {};
+
+ /* ─────────────────────────────────────────────
+ GLOW — Canvas 2D bloom helper
+ ───────────────────────────────────────────── */
+ global.LabFX.glow = {
+ /**
+ * Wraps drawFn with shadowBlur to create a soft glow.
+ * For layers > 1 repeats drawFn with increasing intensity.
+ */
+ drawGlow: function(ctx, drawFn, opts) {
+ opts = opts || {};
+ var color = opts.color || '#fff';
+ var intensity = opts.intensity != null ? opts.intensity : 12;
+ var layers = opts.layers || 1;
+
+ ctx.save();
+ ctx.shadowColor = color;
+ for (var i = 0; i < layers; i++) {
+ ctx.shadowBlur = intensity * (i + 1);
+ drawFn(ctx);
+ }
+ ctx.restore();
+ },
+
+ /**
+ * Returns a 0..1 value that pulses over time.
+ * @param {number} t - current timestamp (ms), e.g. performance.now()
+ * @param {number} period - full period in ms (default 1000)
+ */
+ pulse: function(t, period) {
+ period = period || 1000;
+ return (Math.sin((t / period) * Math.PI * 2) + 1) / 2;
+ }
+ };
+
+ /* ─────────────────────────────────────────────
+ HAPTIC — mobile vibration
+ ───────────────────────────────────────────── */
+ /**
+ * @param {number|Array} pattern - ms duration or [on,off,on,…]
+ */
+ global.LabFX.haptic = function(pattern) {
+ if (!navigator.vibrate) return;
+ navigator.vibrate(pattern);
+ };
+
+ /* ─────────────────────────────────────────────
+ SHAKE — animate translate on a DOM element
+ ───────────────────────────────────────────── */
+ /**
+ * @param {Element} elementOrCanvas
+ * @param {object} opts
+ * @param {number} opts.intensity - max translate px (default 5)
+ * @param {number} opts.durMs - duration ms (default 200)
+ */
+ global.LabFX.shake = function(elementOrCanvas, opts) {
+ opts = opts || {};
+ var intensity = opts.intensity != null ? opts.intensity : 5;
+ var durMs = opts.durMs != null ? opts.durMs : 200;
+
+ var el = elementOrCanvas;
+ var start = performance.now();
+ var origTransform = el.style.transform || '';
+
+ function step(now) {
+ var elapsed = now - start;
+ var progress = Math.min(elapsed / durMs, 1);
+ var decay = 1 - progress;
+ var mag = intensity * decay;
+ var dx = (Math.random() * 2 - 1) * mag;
+ var dy = (Math.random() * 2 - 1) * mag;
+
+ el.style.transform = origTransform + ' translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px)';
+
+ if (progress < 1) {
+ requestAnimationFrame(step);
+ } else {
+ el.style.transform = origTransform;
+ }
+ }
+
+ requestAnimationFrame(step);
+ };
+
+})(window);
diff --git a/frontend/js/labs/_fx_motion.js b/frontend/js/labs/_fx_motion.js
new file mode 100644
index 0000000..6507013
--- /dev/null
+++ b/frontend/js/labs/_fx_motion.js
@@ -0,0 +1,153 @@
+'use strict';
+(function(global) {
+
+ global.LabFX = global.LabFX || {};
+
+ /* ─────────────────────────────────────────────
+ EASINGS — standard Penner equations
+ ───────────────────────────────────────────── */
+ var easings = {
+ linear: function(t) { return t; },
+
+ easeInQuad: function(t) { return t * t; },
+ easeOutQuad: function(t) { return t * (2 - t); },
+ easeInOutQuad: function(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; },
+
+ easeInCubic: function(t) { return t * t * t; },
+ easeOutCubic: function(t) { var u = t - 1; return u * u * u + 1; },
+ easeInOutCubic: function(t) {
+ return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
+ },
+
+ easeInOutQuint: function(t) {
+ return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t;
+ },
+
+ easeOutBack: function(t) {
+ var c1 = 1.70158;
+ var c3 = c1 + 1;
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
+ },
+
+ easeOutElastic: function(t) {
+ if (t === 0 || t === 1) return t;
+ var c4 = (2 * Math.PI) / 3;
+ return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
+ },
+
+ easeInExpo: function(t) { return t === 0 ? 0 : Math.pow(2, 10 * t - 10); },
+ easeOutExpo: function(t) { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); }
+ };
+
+ /* ─────────────────────────────────────────────
+ TWEEN
+ ───────────────────────────────────────────── */
+ /**
+ * Tween a numeric value from `from` to `to` over `durMs` milliseconds.
+ *
+ * @param {number} from
+ * @param {number} to
+ * @param {number} durMs
+ * @param {function|string} easing - easing fn or key in LabFX.motion.easings
+ * @param {function} onUpdate - called with current value each frame
+ * @param {function} [onDone]
+ * @returns {{ cancel: function, running: boolean }}
+ */
+ function tween(from, to, durMs, easing, onUpdate, onDone) {
+ var easeFn = (typeof easing === 'function')
+ ? easing
+ : (easings[easing] || easings.linear);
+
+ var start = null;
+ var handle = { running: true, cancel: null };
+ var rafId;
+
+ function step(now) {
+ if (!handle.running) return;
+ if (start === null) start = now;
+ var elapsed = now - start;
+ var progress = Math.min(elapsed / durMs, 1);
+ var value = from + (to - from) * easeFn(progress);
+
+ onUpdate(value);
+
+ if (progress < 1) {
+ rafId = requestAnimationFrame(step);
+ } else {
+ handle.running = false;
+ if (typeof onDone === 'function') onDone();
+ }
+ }
+
+ handle.cancel = function() {
+ handle.running = false;
+ cancelAnimationFrame(rafId);
+ };
+
+ rafId = requestAnimationFrame(step);
+ return handle;
+ }
+
+ /* ─────────────────────────────────────────────
+ SPRING (critically-damped simulation)
+ ───────────────────────────────────────────── */
+ /**
+ * Returns a spring factory.
+ * Usage: LabFX.motion.spring(170, 26)(from, to, onUpdate, onDone) → handle
+ *
+ * @param {number} stiffness (default 170)
+ * @param {number} damping (default 26)
+ */
+ function springFactory(stiffness, damping) {
+ stiffness = stiffness != null ? stiffness : 170;
+ damping = damping != null ? damping : 26;
+
+ return function(from, to, onUpdate, onDone) {
+ var pos = from;
+ var vel = 0;
+ var prev = null;
+ var handle = { running: true, cancel: null };
+ var rafId;
+
+ function step(now) {
+ if (!handle.running) return;
+ if (prev === null) { prev = now; }
+ var dt = Math.min((now - prev) / 1000, 0.05); /* cap at 50ms */
+ prev = now;
+
+ var force = -stiffness * (pos - to) - damping * vel;
+ vel += force * dt;
+ pos += vel * dt;
+
+ onUpdate(pos);
+
+ var settled = Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01;
+ if (!settled) {
+ rafId = requestAnimationFrame(step);
+ } else {
+ onUpdate(to);
+ handle.running = false;
+ if (typeof onDone === 'function') onDone();
+ }
+ }
+
+ handle.cancel = function() {
+ handle.running = false;
+ cancelAnimationFrame(rafId);
+ };
+
+ rafId = requestAnimationFrame(step);
+ return handle;
+ };
+ }
+
+ /* ─────────────────────────────────────────────
+ EXPORT
+ ───────────────────────────────────────────── */
+ global.LabFX.motion = {
+ tween: tween,
+ spring: springFactory,
+ easings: easings
+ };
+
+})(window);
diff --git a/frontend/js/labs/_fx_particles.js b/frontend/js/labs/_fx_particles.js
new file mode 100644
index 0000000..1a8568c
--- /dev/null
+++ b/frontend/js/labs/_fx_particles.js
@@ -0,0 +1,225 @@
+'use strict';
+(function(global) {
+
+ global.LabFX = global.LabFX || {};
+
+ /* ── pool ── */
+ var POOL_SIZE = 1500;
+ var pool = [];
+
+ (function buildPool() {
+ for (var i = 0; i < POOL_SIZE; i++) {
+ pool.push({
+ alive: false,
+ ctx: null,
+ x: 0, y: 0,
+ vx: 0, vy: 0,
+ ax: 0, ay: 0,
+ life: 1000,
+ age: 0,
+ color: '#fff',
+ size: 3,
+ baseSize: 3,
+ shape: 'dot',
+ glow: false,
+ fade: true,
+ sizeFade: true,
+ /* spark: previous position for motion-blur line */
+ px: 0, py: 0
+ });
+ }
+ })();
+
+ /* grab a dead particle from pool; returns null if pool exhausted */
+ function acquire() {
+ for (var i = 0; i < POOL_SIZE; i++) {
+ if (!pool[i].alive) return pool[i];
+ }
+ return null;
+ }
+
+ /* ── helpers ── */
+ function pickColor(color) {
+ if (Array.isArray(color)) {
+ return color[Math.floor(Math.random() * color.length)];
+ }
+ return color;
+ }
+
+ /* ─────────────────────────────────────────────
+ PUBLIC API
+ ───────────────────────────────────────────── */
+ global.LabFX.particles = {
+
+ /**
+ * Spawn N particles at canvas coords (x, y).
+ */
+ emit: function(opts) {
+ opts = opts || {};
+ var ctx = opts.ctx;
+ var x = opts.x || 0;
+ var y = opts.y || 0;
+ var count = opts.count != null ? opts.count : 10;
+ var color = opts.color != null ? opts.color : '#fff';
+ var speed = opts.speed != null ? opts.speed : 60;
+ var spread = opts.spread != null ? opts.spread : Math.PI * 2;
+ var angle = opts.angle != null ? opts.angle : 0;
+ var gravity = opts.gravity != null ? opts.gravity : 0;
+ var life = opts.life != null ? opts.life : 1000;
+ var fade = opts.fade != null ? opts.fade : true;
+ var glow = opts.glow != null ? opts.glow : false;
+ var shape = opts.shape != null ? opts.shape : 'dot';
+ var size = opts.size != null ? opts.size : 3;
+ var sizeFade = opts.sizeFade != null ? opts.sizeFade : true;
+
+ for (var i = 0; i < count; i++) {
+ var p = acquire();
+ if (!p) break;
+
+ var dir = angle + (Math.random() - 0.5) * spread;
+ var spd = speed * (0.5 + Math.random() * 0.5);
+
+ p.alive = true;
+ p.ctx = ctx;
+ p.x = x;
+ p.y = y;
+ p.px = x;
+ p.py = y;
+ p.vx = Math.cos(dir) * spd;
+ p.vy = Math.sin(dir) * spd;
+ p.ax = 0;
+ p.ay = gravity;
+ p.life = life;
+ p.age = 0;
+ p.color = pickColor(color);
+ p.size = size * (0.7 + Math.random() * 0.6);
+ p.baseSize = p.size;
+ p.shape = shape;
+ p.glow = glow;
+ p.fade = fade;
+ p.sizeFade = sizeFade;
+ }
+ },
+
+ /**
+ * Advance all alive particles by dt seconds.
+ * Call from your sim RAF loop.
+ */
+ update: function(dt) {
+ for (var i = 0; i < POOL_SIZE; i++) {
+ var p = pool[i];
+ if (!p.alive) continue;
+
+ p.age += dt * 1000; /* convert s → ms */
+
+ if (p.age >= p.life) {
+ p.alive = false;
+ continue;
+ }
+
+ p.px = p.x;
+ p.py = p.y;
+
+ p.vx += p.ax * dt;
+ p.vy += p.ay * dt;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ }
+ },
+
+ /**
+ * Draw all live particles that belong to ctx.
+ * Call after update, inside your sim's draw/render fn.
+ */
+ draw: function(ctx) {
+ for (var i = 0; i < POOL_SIZE; i++) {
+ var p = pool[i];
+ if (!p.alive || p.ctx !== ctx) continue;
+
+ var t = p.age / p.life; /* 0..1 */
+ var alpha = p.fade ? (1 - t) : 1;
+ var sz = p.sizeFade ? p.baseSize * (1 - t * 0.8) : p.baseSize;
+
+ ctx.save();
+
+ if (p.glow) {
+ ctx.globalCompositeOperation = 'lighter';
+ }
+
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = p.color;
+ ctx.strokeStyle = p.color;
+
+ switch (p.shape) {
+ case 'spark': {
+ /* line from old to current pos — motion blur */
+ var len = Math.max(2, sz * 3);
+ var dx = p.x - p.px;
+ var dy = p.y - p.py;
+ var d = Math.sqrt(dx * dx + dy * dy) || 1;
+ var nx = dx / d * len;
+ var ny = dy / d * len;
+ ctx.lineWidth = Math.max(0.5, sz * 0.4);
+ ctx.beginPath();
+ ctx.moveTo(p.x - nx, p.y - ny);
+ ctx.lineTo(p.x, p.y);
+ ctx.stroke();
+ break;
+ }
+
+ case 'ring': {
+ var r = p.baseSize * (1 + t * 3);
+ ctx.lineWidth = Math.max(0.5, sz * 0.5);
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
+ ctx.stroke();
+ break;
+ }
+
+ case 'smoke': {
+ var smokeAlpha = alpha * 0.25;
+ ctx.globalAlpha = smokeAlpha;
+ var smokeR = sz * (2 + t * 3);
+ var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, smokeR);
+ grad.addColorStop(0, p.color);
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, smokeR, 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ }
+
+ case 'dust': {
+ ctx.globalAlpha = alpha * 0.6;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, Math.max(0.5, sz * 0.5), 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ }
+
+ case 'splash':
+ /* same as dot but gravity param makes it droop — handled in update */
+ /* fall-through */
+ case 'dot':
+ default: {
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, Math.max(0.5, sz), 0, Math.PI * 2);
+ ctx.fill();
+ break;
+ }
+ }
+
+ ctx.restore();
+ }
+ },
+
+ /** Remove all particles */
+ clear: function() {
+ for (var i = 0; i < POOL_SIZE; i++) {
+ pool[i].alive = false;
+ }
+ }
+ };
+
+})(window);
diff --git a/frontend/js/labs/_fx_sound.js b/frontend/js/labs/_fx_sound.js
new file mode 100644
index 0000000..4fa6fcf
--- /dev/null
+++ b/frontend/js/labs/_fx_sound.js
@@ -0,0 +1,332 @@
+'use strict';
+(function(global) {
+
+ global.LabFX = global.LabFX || {};
+
+ /* ── AudioContext lazy init ── */
+ var _ctx = null;
+ var _masterGain = null;
+ var _enabled = (function() {
+ var stored = localStorage.getItem('labfx-sound');
+ return stored === null ? true : stored === 'true';
+ })();
+
+ function getCtx() {
+ if (!_ctx) {
+ _ctx = new (window.AudioContext || window.webkitAudioContext)();
+ _masterGain = _ctx.createGain();
+ _masterGain.gain.value = _enabled ? 1 : 0;
+ _masterGain.connect(_ctx.destination);
+ }
+ if (_ctx.state === 'suspended') {
+ _ctx.resume();
+ }
+ return _ctx;
+ }
+
+ function master() {
+ getCtx();
+ return _masterGain;
+ }
+
+ /* ── helper: connect chain to master ── */
+ function chain(nodes) {
+ /* nodes: array of AudioNode — each is connected to the next, last → master */
+ for (var i = 0; i < nodes.length - 1; i++) {
+ nodes[i].connect(nodes[i + 1]);
+ }
+ nodes[nodes.length - 1].connect(master());
+ }
+
+ /* ── helper: create gain ── */
+ function mkGain(value) {
+ var g = getCtx().createGain();
+ g.gain.value = value;
+ return g;
+ }
+
+ /* ── helper: create oscillator ── */
+ function mkOsc(type, freq) {
+ var o = getCtx().createOscillator();
+ o.type = type;
+ o.frequency.value = freq;
+ return o;
+ }
+
+ /* ── helper: create biquad ── */
+ function mkFilter(type, freq, Q) {
+ var f = getCtx().createBiquadFilter();
+ f.type = type;
+ f.frequency.value = freq;
+ if (Q != null) f.Q.value = Q;
+ return f;
+ }
+
+ /* ── helper: noise buffer (white) ── */
+ function makeNoise(color) {
+ /* color: 'white' | 'pink' | 'brown' */
+ var ac = getCtx();
+ var length = ac.sampleRate * 2;
+ var buffer = ac.createBuffer(1, length, ac.sampleRate);
+ var data = buffer.getChannelData(0);
+ var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
+
+ for (var i = 0; i < length; i++) {
+ var white = Math.random() * 2 - 1;
+ if (color === 'pink') {
+ b0 = 0.99886 * b0 + white * 0.0555179;
+ b1 = 0.99332 * b1 + white * 0.0750759;
+ b2 = 0.96900 * b2 + white * 0.1538520;
+ b3 = 0.86650 * b3 + white * 0.3104856;
+ b4 = 0.55000 * b4 + white * 0.5329522;
+ b5 = -0.7616 * b5 - white * 0.0168980;
+ data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
+ b6 = white * 0.115926;
+ } else if (color === 'brown') {
+ b0 = (b0 + 0.02 * white) / 1.02;
+ data[i] = b0 * 3.5;
+ } else {
+ data[i] = white;
+ }
+ }
+ return buffer;
+ }
+
+ /* ── noise source ── */
+ function noiseSource(color) {
+ var ac = getCtx();
+ var src = ac.createBufferSource();
+ src.buffer = makeNoise(color || 'white');
+ src.loop = true;
+ return src;
+ }
+
+ /* ─────────────────────────────────────────────
+ SYNTH RECIPES
+ ───────────────────────────────────────────── */
+ var recipes = {
+
+ click: function(opts) {
+ /* short filtered noise burst, 30ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.6;
+ var src = noiseSource('white');
+ var flt = mkFilter('bandpass', 1200 * (opts.pitch || 1), 8);
+ var g = mkGain(vol);
+ g.gain.setValueAtTime(vol, now);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.030);
+ chain([src, flt, g]);
+ src.start(now);
+ src.stop(now + 0.035);
+ },
+
+ tick: function(opts) {
+ /* Geiger tick: brown noise + very short envelope, 15ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.4;
+ var src = noiseSource('brown');
+ var flt = mkFilter('highpass', 800, 1);
+ var g = mkGain(vol);
+ g.gain.setValueAtTime(vol, now);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.015);
+ chain([src, flt, g]);
+ src.start(now);
+ src.stop(now + 0.02);
+ },
+
+ whoosh: function(opts) {
+ /* descending swoosh: filtered noise sweep 800→200 Hz, 250ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.7;
+ var src = noiseSource('pink');
+ var flt = mkFilter('bandpass', 800 * (opts.pitch || 1), 3);
+ var g = mkGain(vol);
+ flt.frequency.setValueAtTime(800 * (opts.pitch || 1), now);
+ flt.frequency.exponentialRampToValueAtTime(200 * (opts.pitch || 1), now + 0.25);
+ g.gain.setValueAtTime(0.0001, now);
+ g.gain.linearRampToValueAtTime(vol, now + 0.03);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
+ chain([src, flt, g]);
+ src.start(now);
+ src.stop(now + 0.28);
+ },
+
+ chime: function(opts) {
+ /* bright bell: additive sines 880+1320+1760 Hz, exp decay, 600ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5);
+ var pitch = opts.pitch || 1;
+ var freqs = [880, 1320, 1760];
+ var pan = opts.pan || 0;
+
+ freqs.forEach(function(f, idx) {
+ var osc = mkOsc('sine', f * pitch);
+ var g = mkGain(vol * (0.5 / (idx + 1)));
+ var panner = ac.createStereoPanner ? ac.createStereoPanner() : null;
+ g.gain.setValueAtTime(vol * (0.5 / (idx + 1)), now);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.6);
+ if (panner) {
+ panner.pan.value = pan;
+ chain([osc, g, panner]);
+ } else {
+ chain([osc, g]);
+ }
+ osc.start(now);
+ osc.stop(now + 0.65);
+ });
+ },
+
+ fizz: function(opts) {
+ /* pink noise, 300ms, low-pass sweep */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.6;
+ var src = noiseSource('pink');
+ var flt = mkFilter('lowpass', 3000 * (opts.pitch || 1), 1);
+ var g = mkGain(vol);
+ flt.frequency.setValueAtTime(3000 * (opts.pitch || 1), now);
+ flt.frequency.exponentialRampToValueAtTime(400 * (opts.pitch || 1), now + 0.3);
+ g.gain.setValueAtTime(vol, now);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
+ chain([src, flt, g]);
+ src.start(now);
+ src.stop(now + 0.35);
+ },
+
+ spark: function(opts) {
+ /* white noise + triangle 2000Hz, 50ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.7;
+ var src = noiseSource('white');
+ var osc = mkOsc('triangle', 2000 * (opts.pitch || 1));
+ var gn = mkGain(vol * 0.5);
+ var go = mkGain(vol * 0.5);
+ var mix = mkGain(1);
+ gn.gain.setValueAtTime(vol * 0.5, now);
+ gn.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
+ go.gain.setValueAtTime(vol * 0.5, now);
+ go.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
+ src.connect(gn); gn.connect(mix);
+ osc.connect(go); go.connect(mix);
+ mix.connect(master());
+ src.start(now); osc.start(now);
+ src.stop(now + 0.06); osc.stop(now + 0.06);
+ },
+
+ bounce: function(opts) {
+ /* sine sweep 600→300 Hz, 100ms */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.6;
+ var osc = mkOsc('sine', 600 * (opts.pitch || 1));
+ var g = mkGain(vol);
+ osc.frequency.setValueAtTime(600 * (opts.pitch || 1), now);
+ osc.frequency.exponentialRampToValueAtTime(300 * (opts.pitch || 1), now + 0.1);
+ g.gain.setValueAtTime(vol, now);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.1);
+ chain([osc, g]);
+ osc.start(now);
+ osc.stop(now + 0.12);
+ },
+
+ pour: function(opts) {
+ /* filtered noise with random pitch wobble, 200ms+ */
+ var ac = getCtx();
+ var now = ac.currentTime;
+ var vol = (opts.volume || 0.5) * 0.5;
+ var src = noiseSource('pink');
+ var flt = mkFilter('bandpass', 400 * (opts.pitch || 1), 2);
+ var g = mkGain(vol);
+ /* wobble frequency */
+ var steps = 8;
+ for (var i = 0; i <= steps; i++) {
+ var t = now + (i / steps) * 0.2;
+ var freq = (350 + Math.random() * 150) * (opts.pitch || 1);
+ flt.frequency.setValueAtTime(freq, t);
+ }
+ g.gain.setValueAtTime(0.0001, now);
+ g.gain.linearRampToValueAtTime(vol, now + 0.04);
+ g.gain.setValueAtTime(vol, now + 0.18);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
+ chain([src, flt, g]);
+ src.start(now);
+ src.stop(now + 0.25);
+ }
+ };
+
+ /* ─────────────────────────────────────────────
+ DRONE — sustained sound (separate lifecycle)
+ ───────────────────────────────────────────── */
+ var _droneNodes = {};
+
+ var droneRecipes = {
+ drone: function(ac, vol) {
+ var sine = mkOsc('sine', 80);
+ var saw = mkOsc('sawtooth', 160);
+ var gs = mkGain(vol * 0.5);
+ var gw = mkGain(vol * 0.25);
+ var mix = mkGain(0.05);
+ sine.connect(gs); gs.connect(mix);
+ saw.connect(gw); gw.connect(mix);
+ mix.connect(master());
+ sine.start();
+ saw.start();
+ return { nodes: [sine, saw, gs, gw, mix] };
+ }
+ };
+
+ /* ─────────────────────────────────────────────
+ PUBLIC API
+ ───────────────────────────────────────────── */
+ global.LabFX.sound = {
+
+ play: function(name, opts) {
+ if (!_enabled) return;
+ opts = opts || {};
+ getCtx();
+ var recipe = recipes[name];
+ if (recipe) {
+ try { recipe(opts); } catch(e) { /* silent */ }
+ }
+ },
+
+ startDrone: function(name) {
+ if (_droneNodes[name]) return _droneNodes[name]; /* already running */
+ var recipe = droneRecipes[name];
+ if (!recipe) return null;
+ var ac = getCtx();
+ var vol = 0.5;
+ var handle = recipe(ac, vol);
+ _droneNodes[name] = handle;
+
+ handle.stop = function() {
+ handle.nodes.forEach(function(n) {
+ try { n.stop(); } catch(e) {}
+ try { n.disconnect(); } catch(e) {}
+ });
+ delete _droneNodes[name];
+ };
+
+ return handle;
+ },
+
+ setEnabled: function(bool) {
+ _enabled = !!bool;
+ localStorage.setItem('labfx-sound', _enabled ? 'true' : 'false');
+ if (_masterGain) {
+ _masterGain.gain.cancelScheduledValues(0);
+ _masterGain.gain.value = _enabled ? 1 : 0;
+ }
+ },
+
+ isEnabled: function() {
+ return _enabled;
+ }
+ };
+
+})(window);
diff --git a/frontend/js/labs/angrybirds.js b/frontend/js/labs/angrybirds.js
index 94d1b41..199de51 100644
--- a/frontend/js/labs/angrybirds.js
+++ b/frontend/js/labs/angrybirds.js
@@ -174,6 +174,7 @@ class AngryBirdsSim {
const tick = (ts) => {
const dt = this._lastTs ? Math.min((ts - this._lastTs) / 1000, 0.05) : 0.016;
this._lastTs = ts;
+ if (window.LabFX) LabFX.particles.update(dt);
this._update(dt);
this.draw();
this._raf = requestAnimationFrame(tick);
@@ -198,6 +199,8 @@ class AngryBirdsSim {
this._drawQueue();
this._drawHUD();
if (this.state === 'win' || this.state === 'lose') this._drawOverlay();
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
info() {
@@ -319,6 +322,8 @@ class AngryBirdsSim {
this.bird.launched = true;
this.state = 'flying';
this._emit('launch', this.bird.x, this.bird.y, this.bird.color, 8);
+ /* LabFX: launch sound */
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
if (this.onUpdate) this.onUpdate(this.info());
}
@@ -482,10 +487,28 @@ class AngryBirdsSim {
b.hp -= dmg;
this.score += Math.max(0, Math.floor(dmg * 3));
this._emit('hit', cx, cy, AB_MATS[b.mat]?.debris || '#888', 5);
+ /* LabFX: bird-block collision */
+ if (window.LabFX) {
+ LabFX.sound.play('bounce');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: cx, y: cy,
+ count: 15, color: '#FFD166', speed: 80,
+ spread: Math.PI * 2, life: 400, shape: 'spark',
+ });
+ }
if (b.hp <= 0) {
b.destroyed = true;
this.score += 500;
this._emit('destroy', b.x + b.w / 2, b.y + b.h / 2, AB_MATS[b.mat]?.debris || '#888', 15);
+ /* LabFX: block destroyed */
+ if (window.LabFX) {
+ LabFX.sound.play('fizz');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: b.x + b.w / 2, y: b.y + b.h / 2,
+ count: 25, color: AB_MATS[b.mat]?.debris || '#888',
+ speed: 100, spread: Math.PI * 2, life: 700, shape: 'splash',
+ });
+ }
}
bird.x += nx * (bird.r - dist + 1);
bird.y += ny * (bird.r - dist + 1);
@@ -519,6 +542,16 @@ class AngryBirdsSim {
p.destroyed = true;
this.score += 5000;
this._emit('destroy', p.x, p.y, '#4ade80', 20);
+ /* LabFX: pig defeated */
+ if (window.LabFX) {
+ LabFX.sound.play('chime');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: p.x, y: p.y,
+ count: 30, color: ['#06D6E0', '#FFD166', '#EF476F'],
+ speed: 130, spread: Math.PI * 2, life: 800,
+ glow: true, shape: 'spark',
+ });
+ }
} else {
this._emit('hit', p.x, p.y, '#86efac', 5);
}
diff --git a/frontend/js/labs/bohratom.js b/frontend/js/labs/bohratom.js
index b5c78cf..c3a90fe 100644
--- a/frontend/js/labs/bohratom.js
+++ b/frontend/js/labs/bohratom.js
@@ -89,6 +89,10 @@ class BohrAtomSim {
isEmission,
};
+ if (window.LabFX) {
+ LabFX.sound.play('chime', { pitch: 1.0 + (from + to) * 0.1, volume: 0.3 });
+ }
+
if (!this.playing) { this.playing = true; this._lastTs = null; this._tick(); }
this._emit();
}
@@ -222,6 +226,11 @@ class BohrAtomSim {
vx: Math.cos(pa) * 120, vy: Math.sin(pa) * 120,
color: this._trans.color, t: 0, maxT: 1.2,
});
+ if (window.LabFX) {
+ LabFX.particles.emit({ ctx: this.ctx, x: ex, y: ey, count: 6,
+ color: this._trans.color, speed: 35, spread: Math.PI * 2, angle: 0,
+ gravity: 0, life: 600, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
+ }
}
this._trans = null;
this._emit();
@@ -236,6 +245,7 @@ class BohrAtomSim {
}
this._photons = this._photons.filter(p => p.t < p.maxT);
+ if (window.LabFX) LabFX.particles.update(dt * 1000);
this.draw();
this._tick();
});
@@ -280,6 +290,7 @@ class BohrAtomSim {
this._drawEnergyDiagram(ctx, panelX, W, H - specH);
this._drawSpectrumBar(ctx, W, H, specH);
this._drawPhotons(ctx);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── atom (left 65%) ───────────────────────── */
diff --git a/frontend/js/labs/brownian.js b/frontend/js/labs/brownian.js
index 4745bb2..94d6d65 100644
--- a/frontend/js/labs/brownian.js
+++ b/frontend/js/labs/brownian.js
@@ -20,6 +20,7 @@ class BrownianSim {
this._raf = null;
this.onUpdate = null;
this._dpr = 1;
+ this._fxLastT = 0;
// v2
this._msdHistory = []; // [{step, msd}]
@@ -93,8 +94,11 @@ class BrownianSim {
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
// ── simulation ──────────────────────────────────────────────────────────────
- _loop() {
+ _loop(now) {
+ const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
+ this._fxLastT = now;
this._step(); this._step(); this._step();
+ if (window.LabFX) LabFX.particles.update(dt);
this.draw();
this._raf = requestAnimationFrame(this._loop.bind(this));
}
@@ -305,6 +309,7 @@ class BrownianSim {
// Hover tooltip
if (this._hover) this._drawBigTooltip(ctx, W, H);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawMsdChart(ctx, W, H) {
diff --git a/frontend/js/labs/celldivision.js b/frontend/js/labs/celldivision.js
index 3df2436..70699ae 100644
--- a/frontend/js/labs/celldivision.js
+++ b/frontend/js/labs/celldivision.js
@@ -110,7 +110,11 @@ class CellDivisionSim {
if (!this._raf) this._draw();
}
- setMode(mode) { this.mode = mode; this.reset(); }
+ setMode(mode) {
+ this.mode = mode;
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
+ this.reset();
+ }
setSpeed(s) { this._speed = s; }
nextPhase() {
@@ -118,6 +122,7 @@ class CellDivisionSim {
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
this._phaseT = 0;
this._particles = [];
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
this._emitUpdate();
if (!this._raf) this._draw();
}
@@ -127,6 +132,7 @@ class CellDivisionSim {
this._phaseIdx = (this._phaseIdx - 1 + phases.length) % phases.length;
this._phaseT = 0;
this._particles = [];
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
this._emitUpdate();
if (!this._raf) this._draw();
}
@@ -136,6 +142,7 @@ class CellDivisionSim {
this._phaseIdx = Math.max(0, Math.min(phases.length - 1, idx));
this._phaseT = 0;
this._particles = [];
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 });
this._emitUpdate();
if (!this._raf) this._draw();
}
@@ -254,9 +261,48 @@ class CellDivisionSim {
this._phaseT > 0.34 && this._phaseT < 0.38 && this._particles.length < 5) {
const cellR = Math.min(this.W, this.H) * 0.37;
this._spawnEnvelopeParticles(this.W / 2, this.H / 2, cellR * 0.46);
+ // dust particles from LabFX around condensing chromosomes
+ if (window.LabFX) {
+ const cx = this.W / 2, cy = this.H / 2;
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 6, color: '#9B5DE5',
+ speed: 18, spread: Math.PI * 2, angle: 0, gravity: 0, life: 700, fade: true,
+ glow: false, shape: 'dust', size: 3, sizeFade: true });
+ }
+ }
+
+ // anaphase: tick sound at start of separation + pole sparks
+ if ((phase.id === 'anaphase' || phase.id === 'anaphase1' || phase.id === 'anaphase2') &&
+ this._phaseT > 0.05 && this._phaseT < 0.10 && !this._anaphaseTickDone) {
+ this._anaphaseTickDone = true;
+ if (window.LabFX) {
+ LabFX.sound.play('tick', { pitch: 1.3 });
+ const cx = this.W / 2, cellR = Math.min(this.W, this.H) * 0.37;
+ const poleY = cellR * 0.7 * this._phaseT;
+ for (const sy of [-1, 1]) {
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: this.H / 2 + sy * poleY,
+ count: 5, color: '#FFD166', speed: 22, spread: Math.PI * 2, angle: 0,
+ gravity: 0, life: 500, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
+ }
+ }
+ }
+ if (phase.id !== 'anaphase' && phase.id !== 'anaphase1' && phase.id !== 'anaphase2') {
+ this._anaphaseTickDone = false;
}
if (this._phaseT >= 1) {
+ // cytokinesis complete
+ if (phase.id === 'cytokinesis') {
+ if (window.LabFX) {
+ LabFX.sound.play('chime');
+ const cx = this.W / 2, cy = this.H / 2;
+ const cellR = Math.min(this.W, this.H) * 0.37;
+ for (const sy of [-1, 1]) {
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy + sy * cellR * 0.5,
+ count: 10, color: '#22d399', speed: 30, spread: Math.PI * 2, angle: 0,
+ gravity: 0, life: 800, fade: true, glow: true, shape: 'ring', size: 5, sizeFade: true });
+ }
+ }
+ }
this._phaseT = 0;
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
this._particles = [];
@@ -269,6 +315,7 @@ class CellDivisionSim {
p.life -= p.decay; return p.life > 0;
});
+ if (window.LabFX) LabFX.particles.update(dt);
this._emitUpdate();
this._draw();
}
@@ -316,6 +363,7 @@ class CellDivisionSim {
this._drawOverlay(phase);
this._drawProgressBar();
this._drawHint();
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Cell / nucleus ─────────────────────────────────────────── */
diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js
index e043ec0..f5535b1 100644
--- a/frontend/js/labs/chemsandbox.js
+++ b/frontend/js/labs/chemsandbox.js
@@ -417,6 +417,13 @@ class ChemSandboxSim {
this.mixContents.push(formula);
const sub = ChemSandboxSim.SUBSTANCES[formula];
+ if (window.LabFX) {
+ LabFX.sound.play('pour');
+ const { cx, nt, nw } = this._g;
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 15, color: sub.color,
+ speed: 60, spread: 1.8, angle: Math.PI / 2, gravity: 300, life: 800, shape: 'splash' });
+ }
+
// pour animation
this._pouring = true;
this._pourColor = sub.color;
@@ -552,6 +559,33 @@ class ChemSandboxSim {
this._spawnSteam(fx.violent ? 25 : 12);
if (fx.violent) this._spawnSparks(35);
}
+
+ if (window.LabFX) {
+ const { cx, cy } = this._g;
+ if (fx.violent) {
+ // Combustion-like: flame sparks + shake
+ LabFX.sound.play('fizz');
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 20, count: 30,
+ color: '#FFA500', speed: 90, spread: 2.5, angle: -Math.PI / 2,
+ gravity: -100, life: 600, shape: 'spark', glow: true });
+ LabFX.shake(this.canvas, { intensity: 3, durMs: 300 });
+ } else if (fx.gas) {
+ // Gas evolution: fizz + rising bubbles
+ LabFX.sound.play('fizz');
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 10,
+ color: '#FFFFFF', speed: 40, spread: 1.2, angle: -Math.PI / 2,
+ gravity: -80, life: 1200, shape: 'ring' });
+ } else if (fx.precip) {
+ // Precipitate: settling dust
+ LabFX.sound.play('fizz');
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 10, count: 12,
+ color: fx.precip.c || '#888888', speed: 20, spread: 1.5, angle: Math.PI / 2,
+ gravity: 60, life: 1500, shape: 'dust' });
+ } else {
+ LabFX.sound.play('fizz');
+ }
+ }
+
this._fireInfo();
}
@@ -648,6 +682,8 @@ class ChemSandboxSim {
this._wave3 += dt * 0.88;
this._glowPulse += dt * 3.2;
+ if (window.LabFX) LabFX.particles.update(dt);
+
if (this._animPhase === 1) {
this._reacTimer += dt;
if (this._reacTimer > 5.0) this._animPhase = 2;
@@ -784,6 +820,7 @@ class ChemSandboxSim {
this._drawDragGhost();
if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint();
if (this._quizMode) this._drawQuizOverlay();
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawBackground() {
@@ -1541,6 +1578,7 @@ class ChemSandboxSim {
handleContextMenu(e) {
e.preventDefault();
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
this.reset();
}
@@ -1718,7 +1756,7 @@ class ChemSandboxSim {
}
function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
- function chemSandReset() { if (chemSandSim) { chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
+ function chemSandReset() { if (chemSandSim) { if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 }); chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }
function chemSandConcChange() {
@@ -1736,6 +1774,7 @@ class ChemSandboxSim {
if (chemSandSim.mixContents.includes(formula)) {
chemSandSim.removeFromMix(formula);
} else {
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
chemSandSim.addToMix(formula);
}
_chemSandBuildReagents(chemSandSim.filterCat);
diff --git a/frontend/js/labs/circuit.js b/frontend/js/labs/circuit.js
index eccfc1e..80ac835 100644
--- a/frontend/js/labs/circuit.js
+++ b/frontend/js/labs/circuit.js
@@ -337,6 +337,30 @@ class CircuitSim {
const speed = Math.min(Math.abs(c._I) * 0.6, 8) / (this.CELL * _compLen(c));
c._t = ((c._t || 0) + speed * dt * 60) % 1;
}
+
+ if (window.LabFX) LabFX.particles.update(dt);
+
+ // heat shimmer: emit smoke above hottest resistor every 5 frames
+ if (window.LabFX && this._heatmapOn && this._solution?.solved) {
+ if (!this._smokeFrame) this._smokeFrame = 0;
+ this._smokeFrame++;
+ if (this._smokeFrame % 5 === 0) {
+ const dissipators = this.components.filter(c => c.type === 'resistor' || c.type === 'lamp');
+ if (dissipators.length) {
+ let hottest = dissipators[0], hotP = 0;
+ for (const c of dissipators) {
+ const P = Math.abs((c._I ?? 0) ** 2 * this._compR(c));
+ if (P > hotP) { hotP = P; hottest = c; }
+ }
+ if (hotP > 0.1) {
+ const p1 = this._nodePixel(hottest.x1, hottest.y1), p2 = this._nodePixel(hottest.x2, hottest.y2);
+ const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
+ LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my - 10, count: 1, color: 'rgba(255,180,100,0.15)', speed: 8, spread: 0.5, angle: -Math.PI / 2, gravity: -50, life: 1500, shape: 'smoke', size: 8, fade: true });
+ }
+ }
+ }
+ }
+
this.draw();
if (this._oscPanel && this._oscPanel.offsetParent !== null) {
this.drawOscilloscope(this._oscPanel);
@@ -467,7 +491,12 @@ class CircuitSim {
/* ─── Component draw methods ───────────────────────────────────────────── */
_drawWire(ctx, c, p1, p2, hasI) {
- this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
+ if (window.LabFX && hasI && Math.abs(c._I) > 0) {
+ const intensity = Math.min(20, 6 + Math.abs(c._I) * 2);
+ LabFX.glow.drawGlow(ctx, () => this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI), { color: '#06D6E0', intensity });
+ } else {
+ this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
+ }
}
_drawResistor(ctx, c, p1, p2, mx, my, hasI) {
@@ -769,35 +798,41 @@ class CircuitSim {
const on=(this._diodeR.get(c.id)??1e9)<1;
const col=c.ledColor||this.ledColor;
- if (on) {
- const grd=ctx.createRadialGradient(mx,my,0,mx,my,38);
- grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00');
- ctx.fillStyle=grd;
- ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill();
- }
-
- ctx.save();
- ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
- ctx.beginPath();
- ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
- ctx.fillStyle=on?col+'55':col+'18'; ctx.fill();
- ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke();
- ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
-
- if (on) {
- ctx.strokeStyle=col; ctx.lineWidth=1.2;
- for (let s=-1;s<=1;s+=2) {
- ctx.beginPath();
- ctx.moveTo(tw+3, s*5);
- ctx.lineTo(tw+11, s*5-s*6);
- ctx.stroke();
- ctx.beginPath();
- ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6);
- ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3);
- ctx.stroke();
+ const drawLEDBody = () => {
+ if (on) {
+ const grd=ctx.createRadialGradient(mx,my,0,mx,my,38);
+ grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00');
+ ctx.fillStyle=grd;
+ ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill();
}
+ ctx.save();
+ ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
+ ctx.beginPath();
+ ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
+ ctx.fillStyle=on?col+'55':col+'18'; ctx.fill();
+ ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
+ if (on) {
+ ctx.strokeStyle=col; ctx.lineWidth=1.2;
+ for (let s=-1;s<=1;s+=2) {
+ ctx.beginPath();
+ ctx.moveTo(tw+3, s*5);
+ ctx.lineTo(tw+11, s*5-s*6);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6);
+ ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3);
+ ctx.stroke();
+ }
+ }
+ ctx.restore();
+ };
+
+ if (window.LabFX && on) {
+ LabFX.glow.drawGlow(ctx, drawLEDBody, { color: col, intensity: 18, layers: 2 });
+ } else {
+ drawLEDBody();
}
- ctx.restore();
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle=col+'cc';
ctx.textAlign='center'; ctx.textBaseline='top';
@@ -985,6 +1020,17 @@ class CircuitSim {
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('Короткое замыкание!', this.W/2, this.H/2);
ctx.textBaseline='alphabetic';
+ if (window.LabFX) {
+ const now4 = performance.now();
+ if (!this._shortFXt || now4 - this._shortFXt > 600) {
+ this._shortFXt = now4;
+ const p1=this._nodePixel(batt.x1,batt.y1), p2=this._nodePixel(batt.x2,batt.y2);
+ const sx=(p1.x+p2.x)/2, sy=(p1.y+p2.y)/2;
+ LabFX.particles.emit({ ctx, x: sx, y: sy, count: 12, color: '#EF476F', speed: 80, spread: Math.PI*2, life: 300, shape: 'spark', size: 3, glow: true });
+ LabFX.sound.play('spark');
+ LabFX.shake(this.canvas, { intensity: 5, durMs: 200 });
+ }
+ }
}
/* ─── Hint ─────────────────────────────────────────────────────────────── */
@@ -1145,6 +1191,7 @@ class CircuitSim {
if (this._drawing&&this._ghostEnd) this._drawGhost(ctx);
this._drawTooltip(ctx);
if (this.components.length===0) this._drawHint(ctx);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ─── Events ───────────────────────────────────────────────────────────── */
@@ -1196,7 +1243,7 @@ class CircuitSim {
const p=pos(e), g=snap(p), hi=hitComp(p);
if (this.addMode==='erase') {
- if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); }
+ if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); }
return;
}
if (hi>=0) {
@@ -1266,13 +1313,14 @@ class CircuitSim {
cvs.addEventListener('contextmenu', e=>{
e.preventDefault();
const p=pos(e), i=hitComp(p);
- if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); }
+ if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); }
});
cvs.addEventListener('dblclick', e=>{
const p=pos(e), i=hitComp(p);
if (i>=0&&this.components[i].type==='switch') {
this._pushHistory(); this.components[i].open=!this.components[i].open; this._solve(); this.draw();
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.5 });
}
});
@@ -1327,6 +1375,7 @@ class CircuitSim {
: undefined;
const L_value = type==='inductor' ? this.L_value : undefined;
this._add(type,x1,y1,x2,y2,value,L_value);
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 });
this._solve(); this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js
index 05c04c6..f3d02cb 100644
--- a/frontend/js/labs/collision.js
+++ b/frontend/js/labs/collision.js
@@ -188,8 +188,10 @@ class CollisionSim {
this._raf = requestAnimationFrame(ts => {
if (!this.playing) return;
if (this._lastTs === null) this._lastTs = ts;
- const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
+ const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
+ const dt = rawDt * this.speed;
+ if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt);
this.draw();
this._emit();
@@ -361,6 +363,18 @@ class CollisionSim {
this._impactPt = { x: ix, y: iy, ts: now, intensity };
+ /* LabFX: collision effects */
+ if (window.LabFX) {
+ LabFX.sound.play('bounce');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: ix, y: iy,
+ count: 12, color: '#FFF', speed: 90,
+ spread: Math.PI * 2, life: 400,
+ glow: true, shape: 'spark', size: 2,
+ });
+ LabFX.shake(this.c, { intensity: 3, durMs: 150 });
+ }
+
/* 4 expanding rings (different radii, colors, lifetimes) */
const ringDefs = [
{ life:1400, col:'255,255,255', maxR:160 },
@@ -913,6 +927,9 @@ class CollisionSim {
if (this._hoverBall) {
this._drawBallTooltip(ctx, this._hoverBall, W, H);
}
+
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── hover inspector ── */
diff --git a/frontend/js/labs/crystal.js b/frontend/js/labs/crystal.js
index 1cc8cf7..e87b333 100644
--- a/frontend/js/labs/crystal.js
+++ b/frontend/js/labs/crystal.js
@@ -88,6 +88,7 @@ class CrystalSim {
/* ── public ── */
setLattice(type) {
this._lattice = type;
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.3 });
this._buildLattice(type);
}
diff --git a/frontend/js/labs/diffusion.js b/frontend/js/labs/diffusion.js
index c89f403..5a87846 100644
--- a/frontend/js/labs/diffusion.js
+++ b/frontend/js/labs/diffusion.js
@@ -24,6 +24,10 @@ class DiffusionSim {
this._poreH = 40; // gap height in pixels
this._heatmap = null; // cached density heatmap
this._hmTick = 0;
+
+ // LabFX
+ this._fxLastT = 0;
+ this._fxEquilDone = false; // equilibrium chime played once
}
// ── public API ──────────────────────────────────────────────────────────────
@@ -77,6 +81,8 @@ class DiffusionSim {
} else {
this.partitionOn = !this.partitionOn;
}
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
+ this._fxEquilDone = false; // allow equilibrium chime again after partition change
}
togglePore() {
@@ -103,8 +109,11 @@ class DiffusionSim {
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
// ── simulation ──────────────────────────────────────────────────────────────
- _loop() {
+ _loop(now) {
+ const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
+ this._fxLastT = now;
this._step(); this._step();
+ if (window.LabFX) LabFX.particles.update(dt);
this.draw();
this._raf = requestAnimationFrame(this._loop.bind(this));
}
@@ -189,6 +198,15 @@ class DiffusionSim {
this._steps++;
if (this._steps % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
+
+ // Equilibrium detection: mixed >= 45% and partition is off
+ if (!this.partitionOn && !this._fxEquilDone && this._steps % 60 === 0 && window.LabFX) {
+ const info = this.info();
+ if (+info.mixed >= 45) {
+ this._fxEquilDone = true;
+ LabFX.sound.play('chime', { pitch: 1.0, volume: 0.3 });
+ }
+ }
}
_updateHeatmap() {
@@ -263,6 +281,7 @@ class DiffusionSim {
// Stats overlay (top-left)
this._drawStats(ctx);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawHeatmap(ctx) {
diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js
index c6133fd..6f469a2 100644
--- a/frontend/js/labs/electrolysis.js
+++ b/frontend/js/labs/electrolysis.js
@@ -207,8 +207,10 @@ class ElectrolysisSim {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this._lastTs) this._lastTs = ts;
- const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
+ const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
+ const dt = rawDt * this.speed;
this._lastTs = ts;
+ if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt); this.draw(); this._emit(); this._tick();
});
}
@@ -231,6 +233,9 @@ class ElectrolysisSim {
// Ion drift + thermal jitter
const drift = I * 0.45;
+ this._fxIonTrailAcc = (this._fxIonTrailAcc || 0) + dt;
+ const doTrail = this._fxIonTrailAcc >= 0.1;
+ if (doTrail) this._fxIonTrailAcc = 0;
for (const ion of this._ions) {
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18;
ion.vy += (Math.random() - 0.5) * 0.14;
@@ -239,6 +244,12 @@ class ElectrolysisSim {
ion.x += ion.vx; ion.y += ion.vy;
ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x));
ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y));
+ // LabFX: ion trail dot
+ if (window.LabFX && doTrail && Math.random() < 0.3) {
+ LabFX.particles.emit({ ctx: this.ctx, x: ion.x, y: ion.y, count: 1,
+ color: '#FFD166', speed: 4, spread: 3.14, angle: 0,
+ gravity: 0, life: 300, shape: 'dot', glow: true });
+ }
}
// Ions reaching electrodes → discharge + bubbles
@@ -253,6 +264,14 @@ class ElectrolysisSim {
elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4,
elec.cathode.y + Math.random() * elec.cathode.h,
el.cathodeBubColor);
+ // LabFX: rising ring bubble from cathode
+ if (window.LabFX) {
+ LabFX.particles.emit({ ctx: this.ctx,
+ x: elec.cathode.x + elec.cathode.w + 3,
+ y: elec.cathode.y + Math.random() * elec.cathode.h,
+ count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
+ gravity: -60, life: 1500, shape: 'ring' });
+ }
}
}
if (ion.charge < 0 && ion.x >= elec.anode.x - 5) {
@@ -263,9 +282,25 @@ class ElectrolysisSim {
elec.anode.x - 2 - Math.random() * 4,
elec.anode.y + Math.random() * elec.anode.h,
el.anodeBubColor);
+ // LabFX: rising ring bubble from anode
+ if (window.LabFX) {
+ LabFX.particles.emit({ ctx: this.ctx,
+ x: elec.anode.x - 3,
+ y: elec.anode.y + Math.random() * elec.anode.h,
+ count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
+ gravity: -60, life: 1500, shape: 'ring' });
+ }
}
}
}
+ // Periodic fizz sound when current is flowing
+ if (window.LabFX && I > 0.01) {
+ this._fxFizzAcc = (this._fxFizzAcc || 0) + dt;
+ if (this._fxFizzAcc >= 2.0) {
+ this._fxFizzAcc = 0;
+ LabFX.sound.play('fizz', { volume: 0.2 });
+ }
+ }
this._ions = this._ions.filter((_, i) => !rm.has(i));
// Replenish ions to keep count ~15 each
@@ -308,6 +343,7 @@ class ElectrolysisSim {
this._drawIons();
this._drawLabels();
this._drawInfoPanel();
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
_drawCellBody() {
@@ -559,7 +595,10 @@ if (typeof module !== 'undefined') module.exports = ElectrolysisSim;
function elecParam(name, val) {
const v = parseFloat(val);
- if (name === 'voltage') document.getElementById('elec-V-val').textContent = v;
+ if (name === 'voltage') {
+ document.getElementById('elec-V-val').textContent = v;
+ if (window.LabFX) LabFX.sound.play('spark', { volume: 0.3 });
+ }
if (elecSim) elecSim.setParams({ [name]: v });
}
diff --git a/frontend/js/labs/emfield.js b/frontend/js/labs/emfield.js
index 5f7a9b7..63704ef 100644
--- a/frontend/js/labs/emfield.js
+++ b/frontend/js/labs/emfield.js
@@ -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;
diff --git a/frontend/js/labs/equilibrium.js b/frontend/js/labs/equilibrium.js
index 99dcc2f..f45652f 100644
--- a/frontend/js/labs/equilibrium.js
+++ b/frontend/js/labs/equilibrium.js
@@ -127,7 +127,9 @@ class EquilibriumSim {
_tick() {
if (!this.playing) return;
- this._raf = requestAnimationFrame(() => {
+ this._raf = requestAnimationFrame((ts) => {
+ const dt = 0.016; // ~60fps fixed step for LabFX
+ if (window.LabFX) LabFX.particles.update(dt);
for (let i = 0; i < 3; i++) this._step();
this.draw();
this._tick();
@@ -256,7 +258,25 @@ class EquilibriumSim {
}
}
- if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
+ if (toRemove.size) {
+ // LabFX: throttled tick sound + spark on each collision
+ if (window.LabFX && toRemove.size > 0) {
+ const now = performance.now();
+ if (!this._lastFxTick || now - this._lastFxTick > 200) {
+ this._lastFxTick = now;
+ LabFX.sound.play('tick', { volume: 0.1 });
+ }
+ for (const id of toRemove) {
+ const hit = this.particles.find(p => p.id === id);
+ if (hit) {
+ LabFX.particles.emit({ ctx: this.ctx, x: hit.x, y: hit.y, count: 2,
+ color: '#FFD166', speed: 30, spread: 3.14, angle: 0,
+ gravity: 0, life: 250, shape: 'spark', glow: true });
+ }
+ }
+ }
+ this.particles = this.particles.filter(p => !toRemove.has(p.id));
+ }
for (const p of toAdd) this.particles.push(p);
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
@@ -328,6 +348,8 @@ class EquilibriumSim {
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center';
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawParticle(ctx, p) {
@@ -497,10 +519,12 @@ if (typeof module !== 'undefined') module.exports = EquilibriumSim;
const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
+ if (name === 'T' && window.LabFX) LabFX.sound.play('whoosh', { pitch: v / 300, volume: 0.3 });
if (eqSim) eqSim.setParams({ [name]: v });
}
function eqPreset(name) {
+ if (window.LabFX) LabFX.sound.play('pour', { volume: 0.3 });
if (eqSim) { eqSim.preset(name); eqSim.play(); }
const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
const d = defs[name] || defs.default;
diff --git a/frontend/js/labs/flask.js b/frontend/js/labs/flask.js
index 0821ac3..05bb67f 100644
--- a/frontend/js/labs/flask.js
+++ b/frontend/js/labs/flask.js
@@ -185,6 +185,15 @@ class FlaskSim {
this._h2 = 0;
this._bubTmr = 0;
this._boomCD = 0;
+
+ if (window.LabFX) {
+ LabFX.sound.play('bounce', { pitch: 0.6 });
+ // Brief delay then fizz as metal hits acid
+ setTimeout(() => { if (window.LabFX) LabFX.sound.play('fizz'); }, 350);
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 5, count: 6,
+ color: '#FFFFFF', speed: 25, spread: 1.6, angle: -Math.PI / 2,
+ gravity: -60, life: 1500, shape: 'ring' });
+ }
}
reset() {
@@ -210,7 +219,7 @@ class FlaskSim {
this.draw();
}
- togglePause() { this._paused = !this._paused; }
+ togglePause() { this._paused = !this._paused; if (window.LabFX) LabFX.sound.play('click'); }
toggleFlame() { this._flameOn = !this._flameOn; }
setMetal(t) { this.metalType = t; }
setAcid(t) { this.acidType = t; this.reset(); }
@@ -230,6 +239,7 @@ class FlaskSim {
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
+ if (window.LabFX) LabFX.particles.update(dt);
if (!this._paused) {
this._wave += dt * 1.7;
this._wave2 += dt * 2.3;
@@ -503,6 +513,12 @@ class FlaskSim {
r: 3 + Math.random() * 5, col: i < 30 ? '#FFD166' : '#FF6B35', life: 1,
});
}
+ if (window.LabFX) {
+ LabFX.sound.play('whoosh', { pitch: 1.5 });
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 20,
+ color: '#FFA500', speed: 80, spread: 2.0, angle: -Math.PI / 2,
+ gravity: -100, life: 300, shape: 'spark', glow: true });
+ }
}
/* ════════════════════════════════════════════════════════════════
@@ -549,6 +565,7 @@ class FlaskSim {
this._drawH2Bar(ctx);
this._drawInfoPanel(ctx);
if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── Тень/отражение колбы на столе ── */
diff --git a/frontend/js/labs/forcesandbox.js b/frontend/js/labs/forcesandbox.js
index c65f4e6..2856cdc 100644
--- a/frontend/js/labs/forcesandbox.js
+++ b/frontend/js/labs/forcesandbox.js
@@ -178,6 +178,8 @@ class ForceSandboxSim {
forces: [],
};
this.bodies.push(body);
+ /* LabFX: spawn sound */
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
return body;
}
@@ -602,10 +604,11 @@ class ForceSandboxSim {
/* ── Tick ─────────────────────────────────────────────────── */
_tick(now) {
- let dt = Math.min((now - this._last) / 1000, 0.05);
+ let rawDt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
+ if (window.LabFX) LabFX.particles.update(rawDt);
if (this._paused) { this.draw(); return; }
- dt *= this.timeScale;
+ const dt = rawDt * this.timeScale;
this._simTime += dt;
this._step(dt);
this.draw();
@@ -1183,6 +1186,15 @@ class ForceSandboxSim {
if (!a.pinned) this._applyImpulse(a, -J, nx, ny, rAx, rAy);
if (!b.pinned) this._applyImpulse(b, J, nx, ny, rBx, rBy);
const keAfter = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy);
+ /* LabFX: body collision */
+ if (window.LabFX && J > 0.5) {
+ LabFX.sound.play('bounce');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: cx, y: cy,
+ count: 8, color: '#FFF', speed: 60,
+ spread: Math.PI * 2, life: 300, shape: 'spark', glow: true,
+ });
+ }
this._energyLoss += Math.max(0, keBefore - keAfter) / (S * S);
// Трение между телами
@@ -1393,7 +1405,11 @@ class ForceSandboxSim {
if (this.ramp) this._drawRamp(ctx);
if (this.showTrail) this._drawTrails(ctx);
this._drawRopes(ctx);
- this._drawSprings(ctx);
+ if (window.LabFX && this.springs.length > 0) {
+ LabFX.glow.drawGlow(ctx, () => this._drawSprings(ctx), { color: '#9B5DE5', intensity: 4 });
+ } else {
+ this._drawSprings(ctx);
+ }
this._drawBodies(ctx);
if (this.showForces) this._drawForceArrows(ctx);
if (this.showVelocity) this._drawVelocities(ctx);
@@ -1402,6 +1418,8 @@ class ForceSandboxSim {
if (this.showEnergy) this._drawEnergyBar(ctx);
if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx);
if (this.bodies.length === 0) this._drawHint(ctx);
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
_drawBg(ctx, W, H) {
diff --git a/frontend/js/labs/gas.js b/frontend/js/labs/gas.js
index 4f080c0..80f26fb 100644
--- a/frontend/js/labs/gas.js
+++ b/frontend/js/labs/gas.js
@@ -24,6 +24,10 @@ class GasSim {
this._hover = null; // hovered particle
this._pistonDrag = false;
+ // LabFX throttle
+ this._fxPressureTimer = 0;
+ this._fxLastT = 0;
+
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hover = null; this._pistonDrag = false; });
canvas.addEventListener('mousedown', e => this._onMouseDown(e));
@@ -116,9 +120,21 @@ class GasSim {
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
// ── simulation ──────────────────────────────────────────────────────────────
- _loop() {
+ _loop(now) {
+ const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
+ this._fxLastT = now;
this._step();
this._step();
+ if (window.LabFX) {
+ LabFX.particles.update(dt);
+ // throttled pressure tick sound (~every 150ms, proportional to pressure)
+ this._fxPressureTimer += dt;
+ if (this._fxPressureTimer >= 150) {
+ this._fxPressureTimer = 0;
+ const P = parseFloat(this.info().P);
+ if (P > 5) LabFX.sound.play('tick', { volume: 0.05 });
+ }
+ }
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
@@ -318,6 +334,8 @@ class GasSim {
// Histogram
this._drawHistogram(ctx, W, H);
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawPiston(ctx, pistonX, H) {
@@ -517,6 +535,7 @@ class GasSim {
}
function molReset() {
+ if (window.LabFX) LabFX.sound.play('click');
if (_molMode === 'gas' && gasSim) {
gasSim.reset();
document.getElementById('sl-gPiston').value = 100;
@@ -620,6 +639,10 @@ class GasSim {
function statesPreset(t) {
document.getElementById('sl-stT').value = Math.round(t * 100);
document.getElementById('st-T').textContent = t.toFixed(2);
+ if (window.LabFX) {
+ const stateIdx = t < 0.2 ? 0 : t < 0.5 ? 1 : 2;
+ LabFX.sound.play('whoosh', { pitch: [0.7, 1.0, 1.3][stateIdx], volume: 0.3 });
+ }
if (statesSim) statesSim.setT(t);
}
diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js
index 3763a59..1d50f56 100644
--- a/frontend/js/labs/geometry.js
+++ b/frontend/js/labs/geometry.js
@@ -644,6 +644,8 @@ class GeoSim {
if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef);
// Индикатор снапа
if (this._snapPt) this._drawSnapIndicator(ctx);
+ // LabFX particles
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawBg(ctx, W, H) {
@@ -1437,20 +1439,28 @@ class GeoSim {
_drawLocus(ctx, obj) {
const pts = obj.samples;
if (!pts || pts.length < 2) return;
- ctx.save();
- ctx.strokeStyle = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
- ctx.lineWidth = 2;
- ctx.globalAlpha = 0.65;
- ctx.setLineDash([]);
- ctx.beginPath();
- const first = this.vp.toCanvas(pts[0].x, pts[0].y);
- ctx.moveTo(first.x, first.y);
- for (let i = 1; i < pts.length; i++) {
- const p = this.vp.toCanvas(pts[i].x, pts[i].y);
- ctx.lineTo(p.x, p.y);
+ const locusColor = obj.style && obj.style.color ? obj.style.color : '#F59E0B';
+ const drawPolyline = () => {
+ ctx.save();
+ ctx.strokeStyle = locusColor;
+ ctx.lineWidth = 2;
+ ctx.globalAlpha = 0.65;
+ ctx.setLineDash([]);
+ ctx.beginPath();
+ const first = this.vp.toCanvas(pts[0].x, pts[0].y);
+ ctx.moveTo(first.x, first.y);
+ for (let i = 1; i < pts.length; i++) {
+ const p = this.vp.toCanvas(pts[i].x, pts[i].y);
+ ctx.lineTo(p.x, p.y);
+ }
+ ctx.stroke();
+ ctx.restore();
+ };
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, drawPolyline, { color: '#F59E0B', intensity: 8 });
+ } else {
+ drawPolyline();
}
- ctx.stroke();
- ctx.restore();
}
/* ── Предпросмотр (строящийся объект) ─────────────────────── */
@@ -1801,6 +1811,7 @@ class GeoSim {
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
}
break;
}
@@ -1816,6 +1827,7 @@ class GeoSim {
this._pending = [];
this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
}
break;
@@ -2777,6 +2789,12 @@ class GeoSim {
const pt = this.eng.add({ type:'point', x:m.x, y:m.y, label:this._nextLabel(),
style:{color:'#9B5DE5', size:5} });
if (this.onUpdate) this.onUpdate(this.getStats());
+ if (window.LabFX) {
+ LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
+ const cp = this.vp.toCanvas(m.x, m.y);
+ LabFX.particles.emit({ ctx: this.ctx, x: cp.x, y: cp.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 400, speed: 35, spread: Math.PI * 2, gravity: 0, glow: true });
+ if (this._fxFrames !== undefined) this._fxFrames = 60;
+ }
return pt;
}
@@ -3079,6 +3097,7 @@ class GeoSim {
document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
if (btnEl) btnEl.classList.add('active');
_geoShowHint(name);
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 });
}
const _GEO_PHASE_HINTS = {
@@ -3177,6 +3196,7 @@ class GeoSim {
const prev = hint.textContent;
hint.textContent = msg;
hint.style.color = '#f87171';
+ if (window.LabFX) LabFX.sound.play('fizz', { pitch: 0.5, volume: 0.2 });
setTimeout(() => {
hint.textContent = prev;
hint.style.color = '';
@@ -3235,6 +3255,19 @@ class GeoSim {
const el = document.getElementById('geo-tog-' + p);
if (el) el.classList.toggle('on', !!geomSim[p]);
});
+
+ // LabFX particle RAF loop
+ if (!geomSim._fxRaf && window.LabFX) {
+ let _fxLast = performance.now();
+ geomSim._fxFrames = 0;
+ const _fxLoop = (now) => {
+ const dt = (now - _fxLast) / 1000; _fxLast = now;
+ LabFX.particles.update(dt);
+ if (geomSim._fxFrames > 0) { geomSim._fxFrames--; geomSim.render(); }
+ geomSim._fxRaf = requestAnimationFrame(_fxLoop);
+ };
+ geomSim._fxRaf = requestAnimationFrame(_fxLoop);
+ }
}));
}
@@ -3655,49 +3688,62 @@ class GeoSim {
outer.appendChild(label);
setTimeout(() => label.remove(), 2400);
- // Confetti particles on canvas
if (!geomSim) return;
- const canvas = geomSim.canvas;
- const ctx = geomSim.ctx;
- const W = canvas.width, H = canvas.height;
- const particles = [];
- const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
- for (let i = 0; i < 60; i++) {
- particles.push({
- x: W / 2 + (Math.random() - 0.5) * W * 0.4,
- y: H / 2 + (Math.random() - 0.5) * H * 0.3,
- vx: (Math.random() - 0.5) * 5,
- vy: (Math.random() - 0.6) * 6,
- r: 3 + Math.random() * 4,
- color: colors[Math.floor(Math.random() * colors.length)],
- alpha: 1,
- rot: Math.random() * Math.PI * 2,
- rotV: (Math.random() - 0.5) * 0.3,
- });
- }
- let frame = 0;
- const maxFrames = 60;
- function burst() {
- if (frame >= maxFrames) { geomSim.render(); return; }
- geomSim.render();
- for (const p of particles) {
- ctx.save();
- ctx.globalAlpha = p.alpha;
- ctx.translate(p.x, p.y);
- ctx.rotate(p.rot);
- ctx.fillStyle = p.color;
- ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
- ctx.restore();
- p.x += p.vx;
- p.y += p.vy;
- p.vy += 0.18; // gravity
- p.alpha -= 1 / maxFrames;
- p.rot += p.rotV;
+ if (window.LabFX) {
+ // Migrate to LabFX particles
+ const ctx = geomSim.ctx;
+ const W = geomSim.vp.W, H = geomSim.vp.H;
+ const confettiColors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
+ confettiColors.forEach(color => {
+ LabFX.particles.emit({ ctx, x: W / 2, y: H / 2, count: 10, color, shape: 'spark', spread: Math.PI * 2, life: 1600, speed: 180, gravity: 200, glow: true });
+ });
+ LabFX.sound.play('chime');
+ LabFX.haptic([15, 30, 15, 30, 15]);
+ if (geomSim._fxFrames !== undefined) geomSim._fxFrames = 120;
+ } else {
+ // Fallback: original confetti
+ const canvas = geomSim.canvas;
+ const ctx = geomSim.ctx;
+ const W = canvas.width, H = canvas.height;
+ const particles = [];
+ const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6'];
+ for (let i = 0; i < 60; i++) {
+ particles.push({
+ x: W / 2 + (Math.random() - 0.5) * W * 0.4,
+ y: H / 2 + (Math.random() - 0.5) * H * 0.3,
+ vx: (Math.random() - 0.5) * 5,
+ vy: (Math.random() - 0.6) * 6,
+ r: 3 + Math.random() * 4,
+ color: colors[Math.floor(Math.random() * colors.length)],
+ alpha: 1,
+ rot: Math.random() * Math.PI * 2,
+ rotV: (Math.random() - 0.5) * 0.3,
+ });
+ }
+ let frame = 0;
+ const maxFrames = 60;
+ function burst() {
+ if (frame >= maxFrames) { geomSim.render(); return; }
+ geomSim.render();
+ for (const p of particles) {
+ ctx.save();
+ ctx.globalAlpha = p.alpha;
+ ctx.translate(p.x, p.y);
+ ctx.rotate(p.rot);
+ ctx.fillStyle = p.color;
+ ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6);
+ ctx.restore();
+ p.x += p.vx;
+ p.y += p.vy;
+ p.vy += 0.18;
+ p.alpha -= 1 / maxFrames;
+ p.rot += p.rotV;
+ }
+ frame++;
+ requestAnimationFrame(burst);
}
- frame++;
requestAnimationFrame(burst);
}
- requestAnimationFrame(burst);
}
diff --git a/frontend/js/labs/graph.js b/frontend/js/labs/graph.js
index 4828aa0..185aa9d 100644
--- a/frontend/js/labs/graph.js
+++ b/frontend/js/labs/graph.js
@@ -47,8 +47,10 @@ class GraphSim {
try {
const fn = this._compile(expr);
fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test
+ const wasNull = !this.fns[idx];
this.fns[idx] = { color, fn };
this.draw();
+ if (wasNull && window.LabFX) LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
return null;
} catch {
this.fns[idx] = null;
@@ -369,28 +371,36 @@ class GraphSim {
const dx = (x1 - x0) / steps;
const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units)
- c.strokeStyle = color;
- c.lineWidth = 2.5;
- c.lineJoin = 'round';
- c.beginPath();
+ const drawPath = () => {
+ c.strokeStyle = color;
+ c.lineWidth = 2.5;
+ c.lineJoin = 'round';
+ c.beginPath();
- let pen = false, pyPrev = null;
- for (let i = 0; i <= steps; i++) {
- const mx = x0 + i * dx;
- let my;
- try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
- if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
+ let pen = false, pyPrev = null;
+ for (let i = 0; i <= steps; i++) {
+ const mx = x0 + i * dx;
+ let my;
+ try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
+ if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
- // discontinuity guard
- if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
- pen = false;
+ // discontinuity guard
+ if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
+ pen = false;
+ }
+
+ const [px, py] = this._toPx(mx, my);
+ pen ? c.lineTo(px, py) : c.moveTo(px, py);
+ pen = true; pyPrev = my;
}
+ c.stroke();
+ };
- const [px, py] = this._toPx(mx, my);
- pen ? c.lineTo(px, py) : c.moveTo(px, py);
- pen = true; pyPrev = my;
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(c, drawPath, { color, intensity: 4 });
+ } else {
+ drawPath();
}
- c.stroke();
}
/* ── hover crosshair ───────────────────────── */
@@ -579,9 +589,15 @@ class GraphSim {
/* debounced formula update */
const _debounce = {};
+ let _graphSoundTs = 0;
function updateFn(idx) {
clearTimeout(_debounce[idx]);
renderPreview(idx); // instant preview
+ const now = performance.now();
+ if (window.LabFX && now - _graphSoundTs > 80) {
+ _graphSoundTs = now;
+ LabFX.sound.play('tick', { pitch: 1.0, volume: 0.1 });
+ }
_debounce[idx] = setTimeout(() => {
if (!gSim) return;
const raw = document.getElementById('fn' + idx).value;
diff --git a/frontend/js/labs/graphtransform.js b/frontend/js/labs/graphtransform.js
index afd82d7..47b57e6 100644
--- a/frontend/js/labs/graphtransform.js
+++ b/frontend/js/labs/graphtransform.js
@@ -141,7 +141,13 @@ class GraphTransformSim {
this._drawGrid(ctx, W, H);
this._drawAxes(ctx, W, H);
this._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded
- this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5);
+ }, { color: '#9B5DE5', intensity: 4 });
+ } else {
+ this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5);
+ }
this._drawEquation(ctx, W, H);
if (this.hx !== null) this._drawHover(ctx, W, H);
}
@@ -374,10 +380,16 @@ class GraphTransformSim {
}));
}
+ let _gtSoundTs = 0;
function gtParam(name, val) {
const v = parseFloat(val);
document.getElementById('gt-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
if (gtSim) gtSim.setParams({ [name]: v });
+ const now = performance.now();
+ if (window.LabFX && now - _gtSoundTs > 80) {
+ _gtSoundTs = now;
+ LabFX.sound.play('tick', { volume: 0.1 });
+ }
}
function gtBase(name, btn) {
@@ -392,6 +404,7 @@ class GraphTransformSim {
document.getElementById('sl-gt-b').value = b; document.getElementById('gt-b-val').textContent = b;
document.getElementById('sl-gt-c').value = c; document.getElementById('gt-c-val').textContent = c;
if (gtSim) gtSim.setParams({ a, k, b, c });
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
}
function _gtUpdateUI(info) {
diff --git a/frontend/js/labs/heatengine.js b/frontend/js/labs/heatengine.js
index 74a21d1..13a117e 100644
--- a/frontend/js/labs/heatengine.js
+++ b/frontend/js/labs/heatengine.js
@@ -63,8 +63,25 @@ class HeatEngineSim {
setTc(v) { this.Tc = Math.max(200, Math.min(this.Th - 10, +v)); this._recompute(); }
setCR(v) { this.cr = Math.max(2, Math.min(20, +v)); this._recompute(); }
- start() { if (!this._running) { this._running = true; this._loop(); } }
- pause() { this._running = false; cancelAnimationFrame(this._raf); }
+ start() {
+ if (!this._running) {
+ this._running = true;
+ this._lastPhase = null;
+ this._fxDroneHandle = null;
+ if (window.LabFX) {
+ this._fxDroneHandle = LabFX.sound.startDrone('drone');
+ }
+ this._loop();
+ }
+ }
+ pause() {
+ this._running = false;
+ cancelAnimationFrame(this._raf);
+ if (window.LabFX && this._fxDroneHandle) {
+ try { this._fxDroneHandle.stop(); } catch {}
+ this._fxDroneHandle = null;
+ }
+ }
stop() { this.pause(); this._t = 0; this._drawPv(); this._drawPiston(); }
step() { this._t = (this._t + this._speed * 10) % 1; this._drawPv(); this._drawPiston(); }
reset() { this.stop(); this._recompute(); }
@@ -286,11 +303,64 @@ class HeatEngineSim {
if (!this._running) return;
this._t = (this._t + this._speed) % 1;
this._updateParticles();
+ this._fxUpdate();
this._drawPv();
this._drawPiston();
this._raf = requestAnimationFrame(() => this._loop());
}
+ _fxUpdate() {
+ if (!window.LabFX) return;
+ const st = this._stateAt(this._t);
+ if (!st) return;
+
+ const pisCtx = this._pisCtx;
+ const W = this._pis.offsetWidth || 300;
+ const H = this._pis.offsetHeight || 300;
+
+ /* phase change tick */
+ if (this._lastPhase !== null && st.phase !== this._lastPhase) {
+ LabFX.sound.play('tick', { pitch: 0.8, volume: 0.2 });
+ /* BDC / TDC bounce — detect segment boundaries (u≈0) */
+ LabFX.sound.play('bounce', { pitch: 0.5, volume: 0.15 });
+ }
+ this._lastPhase = st.phase;
+
+ /* compute piston position for particle emit */
+ const ns = this._nodes;
+ const Vmin = Math.min(...ns.map(n => n.V));
+ const Vmax = Math.max(...ns.map(n => n.V));
+ const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
+ const cylX = W * 0.2, cylW = W * 0.6;
+ const cylTop = H * 0.12, cylBot = H * 0.92;
+ const cylH = cylBot - cylTop;
+ const pistonY = cylTop + cylH * (1 - Vfrac);
+ const resX = W * 0.05, resW = W * 0.12;
+
+ const isHot = st.phase === 'isotherm_hot' || st.phase === 'isochoric_hot' || st.phase === 'isobar_hot';
+ const isCold = st.phase === 'isotherm_cold' || st.phase === 'isochoric_cold' || st.phase === 'isobar_cold';
+
+ if (isHot) {
+ /* red smoke upward from hot reservoir */
+ LabFX.particles.emit({
+ ctx: pisCtx, x: resX + resW / 2, y: pistonY - 5,
+ count: 1, color: 'rgba(255,80,40,0.3)', speed: 15,
+ spread: 0.6, angle: -Math.PI / 2, gravity: -50,
+ life: 1500, shape: 'smoke', size: 6,
+ });
+ } else if (isCold) {
+ /* blue dust downward from cold reservoir */
+ LabFX.particles.emit({
+ ctx: pisCtx, x: resX + resW / 2, y: pistonY + 10,
+ count: 1, color: 'rgba(100,150,255,0.3)', speed: 10,
+ spread: 0.6, angle: Math.PI / 2, gravity: 30,
+ life: 800, shape: 'dust', size: 4,
+ });
+ }
+
+ LabFX.particles.update(this._speed);
+ }
+
/* ── interpolated state ──────────────────────────── */
_stateAt(t) {
@@ -824,6 +894,19 @@ class HeatEngineSim {
ctx.textAlign = 'center';
ctx.fillText('T = ' + Math.round(st.T) + ' K', W * 0.75, cylTop + 16);
ctx.fillText('V = ' + this._fmtSci(st.V) + ' m³', W * 0.75, cylTop + 30);
+
+ /* adiabatic shimmer on insulation lines */
+ const isAdia2 = phase === 'adiabat_exp' || phase === 'adiabat_comp';
+ if (isAdia2 && window.LabFX) {
+ const pulse = LabFX.glow.pulse(performance.now() / 1000, 0.6);
+ ctx.save();
+ ctx.globalAlpha = 0.1 + pulse * 0.25;
+ ctx.fillStyle = 'rgba(255,200,0,0.6)';
+ ctx.fillRect(resX, cylTop, resW, cylH);
+ ctx.restore();
+ }
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawHeatArrows(ctx, x0, y0, x1, col, rightward) {
diff --git a/frontend/js/labs/hydrostatics.js b/frontend/js/labs/hydrostatics.js
index c34ce58..d2e5909 100644
--- a/frontend/js/labs/hydrostatics.js
+++ b/frontend/js/labs/hydrostatics.js
@@ -86,8 +86,28 @@ class HydroSim {
this._vesselShapes = Array.from({ length: n }, (_, i) => ['rect','wide','narrow','trapezoid'][i % 4]);
this._recalcVessels(); this._notify();
}
- setValve(open) { this._valveOpen = open; this._recalcVessels(); this._notify(); }
- addLiquid() { this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05); this._recalcVessels(); }
+ setValve(open) {
+ this._valveOpen = open;
+ this._recalcVessels();
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 0.7 });
+ this._notify();
+ }
+ addLiquid() {
+ this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05);
+ this._recalcVessels();
+ if (window.LabFX) {
+ LabFX.sound.play('pour');
+ /* splash at approximate pour point (top-center of tank) */
+ const W = this.W || 600, H = this.H || 400;
+ const pourX = W * 0.27, pourY = H * 0.10;
+ LabFX.particles.emit({
+ ctx: this.ctx, x: pourX, y: pourY,
+ count: 12, color: HydroSim.LIQUIDS[this.liquidKey].color,
+ speed: 60, spread: Math.PI / 1.5, angle: Math.PI / 2,
+ gravity: 150, life: 500, shape: 'splash', size: 3,
+ });
+ }
+ }
removeLiquid() { this._liquidFrac = Math.max(0.05, this._liquidFrac - 0.05); this._recalcVessels(); }
setBodyShape(s){ this._bodyShape = s; if (this.mode === 'archimedes') this._archReset(); }
addBody() { this._archAddBody(); }
@@ -153,7 +173,10 @@ class HydroSim {
_loop(t) {
if (!this._running) return;
this._raf = requestAnimationFrame(ts => this._loop(ts));
+ const dt = this._loopLast ? Math.min((t - this._loopLast) / 1000, 0.05) : 0.016;
+ this._loopLast = t;
this._t = t;
+ if (window.LabFX) LabFX.particles.update(dt);
this._update(t);
this._draw(t);
if (t - this._lastNotify > 120) { this._lastNotify = t; this._notify(); }
@@ -182,6 +205,7 @@ class HydroSim {
case 'communicating': this._drawCommunicating(t); break;
case 'archimedes': this._drawArchimedes(t); break;
}
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ═══════════════════════════════════════════════════
@@ -727,6 +751,17 @@ class HydroSim {
mat: this.materialKey, submergedFrac: sinks ? 1 : eqFrac,
wobble: 0, volume,
});
+
+ if (window.LabFX) {
+ const liq2 = HydroSim.LIQUIDS[this.liquidKey];
+ LabFX.sound.play('bounce', { pitch: 0.5 });
+ LabFX.particles.emit({
+ ctx: this.ctx, x, y: waterlineY,
+ count: 20, color: liq2.color, speed: 80,
+ spread: Math.PI / 1.5, angle: -Math.PI / 2,
+ gravity: 200, life: 600, shape: 'splash', size: 3,
+ });
+ }
}
_archClear() { this._bodies = []; }
@@ -1410,6 +1445,7 @@ class HydroSim {
function hydroToggleSurface() {
if (!hydroSim) return;
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.4, volume: 0.3 });
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
hydroSim._stMode = next;
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
diff --git a/frontend/js/labs/ionexchange.js b/frontend/js/labs/ionexchange.js
index 5774328..436b6fb 100644
--- a/frontend/js/labs/ionexchange.js
+++ b/frontend/js/labs/ionexchange.js
@@ -163,6 +163,16 @@ class IonExSim {
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
+ if (window.LabFX) {
+ LabFX.sound.play('pour');
+ const { W, H } = this;
+ LabFX.particles.emit({ ctx: this.ctx, x: W * 0.3, y: H * 0.4, count: 10,
+ color: '#4CC9F0', speed: 40, spread: 2.0, angle: 0,
+ gravity: 120, life: 700, shape: 'splash' });
+ LabFX.particles.emit({ ctx: this.ctx, x: W * 0.7, y: H * 0.4, count: 10,
+ color: '#EF476F', speed: 40, spread: 2.0, angle: 0,
+ gravity: 120, life: 700, shape: 'splash' });
+ }
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
@@ -176,6 +186,7 @@ class IonExSim {
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
+ if (window.LabFX) LabFX.particles.update(dt);
const { W, H } = this;
const rxn = IonExSim.RXN[this.rxnId];
@@ -224,6 +235,16 @@ class IonExSim {
if (p.y >= H * 0.78 && !p.settled) {
p.y = H * 0.78; p.vy = 0; p.settled = true;
this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id });
+ if (window.LabFX) {
+ const now2 = performance.now();
+ if (!this._fxFizzLast || now2 - this._fxFizzLast > 800) {
+ this._fxFizzLast = now2;
+ LabFX.sound.play('fizz');
+ }
+ LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: H * 0.78, count: 5,
+ color: '#888888', speed: 15, spread: 1.8, angle: -Math.PI / 2,
+ gravity: 30, life: 1200, shape: 'dust' });
+ }
}
} else if (rxn.type === 'gas') {
p.vy = Math.max(p.vy - 0.08, -4);
@@ -305,6 +326,7 @@ class IonExSim {
this._drawPairs(ctx, rxn);
this._drawPrecipitate(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawTwoBeakers(ctx, W, H, rxn) {
diff --git a/frontend/js/labs/isoprocess.js b/frontend/js/labs/isoprocess.js
index a35dd84..c13f6f2 100644
--- a/frontend/js/labs/isoprocess.js
+++ b/frontend/js/labs/isoprocess.js
@@ -54,7 +54,13 @@ class IsoprocessSim {
this.W = w; this.H = h;
}
- setProcess(p) { this.process = p; this.draw(); this._emit(); }
+ setProcess(p) {
+ this.process = p;
+ if (window.LabFX) LabFX.sound.play('click');
+ this._dustFrame = 0;
+ this.draw();
+ this._emit();
+ }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
setParams({ P1, V1 } = {}) {
@@ -158,6 +164,30 @@ class IsoprocessSim {
this._drawActiveCurve(ctx);
this._drawPoints(ctx);
this._drawInfoBox(ctx);
+ /* emit dust particle along PV curve every ~5 draw calls */
+ if (window.LabFX) {
+ this._dustFrame = (this._dustFrame || 0) + 1;
+ if (this._dustFrame % 5 === 0) {
+ const { P2, V2 } = this._state2();
+ /* interpolated point at 50% of curve for dust */
+ const u = ((this._dustFrame / 5) % 10) / 10;
+ let px, py;
+ switch (this.process) {
+ case 'isothermal': { const v = this.V1 + u * (V2 - this.V1); px = this._vx(v); py = this._py(this.P1 * this.V1 / v); break; }
+ case 'isochoric': { px = this._vx(this.V1); py = this._py(this.P1 + u * (P2 - this.P1)); break; }
+ case 'isobaric': { const v2 = this.V1 + u * (V2 - this.V1); px = this._vx(v2); py = this._py(this.P1); break; }
+ case 'adiabatic': { const v3 = this.V1 + u * (V2 - this.V1); px = this._vx(v3); py = this._py(this.P1 * Math.pow(this.V1 / v3, this.gamma)); break; }
+ default: px = this._vx(this.V1); py = this._py(this.P1);
+ }
+ LabFX.particles.emit({
+ ctx, x: px, y: py,
+ count: 1, color: '#06D6E0', speed: 8,
+ spread: Math.PI * 2, angle: 0, gravity: 0,
+ life: 300, shape: 'dust', size: 2,
+ });
+ }
+ LabFX.particles.draw(ctx);
+ }
}
_drawGrid(ctx) {
@@ -488,6 +518,7 @@ class IsoprocessSim {
document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (isoSim) isoSim.setProcess(proc);
+ /* sound already emitted inside setProcess */
}
function isoGamma(g, el) {
diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js
index fe239a6..4af8297 100644
--- a/frontend/js/labs/lab-glue.js
+++ b/frontend/js/labs/lab-glue.js
@@ -966,6 +966,35 @@
}
};
+ /* ─── Sim Fade Transition + View Transitions API ─────────────────────────
+ Wraps openSim with a fade-out (150ms) → swap → fade-in (200ms) sequence.
+ If document.startViewTransition is available it is used for GPU-composited
+ cross-fade; otherwise the manual .sim-fading CSS class is toggled.
+ The hash-router wrap above runs synchronously during the transition so URL
+ updates are not delayed.
+ ──────────────────────────────────────────────────────────────────────── */
+ var _hashRouterOpenSim = openSim; // reference after hash-router wrap
+ openSim = function(id) {
+ var labSim = document.getElementById('lab-sim');
+ if (!labSim) { _hashRouterOpenSim(id); return; }
+
+ function _doSwitch() {
+ labSim.classList.add('sim-fading');
+ setTimeout(function() {
+ _hashRouterOpenSim(id);
+ labSim.classList.remove('sim-fading');
+ }, 150);
+ }
+
+ if (typeof document.startViewTransition === 'function' && !_embedMode) {
+ document.startViewTransition(function() {
+ _hashRouterOpenSim(id);
+ });
+ } else {
+ _doSwitch();
+ }
+ };
+
// Intercept closeSim to clear hash when returning to home grid
var _origCloseSim = closeSim;
closeSim = function() {
diff --git a/frontend/js/labs/logic.js b/frontend/js/labs/logic.js
index 8e75d76..6a10ef7 100644
--- a/frontend/js/labs/logic.js
+++ b/frontend/js/labs/logic.js
@@ -66,9 +66,11 @@ class LogicSim {
this._raf = null;
this._clockRaf = null;
+ this._fxLastT = 0;
this._bindEvents();
this._startClock();
+ this._startDrawLoop();
}
/* ── port pixel positions ── */
@@ -147,6 +149,7 @@ class LogicSim {
if (!def) return;
this._pushHistory();
const g = this._addGate(type, this._snap(x), this._snap(y));
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
this._propagate();
this._updatePanels();
this.draw();
@@ -204,6 +207,7 @@ class LogicSim {
if (!exists) {
this._pushHistory();
this._wires.push({ from: { gateId: this._wireStart.gateId, port: this._wireStart.port }, to: { gateId: hitP.gateId, port: hitP.port } });
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
this._propagate();
this._updatePanels();
}
@@ -219,6 +223,12 @@ class LogicSim {
if (g && (g.type === 'INPUT')) {
this._pushHistory();
g.value = g.value ? 0 : 1;
+ if (window.LabFX) {
+ LabFX.sound.play('bounce', { pitch: 1.2 });
+ LabFX.particles.emit({ ctx: this._ctx, x: g.x, y: g.y, count: 5, color: '#00ff88',
+ speed: 20, spread: Math.PI * 2, angle: 0, gravity: 0, life: 400, fade: true,
+ glow: true, shape: 'spark', size: 3, sizeFade: true });
+ }
this._propagate();
this._updatePanels();
this.draw();
@@ -252,6 +262,7 @@ class LogicSim {
this._pushHistory();
this._wires = this._wires.filter(w => w.from.gateId !== g.id && w.to.gateId !== g.id);
this._gates = this._gates.filter(gg => gg.id !== g.id);
+ if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.2 });
this._propagate();
this._updatePanels();
this.draw();
@@ -341,6 +352,7 @@ class LogicSim {
const tick = (now) => {
this._clockRaf = requestAnimationFrame(tick);
const dt = (now - last) / 1000;
+ const dtMs = now - (last || now);
last = now;
let changed = false;
this._gates.forEach(g => {
@@ -349,6 +361,7 @@ class LogicSim {
const newVal = g._phase % 1 < 0.5 ? 1 : 0;
if (newVal !== g.value) { g.value = newVal; changed = true; }
});
+ if (window.LabFX && dtMs > 0) LabFX.particles.update(dtMs);
if (changed) {
this._propagate();
this._updatePanels();
@@ -435,6 +448,8 @@ class LogicSim {
// gates
this._gates.forEach(g => this._drawGate(ctx, g));
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawWire(ctx, w) {
@@ -454,6 +469,36 @@ class LogicSim {
ctx.strokeStyle = val ? '#00ff88' : 'rgba(255,255,255,0.25)';
ctx.lineWidth = val ? 2.2 : 1.5;
ctx.stroke();
+
+ // Wire HIGH: animated dot flowing along path
+ if (val && window.LabFX) {
+ const frac = ((performance.now() * 0.001) % 1);
+ // interpolate along L-route: seg1 p1→(mx,p1.y), seg2 (mx,p1.y)→(mx,p2.y), seg3 (mx,p2.y)→p2
+ const seg1 = Math.abs(mx - p1.x);
+ const seg2 = Math.abs(p2.y - p1.y);
+ const seg3 = Math.abs(p2.x - mx);
+ const total = seg1 + seg2 + seg3 || 1;
+ const dist = frac * total;
+ let dx, dy;
+ if (dist <= seg1) {
+ dx = p1.x + (mx - p1.x) * (dist / (seg1 || 1));
+ dy = p1.y;
+ } else if (dist <= seg1 + seg2) {
+ dx = mx;
+ dy = p1.y + (p2.y - p1.y) * ((dist - seg1) / (seg2 || 1));
+ } else {
+ dx = mx + (p2.x - mx) * ((dist - seg1 - seg2) / (seg3 || 1));
+ dy = p2.y;
+ }
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(dx, dy, 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#06D6E0';
+ ctx.shadowColor = '#06D6E0';
+ ctx.shadowBlur = 8;
+ ctx.fill();
+ ctx.restore();
+ }
}
_drawGate(ctx, g) {
@@ -461,8 +506,17 @@ class LogicSim {
const hw = def.w / 2, hh = def.h / 2;
const x = g.x, y = g.y;
- // gate body
+ // OUTPUT LED glow via LabFX
const isHigh = g.value === 1;
+ if (g.type === 'OUTPUT' && isHigh && window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ ctx.beginPath();
+ ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
+ ctx.fill();
+ }, { color: '#00FF80', intensity: 18, layers: 2 });
+ }
+
+ // gate body
ctx.beginPath();
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
let fill = 'rgba(30,30,60,0.9)';
@@ -618,6 +672,7 @@ class LogicSim {
this._gates = [];
this._wires = [];
this._nextId = 1;
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.3 });
const add = (type, x, y) => this._addGate(type, x, y);
const wire = (a, ap, b, bp) => this._wires.push({ from: { gateId: a.id, port: ap }, to: { gateId: b.id, port: bp } });
@@ -762,6 +817,20 @@ class LogicSim {
this.draw();
}
+ /* ── Continuous draw loop for wire animations ── */
+ _startDrawLoop() {
+ const loop = () => {
+ this._raf = requestAnimationFrame(loop);
+ // Only redraw if any wire is HIGH (animated dot)
+ const anyHigh = this._wires.some(w => {
+ const g = this._gateById(w.from.gateId);
+ return g && g.value === 1;
+ });
+ if (anyHigh) this.draw();
+ };
+ this._raf = requestAnimationFrame(loop);
+ }
+
/* ── Destroy ── */
destroy() {
if (this._clockRaf) cancelAnimationFrame(this._clockRaf);
diff --git a/frontend/js/labs/newton.js b/frontend/js/labs/newton.js
index 308e70e..d96929f 100644
--- a/frontend/js/labs/newton.js
+++ b/frontend/js/labs/newton.js
@@ -149,8 +149,8 @@ class NewtonSim {
/* ── Публичный API ───────────────────────────────────────── */
- setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (this.onModeChange) this.onModeChange(); }
- setScene(s) { this.scene = s; this._resetAll(); }
+ setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); if (this.onModeChange) this.onModeChange(); }
+ setScene(s) { this.scene = s; this._resetAll(); if (window.LabFX) LabFX.sound.play('click'); }
setMu(v) { this.mu = v; }
setMass1(v) { this.mass1 = v; this._reset3B(); }
setMass2(v) { this.mass2 = v; this._reset3B(); }
@@ -215,6 +215,7 @@ class NewtonSim {
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
+ if (window.LabFX) LabFX.particles.update(dt);
if (!this._paused) {
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
else if (this.law === 1) this._step1B(dt);
@@ -448,6 +449,18 @@ class NewtonSim {
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6;
}
s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20);
+
+ /* LabFX: rocket flame trail at nozzle */
+ if (window.LabFX) {
+ const nozzleX = W * 0.5;
+ const nozzleY = s.ry + 22;
+ LabFX.particles.emit({
+ ctx: this.ctx, x: nozzleX, y: nozzleY,
+ count: 3, color: ['#FFD166', '#FF6B35', '#EF476F'],
+ speed: 80, spread: Math.PI / 6, angle: Math.PI / 2,
+ gravity: -50, life: 400, glow: true, shape: 'spark',
+ });
+ }
}
/* ── Мышь ─────────────────────────────────────────────────── */
@@ -497,6 +510,9 @@ class NewtonSim {
else if (this.scene === 'A') this._drawL3A(ctx);
else if (this.scene === 'B') this._drawL3B(ctx);
else this._drawL3C(ctx);
+
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Закон I — Сцена A ───────────────────────────────────── */
@@ -659,9 +675,20 @@ class NewtonSim {
this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`);
/* Сила F */
+ const _fTipX = bx + BW / 2 + 48 + this.force * 0.9;
+ const _fTipY = by;
this._arrow(ctx, bx + BW / 2, by,
- bx + BW / 2 + 48 + this.force * 0.9, by,
+ _fTipX, _fTipY,
'#EF476F', `F = ${this.force} Н`, 2.5);
+ /* LabFX: spark at force arrow tip (scene II A, running) */
+ if (window.LabFX && this._2.running && Math.random() < 0.25) {
+ LabFX.particles.emit({
+ ctx, x: _fTipX, y: _fTipY,
+ count: 5, color: '#FFD166', speed: 40,
+ spread: Math.PI / 2, angle: 0, life: 200,
+ glow: true, shape: 'spark',
+ });
+ }
/* Ускорение a */
const aLen = 32 + a1 * 5;
diff --git a/frontend/js/labs/normaldist.js b/frontend/js/labs/normaldist.js
index dc63ff5..9fbd054 100644
--- a/frontend/js/labs/normaldist.js
+++ b/frontend/js/labs/normaldist.js
@@ -190,11 +190,13 @@ class NormalDistSim {
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
- // Filled area with gradient
+ // 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,0.10)');
- grd.addColorStop(0.5, 'rgba(155,93,229,0.30)');
- grd.addColorStop(1, 'rgba(155,93,229,0.10)');
+ 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);
@@ -221,23 +223,31 @@ class NormalDistSim {
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();
+ 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)));
+ // 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();
}
- ctx.stroke();
// μ marker
const muPx = xp(this.mu);
@@ -396,6 +406,7 @@ class NormalDistSim {
/* ─── lab UI init ─────────────────────────────────── */
var ndSim = null;
+ let _ndPulseRaf = null;
function _openNormalDist() {
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
_simShow('sim-normaldist');
@@ -409,14 +420,32 @@ class NormalDistSim {
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) {
diff --git a/frontend/js/labs/opticsbench.js b/frontend/js/labs/opticsbench.js
index eb35bd2..0056e40 100644
--- a/frontend/js/labs/opticsbench.js
+++ b/frontend/js/labs/opticsbench.js
@@ -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 */
diff --git a/frontend/js/labs/orbitals.js b/frontend/js/labs/orbitals.js
index 2048934..30a0491 100644
--- a/frontend/js/labs/orbitals.js
+++ b/frontend/js/labs/orbitals.js
@@ -88,6 +88,7 @@ class OrbitalsSim {
/* ── public ── */
setMode(mode) {
this._mode = mode;
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.1, volume: 0.3 });
this._buildOrbital(mode);
}
diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js
index 4c64a01..3e05cab 100644
--- a/frontend/js/labs/pendulum.js
+++ b/frontend/js/labs/pendulum.js
@@ -82,6 +82,7 @@ class PendulumSim {
this._tSim = 0;
this._clearTrail();
this._eHistory = [];
+ if (window.LabFX) LabFX.sound.play('click');
this.draw();
this._emit();
}
@@ -116,9 +117,17 @@ class PendulumSim {
this._lastTs = ts;
const dt = rawDt * this.speed;
+ const prevOmega = this.omega;
this._step(dt);
this._tSim += dt;
+ if (window.LabFX) LabFX.particles.update(rawDt);
+
+ /* LabFX: tick sound at maximum extension (velocity sign flip) */
+ if (window.LabFX && prevOmega !== 0 && Math.sign(this.omega) !== Math.sign(prevOmega)) {
+ LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
+ }
+
// trail
const { bx, by } = this._bobPos();
this._trail.push({ x: bx, y: by });
@@ -195,16 +204,23 @@ class PendulumSim {
// bob
const bobR = 18;
- ctx.fillStyle = '#9B5DE5';
- ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
- ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
+ const _drawBob = () => {
+ ctx.fillStyle = '#9B5DE5';
+ ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
- // glow
- const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
- grad.addColorStop(0, 'rgba(155,93,229,0.25)');
- grad.addColorStop(1, 'rgba(155,93,229,0)');
- ctx.fillStyle = grad;
- ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
+ // glow
+ const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
+ grad.addColorStop(0, 'rgba(155,93,229,0.25)');
+ grad.addColorStop(1, 'rgba(155,93,229,0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
+ };
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, _drawBob, { color: '#9B5DE5', intensity: 8 });
+ } else {
+ _drawBob();
+ }
// angle arc
if (Math.abs(this.theta) > 0.02) {
@@ -233,6 +249,9 @@ class PendulumSim {
// energy chart
this._drawEnergyChart(ctx, W, H);
+
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawTrail(ctx) {
diff --git a/frontend/js/labs/photosynthesis.js b/frontend/js/labs/photosynthesis.js
index 37248d1..a7ea612 100644
--- a/frontend/js/labs/photosynthesis.js
+++ b/frontend/js/labs/photosynthesis.js
@@ -62,6 +62,11 @@ class PhotosynthesisSim {
this._krebsAngle = 0;
this._etcOffset = 0;
+ // LabFX throttle timers
+ this._fxPhotonThrottle = 0;
+ this._fxAtpSound = 0;
+ this._fxGlucoseSound = 0;
+
// layout (computed in fit)
this._layout = {};
@@ -109,6 +114,7 @@ class PhotosynthesisSim {
setMode(mode) {
this.mode = mode;
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
this.reset();
}
@@ -168,6 +174,7 @@ class PhotosynthesisSim {
this._updateResp(dt);
}
+ if (window.LabFX) LabFX.particles.update(dt);
this._updateParticles(dt);
this._draw();
this._emitUpdate();
@@ -186,6 +193,12 @@ class PhotosynthesisSim {
while (this._photonTimer > photonInterval) {
this._photonTimer -= photonInterval;
this._spawnPhoton();
+ // throttled photon absorption sound (~5/sec max)
+ this._fxPhotonThrottle += photonInterval;
+ if (this._fxPhotonThrottle >= 200 && window.LabFX) {
+ LabFX.sound.play('tick', { pitch: 1.8, volume: 0.1 });
+ this._fxPhotonThrottle = 0;
+ }
}
// H2O splitting (thylakoid)
@@ -202,18 +215,40 @@ class PhotosynthesisSim {
if (CO > 0.05) this._spawnCO2();
}
- // ATP from thylakoid stroma
+ // ATP from thylakoid → stroma
this._atpTimer += dt;
if (this._atpTimer > 400 / (rate + 0.1)) {
this._atpTimer = 0;
- if (L > 0.05) this._spawnATP();
+ if (L > 0.05) {
+ this._spawnATP();
+ // subtle chime on ATP formation (throttled)
+ this._fxAtpSound += 400 / (rate + 0.1);
+ if (this._fxAtpSound >= 1200 && window.LabFX) {
+ LabFX.sound.play('chime', { pitch: 1.2, volume: 0.15 });
+ this._fxAtpSound = 0;
+ }
+ }
}
// G3P output
this._glucoseTimer += dt;
if (this._glucoseTimer > 800 / (rate * CO + 0.1)) {
this._glucoseTimer = 0;
- if (L > 0.1 && CO > 0.05) this._spawnG3P();
+ if (L > 0.1 && CO > 0.05) {
+ this._spawnG3P();
+ // Calvin cycle complete — glucose sparkle + chime (throttled)
+ this._fxGlucoseSound = (this._fxGlucoseSound || 0) + 1;
+ if (this._fxGlucoseSound >= 3 && window.LabFX) {
+ this._fxGlucoseSound = 0;
+ LabFX.sound.play('chime', { pitch: 0.8, volume: 0.3 });
+ const L2 = this._layout;
+ if (L2.cx) {
+ LabFX.particles.emit({ ctx: this.ctx, x: L2.cx, y: L2.cy - (L2.thylH || 0) * 0.8,
+ count: 8, color: '#FFD166', speed: 28, spread: Math.PI * 2, angle: 0,
+ gravity: -8, life: 700, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true });
+ }
+ }
+ }
}
// Calvin cycle rotation
@@ -435,6 +470,7 @@ class PhotosynthesisSim {
this._drawParticles();
this._drawEquation();
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Chloroplast ──────────────────────────────────────────── */
diff --git a/frontend/js/labs/probability.js b/frontend/js/labs/probability.js
index 7e84e17..bef4902 100644
--- a/frontend/js/labs/probability.js
+++ b/frontend/js/labs/probability.js
@@ -178,21 +178,36 @@ class ProbabilitySim {
_tick() {
if (!this.playing) return;
- this._raf = requestAnimationFrame(() => {
+ this._raf = requestAnimationFrame(now => {
+ const dt = (now - (this._lastTickTs || now)) / 1000;
+ this._lastTickTs = now;
+ if (window.LabFX) LabFX.particles.update(dt);
+
let added = 0;
for (let i = 0; i < this.speed; i++) {
if (!this._addTrial()) break;
added++;
}
this._animT += 0.15;
- if (added > 0) this._shakeT = 1;
- else this._shakeT *= 0.9;
+ if (added > 0) {
+ this._shakeT = 1;
+ if (window.LabFX && now - (this._lastBounceSoundTs || 0) > 120) {
+ this._lastBounceSoundTs = now;
+ LabFX.sound.play('bounce', { pitch: 1.0 + Math.random() * 0.3 });
+ }
+ } else this._shakeT *= 0.9;
this.draw();
this._emit();
if (this.results.length >= this.trials) {
this.pause();
+ if (window.LabFX) {
+ LabFX.sound.play('chime');
+ const ctx = this.ctx;
+ const W = this.W, H = this.H;
+ LabFX.particles.emit({ ctx, x: W / 2, y: H * 0.4, count: 40, color: '#9B5DE5', shape: 'spark', spread: Math.PI * 2, life: 1400, speed: 120, gravity: 200, glow: true });
+ }
return;
}
this._tick();
@@ -216,6 +231,7 @@ class ProbabilitySim {
this._drawHistogram(ctx, 0, vizH, W, histH);
this._drawConvergence(ctx, 0, vizH + histH, W, convH);
this._drawStats(ctx, W, H);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── top visual: coin or dice ──────────────── */
@@ -591,6 +607,7 @@ if (typeof module !== 'undefined') module.exports = ProbabilitySim;
document.querySelectorAll('.prob-mode-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (probSim) { probSim.setParams({ mode }); probSim.reset(); probSim.play(); }
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
}
function probPreset(mode, trials) {
diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js
index cb95ac4..aff077d 100644
--- a/frontend/js/labs/projectile.js
+++ b/frontend/js/labs/projectile.js
@@ -140,6 +140,21 @@ class ProjectileSim {
this._lastTs = null;
/* reset p2 at launch so both start simultaneously */
if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
+ /* LabFX: launch effects */
+ if (window.LabFX) {
+ const _vp = this._viewParams;
+ const _H = _vp ? _vp.H : (this._ch || this.c.height);
+ const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
+ const launchX = _vp ? _PL : 54;
+ const launchY = _vp ? _H - _PB - (this.h0 || 0) * (_H - _PB - (_vp.PT || 26)) / _vp.yMax : _H - 44;
+ LabFX.sound.play('whoosh');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: launchX, y: launchY,
+ count: 18, color: '#FFD166', speed: 120,
+ spread: Math.PI / 3, angle: -Math.PI / 3,
+ life: 500, glow: true, shape: 'spark',
+ });
+ }
this._tick();
}
@@ -228,6 +243,19 @@ class ProjectileSim {
tgt.hit = true;
tgt.flashTs = performance.now();
this._emitTargets();
+ /* LabFX: target hit effects */
+ if (window.LabFX) {
+ const hx = this.tx ? this.tx(st.x) : st.x;
+ const hy = this.ty ? this.ty(st.y) : st.y;
+ LabFX.sound.play('chime');
+ LabFX.particles.emit({
+ ctx: this.ctx, x: hx, y: hy,
+ count: 40, color: ['#FFD700', '#FFA500', '#FF6B35'],
+ speed: 140, spread: Math.PI * 2, life: 900,
+ glow: true, shape: 'spark',
+ });
+ LabFX.haptic([15, 30, 15]);
+ }
}
}
}
@@ -722,6 +750,8 @@ class ProjectileSim {
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
+ if (window.LabFX) LabFX.particles.update(rawDt);
+
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
const prevT = this.t;
@@ -765,6 +795,26 @@ class ProjectileSim {
const spd = 40 + Math.random() * 80;
return { ang, spd, mx: end.x };
});
+ /* LabFX: landing effects */
+ if (window.LabFX) {
+ const _vp = this._viewParams;
+ const _W = _vp ? _vp.W : (this._cw || this.c.width);
+ const _H = _vp ? _vp.H : (this._ch || this.c.height);
+ const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
+ const _scX = _vp ? (_W - _PL - (_vp.PR || 20)) / _vp.xMax : 1;
+ const _scY = _vp ? (_H - _PB - (_vp.PT || 26)) / _vp.yMax : 1;
+ const landX = _vp ? _PL + end.x * _scX : 54;
+ const landY = _vp ? _H - _PB : _H - 44;
+ LabFX.sound.play('bounce', { pitch: 0.6 });
+ LabFX.particles.emit({
+ ctx: this.ctx, x: landX, y: landY,
+ count: 30, color: '#8B7355', speed: 80,
+ spread: Math.PI, angle: -Math.PI / 2,
+ gravity: 200, life: 1200, shape: 'splash',
+ });
+ LabFX.shake(this.c, { intensity: 4, durMs: 200 });
+ LabFX.haptic(15);
+ }
this._tickFX();
}
@@ -854,6 +904,22 @@ class ProjectileSim {
/* ── 2.5. Wind streaks ── */
if (this.wind !== 0) {
this._drawWind(ctx, PL, PT, pw, gy - PT);
+ /* LabFX: wind dust particles */
+ if (window.LabFX && this.playing) {
+ const dir = this.wind > 0 ? 1 : -1;
+ const dustCount = Math.floor(3 + Math.random() * 3);
+ for (let _d = 0; _d < dustCount; _d++) {
+ const dustX = dir > 0 ? PL : PL + pw;
+ const dustY = PT + Math.random() * (gy - PT);
+ LabFX.particles.emit({
+ ctx, x: dustX, y: dustY,
+ count: 1, color: 'rgba(255,255,255,0.3)',
+ speed: 0, spread: 0, angle: 0,
+ life: 1500, shape: 'dust', gravity: 0,
+ _vx: this.wind * 5, _vy: -10,
+ });
+ }
+ }
}
/* ── 3. Ground ── */
@@ -1336,6 +1402,9 @@ class ProjectileSim {
if (!this.playing && this._hover) {
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
}
+
+ /* LabFX: particles overlay */
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── hover inspector ── */
diff --git a/frontend/js/labs/quadratic.js b/frontend/js/labs/quadratic.js
index e70152a..8eab498 100644
--- a/frontend/js/labs/quadratic.js
+++ b/frontend/js/labs/quadratic.js
@@ -14,6 +14,7 @@ class QuadraticSim {
this.a = 1;
this.b = 0;
this.c = -1;
+ this._lastDSign = Math.sign(1 * 1 * 1 - 4 * 1 * (-1)); // track discriminant sign
/* view */
this.ox = 0;
@@ -100,6 +101,14 @@ class QuadraticSim {
_emit() {
if (this.onUpdate) this.onUpdate(this.info());
+ if (window.LabFX) {
+ const D = this.b * this.b - 4 * this.a * this.c;
+ const sign = Math.sign(D);
+ if (sign !== this._lastDSign) {
+ this._lastDSign = sign;
+ LabFX.sound.play('chime', { pitch: 1.3 });
+ }
+ }
}
/* ── coordinate transforms ─────────────────────────── */
@@ -310,10 +319,18 @@ class QuadraticSim {
const [rpx, rpy] = this._toPx(rx, 0);
if (rpx < -20 || rpx > W + 20) continue;
- // root dot
- ctx.fillStyle = '#EF476F';
- ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
- ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
+ // root dot with glow
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ ctx.fillStyle = '#EF476F';
+ ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
+ }, { color: '#F59E0B', intensity: 8 });
+ } else {
+ ctx.fillStyle = '#EF476F';
+ ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
+ }
// label
ctx.fillStyle = '#EF476F';
@@ -450,10 +467,16 @@ class QuadraticSim {
}));
}
+ let _quadSoundTs = 0;
function quadParam(name, val) {
const v = parseFloat(val);
document.getElementById('quad-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
if (quadSim) quadSim.setParams({ [name]: v });
+ const now = performance.now();
+ if (window.LabFX && now - _quadSoundTs > 80) {
+ _quadSoundTs = now;
+ LabFX.sound.play('tick', { volume: 0.1 });
+ }
}
function quadPreset(a, b, c) {
diff --git a/frontend/js/labs/radioactive.js b/frontend/js/labs/radioactive.js
index 98bc8fa..43baed5 100644
--- a/frontend/js/labs/radioactive.js
+++ b/frontend/js/labs/radioactive.js
@@ -120,6 +120,8 @@ class RadioactiveSim {
);
this.simTime = 0;
this.history = [];
+ this._lastHalfLifeMark = 0;
+ this._fxDecayCount = 0;
}
/* ══════════════ public API ══════════════ */
@@ -265,6 +267,16 @@ class RadioactiveSim {
: decayType.startsWith('β') ? 'β'
: 'γ';
this._flashes.push({ x: p.x, y: p.y, t: 0, maxT: 0.35, sym });
+ // LabFX decay effects (throttled)
+ if (window.LabFX) {
+ this._fxFrameDecays = (this._fxFrameDecays || 0) + 1;
+ LabFX.particles.emit({
+ ctx: this.ctx, x: p.x, y: p.y,
+ count: 6, color: '#FFD700', speed: 60,
+ spread: Math.PI * 2, angle: 0, gravity: 0,
+ life: 300, shape: 'spark', glow: true, size: 2,
+ });
+ }
}
// age flash on particle itself
@@ -284,6 +296,32 @@ class RadioactiveSim {
this.simTime += dt;
+ // LabFX half-life crossing + throttled tick sound
+ if (window.LabFX) {
+ /* throttle tick: play at most once per frame, only if ≤10 decays/s effective */
+ const frameDecays = this._fxFrameDecays || 0;
+ this._fxFrameDecays = 0;
+ if (frameDecays > 0) {
+ const decaysPerSec = frameDecays / Math.max(wallDt, 0.001);
+ const N = Math.max(1, Math.round(decaysPerSec / 10));
+ if (Math.random() < 1 / N) {
+ LabFX.sound.play('tick', { pitch: 0.8 + Math.random() * 0.4, volume: 0.08 });
+ }
+ }
+ LabFX.particles.update(wallDt);
+ const T0 = this.steps[0].T_half;
+ if (T0 !== Infinity) {
+ const halfLifesElapsed = Math.floor(this.simTime / T0);
+ if (halfLifesElapsed > (this._lastHalfLifeMark || 0)) {
+ this._lastHalfLifeMark = halfLifesElapsed;
+ LabFX.sound.play('chime', {
+ pitch: 0.6 + halfLifesElapsed * 0.1,
+ volume: 0.3,
+ });
+ }
+ }
+ }
+
// record history every ~2 ticks (≈30ms)
const last = this.history[this.history.length - 1];
if (!last || this.simTime - last.t > this.steps[0].T_half * 0.005 || this.history.length < 5) {
@@ -342,11 +380,23 @@ class RadioactiveSim {
ctx.fillStyle = `rgba(255,255,200,${alpha * 0.45})`;
ctx.fill();
- ctx.font = `bold ${Math.round(8 + alpha * 4)}px Manrope,sans-serif`;
- ctx.fillStyle = `rgba(255,255,180,${alpha})`;
+ const symSize = Math.round(8 + alpha * 4);
+ ctx.font = `bold ${symSize}px Manrope,sans-serif`;
+ const symColor = `rgba(255,255,180,${alpha})`;
+ ctx.fillStyle = symColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ ctx.font = `bold ${symSize}px Manrope,sans-serif`;
+ ctx.fillStyle = symColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
+ }, { color: '#FFFFFF', intensity: 6 });
+ } else {
+ ctx.fillText(fl.sym, fl.x, fl.y - r - 4);
+ }
}
// draw particles
@@ -374,6 +424,8 @@ class RadioactiveSim {
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fillText(s.name, lx + 15, y);
}
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawGraph() {
@@ -549,6 +601,7 @@ function radioactiveN0(val) {
function radioactivePlay() {
if (!radioactiveSim) return;
+ if (window.LabFX) LabFX.sound.play('click');
if (radioactiveSim.playing) {
radioactiveSim.pause();
document.getElementById('rd-play-btn').textContent = 'Старт';
@@ -560,6 +613,9 @@ function radioactivePlay() {
function radioactiveReset() {
if (!radioactiveSim) return;
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.5, volume: 0.3 });
+ radioactiveSim._lastHalfLifeMark = 0;
+ radioactiveSim._fxDecayCount = 0;
radioactiveSim.reset();
document.getElementById('rd-play-btn').textContent = 'Старт';
}
diff --git a/frontend/js/labs/reactions.js b/frontend/js/labs/reactions.js
index 9d0225c..7eb1147 100644
--- a/frontend/js/labs/reactions.js
+++ b/frontend/js/labs/reactions.js
@@ -95,8 +95,12 @@ class ReactionSim {
start() {
if (this._raf) return;
- const loop = () => {
+ this._lastTs = performance.now();
+ const loop = (ts) => {
this._raf = requestAnimationFrame(loop);
+ const dt = Math.min((ts - this._lastTs) / 1000, 0.05);
+ this._lastTs = ts;
+ if (window.LabFX) LabFX.particles.update(dt);
for (let i = 0; i < 3; i++) this._step();
this.draw();
};
@@ -317,6 +321,19 @@ class ReactionSim {
this._totalReactions++;
this._recentReactions++;
+
+ // LabFX: flash spark + throttled tick sound at collision point
+ if (window.LabFX) {
+ const now = performance.now();
+ if (!this._fxLastTick || now - this._fxLastTick > 200) {
+ this._fxLastTick = now;
+ LabFX.sound.play('tick', { volume: 0.1 });
+ }
+ LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 3,
+ color: '#FFD166', speed: 45, spread: 3.14, angle: 0,
+ gravity: 0, life: 200, shape: 'spark', glow: true });
+ }
+
return true;
}
@@ -380,6 +397,8 @@ class ReactionSim {
ctx.textAlign = 'center';
ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2);
}
+
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawParticle(ctx, p) {
@@ -687,6 +706,7 @@ class ReactionSim {
}
function chemReset() {
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (_chemMode === 'kinetics' && reacSim) reacSim.reset();
if (_chemMode === 'flask' && flaskSim) flaskSim.reset();
if (_chemMode === 'redox') redoxReset();
@@ -716,6 +736,7 @@ class ReactionSim {
}
function reacMode(mode, el) {
+ if (window.LabFX) LabFX.sound.play('click');
if (reacSim) reacSim.setMode(mode);
document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
@@ -827,6 +848,7 @@ class ReactionSim {
}
function redoxReset() {
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (rdxSim) rdxSim.reset();
}
@@ -858,6 +880,7 @@ class ReactionSim {
}
function ionexReset() {
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (ioxSim) ioxSim.reset();
}
diff --git a/frontend/js/labs/redox.js b/frontend/js/labs/redox.js
index 962046a..f675136 100644
--- a/frontend/js/labs/redox.js
+++ b/frontend/js/labs/redox.js
@@ -152,6 +152,7 @@ class RedoxSim {
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
@@ -165,6 +166,7 @@ class RedoxSim {
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
+ if (window.LabFX) LabFX.particles.update(dt);
const { W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
@@ -282,6 +284,12 @@ class RedoxSim {
mx, my, x: rp.x, y: rp.y,
t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0,
});
+ // LabFX: electron transfer spark at the midpoint
+ if (window.LabFX) {
+ LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my, count: 2,
+ color: '#06D6E0', speed: 30, spread: 3.14, angle: 0,
+ gravity: 0, life: 200, shape: 'spark', glow: true });
+ }
}
/* ── Рендеринг ──────────────────────────────────────────────────── */
@@ -341,6 +349,7 @@ class RedoxSim {
if (rxn.precip) this._drawPrecip(ctx, rxn);
if (rxn.gas) this._drawGas(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
+ if (window.LabFX) LabFX.particles.draw(this.ctx);
}
_drawBeaker(ctx, W, H) {
diff --git a/frontend/js/labs/states.js b/frontend/js/labs/states.js
index 7b505f3..cc81cee 100644
--- a/frontend/js/labs/states.js
+++ b/frontend/js/labs/states.js
@@ -22,6 +22,8 @@ class StatesSim {
this._raf = null;
this._stepCount = 0;
this._loop = this._loop.bind(this);
+ this._fxLastT = 0;
+ this._fxTickTimer = 0; // throttle temperature slider tick sound
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._energyHistory = [];
@@ -98,6 +100,15 @@ class StatesSim {
const f = Math.min(4, Math.sqrt(this.T / old));
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
}
+ // throttled tick sound for temperature slider (pitch ∝ T)
+ if (window.LabFX) {
+ const nowMs = performance.now();
+ if (nowMs - this._fxTickTimer > 120) {
+ this._fxTickTimer = nowMs;
+ const pitch = 0.7 + this.T * 0.9;
+ LabFX.sound.play('tick', { pitch: Math.min(2.5, pitch), volume: 0.12 });
+ }
+ }
}
setN(n) {
@@ -110,8 +121,11 @@ class StatesSim {
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
// ── simulation ────────────────────────────────────────────────────────────
- _loop() {
+ _loop(now) {
+ const dt = this._fxLastT ? Math.min(now - this._fxLastT, 80) : 16;
+ this._fxLastT = now;
for (let i = 0; i < 5; i++) this._stepPhysics();
+ if (window.LabFX) LabFX.particles.update(dt);
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
@@ -391,6 +405,7 @@ class StatesSim {
// hover inspector (may extend into chart area)
if (this._hover) this._drawInspector(ctx, this._hover, speeds, maxSpd, W, H);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
// ── helpers ───────────────────────────────────────────────────────────────
diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js
index 3dcf1e1..390f301 100644
--- a/frontend/js/labs/stereo.js
+++ b/frontend/js/labs/stereo.js
@@ -1832,11 +1832,13 @@ class StereoSim {
this._section3PPicks.push(bestPos);
this._drawSection3P();
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
if (this._section3PPicks.length === 3) {
this._computeSection3P();
this._drawSection3P();
this._notify();
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.1 });
}
}
@@ -2101,6 +2103,7 @@ class StereoSim {
});
this._rebuildPointVisuals();
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
// If custom section mode and ≥3 points, update section
if (this.showSection && this.sectionType === 'custom') this._updateSection();
@@ -3328,6 +3331,7 @@ class StereoSim {
stereoSim.setFigure(type);
_stereoShowParams(type);
_stereoUpdateFormulas();
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
// reset toggles and tool buttons
document.getElementById('sect-toggle').classList.remove('active');
document.getElementById('stereo-unfold-btn').classList.remove('active');
diff --git a/frontend/js/labs/stoichiometry.js b/frontend/js/labs/stoichiometry.js
index 46f1fe6..3fbfe9a 100644
--- a/frontend/js/labs/stoichiometry.js
+++ b/frontend/js/labs/stoichiometry.js
@@ -199,6 +199,9 @@ class StoichSim {
/* ── Выбрать рецепт ─────────────────────────────────────────────── */
_setRecipe(idx) {
+ if (this._recipeIdx !== undefined && this._recipeIdx !== idx && window.LabFX) {
+ LabFX.sound.play('click', { pitch: 1.3 });
+ }
this._recipeIdx = idx;
const r = StoichSim.RECIPES[idx];
@@ -465,7 +468,14 @@ class StoichSim {
};
});
+ const prevLimitIdx = this._computed ? this._computed.limitIdx : -1;
this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ };
+
+ // LabFX: haptic + tick when limiting reagent changes
+ if (window.LabFX && prevLimitIdx !== limitIdx) {
+ LabFX.haptic(20);
+ LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 });
+ }
}
/* ── Правая панель: пошаговый расчёт ───────────────────────────── */
@@ -772,10 +782,29 @@ class StoichSim {
this._animT = 0;
const dur = 1200; // ms
const start = performance.now();
+ let lastTs = start;
+
+ // LabFX: fizz sound + bubble particles at reactant boxes
+ if (window.LabFX && this._ctx) {
+ LabFX.sound.play('fizz');
+ const W = this._canvas.offsetWidth || 300;
+ const H = this._canvas.offsetHeight || 200;
+ const r = StoichSim.RECIPES[this._recipeIdx];
+ r.reactants.forEach((re, i) => {
+ const x = (W / (r.reactants.length + 1)) * (i + 1);
+ LabFX.particles.emit({ ctx: this._ctx, x, y: H * 0.4, count: 8,
+ color: re.color || '#FFFFFF', speed: 35, spread: 2.5, angle: -Math.PI / 2,
+ gravity: -50, life: 800, shape: 'ring' });
+ });
+ }
const tick = (now) => {
+ const dt = (now - lastTs) / 1000;
+ lastTs = now;
+ if (window.LabFX) LabFX.particles.update(dt);
this._animT = Math.min(1, (now - start) / dur);
this._draw();
+ if (window.LabFX && this._ctx) LabFX.particles.draw(this._ctx);
if (this._animT < 1) {
this._raf = requestAnimationFrame(tick);
} else {
diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js
index 95655fa..5d7be55 100644
--- a/frontend/js/labs/titration.js
+++ b/frontend/js/labs/titration.js
@@ -229,6 +229,7 @@ class TitrationSim {
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
+ if (window.LabFX) LabFX.particles.update(rawDt);
this._wave += rawDt * 2.0;
@@ -246,10 +247,31 @@ class TitrationSim {
for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; }
const surfY = this.H * 0.72;
/* spawn splashes when drops hit surface */
+ const eqVol = this._eqVolume();
+ const wasNearEq = this._fxNearEq;
+ this._fxNearEq = Math.abs(this.baseAdded - eqVol) < eqVol * 0.05;
+ if (!wasNearEq && this._fxNearEq && window.LabFX) {
+ // Equivalence point crossed
+ LabFX.sound.play('chime', { pitch: 1.3 });
+ }
for (const d of this._drops) {
if (d.y >= surfY && !d.hit) {
d.hit = true;
const bx = d.x;
+ // LabFX: pour sound + splash particles (throttled to ~3/sec)
+ if (window.LabFX) {
+ const now = performance.now();
+ if (!this._fxDropSnd || now - this._fxDropSnd > 330) {
+ this._fxDropSnd = now;
+ LabFX.sound.play('pour', { volume: 0.4, pitch: 1.2 });
+ LabFX.haptic(5);
+ }
+ const curPH = this._calcPH(this.baseAdded);
+ const col = this._indicatorColor(curPH);
+ LabFX.particles.emit({ ctx: this.ctx, x: bx, y: surfY, count: 4,
+ color: col, speed: 25, spread: 2.2, angle: -Math.PI / 2,
+ gravity: 200, life: 600, shape: 'splash' });
+ }
for (let i = 0; i < 3; i++) {
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI;
const s = 15 + Math.random() * 25;
@@ -315,6 +337,7 @@ class TitrationSim {
this._drawParticles(ctx);
this._drawOverlay(ctx);
this._drawPHCurve(ctx, simW, W, H);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── Lab stand ──────────────────────────────────────────── */
diff --git a/frontend/js/labs/triangle.js b/frontend/js/labs/triangle.js
index f2c770e..e8dcfbb 100644
--- a/frontend/js/labs/triangle.js
+++ b/frontend/js/labs/triangle.js
@@ -29,7 +29,27 @@ class TriangleSim {
};
this.onUpdate = null; // cb(stats)
+ this._lastHapticTs = 0;
+ this._lastRafTs = 0;
this._bindEvents();
+ this._startParticleLoop();
+ }
+
+ _startParticleLoop() {
+ let last = performance.now();
+ let fxFrames = 0; // countdown frames to keep drawing after last emit
+ const loop = (now) => {
+ const dt = (now - last) / 1000;
+ last = now;
+ if (window.LabFX) {
+ LabFX.particles.update(dt);
+ if (fxFrames > 0) { fxFrames--; this.draw(); }
+ }
+ requestAnimationFrame(loop);
+ };
+ // expose a trigger so emit events bump the counter
+ this._fxActivate = () => { fxFrames = 60; };
+ requestAnimationFrame(loop);
}
/* ── sizing ── */
@@ -70,6 +90,7 @@ class TriangleSim {
this._initPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
+ if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
}
/* ── pointer events ── */
@@ -97,6 +118,11 @@ class TriangleSim {
this._clampPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
+ const now = performance.now();
+ if (window.LabFX && now - (this._lastHapticTs || 0) > 100) {
+ this._lastHapticTs = now;
+ LabFX.haptic(5);
+ }
};
c.addEventListener('mousedown', e => {
@@ -115,6 +141,14 @@ class TriangleSim {
});
c.addEventListener('mouseup', () => {
+ if (this._dragging !== null && this.pts) {
+ const p = this.pts[this._dragging];
+ if (window.LabFX) {
+ LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
+ LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 4, color: '#9B5DE5', shape: 'dust', life: 300, speed: 40, spread: Math.PI * 2, gravity: 0 });
+ if (this._fxActivate) this._fxActivate();
+ }
+ }
this._dragging = null;
c.style.cursor = this._hovered !== null ? 'grab' : 'default';
});
@@ -298,6 +332,7 @@ class TriangleSim {
this._drawVertices(ctx);
this._drawSideLabels(ctx);
this._drawAngleLabels(ctx);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* helpers */
@@ -506,8 +541,14 @@ class TriangleSim {
// midpoint ticks
[mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e'));
- // centroid
- this._dot(ctx, G.x, G.y, 7, '#22d55e');
+ // centroid with glow
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ this._dot(ctx, G.x, G.y, 7, '#22d55e');
+ }, { color: '#F59E0B', intensity: 6 });
+ } else {
+ this._dot(ctx, G.x, G.y, 7, '#22d55e');
+ }
this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13);
ctx.restore();
@@ -549,7 +590,13 @@ class TriangleSim {
[fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b'));
if (H) {
- this._dot(ctx, H.x, H.y, 7, '#f59e0b');
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, () => {
+ this._dot(ctx, H.x, H.y, 7, '#f59e0b');
+ }, { color: '#F59E0B', intensity: 6 });
+ } else {
+ this._dot(ctx, H.x, H.y, 7, '#f59e0b');
+ }
this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13);
}
ctx.restore();
diff --git a/frontend/js/labs/trigcircle.js b/frontend/js/labs/trigcircle.js
index 86151c1..1c4a0dc 100644
--- a/frontend/js/labs/trigcircle.js
+++ b/frontend/js/labs/trigcircle.js
@@ -98,6 +98,7 @@ class TrigCircleSim {
this._drawCircle(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
+ if (window.LabFX) LabFX.particles.draw(c);
c.restore();
this._fireUpdate();
@@ -320,14 +321,22 @@ class TrigCircleSim {
/* ═══ trig segments ═══ */
if (this.showCos) {
- this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(c, () => { this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4); }, { color: '#06D6E0', intensity: 4 });
+ } else {
+ this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
+ }
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cos;
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'top' : 'bottom';
c.fillText('cos', (cx + projX) / 2, cy + (sinA >= 0 ? 12 : -12));
}
if (this.showSin) {
- this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(c, () => { this._glowLine(c, projX, cy, px, py, _TC.sin, 4); }, { color: '#06D6E0', intensity: 4 });
+ } else {
+ this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
+ }
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.sin;
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
c.fillText('sin', projX + (cosA >= 0 ? 9 : -9), (cy + py) / 2);
@@ -866,10 +875,18 @@ class TrigCircleSim {
cv.style.cursor = 'grabbing';
});
+ this._lastDragSoundTs = 0;
cv.addEventListener('mousemove', e => {
if (this._drag) {
const na = snapAngle(e); checkSnap(na);
this.angle = na; this.draw();
+ const now = performance.now();
+ if (window.LabFX && now - this._lastDragSoundTs > 100) {
+ this._lastDragSoundTs = now;
+ const pitch = 0.8 + (this.angle / (2 * Math.PI)) * 0.8;
+ LabFX.sound.play('tick', { pitch, volume: 0.05 });
+ LabFX.haptic(5);
+ }
} else {
const h = hit(e);
if (h !== this._hover) { this._hover = h; this.draw(); }
@@ -954,6 +971,7 @@ class TrigCircleSim {
const loop = now => {
const dt = (now-last)/1000; last = now;
this._idlePulse += dt * 1.5;
+ if (window.LabFX) LabFX.particles.update(dt);
/* update particles */
if (this._particles.length > 0 || (!this._drag && !this.animating)) this.draw();
this._idleRaf = requestAnimationFrame(loop);
@@ -1008,6 +1026,7 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
function trigReset() {
if (!trigSim) return;
trigSim.setAngle(Math.PI / 4);
+ if (window.LabFX) LabFX.sound.play('click');
}
function _trigUpdateUI(s) {
diff --git a/frontend/js/labs/waves.js b/frontend/js/labs/waves.js
index 56deb4d..d4af14b 100644
--- a/frontend/js/labs/waves.js
+++ b/frontend/js/labs/waves.js
@@ -78,6 +78,7 @@ class WavesSim {
if (mode === 'doppler') this._dopInit();
if (mode === 'spectrum' && !this._specComponents.length)
this._specComponents = [{ f: 5, A: 60 }, { f: 10, A: 30 }];
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
this.draw();
this._emit();
}
@@ -135,6 +136,21 @@ class WavesSim {
const dt = Math.min((ts - this._last) / 1000, 0.05) * this._speed;
this._t += dt;
if (this._mode === 'doppler') this._dopStep(dt);
+ if (window.LabFX) LabFX.particles.update(dt);
+ /* beats: play tick at envelope peaks (throttle to beat period) */
+ if (this._mode === 'beats') {
+ const fBeat = Math.abs(this._beatsF1 - this._beatsF2);
+ if (fBeat > 0) {
+ const TBeat = 1 / fBeat;
+ const beatPhase = this._t % TBeat;
+ if (!this._lastBeatTick || this._t - this._lastBeatTick >= TBeat * 0.95) {
+ if (beatPhase < dt * this._speed + 0.02) {
+ if (window.LabFX) LabFX.sound.play('tick', { pitch: 0.5, volume: 0.15 });
+ this._lastBeatTick = this._t;
+ }
+ }
+ }
+ }
}
this._last = ts;
this._raf = requestAnimationFrame(t => this._tick(t));
@@ -161,6 +177,7 @@ class WavesSim {
else if (this._mode === 'doppler') this._dopplerDraw(ctx, W, H);
else if (this._mode === 'beats') this._beatsDraw(ctx, W, H);
else if (this._mode === 'spectrum') this._spectrumDraw(ctx, W, H);
+ if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ══════════════════════════════════════
@@ -185,18 +202,24 @@ class WavesSim {
const y = x => A * Math.sin(om * t - k * (x - PL) + phi);
/* волновая кривая */
- ctx.save();
- ctx.shadowColor = WavesSim.V;
- ctx.shadowBlur = 16;
- ctx.strokeStyle = WavesSim.V;
- ctx.lineWidth = 2.5;
- ctx.beginPath();
- for (let x = PL; x <= PL + cw; x += 1) {
- const py = cy + y(x);
- x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
+ const _drawTransvWave = () => {
+ ctx.strokeStyle = WavesSim.V;
+ ctx.lineWidth = 2.5;
+ ctx.beginPath();
+ for (let x = PL; x <= PL + cw; x += 1) {
+ const py = cy + y(x);
+ x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
+ }
+ ctx.stroke();
+ };
+ if (window.LabFX) {
+ LabFX.glow.drawGlow(ctx, _drawTransvWave, { color: WavesSim.V, intensity: 5 });
+ } else {
+ ctx.save();
+ ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 16;
+ _drawTransvWave();
+ ctx.restore();
}
- ctx.stroke();
- ctx.restore();
/* частицы */
const step = Math.max(12, Math.floor(lam / 10));
@@ -488,6 +511,32 @@ class WavesSim {
ring.age += dt;
return ring.r < maxR;
});
+
+ /* LabFX: Mach shock particles when vs >= c */
+ const mach = vsPx / c_px;
+ if (window.LabFX) {
+ if (mach >= 1.0 && !this._dopWasMach) {
+ this._dopWasMach = true;
+ LabFX.sound.play('spark', { volume: 0.4 });
+ LabFX.particles.emit({
+ ctx: this._ctx, x: this._dopSrcX, y: this._dopSrcY,
+ count: 20, color: '#FF6B35', speed: 120,
+ spread: Math.PI * 2, angle: 0, gravity: 80,
+ life: 600, shape: 'spark', glow: true, size: 3,
+ });
+ } else if (mach < 1.0) {
+ this._dopWasMach = false;
+ }
+
+ /* haptic while dragging src (throttle 100ms) */
+ if (this._dopDrag === 'src') {
+ const now2 = performance.now();
+ if (!this._dopHapticLast || now2 - this._dopHapticLast >= 100) {
+ LabFX.haptic(5);
+ this._dopHapticLast = now2;
+ }
+ }
+ }
}
_dopplerDraw(ctx, W, H) {
@@ -806,16 +855,24 @@ class WavesSim {
══════════════════════════════════════ */
_waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
+ const drawFn = () => {
+ ctx.strokeStyle = color; ctx.lineWidth = lw;
+ ctx.beginPath();
+ for (let x = PL; x <= PL + cw; x += 1) {
+ const py = cy + fn(x);
+ x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
+ }
+ ctx.stroke();
+ };
ctx.save();
ctx.globalAlpha = alpha;
- if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
- ctx.strokeStyle = color; ctx.lineWidth = lw;
- ctx.beginPath();
- for (let x = PL; x <= PL + cw; x += 1) {
- const py = cy + fn(x);
- x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
+ if (glow && window.LabFX) {
+ LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 5 });
+ } else {
+ if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
+ drawFn();
}
- ctx.stroke(); ctx.restore();
+ ctx.restore();
}
_grid(ctx, PL, PR, PT, PB, W, H) {
@@ -847,8 +904,21 @@ class WavesSim {
specAddComponent() {
const f = this._specNewF;
const A = 60;
- if (this._specComponents.length < 12)
+ if (this._specComponents.length < 12) {
this._specComponents.push({ f, A });
+ if (window.LabFX) {
+ LabFX.sound.play('chime', { pitch: Math.pow(2, f / 12), volume: 0.3 });
+ /* emit dust at approximate peak position on spectrum */
+ const peakX = this._W ? (this._W * 0.08) + (f / 50) * (this._W * 0.84) : 200;
+ const peakY = this._H ? this._H * 0.3 : 100;
+ LabFX.particles.emit({
+ ctx: this._ctx, x: peakX, y: peakY,
+ count: 8, color: '#FFD166', speed: 30,
+ spread: Math.PI, angle: -Math.PI / 2, gravity: 50,
+ life: 400, shape: 'dust', size: 2,
+ });
+ }
+ }
this.draw();
}
diff --git a/frontend/lab.html b/frontend/lab.html
index 44fb6ab..0256899 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -342,6 +342,22 @@
+
+
+
@@ -3940,6 +3956,10 @@
+
+
+
+
@@ -3989,5 +4009,19 @@
+