Files
Maxim Dolgolyov 6afe928c0d 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>
2026-05-23 13:58:49 +03:00

367 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════
OrbitalsSim — 3D molecular orbitals (Three.js)
s, p, d orbitals + H₂ / H₂O molecular bonding
═══════════════════════════════════════════════ */
class OrbitalsSim {
constructor(container) {
this.container = container;
this._running = false;
/* Three.js */
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x0D0D1A, 1);
container.appendChild(this.renderer.domElement);
/* lighting */
this.scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const dir = new THREE.DirectionalLight(0xffffff, 0.7);
dir.position.set(5, 8, 6);
this.scene.add(dir);
const pt = new THREE.PointLight(0x9B5DE5, 0.3, 50);
pt.position.set(-4, 3, 5);
this.scene.add(pt);
this.camera.position.set(6, 4, 6);
this.camera.lookAt(0, 0, 0);
/* orbit controls (manual) */
this._drag = false;
this._prevX = 0;
this._prevY = 0;
this._rotY = 0.6;
this._rotX = 0.3;
this._dist = 8;
this._autoSpin = true;
const el = this.renderer.domElement;
el.style.cursor = 'grab';
el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; });
window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; });
window.addEventListener('pointermove', e => {
if (!this._drag) return;
this._rotY += (e.clientX - this._prevX) * 0.008;
this._rotX += (e.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = e.clientX; this._prevY = e.clientY;
});
el.addEventListener('wheel', e => {
e.preventDefault();
this._dist = Math.max(3, Math.min(20, this._dist + e.deltaY * 0.02));
}, { passive: false });
/* touch */
el.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false;
}
}, { passive: true });
el.addEventListener('touchmove', e => {
if (!this._drag || e.touches.length !== 1) return;
const t = e.touches[0];
this._rotY += (t.clientX - this._prevX) * 0.008;
this._rotX += (t.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = t.clientX; this._prevY = t.clientY;
}, { passive: true });
el.addEventListener('touchend', () => { this._drag = false; });
/* resize */
this._ro = new ResizeObserver(() => this.fit());
this._ro.observe(container);
/* state */
this._mode = 's';
this._group = new THREE.Group();
this.scene.add(this._group);
this._buildOrbital('s');
this.fit();
this.play();
}
/* ── public ── */
setMode(mode) {
this._mode = mode;
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.1, volume: 0.3 });
this._buildOrbital(mode);
}
fit() {
const w = this.container.clientWidth || 600;
const h = this.container.clientHeight || 400;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
play() { if (!this._running) { this._running = true; this._loop(); } }
stop() { this._running = false; }
pause() { this._running = false; }
/* ── clear scene group ── */
_clear() {
while (this._group.children.length) {
const c = this._group.children[0];
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
this._group.remove(c);
}
}
/* ── orbital builders ── */
_buildOrbital(mode) {
this._clear();
const b = {
s: () => this._buildS(),
p: () => this._buildP(),
d: () => this._buildD(),
h2: () => this._buildH2(),
h2o: () => this._buildH2O(),
};
(b[mode] || b.s)();
}
/* nucleus dot */
_nucleus(pos, color = 0xffffff) {
const geo = new THREE.SphereGeometry(0.12, 16, 16);
const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
this._group.add(m);
return m;
}
/* cloud lobe (ellipsoid) */
_lobe(pos, scale, color, opacity = 0.3) {
const geo = new THREE.SphereGeometry(1, 32, 32);
const mat = new THREE.MeshPhysicalMaterial({
color, transparent: true, opacity,
metalness: 0, roughness: 0.6,
clearcoat: 0.3, side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
mesh.scale.set(scale.x, scale.y, scale.z);
this._group.add(mesh);
return mesh;
}
/* particle cloud (points) */
_cloud(center, radius, count, color) {
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// gaussian distribution
const r = radius * Math.pow(Math.random(), 0.33);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i * 3] = center.x + r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = center.y + r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = center.z + r * Math.cos(phi);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({ color, size: 0.04, transparent: true, opacity: 0.6, depthWrite: false });
const pts = new THREE.Points(geo, mat);
this._group.add(pts);
return pts;
}
/* ── s orbital: spherical ── */
_buildS() {
this._nucleus(new THREE.Vector3(0, 0, 0));
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 1.5, 1.5), 0x9B5DE5, 0.18);
this._cloud(new THREE.Vector3(0, 0, 0), 1.5, 2000, 0x9B5DE5);
}
/* ── p orbitals: 3 dumbbell shapes ── */
_buildP() {
this._nucleus(new THREE.Vector3(0, 0, 0));
// px (red)
this._lobe(new THREE.Vector3(1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
this._lobe(new THREE.Vector3(-1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
// py (green)
this._lobe(new THREE.Vector3(0, 1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
this._lobe(new THREE.Vector3(0, -1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
// pz (blue)
this._lobe(new THREE.Vector3(0, 0, 1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
this._lobe(new THREE.Vector3(0, 0, -1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
// axis labels
this._addLabel('px', 2, 0, 0, 0xF15BB5);
this._addLabel('py', 0, 2, 0, 0x34d399);
this._addLabel('pz', 0, 0, 2, 0x60a5fa);
}
/* ── d orbital: dxy cloverleaf ── */
_buildD() {
this._nucleus(new THREE.Vector3(0, 0, 0));
// four lobes in xy plane
const r = 1.1, s = 0.45;
const angles = [Math.PI / 4, 3 * Math.PI / 4, 5 * Math.PI / 4, 7 * Math.PI / 4];
const colors = [0xF59E0B, 0x9B5DE5, 0xF59E0B, 0x9B5DE5]; // alternating sign
for (let i = 0; i < 4; i++) {
const x = r * Math.cos(angles[i]);
const y = r * Math.sin(angles[i]);
this._lobe(new THREE.Vector3(x, y, 0), new THREE.Vector3(s, s, s * 0.6), colors[i], 0.28);
}
// dz² torus + lobes
this._lobe(new THREE.Vector3(0, 0, 1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
this._lobe(new THREE.Vector3(0, 0, -1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
// torus ring
const tGeo = new THREE.TorusGeometry(0.7, 0.18, 16, 32);
const tMat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.15, side: THREE.DoubleSide, depthWrite: false });
const torus = new THREE.Mesh(tGeo, tMat);
this._group.add(torus);
this._addLabel('d_{xy}', 1.8, 1.8, 0, 0xF59E0B);
this._addLabel('d_{z²}', 0, 0, 2.2, 0x06D6E0);
}
/* ── H₂ sigma bond ── */
_buildH2() {
const sep = 1.5;
this._nucleus(new THREE.Vector3(-sep / 2, 0, 0), 0xffffff);
this._nucleus(new THREE.Vector3(sep / 2, 0, 0), 0xffffff);
// individual 1s orbitals (faded)
this._lobe(new THREE.Vector3(-sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
this._lobe(new THREE.Vector3(sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
// bonding σ (overlap region — elongated ellipsoid)
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.4, 0.6, 0.6), 0x06D6E0, 0.22);
this._cloud(new THREE.Vector3(0, 0, 0), 1.0, 3000, 0x06D6E0);
// bond line
const bGeo = new THREE.CylinderGeometry(0.03, 0.03, sep, 8);
const bMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.3 });
const bond = new THREE.Mesh(bGeo, bMat);
bond.rotation.z = Math.PI / 2;
this._group.add(bond);
this._addLabel('H', -sep / 2 - 0.5, 0.6, 0, 0xffffff);
this._addLabel('H', sep / 2 + 0.3, 0.6, 0, 0xffffff);
this._addLabel('σ', 0, -0.9, 0, 0x06D6E0);
}
/* ── H₂O bent molecule ── */
_buildH2O() {
const angle = 104.5 * Math.PI / 180;
const bondLen = 1.6;
const oPos = new THREE.Vector3(0, 0, 0);
const h1 = new THREE.Vector3(-bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
const h2 = new THREE.Vector3(bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
// nuclei
const oNuc = this._nucleus(oPos, 0xF15BB5);
oNuc.scale.set(1.5, 1.5, 1.5);
this._nucleus(h1, 0xffffff);
this._nucleus(h2, 0xffffff);
// O lone pairs (above)
this._lobe(new THREE.Vector3(-0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
this._lobe(new THREE.Vector3(0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
// O-H σ bonds (electron density)
const mid1 = new THREE.Vector3().addVectors(oPos, h1).multiplyScalar(0.5);
const mid2 = new THREE.Vector3().addVectors(oPos, h2).multiplyScalar(0.5);
this._lobe(mid1, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
this._lobe(mid2, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
// bond lines
for (const hPos of [h1, h2]) {
const d = new THREE.Vector3().subVectors(hPos, oPos);
const len = d.length();
const bGeo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
const bMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
const bond = new THREE.Mesh(bGeo, bMat);
bond.position.copy(oPos).add(d.clone().multiplyScalar(0.5));
bond.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize());
this._group.add(bond);
}
// electron cloud
this._cloud(oPos, 1.2, 1500, 0xF15BB5);
this._addLabel('O', 0.3, 0.3, 0, 0xF15BB5);
this._addLabel('H', h1.x - 0.4, h1.y - 0.3, 0, 0xffffff);
this._addLabel('H', h2.x + 0.2, h2.y - 0.3, 0, 0xffffff);
this._addLabel('104.5°', 0, -0.5, 0, 0x888888);
}
/* ── text label (using sprite) ── */
_addLabel(text, x, y, z, color) {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 48;
const ctx = canvas.getContext('2d');
ctx.font = 'bold 28px Manrope, sans-serif';
ctx.fillStyle = '#' + color.toString(16).padStart(6, '0');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 64, 24);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(mat);
sprite.position.set(x, y, z);
sprite.scale.set(1, 0.4, 1);
this._group.add(sprite);
}
/* ── animation ── */
_loop() {
if (!this._running) return;
requestAnimationFrame(() => this._loop());
if (this._autoSpin) this._rotY += 0.004;
this.camera.position.set(
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
this._dist * Math.sin(this._rotX),
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
);
this.camera.lookAt(0, 0, 0);
this.renderer.render(this.scene, this.camera);
}
}
/* ─── lab UI init ─────────────────────────────────── */
var orbitalsSim = null;
function _openOrbitals() {
document.getElementById('sim-topbar-title').textContent = 'Молекулярные орбитали';
_simShow('sim-orbitals');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!orbitalsSim) {
orbitalsSim = new OrbitalsSim(document.getElementById('orbitals-container'));
} else {
orbitalsSim.fit();
orbitalsSim.play();
}
}));
}
function setOrbital(mode, btn) {
document.querySelectorAll('.orbital-mode-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
btn.classList.add('active');
btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
if (orbitalsSim) orbitalsSim.setMode(mode);
}
/* ── stereometry 3D ── */