'use strict'; /** * RadioactiveSim — Radioactive decay simulation. * Left panel: particle canvas (circles colored by species). * Right panel: N(t) graph with theoretical curve overlay. * Supports single-step decays and short decay chains. * * Decay chains are simplified to 4-5 prominent steps; * the full U-238 chain (14 nuclides) is condensed to 5. */ class RadioactiveSim { constructor(canvas, graphCanvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.graphCanvas = graphCanvas; this.gCtx = graphCanvas.getContext('2d'); /* layout */ this.W = 0; this.H = 0; this.GW = 0; this.GH = 0; this._dpr = 1; /* simulation state */ this.particles = []; // [{x, y, vx, vy, step, flash, flashT}] this.history = []; // [{t, counts:[...per step]}] this._raf = null; this._last = 0; this.simTime = 0; // sim time in seconds (scaled) this.playing = false; /* parameters */ this.isotope = 'C-14'; this.N0 = 500; this.speed = 10; // time multiplier /* callbacks */ this.onUpdate = null; /* load preset */ this._loadIsotope(this.isotope); this._spawn(); new ResizeObserver(() => { this.fit(); }).observe(canvas.parentElement || canvas); } /* ══════════════ isotope presets ══════════════ */ static ISOTOPES = { 'C-14': { label: '¹⁴C', steps: [ { name: '¹⁴C', T_half: 5730 * 3.156e7, type: 'β⁻', color: '#9B5DE5' }, { name: '¹⁴N', T_half: Infinity, type: null, color: '#4CAF50' }, ] }, 'I-131': { label: '¹³¹I', steps: [ { name: '¹³¹I', T_half: 8.02 * 86400, type: 'β⁻', color: '#F15BB5' }, { name: '¹³¹Xe', T_half: Infinity, type: null, color: '#06D6E0' }, ] }, 'Cs-137': { label: '¹³⁷Cs', steps: [ { name: '¹³⁷Cs', T_half: 30.2 * 3.156e7, type: 'β⁻', color: '#FFD166' }, { name: '¹³⁷Ba', T_half: Infinity, type: null, color: '#7BF5A4' }, ] }, 'Ra-226': { label: '²²⁶Ra', steps: [ { name: '²²⁶Ra', T_half: 1600 * 3.156e7, type: 'α', color: '#EF476F' }, { name: '²²²Rn', T_half: 3.82 * 86400, type: 'α', color: '#FF9F1C' }, { name: '²¹⁸Po', T_half: 3.05 * 60, type: 'α', color: '#F15BB5' }, { name: '²¹⁴Pb', T_half: 26.8 * 60, type: 'β⁻', color: '#9B5DE5' }, { name: '²⁰⁶Pb', T_half: Infinity, type: null, color: '#4CAF50' }, ] }, 'K-40': { label: '⁴⁰K', steps: [ { name: '⁴⁰K', T_half: 1.248e9 * 3.156e7, type: 'β⁻/EC', color: '#06D6E0' }, { name: '⁴⁰Ca/⁴⁰Ar', T_half: Infinity, type: null, color: '#7BF5A4' }, ] }, 'U-238': { label: '²³⁸U', // Condensed chain: U-238 → Th-234 → Ra-226 → Rn-222 → Pb-206 (stable) // Full chain has 14 steps; we keep 5 most prominent steps: [ { name: '²³⁸U', T_half: 4.468e9 * 3.156e7, type: 'α', color: '#FFD166' }, { name: '²³⁴Th', T_half: 24.1 * 86400, type: 'β⁻', color: '#F15BB5' }, { name: '²²⁶Ra', T_half: 1600 * 3.156e7, type: 'α', color: '#EF476F' }, { name: '²²²Rn', T_half: 3.82 * 86400, type: 'α', color: '#9B5DE5' }, { name: '²⁰⁶Pb', T_half: Infinity, type: null, color: '#4CAF50' }, ] }, 'U-235': { label: '²³⁵U', // Condensed: U-235 → Pa-231 → Ac-227 → Bi-211 → Pb-207 (stable) steps: [ { name: '²³⁵U', T_half: 7.04e8 * 3.156e7, type: 'α', color: '#FF9F1C' }, { name: '²³¹Pa', T_half: 32760 * 3.156e7, type: 'α', color: '#F15BB5' }, { name: '²²⁷Ac', T_half: 21.77 * 3.156e7, type: 'β⁻', color: '#9B5DE5' }, { name: '²¹¹Bi', T_half: 2.14 * 60, type: 'α', color: '#06D6E0' }, { name: '²⁰⁷Pb', T_half: Infinity, type: null, color: '#4CAF50' }, ] }, }; _loadIsotope(id) { this.isotope = id; const preset = RadioactiveSim.ISOTOPES[id]; this.steps = preset.steps; // λ for each step this.lambdas = this.steps.map(s => s.T_half === Infinity ? 0 : Math.LN2 / s.T_half ); this.simTime = 0; this.history = []; this._lastHalfLifeMark = 0; this._fxDecayCount = 0; } /* ══════════════ public API ══════════════ */ fit() { const dpr = window.devicePixelRatio || 1; this._dpr = dpr; const pw = this.canvas.offsetWidth || 480; const ph = this.canvas.offsetHeight || 400; this.canvas.width = pw * dpr; this.canvas.height = ph * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = pw; this.H = ph; const gw = this.graphCanvas.offsetWidth || 340; const gh = this.graphCanvas.offsetHeight || 400; this.graphCanvas.width = gw * dpr; this.graphCanvas.height = gh * dpr; this.gCtx.setTransform(dpr, 0, 0, dpr, 0, 0); this.GW = gw; this.GH = gh; this._layoutParticles(); this.draw(); } reset() { this.pause(); this._loadIsotope(this.isotope); this._spawn(); this.draw(); this._emit(); } play() { if (this.playing) return; this.playing = true; this._last = performance.now(); this._raf = requestAnimationFrame(ts => this._tick(ts)); } pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } stop() { this.pause(); } setIsotope(id) { if (!RadioactiveSim.ISOTOPES[id]) return; this.isotope = id; this.reset(); } setSpeed(v) { this.speed = Math.max(1, Math.min(1000, +v)); } setN0(v) { this.N0 = Math.max(50, Math.min(2000, +v)); this.reset(); } getParams() { return { isotope: this.isotope, N0: this.N0, speed: this.speed }; } info() { const counts = this._counts(); const T = this.steps[0].T_half; const periods = T === Infinity ? 0 : this.simTime / T; const decayed = this.N0 > 0 ? 1 - counts[0] / this.N0 : 0; const lambda0 = this.lambdas[0]; const activity = Math.round(counts[0] * lambda0); return { periods: periods.toFixed(2), decayPct: (decayed * 100).toFixed(1), activity, counts, names: this.steps.map(s => s.name), }; } /* ══════════════ internal ══════════════ */ _spawn() { this.particles = []; this._flashes = []; const simW = this.W || 480; const simH = this.H || 400; for (let i = 0; i < this.N0; i++) { this.particles.push({ x: Math.random() * simW, y: Math.random() * simH, vx: (Math.random() - 0.5) * 30, vy: (Math.random() - 0.5) * 30, step: 0, // index into this.steps flash: false, flashT: 0, flashSymbol: '', }); } } _layoutParticles() { // re-distribute within new canvas size after fit const W = this.W, H = this.H; if (!W || !H) return; for (const p of this.particles) { if (p.x > W) p.x = Math.random() * W; if (p.y > H) p.y = Math.random() * H; } } _counts() { const c = new Array(this.steps.length).fill(0); for (const p of this.particles) { if (p.step < this.steps.length) c[p.step]++; } return c; } _tick(ts) { if (!this.playing) return; const wallDt = Math.min((ts - this._last) / 1000, 0.05); // s, capped this._last = ts; const dt = wallDt * this.speed; // scaled sim time step // physics + decay const W = this.W, H = this.H; for (const p of this.particles) { // move p.x += p.vx * wallDt; p.y += p.vy * wallDt; // bounce off walls if (p.x < 0) { p.x = 0; p.vx = Math.abs(p.vx); } if (p.x > W) { p.x = W; p.vx = -Math.abs(p.vx); } if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy); } if (p.y > H) { p.y = H; p.vy = -Math.abs(p.vy); } // decay (only if not at final stable step) const step = p.step; const lambda = this.lambdas[step]; if (lambda > 0 && Math.random() < lambda * dt) { p.step = Math.min(step + 1, this.steps.length - 1); // emit flash const decayType = this.steps[step].type || ''; const sym = decayType.startsWith('α') ? 'α' : decayType.startsWith('β') ? 'β' : 'γ'; this._flashes.push({ x: p.x, y: p.y, t: 0, maxT: 0.35, sym }); // LabFX decay effects (throttled) if (window.LabFX) { this._fxFrameDecays = (this._fxFrameDecays || 0) + 1; LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 6, color: '#FFD700', speed: 60, spread: Math.PI * 2, angle: 0, gravity: 0, life: 300, shape: 'spark', glow: true, size: 2, }); } } // age flash on particle itself if (p.flash) { p.flashT -= wallDt; if (p.flashT <= 0) p.flash = false; } } // age global flashes for (let i = this._flashes.length - 1; i >= 0; i--) { this._flashes[i].t += wallDt; if (this._flashes[i].t >= this._flashes[i].maxT) { this._flashes.splice(i, 1); } } this.simTime += dt; // LabFX half-life crossing + throttled tick sound if (window.LabFX) { /* throttle tick: play at most once per frame, only if ≤10 decays/s effective */ const frameDecays = this._fxFrameDecays || 0; this._fxFrameDecays = 0; if (frameDecays > 0) { const decaysPerSec = frameDecays / Math.max(wallDt, 0.001); const N = Math.max(1, Math.round(decaysPerSec / 10)); if (Math.random() < 1 / N) { LabFX.sound.play('tick', { pitch: 0.8 + Math.random() * 0.4, volume: 0.08 }); } } LabFX.particles.update(wallDt); const T0 = this.steps[0].T_half; if (T0 !== Infinity) { const halfLifesElapsed = Math.floor(this.simTime / T0); if (halfLifesElapsed > (this._lastHalfLifeMark || 0)) { this._lastHalfLifeMark = halfLifesElapsed; LabFX.sound.play('chime', { pitch: 0.6 + halfLifesElapsed * 0.1, volume: 0.3, }); } } } // record history every ~2 ticks (≈30ms) const last = this.history[this.history.length - 1]; if (!last || this.simTime - last.t > this.steps[0].T_half * 0.005 || this.history.length < 5) { this._recordHistory(); } this.draw(); this._emit(); this._raf = requestAnimationFrame(ts2 => this._tick(ts2)); } _recordHistory() { this.history.push({ t: this.simTime, counts: this._counts() }); // keep last 500 points if (this.history.length > 500) this.history.shift(); } _emit() { if (this.onUpdate) this.onUpdate(this.info()); } /* ══════════════ drawing ══════════════ */ draw() { this._drawParticles(); this._drawGraph(); } _drawParticles() { const ctx = this.ctx; const W = this.W, H = this.H; if (!W || !H) return; ctx.clearRect(0, 0, W, H); // background ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); // subtle grid ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; const step = 40; ctx.beginPath(); for (let x = 0; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = 0; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); // draw flashes first (under particles) for (const fl of this._flashes) { const alpha = 1 - fl.t / fl.maxT; const r = 6 + fl.t / fl.maxT * 12; ctx.beginPath(); ctx.arc(fl.x, fl.y, r, 0, Math.PI * 2); ctx.fillStyle = `rgba(255,255,200,${alpha * 0.45})`; ctx.fill(); const symSize = Math.round(8 + alpha * 4); ctx.font = `bold ${symSize}px Manrope,sans-serif`; const symColor = `rgba(255,255,180,${alpha})`; ctx.fillStyle = symColor; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if (window.LabFX) { LabFX.glow.drawGlow(ctx, () => { ctx.font = `bold ${symSize}px Manrope,sans-serif`; ctx.fillStyle = symColor; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(fl.sym, fl.x, fl.y - r - 4); }, { color: '#FFFFFF', intensity: 6 }); } else { ctx.fillText(fl.sym, fl.x, fl.y - r - 4); } } // draw particles const R = 4; for (const p of this.particles) { const s = this.steps[p.step]; ctx.beginPath(); ctx.arc(p.x, p.y, R, 0, Math.PI * 2); ctx.fillStyle = s.color; ctx.fill(); } // legend const lx = 10, ly = 10; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; for (let i = 0; i < this.steps.length; i++) { const s = this.steps[i]; const y = ly + i * 18; ctx.fillStyle = s.color; ctx.beginPath(); ctx.arc(lx + 5, y + 6, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.fillText(s.name, lx + 15, y); } if (window.LabFX) LabFX.particles.draw(ctx); } _drawGraph() { const ctx = this.gCtx; const W = this.GW, H = this.GH; if (!W || !H) return; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); const pad = { l: 40, r: 14, t: 20, b: 36 }; const gW = W - pad.l - pad.r; const gH = H - pad.t - pad.b; // grid ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i <= 4; i++) { const y = pad.t + gH - i * gH / 4; ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + gW, y); } for (let i = 0; i <= 5; i++) { const x = pad.l + i * gW / 5; ctx.moveTo(x, pad.t); ctx.lineTo(x, pad.t + gH); } ctx.stroke(); // axes ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + gH); ctx.moveTo(pad.l, pad.t + gH); ctx.lineTo(pad.l + gW, pad.t + gH); ctx.stroke(); // axis labels ctx.font = '10px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let i = 0; i <= 4; i++) { const y = pad.t + gH - i * gH / 4; const val = Math.round(this.N0 * i / 4); ctx.fillText(val, pad.l - 4, y); } const T0 = this.steps[0].T_half; const tMax = T0 === Infinity ? Math.max(this.simTime * 1.1, 1e-6) : T0 * 5; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let i = 0; i <= 5; i++) { const x = pad.l + i * gW / 5; const tVal = tMax * i / 5; const label = T0 === Infinity ? tVal.toFixed(0) + 's' : (tVal / T0).toFixed(1) + 'T'; ctx.fillText(label, x, pad.t + gH + 4); } // axis title ctx.font = '9px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.textAlign = 'left'; ctx.fillText('N', pad.l + 2, pad.t + 2); ctx.textAlign = 'right'; ctx.fillText(T0 === Infinity ? 't, с' : 't / T½', pad.l + gW, pad.t + gH + 28); if (this.history.length < 2) return; const tx = t => pad.l + (t / tMax) * gW; const ty = n => pad.t + gH - (n / this.N0) * gH; // theoretical decay curve for step 0 (semi-transparent) if (T0 !== Infinity) { const lam = this.lambdas[0]; ctx.beginPath(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); const nPts = 80; for (let i = 0; i <= nPts; i++) { const t = tMax * i / nPts; const n = this.N0 * Math.exp(-lam * t); const x = tx(t), y = ty(n); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); ctx.setLineDash([]); } // actual curves per species for (let si = 0; si < this.steps.length; si++) { const color = this.steps[si].color; ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; let first = true; for (const pt of this.history) { const x = tx(pt.t); const y = ty(pt.counts[si]); if (x < pad.l || x > pad.l + gW) continue; first ? ctx.moveTo(x, y) : ctx.lineTo(x, y); first = false; } ctx.stroke(); } // current time marker const curX = tx(this.simTime); if (curX >= pad.l && curX <= pad.l + gW) { ctx.beginPath(); ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.moveTo(curX, pad.t); ctx.lineTo(curX, pad.t + gH); ctx.stroke(); ctx.setLineDash([]); } } } /* ══════════════════════════════════════════════ _openRadioactive — wiring ══════════════════════════════════════════════ */ var radioactiveSim = null; function _openRadioactive() { document.getElementById('sim-topbar-title').textContent = 'Радиоактивный распад'; document.getElementById('ctrl-radioactive').style.display = ''; _simShow('sim-radioactive'); _registerSimState('radioactive', () => radioactiveSim?.getParams(), st => { if (radioactiveSim && st) { if (st.isotope) radioactiveSim.setIsotope(st.isotope); if (st.N0) radioactiveSim.setN0(st.N0); if (st.speed) radioactiveSim.setSpeed(st.speed); }}); if (typeof _embedMode !== 'undefined' && _embedMode) _startStateEmit('radioactive'); requestAnimationFrame(() => requestAnimationFrame(() => { if (!radioactiveSim) { radioactiveSim = new RadioactiveSim( document.getElementById('radioactive-canvas'), document.getElementById('radioactive-graph') ); radioactiveSim.onUpdate = _radioactiveUpdateHUD; } radioactiveSim.fit(); radioactiveSim.reset(); radioactiveSim.play(); _radioactiveUpdateHUD(radioactiveSim.info()); })); } function radioactiveIsotope(id) { if (radioactiveSim) { radioactiveSim.setIsotope(id); radioactiveSim.play(); } } function radioactiveSpeed(val) { if (radioactiveSim) radioactiveSim.setSpeed(+val); const el = document.getElementById('rd-speed-val'); if (el) el.textContent = '×' + (+val).toFixed(0); } function radioactiveN0(val) { if (radioactiveSim) radioactiveSim.setN0(+val); const el = document.getElementById('rd-n0-val'); if (el) el.textContent = val; } function radioactivePlay() { if (!radioactiveSim) return; if (window.LabFX) LabFX.sound.play('click'); if (radioactiveSim.playing) { radioactiveSim.pause(); document.getElementById('rd-play-btn').textContent = 'Старт'; } else { radioactiveSim.play(); document.getElementById('rd-play-btn').textContent = 'Пауза'; } } function radioactiveReset() { if (!radioactiveSim) return; if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.5, volume: 0.3 }); radioactiveSim._lastHalfLifeMark = 0; radioactiveSim._fxDecayCount = 0; radioactiveSim.reset(); document.getElementById('rd-play-btn').textContent = 'Старт'; } function _radioactiveUpdateHUD(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('rd-hud-periods', info.periods + ' T½'); v('rd-hud-decayed', info.decayPct + '%'); v('rd-hud-activity', info.activity + ' Бк'); } /* ── dating mode ── */ function radioactiveDating(pctLeft) { // pct of parent remaining (0-100) const ratio = Math.max(0.001, Math.min(0.999, (+pctLeft) / 100)); const T = radioactiveSim ? radioactiveSim.steps[0].T_half : null; if (!T || T === Infinity) return; const lambda = Math.LN2 / T; const age = -Math.log(ratio) / lambda; const el = document.getElementById('rd-dating-result'); if (el) { const years = (age / 3.156e7).toExponential(3); el.textContent = 'Возраст: ' + years + ' лет'; } const pctEl = document.getElementById('rd-dating-pct-val'); if (pctEl) pctEl.textContent = (+pctLeft).toFixed(0) + '% осталось'; }