feat(labs): wave 3 — 5 new sims + optics merger

Оптическая скамья (opticsbench) — merger thinlens + mirror + refraction
- 4 режима: «Свободная сборка» / «Линза» / «Зеркало» / «Преломление»
- Все 3 движка слиты в OpticsBenchSim (1583 строк)
- Backward compat: #thinlens / #mirrors / #refraction → #opticsbench
- Удалены: thinlens.js, mirror.js, refraction.js

Радиоактивный распад (radioactive) — новая сима
- Monte-Carlo распад: λ·dt вероятность на тик, частицы меняют цвет, эмитируются α/β/γ
- Real-time N(t) график с теоретической кривой N₀·exp(-λt)
- 7 изотопов: ¹⁴C, ¹³¹I, ¹³⁷Cs, ²²⁶Ra, ⁴⁰K, ²³⁸U-chain, ²³⁵U-chain
- Цепочки распадов (U-238: 14 шагов сокращены до 5 ключевых)
- Dating mode для C-14: t = ln(N₀/N)/λ
- HUD: периодов прошло, % распалось, активность в Бк

Тепловые двигатели (heatengine) — новая сима
- 4 цикла: Карно / Отто / Дизель / Брайтон
- PV-диаграмма с замкнутым циклом, заполненной площадью работы
- Аналитически точные изотермы (PV=nRT) и адиабаты (PV^γ=const)
- Анимированный поршень с резервуарами (красный T_h / синий T_c)
- Частицы газа, скорость ∝ √T
- Hover-tooltips с формулами для каждого сегмента

Логические схемы (logic) — новая сима для информатики
- Drag-drop конструктор: 12 типов компонентов (INPUT/CLOCK/OUTPUT/AND/OR/NOT/XOR/NAND/NOR/XNOR/BUF/wire)
- Топологическая сортировка для propagation, цветовая подсветка HIGH/LOW
- Авто-генерация булевого выражения (∧ ∨ ¬ ⊕)
- Авто-таблица истинности (до 2^6 = 64 строк)
- 6 пресетов: полусумматор, полный сумматор, RS-триггер, D-триггер, декодер 2-в-4, мультиплексор 2-в-1

Стехиометрия (stoichiometry) — новая сима
- 10 реакций: Zn+HCl, H₂+O₂, CH₄+O₂, N₂+H₂ (Габер), Al+CuSO₄, Mg+O₂, CaCO₃→, HCl+NaOH, KMnO₄→, C₂H₅OH+O₂
- Sliders с переключением m/n/V (для газов V=n·22.4 при н.у.)
- Анимация частиц при реакции, подсветка лимитирующего реагента
- Пошаговый расчёт m→n→n_product→m_product с KaTeX
- HUD: лимит, избытки, теоретический выход

Каталог: 33 → 35 сим (5 новых − 3 удалённых merger)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 13:25:16 +03:00
parent 8f30a8cef6
commit 8b3159b529
13 changed files with 5347 additions and 2232 deletions
+589
View File
@@ -0,0 +1,589 @@
'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 = [];
}
/* ══════════════ 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 });
}
// 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;
// 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();
ctx.font = `bold ${Math.round(8 + alpha * 4)}px Manrope,sans-serif`;
ctx.fillStyle = `rgba(255,255,180,${alpha})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
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);
}
}
_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 (radioactiveSim.playing) {
radioactiveSim.pause();
document.getElementById('rd-play-btn').textContent = 'Старт';
} else {
radioactiveSim.play();
document.getElementById('rd-play-btn').textContent = 'Пауза';
}
}
function radioactiveReset() {
if (!radioactiveSim) return;
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) + '% осталось';
}