'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 (!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,
});
}
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);
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);
}
/* ── 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);
}
});
}
}