Files
Learn_System/frontend/js/labs/bohratom.js
T
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

692 lines
22 KiB
JavaScript
Raw 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';
/* ══════════════════════════════════════════════════════════════
BohrAtomSim — Bohr atomic model simulation (hydrogen)
E_n = 13.6 / n² eV λ = 1240 / ΔE nm
Orbital animation · energy diagram · spectrum bar
══════════════════════════════════════════════════════════════ */
class BohrAtomSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.level = 2; // current energy level n (16)
this._angle = 0; // electron orbital angle
this._lastTransition = null; // { from, to, deltaE, wavelength, series }
this._emittedPhotons = []; // wavelengths emitted so far
/* transition animation */
this._trans = null; // { from, to, t, dur, photon }
this._photons = []; // flying photon particles [{x,y,vx,vy,color,t,maxT}]
/* spectrum marks */
this._specMarks = []; // wavelengths (nm)
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
/* interaction */
this._hoverLevel = null;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
getParams() { return { level: this.level }; }
setParams({ level } = {}) {
if (level !== undefined) {
const n = Math.max(1, Math.min(6, Math.round(+level)));
if (n !== this.level) this.transition(this.level, n);
}
this.draw();
this._emit();
}
transition(from, to) {
from = Math.max(1, Math.min(6, Math.round(+from)));
to = Math.max(1, Math.min(6, Math.round(+to)));
if (from === to) return;
const eFrom = -13.6 / (from * from);
const eTo = -13.6 / (to * to);
const deltaE = Math.abs(eTo - eFrom);
const wl = 1240 / deltaE;
const color = this._wavelengthToColor(wl);
const series = this._seriesName(from, to);
this._lastTransition = { from, to, deltaE, wavelength: wl, series };
const isEmission = from > to;
if (isEmission) this._emittedPhotons.push(wl);
/* push spectrum mark */
if (!this._specMarks.includes(Math.round(wl))) {
this._specMarks.push(Math.round(wl));
}
/* start animation */
this._trans = {
from, to, t: 0, dur: 0.5,
color, wavelength: wl,
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();
}
preset(name) {
const presets = {
lyman_alpha: { from: 2, to: 1 },
balmer_alpha: { from: 3, to: 2 },
balmer_beta: { from: 4, to: 2 },
paschen: { from: 4, to: 3 },
};
const p = presets[name];
if (!p) return;
this.level = p.from;
this.transition(p.from, p.to);
}
reset() {
this.pause();
this.level = 2;
this._angle = 0;
this._lastTransition = null;
this._emittedPhotons = [];
this._specMarks = [];
this._trans = null;
this._photons = [];
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const n = this.level;
const en = -13.6 / (n * n);
return {
level: n,
energy: +en.toFixed(4),
lastTransition: this._lastTransition ? { ...this._lastTransition } : null,
emittedPhotons: this._emittedPhotons.slice(),
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_energyOf(n) { return -13.6 / (n * n); }
_seriesName(from, to) {
const lo = Math.min(from, to);
if (lo === 1) return 'Lyman';
if (lo === 2) return 'Balmer';
if (lo === 3) return 'Paschen';
if (lo === 4) return 'Brackett';
if (lo === 5) return 'Pfund';
return '';
}
_wavelengthToColor(nm) {
if (nm < 380) return '#9B5DE5';
if (nm > 780) return '#EF476F';
/* approximate visible spectrum */
let r = 0, g = 0, b = 0;
if (nm < 450) {
const t = (nm - 380) / 70;
r = (1 - t) * 0.6; g = 0; b = 1;
} else if (nm < 495) {
const t = (nm - 450) / 45;
r = 0; g = t; b = 1;
} else if (nm < 570) {
const t = (nm - 495) / 75;
r = t; g = 1; b = 1 - t;
} else if (nm < 590) {
const t = (nm - 570) / 20;
r = 1; g = 1 - t * 0.5; b = 0;
} else if (nm < 620) {
const t = (nm - 590) / 30;
r = 1; g = 0.5 - t * 0.5; b = 0;
} else {
const t = Math.min((nm - 620) / 160, 1);
r = 1; g = 0; b = 0;
}
const clamp = v => Math.max(0, Math.min(255, Math.round(v * 255)));
return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`;
}
/* ── tick / animate ────────────────────────── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const dt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
/* orbital motion — angular speed inversely proportional to n */
const omega = (2.5 / this.level);
this._angle += omega * dt * 2 * Math.PI;
if (this._angle > Math.PI * 2) this._angle -= Math.PI * 2;
/* transition animation */
if (this._trans) {
this._trans.t += dt;
if (this._trans.t >= this._trans.dur) {
this.level = this._trans.to;
/* spawn photon */
if (this._trans.isEmission) {
const cx = this.W * 0.325;
const cy = (this.H - 44) * 0.5;
const a = this._angle;
const r = this._orbitRadius(this._trans.to);
const ex = cx + r * Math.cos(a);
const ey = cy + r * Math.sin(a);
const pa = Math.random() * Math.PI * 2;
this._photons.push({
x: ex, y: ey,
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();
}
}
/* update photons */
for (const p of this._photons) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.t += dt;
}
this._photons = this._photons.filter(p => p.t < p.maxT);
if (window.LabFX) LabFX.particles.update(dt * 1000);
this.draw();
this._tick();
});
}
/* ── geometry helpers ──────────────────────── */
_orbitRadius(n) {
const maxR = Math.min(this.W * 0.325, (this.H - 44) * 0.5) * 0.85;
return 18 + (n - 1) * (maxR - 18) / 5;
}
_diagramLevelY(n) {
/* energy diagram in right panel; map energy <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> y */
const panelTop = 30;
const panelBot = this.H - 74;
const eMin = -13.6; // n=1
const eMax = -0.378; // n=6
const en = this._energyOf(n);
const t = (en - eMin) / (eMax - eMin);
return panelBot - t * (panelBot - panelTop);
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const atomW = W * 0.65;
const panelX = atomW;
const specH = 44; // spectrum bar height at bottom
/* divider */
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(atomW - 1, 0, 2, H - specH);
this._drawAtom(ctx, atomW, H - specH);
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%) ───────────────────────── */
_drawAtom(ctx, aW, aH) {
const cx = aW * 0.5;
const cy = aH * 0.5;
/* nucleus glow */
const ng = ctx.createRadialGradient(cx, cy, 0, cx, cy, 20);
ng.addColorStop(0, 'rgba(255,220,80,0.9)');
ng.addColorStop(0.3, 'rgba(255,200,60,0.3)');
ng.addColorStop(1, 'rgba(255,200,60,0)');
ctx.fillStyle = ng;
ctx.beginPath(); ctx.arc(cx, cy, 20, 0, Math.PI * 2); ctx.fill();
/* nucleus dot */
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fill();
/* orbitals */
for (let n = 1; n <= 6; n++) {
const r = this._orbitRadius(n);
const isCurrent = n === this._currentDisplayLevel();
const alpha = isCurrent ? 0.6 : 0.15;
ctx.strokeStyle = isCurrent ? '#06D6E0' : `rgba(255,255,255,${alpha})`;
ctx.lineWidth = isCurrent ? 2 : 1;
ctx.setLineDash(isCurrent ? [] : [4, 4]);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
/* label */
const en = this._energyOf(n);
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.35)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(`n=${n} ${en.toFixed(2)} eV`, cx + r + 6, cy - 4);
}
/* electron */
const eLevel = this._currentDisplayLevel();
let eAngle = this._angle;
let eR = this._orbitRadius(eLevel);
/* during transition: interpolate radius */
if (this._trans) {
const prog = Math.min(this._trans.t / this._trans.dur, 1);
const ease = prog * prog * (3 - 2 * prog); // smoothstep
const rFrom = this._orbitRadius(this._trans.from);
const rTo = this._orbitRadius(this._trans.to);
eR = rFrom + (rTo - rFrom) * ease;
}
const ex = cx + eR * Math.cos(eAngle);
const ey = cy + eR * Math.sin(eAngle);
/* electron glow */
const eg = ctx.createRadialGradient(ex, ey, 0, ex, ey, 14);
eg.addColorStop(0, 'rgba(6,214,224,0.8)');
eg.addColorStop(0.4, 'rgba(6,214,224,0.2)');
eg.addColorStop(1, 'rgba(6,214,224,0)');
ctx.fillStyle = eg;
ctx.beginPath(); ctx.arc(ex, ey, 14, 0, Math.PI * 2); ctx.fill();
/* electron dot */
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(ex, ey, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(ex - 1.5, ey - 1.5, 1.5, 0, Math.PI * 2); ctx.fill();
/* title */
ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('Модель атома Бора (водород)', aW * 0.5, 10);
}
_currentDisplayLevel() {
if (this._trans) return this._trans.from;
return this.level;
}
/* ── energy diagram (right 35%) ────────────── */
_drawEnergyDiagram(ctx, x0, W, pH) {
const pW = W - x0;
const pad = { l: 52, r: 16, t: 30, b: 20 };
const lineX0 = x0 + pad.l;
const lineX1 = W - pad.r;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, pW, pH);
/* title */
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Энергетические уровни', x0 + 10, 10);
/* draw each level */
for (let n = 1; n <= 6; n++) {
const y = this._diagramLevelY(n);
const en = this._energyOf(n);
const isCurrent = n === this.level && !this._trans;
/* line */
ctx.strokeStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.3)';
ctx.lineWidth = isCurrent ? 2.5 : 1.5;
ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke();
/* hover highlight */
if (this._hoverLevel === n && n !== this.level) {
ctx.strokeStyle = 'rgba(155,93,229,0.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke();
}
/* n label (right) */
ctx.font = "11px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.6)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(`n=${n}`, lineX0 - 4, y);
/* energy label (left of n) */
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'right';
ctx.fillText(`${en.toFixed(2)}`, lineX0 - 30, y);
/* dot on current level */
if (isCurrent) {
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(lineX0 + 8, y, 4, 0, Math.PI * 2); ctx.fill();
}
}
/* transition arrow */
if (this._lastTransition) {
const lt = this._lastTransition;
const y1 = this._diagramLevelY(lt.from);
const y2 = this._diagramLevelY(lt.to);
const ax = (lineX0 + lineX1) * 0.5 + 10;
const col = this._wavelengthToColor(lt.wavelength);
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ax, y1); ctx.lineTo(ax, y2); ctx.stroke();
/* arrowhead */
const dir = y2 > y1 ? 1 : -1;
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(ax, y2);
ctx.lineTo(ax - 5, y2 - dir * 8);
ctx.lineTo(ax + 5, y2 - dir * 8);
ctx.closePath(); ctx.fill();
/* ΔE and λ labels */
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = col;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const midY = (y1 + y2) / 2;
ctx.fillText(`ΔE=${lt.deltaE.toFixed(2)} eV`, ax + 8, midY - 8);
ctx.fillText(`λ=${lt.wavelength.toFixed(1)} nm`, ax + 8, midY + 8);
/* series name */
if (lt.series) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillText(lt.series, ax + 8, midY + 22);
}
}
}
/* ── spectrum bar (bottom) ─────────────────── */
_drawSpectrumBar(ctx, W, H, barH) {
const y0 = H - barH;
/* background strip */
ctx.fillStyle = 'rgba(5,5,20,0.9)';
ctx.fillRect(0, y0, W, barH);
/* label */
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Спектр', 6, y0 + 2);
/* visible spectrum gradient */
const gradX0 = 50, gradX1 = W - 16;
const gradW = gradX1 - gradX0;
const gradY = y0 + 14, gradH = 16;
const nmMin = 380, nmMax = 780;
for (let px = 0; px < gradW; px++) {
const nm = nmMin + (px / gradW) * (nmMax - nmMin);
ctx.fillStyle = this._wavelengthToColor(nm);
ctx.globalAlpha = 0.6;
ctx.fillRect(gradX0 + px, gradY, 1, gradH);
}
ctx.globalAlpha = 1;
/* border */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.strokeRect(gradX0, gradY, gradW, gradH);
/* nm tick labels */
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let nm = 400; nm <= 750; nm += 50) {
const px = gradX0 + ((nm - nmMin) / (nmMax - nmMin)) * gradW;
ctx.fillText(nm, px, gradY + gradH + 2);
}
/* UV / IR labels */
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'right';
ctx.fillText('UV', gradX0 - 4, gradY + 4);
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left';
ctx.fillText('IR', gradX1 + 4, gradY + 4);
/* emission marks */
for (const wl of this._specMarks) {
let px;
if (wl < nmMin) {
px = gradX0 - 6;
} else if (wl > nmMax) {
px = gradX1 + 6;
} else {
px = gradX0 + ((wl - nmMin) / (nmMax - nmMin)) * gradW;
}
const col = this._wavelengthToColor(wl);
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, gradY - 3); ctx.lineTo(px, gradY + gradH + 3); ctx.stroke();
/* tiny wavelength label above */
ctx.font = "7px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(wl, px, gradY - 4);
}
}
/* ── flying photons ────────────────────────── */
_drawPhotons(ctx) {
for (const p of this._photons) {
const alpha = 1 - p.t / p.maxT;
const r = 4 + p.t * 6;
/* glow */
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 2);
g.addColorStop(0, p.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = alpha * 0.5;
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p.x, p.y, r * 2, 0, Math.PI * 2); ctx.fill();
/* core */
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, r * 0.5, 0, Math.PI * 2); ctx.fill();
/* wavy trail */
ctx.strokeStyle = p.color;
ctx.lineWidth = 1;
ctx.globalAlpha = alpha * 0.4;
ctx.beginPath();
const len = 30;
const vMag = Math.hypot(p.vx, p.vy) || 1;
const dx = -p.vx / vMag, dy = -p.vy / vMag;
const nx = -dy, ny = dx;
for (let i = 0; i <= len; i++) {
const t = i / len;
const wx = p.x + dx * i * 1.5 + nx * Math.sin(t * 8 + p.t * 12) * 3;
const wy = p.y + dy * i * 1.5 + ny * Math.sin(t * 8 + p.t * 12) * 3;
i === 0 ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
mx: (t.clientX - r.left) * (this.W / r.width),
my: (t.clientY - r.top) * (this.H / r.height),
};
};
const hitLevel = (mx, my) => {
/* check energy diagram area */
const panelX = this.W * 0.65;
if (mx < panelX) return null;
const pH = this.H - 44;
const pad = { l: 52, r: 16 };
const lineX0 = panelX + pad.l;
const lineX1 = this.W - pad.r;
for (let n = 1; n <= 6; n++) {
const y = this._diagramLevelY(n);
if (mx >= lineX0 - 10 && mx <= lineX1 + 10 && Math.abs(my - y) < 10) {
return n;
}
}
return null;
};
/* click on level <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> transition */
cv.addEventListener('click', e => {
const { mx, my } = getPos(e);
const n = hitLevel(mx, my);
if (n !== null && n !== this.level && !this._trans) {
this.transition(this.level, n);
}
});
/* hover cursor */
cv.addEventListener('mousemove', e => {
const { mx, my } = getPos(e);
const n = hitLevel(mx, my);
this._hoverLevel = n;
cv.style.cursor = (n !== null && n !== this.level) ? 'pointer' : 'default';
});
cv.addEventListener('mouseleave', () => {
this._hoverLevel = null;
});
/* touch tap */
cv.addEventListener('touchend', e => {
if (e.changedTouches.length !== 1) return;
const r = cv.getBoundingClientRect();
const mx = (e.changedTouches[0].clientX - r.left) * (this.W / r.width);
const my = (e.changedTouches[0].clientY - r.top) * (this.H / r.height);
const n = hitLevel(mx, my);
if (n !== null && n !== this.level && !this._trans) {
this.transition(this.level, n);
}
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openBohrAtom() {
document.getElementById('sim-topbar-title').textContent = 'Атом Бора';
_simShow('sim-bohratom');
_registerSimState('bohratom', () => bohrSim?.getParams(), st => bohrSim?.setParams(st));
if (_embedMode) _startStateEmit('bohratom');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!bohrSim) {
bohrSim = new BohrAtomSim(document.getElementById('bohratom-canvas'));
bohrSim.onUpdate = _bohrUpdateUI;
}
bohrSim.fit();
bohrSim.play();
}));
}
function bohrLevel(n) {
if (bohrSim) {
const from = bohrSim.info().level;
if (from !== n) bohrSim.transition(from, n);
}
}
function bohrTransition(from, to) {
if (bohrSim) bohrSim.transition(from, to);
}
function _bohrUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('bohrbar-v1', info.level);
v('bohrbar-v2', info.energy.toFixed(2));
if (info.lastTransition) {
v('bohrbar-v3', info.lastTransition.wavelength.toFixed(0));
v('bohrbar-v4', info.lastTransition.series || '—');
}
}
/* ── electrolysis ── */