6afe928c0d
ФУНДАМЕНТ (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>
646 lines
20 KiB
JavaScript
646 lines
20 KiB
JavaScript
'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) + '% осталось';
|
||
}
|