'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 (1–6) 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 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 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 ── */