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 @@ + diff --git a/js/api.js b/js/api.js index 7684d50..76f13e5 100644 --- a/js/api.js +++ b/js/api.js @@ -372,6 +372,11 @@ function lsToast(message, type = 'info', duration = 3500) { el.setAttribute('role', type === 'error' ? 'alert' : 'status'); el.innerHTML = `${lsIcon(_tIcons[type] || 'info', 18)}`; el.querySelector('.ls-toast-msg').textContent = message; + // Progress bar — thin line at bottom that drains over the toast duration + const bar = document.createElement('span'); + bar.className = 'ls-toast-bar'; + bar.style.setProperty('--toast-dur', (duration / 1000).toFixed(2) + 's'); + el.appendChild(bar); wrap.appendChild(el); requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))); @@ -380,7 +385,7 @@ function lsToast(message, type = 'info', duration = 3500) { setTimeout(() => el.remove(), 320); }; const timer = setTimeout(hide, duration); - el.querySelector('.ls-toast-close').addEventListener('click', () => clearTimeout(timer)); + el.querySelector('.ls-toast-close').addEventListener('click', () => { clearTimeout(timer); hide(); }); } /* ── State helpers: единый паттерн loading/empty/error ───────────────────