feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up

ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 13:58:49 +03:00
parent 8b3159b529
commit 6afe928c0d
50 changed files with 2748 additions and 215 deletions
+366
View File
@@ -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:
<div class="sim-empty-state">
<svg class="sim-empty-icon" ...>...</svg>
<div class="sim-empty-title">Title</div>
<div class="sim-empty-desc">Description text</div>
<button class="sim-empty-cta">Action</button> <!-- optional -->
</div>
─────────────────────────────────────────────────────────────────────────── */
.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;
}
+90
View File
@@ -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<number>} 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);
+153
View File
@@ -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);
+225
View File
@@ -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);
+332
View File
@@ -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);
+33
View File
@@ -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);
}
+11
View File
@@ -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%) ───────────────────────── */
+6 -1
View File
@@ -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) {
+49 -1
View File
@@ -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 ─────────────────────────────────────────── */
+40 -1
View File
@@ -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);
+79 -30
View File
@@ -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());
}
+18 -1
View File
@@ -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 ── */
+1
View File
@@ -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);
}
+20 -1
View File
@@ -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) {
+41 -2
View File
@@ -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 });
}
+84 -10
View File
@@ -181,6 +181,11 @@ class EMFieldSim {
this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
this._invalidateAll();
this.draw();
if (window.LabFX) {
LabFX.sound.play('spark', { pitch: 1.1 });
const col = q > 0 ? '#EF476F' : '#4CC9F0';
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
}
if (this.onUpdate) this.onUpdate(this.info());
}
@@ -191,6 +196,11 @@ class EMFieldSim {
this.sources.push({ kind, id: this._nextId++, x, y, I });
this._invalidateAll();
this.draw();
if (window.LabFX) {
LabFX.sound.play('spark', { pitch: 1.1 });
const col = I > 0 ? '#06D6E0' : '#F15BB5';
LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
}
if (this.onUpdate) this.onUpdate(this.info());
}
@@ -318,6 +328,14 @@ class EMFieldSim {
if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; }
if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; }
if (window.LabFX) {
LabFX.particles.update(dt);
const v2 = Math.hypot(rod.vx, rod.vy);
if (v2 > 30) {
LabFX.particles.emit({ ctx: this.ctx, x: rod.x1, y: rod.y1, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
LabFX.particles.emit({ ctx: this.ctx, x: rod.x2, y: rod.y2, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
}
}
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
rod._raf = requestAnimationFrame(() => this._tickRod());
@@ -389,8 +407,11 @@ class EMFieldSim {
_tickParticle() {
if (!this.particleOn || !this._particle) return;
const now = performance.now();
const rawDt = Math.min((now - this._pLast) * 0.001, 0.05); // seconds
const dt = Math.min((now - this._pLast) * 0.06, 2.5);
this._pLast = now;
if (!this._pFrame) this._pFrame = 0;
this._pFrame++;
const p = this._particle;
@@ -433,6 +454,12 @@ class EMFieldSim {
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > 350) p.trail.shift();
if (window.LabFX && this._pFrame % 2 === 0) {
const trailCol = p.q > 0 ? '#FFD166' : '#4CC9F0';
LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 1, color: trailCol, life: 400, shape: 'dot', size: 2, glow: true });
}
if (window.LabFX) LabFX.particles.update(rawDt);
this.draw();
this._pRaf = requestAnimationFrame(() => this._tickParticle());
}
@@ -792,6 +819,11 @@ class EMFieldSim {
if (this._gauss._dragging) {
this._gauss.x = p.x; this._gauss.y = p.y;
const now2 = performance.now();
if (!this._gaussHapticT || now2 - this._gaussHapticT > 100) {
this._gaussHapticT = now2;
if (window.LabFX) LabFX.haptic(5);
}
this.draw(); return;
}
@@ -970,6 +1002,28 @@ class EMFieldSim {
/* sources */
this._drawSources(ctx);
/* high-field lightning FX */
if (window.LabFX && this.sources.length >= 2) {
const now3 = performance.now();
if (!this._lightningT) this._lightningT = 0;
if (now3 - this._lightningT > 500) {
// sample max field at center
const cx = this.W / 2, cy = this.H / 2;
const em = this._eField(cx, cy), bm = this._bField(cx, cy);
const maxField = Math.max(em.mag, bm.mag);
if (maxField > 30000) {
this._lightningT = now3;
const i1 = Math.floor(Math.random() * this.sources.length);
let i2 = Math.floor(Math.random() * this.sources.length);
if (i2 === i1) i2 = (i1 + 1) % this.sources.length;
const s1 = this.sources[i1], s2 = this.sources[i2];
const lx = (s1.x + s2.x) / 2, ly = (s1.y + s2.y) / 2;
LabFX.particles.emit({ ctx: this.ctx, x: lx, y: ly, count: 5, color: '#FFFFFF', speed: 30, spread: Math.PI * 2, life: 80, shape: 'spark', glow: true });
LabFX.sound.play('spark', { volume: 0.2 });
}
}
}
/* cursor readout */
if (this._mousePos) {
if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx);
@@ -977,6 +1031,8 @@ class EMFieldSim {
}
if (this.sources.length === 0) this._drawHint(ctx);
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── grid ── */
@@ -1125,7 +1181,9 @@ class EMFieldSim {
/* ── E vectors ── */
_drawVectorsE(ctx) {
const GRID = 45;
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
ctx.save();
ctx.globalAlpha = _pulse;
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
@@ -1195,10 +1253,17 @@ class EMFieldSim {
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.strokeStyle = grad;
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
const drawStroke = () => {
ctx.strokeStyle = grad;
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
};
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, drawStroke, { color: '#06D6E0', intensity: 6 });
} else {
drawStroke();
}
}
}
ctx.restore();
@@ -1266,11 +1331,19 @@ class EMFieldSim {
pts.push({ x, y });
}
if (pts.length < 3) continue;
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
ctx.stroke();
const drawBLine = () => {
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
ctx.stroke();
};
if (window.LabFX) {
const bGlowCol = src.I > 0 ? '#06D6E0' : '#9B5DE5';
LabFX.glow.drawGlow(ctx, drawBLine, { color: bGlowCol, intensity: 6 });
} else {
drawBLine();
}
this._drawBArrows(ctx, pts, col);
}
}
@@ -1298,6 +1371,7 @@ class EMFieldSim {
/* ── B vector field ── */
_drawVectorsB(ctx) {
const step = 42;
const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
ctx.save();
for (let px = step*0.5; px < this.W; px += step) {
for (let py = step*0.5; py < this.H; py += step) {
@@ -1306,7 +1380,7 @@ class EMFieldSim {
const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4);
const len = 8 + t * 14;
const nx = bx / mag, ny = by / mag;
const alp = 0.28 + t * 0.6;
const alp = (0.28 + t * 0.6) * _pulse;
ctx.save();
ctx.translate(px, py); ctx.rotate(Math.atan2(ny, nx));
ctx.globalAlpha = alp;
+26 -2
View File
@@ -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;
+18 -1
View File
@@ -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);
}
/* ── Тень/отражение колбы на столе ── */
+21 -3
View File
@@ -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) {
+24 -1
View File
@@ -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);
}
+98 -52
View File
@@ -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);
}
+33 -17
View File
@@ -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;
+14 -1
View File
@@ -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) {
+85 -2
View File
@@ -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) {
+38 -2
View File
@@ -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';
+22
View File
@@ -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) {
+32 -1
View File
@@ -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) {
+29
View File
@@ -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() {
+70 -1
View File
@@ -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);
+30 -3
View File
@@ -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;
+48 -19
View File
@@ -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) {
+64 -14
View File
@@ -121,6 +121,17 @@ class ThinLensSim {
}
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
// Lens caustics: emit dust near focal point when image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const imgX = lensX + dPrime;
if (!this._causticFrame) this._causticFrame = 0;
this._causticFrame++;
if (this._causticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: imgX + (Math.random() - 0.5) * 10, y: axisY + (Math.random() - 0.5) * 10, count: 3, color: '#FFD166', speed: 8, spread: Math.PI * 2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1 / 60); LabFX.particles.draw(ctx); }
}
_drawLens(ctx, lx, ay, f) {
@@ -193,9 +204,13 @@ class ThinLensSim {
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
const _doGlow = (color, fn) => {
if (window.LabFX) LabFX.glow.drawGlow(ctx, fn, { color, intensity: 8 });
else fn();
};
// Ray 1: parallel to axis
{
_doGlow(colors[0], () => {
ctx.strokeStyle = colors[0]; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
if (hasImage) {
@@ -211,10 +226,10 @@ class ThinLensSim {
ctx.setLineDash([]);
}
}
}
});
// Ray 2: through center
{
_doGlow(colors[1], () => {
ctx.strokeStyle = colors[1]; ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350, farY = ay + slope * 350;
@@ -225,10 +240,10 @@ class ThinLensSim {
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
}
});
// Ray 3: through F
{
_doGlow(colors[2], () => {
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f, slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
@@ -240,7 +255,7 @@ class ThinLensSim {
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
}
});
}
_extendRay(ctx, x1, y1, x2, y2, color) {
@@ -298,7 +313,7 @@ class ThinLensSim {
else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx));
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@@ -622,6 +637,17 @@ class MirrorSim {
if (this._showPhotons && this._photons.length) this._drawPhotons(ctx);
this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime);
if (step >= 0) this._drawStepOverlay(ctx, step);
// Mirror caustics near focal point when real image exists
if (window.LabFX && dPrime !== null && isFinite(dPrime) && dPrime > 0) {
const focX = mx - dPrime, focY = ay - (this._pointMode ? 0 : hPrime);
if (!this._mCausticFrame) this._mCausticFrame = 0;
this._mCausticFrame++;
if (this._mCausticFrame % 4 === 0) {
LabFX.particles.emit({ ctx, x: focX + (Math.random()-0.5)*10, y: focY + (Math.random()-0.5)*10, count: 2, color: '#FFD166', speed: 6, spread: Math.PI*2, life: 500, shape: 'dust', glow: true });
}
}
if (window.LabFX) { LabFX.particles.update(1/60); LabFX.particles.draw(ctx); }
}
_drawGrid(ctx) {
@@ -718,6 +744,7 @@ class MirrorSim {
_oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) {
if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return;
ctx.save(); ctx.globalAlpha = alpha;
if (window.LabFX && alpha > 0.5) { ctx.shadowColor = color; ctx.shadowBlur = 8; }
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke();
if (!hasImg) { ctx.restore(); return; }
@@ -1062,7 +1089,7 @@ class MirrorSim {
this.draw(); this._emit();
} else if (!this._photonRaf && !this._playing) { this.draw(); }
});
window.addEventListener('mouseup', () => { this._drag = null; });
window.addEventListener('mouseup', () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = null; });
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor='grabbing'; return; }
const {px,py}=getPos(e);
@@ -1192,6 +1219,24 @@ class RefractionSim {
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
// TIR one-shot sound
if (window.LabFX) {
if (isTIR && !this._wasTIR) {
LabFX.sound.play('spark', { volume: 0.2 });
}
this._wasTIR = isTIR;
// Brewster angle: R ≈ 0 (reflected intensity near zero for s-pol)
const _isBrew = !isTIR && R < 0.005 && this.angle > 0;
if (_isBrew && !this._wasBrewster) {
LabFX.sound.play('chime', { pitch: 1.5, volume: 0.3 });
}
this._wasBrewster = _isBrew;
LabFX.particles.update(1 / 60);
LabFX.particles.draw(ctx);
}
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
@@ -1245,11 +1290,15 @@ class RefractionSim {
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
const drawFn = () => {
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
};
if (window.LabFX) LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 8 });
else drawFn();
}
_drawArrowhead(ctx, x, y, angle, color) {
@@ -1341,7 +1390,7 @@ class RefractionSim {
this.angle = angleFromMouse(mx, my);
this.draw(); this._emit();
};
const onUp = () => { this._drag = false; };
const onUp = () => { if (this._drag && window.LabFX) LabFX.sound.play('click'); this._drag = false; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
@@ -1397,6 +1446,7 @@ function _obApplyState(st) {
/* Switch between modes — mirrors emSwitchMode pattern */
function obSwitchMode(mode, silent) {
if (!silent && window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.3, volume: 0.3 });
_obMode = mode;
/* tab button styling */
+1
View File
@@ -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);
}
+28 -9
View File
@@ -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) {
+39 -3
View File
@@ -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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 ──────────────────────────────────────────── */
+20 -3
View File
@@ -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) {
+69
View File
@@ -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 ── */
+27 -4
View File
@@ -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) {
+59 -3
View File
@@ -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 = 'Старт';
}
+24 -1
View File
@@ -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();
}
+9
View File
@@ -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) {
+16 -1
View File
@@ -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 ───────────────────────────────────────────────────────────────
+4
View File
@@ -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');
+29
View File
@@ -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 {
+23
View File
@@ -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 ──────────────────────────────────────────── */
+50 -3
View File
@@ -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();
+21 -2
View File
@@ -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) {
+89 -19
View File
@@ -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();
}
+34
View File
@@ -342,6 +342,22 @@
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
</button>
<!-- sound toggle -->
<button class="zoom-btn" id="labfx-sound-btn" onclick="(function(){var e=window.LabFX&&window.LabFX.sound;if(!e)return;e.setEnabled(!e.isEnabled());document.getElementById('labfx-sound-btn').setAttribute('aria-pressed',e.isEnabled());document.getElementById('labfx-sound-icon-on').style.display=e.isEnabled()?'':'none';document.getElementById('labfx-sound-icon-off').style.display=e.isEnabled()?'none':'';})()" title="Звук симуляций" style="position:relative" aria-pressed="true">
<!-- speaker on -->
<svg id="labfx-sound-icon-on" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
<!-- speaker off (muted) -->
<svg id="labfx-sound-icon-off" class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/>
<line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
</div>
<!-- ── GRAPH sim body ── -->
@@ -3940,6 +3956,10 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
<script src="/js/labs/_fx_core.js"></script>
<script src="/js/labs/_fx_particles.js"></script>
<script src="/js/labs/_fx_motion.js"></script>
<script src="/js/labs/_fx_sound.js"></script>
<script src="/js/labs/graph.js"></script>
<script src="/js/labs/emfield.js"></script>
<script src="/js/labs/triangle.js"></script>
@@ -3989,5 +4009,19 @@
<script src="/js/labs/geometry.js"></script>
<script src="/js/labs/logic.js"></script>
<script src="/js/labs/heatengine.js"></script>
<script>
/* Sync sound toggle button icon with localStorage state on load */
(function() {
var stored = localStorage.getItem('labfx-sound');
var on = stored === null ? true : stored === 'true';
var iconOn = document.getElementById('labfx-sound-icon-on');
var iconOff = document.getElementById('labfx-sound-icon-off');
var btn = document.getElementById('labfx-sound-btn');
if (!iconOn || !iconOff || !btn) return;
iconOn.style.display = on ? '' : 'none';
iconOff.style.display = on ? 'none' : '';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
})();
</script>
</body>
</html>
+6 -1
View File
@@ -372,6 +372,11 @@ function lsToast(message, type = 'info', duration = 3500) {
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.innerHTML = `<span class="ls-toast-icon">${lsIcon(_tIcons[type] || 'info', 18)}</span><span class="ls-toast-msg"></span><button class="ls-toast-close" aria-label="Закрыть уведомление" onclick="this.closest('.ls-toast').remove()">${lsIcon('x-close', 14)}</button>`;
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