ae31e4c4e8
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only) Each sim's _open*() + UI helpers moved to its engine file: graph.js, projectile.js, collision.js, magnetic.js, triangle.js, geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js, reactions.js (chemistry), newton.js (dynamics), chemsandbox.js, celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js, normaldist.js, graphtransform.js, pendulum.js, equilibrium.js, thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js, probability.js, bohratom.js, electrolysis.js, waves.js, crystal.js, orbitals.js, stereo.js, hydrostatics.js All 34 engine files syntax-checked OK.
681 lines
22 KiB
JavaScript
681 lines
22 KiB
JavaScript
'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 <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);
|
||
}
|
||
|
||
/* ── 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 ── */
|
||
|