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
+24
View File
@@ -1087,3 +1087,27 @@
text-shadow: 0 0 30px rgba(74,222,128,.7), 0 2px 6px rgba(0,0,0,.8);
animation: chall-success-fade 2.2s ease-out forwards;
}
/* ── Logic Circuits truth table ── */
.logic-tt {
border-collapse: collapse;
font-size: 0.76rem;
color: rgba(255,255,255,0.75);
}
.logic-tt th {
padding: 3px 10px;
background: rgba(155,93,229,0.15);
color: var(--violet);
font-weight: 700;
border: 1px solid rgba(255,255,255,0.08);
text-align: center;
}
.logic-tt td {
padding: 2px 10px;
border: 1px solid rgba(255,255,255,0.06);
text-align: center;
}
.logic-tt-cur td {
background: rgba(155,93,229,0.18);
font-weight: 700;
}
+5 -3
View File
@@ -22,11 +22,12 @@
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
{ id: 'opticsbench', cat: 'Физика', title: 'Оптическая скамья' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'heatengine', cat: 'Физика', title: 'Тепловые двигатели' },
{ id: 'radioactive', cat: 'Физика', title: 'Радиоактивный распад' },
{ id: 'logic', cat: 'Физика', title: 'Логические схемы' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
@@ -35,6 +36,7 @@
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
{ id: 'stoichiometry', cat: 'Химия', title: 'Стехиометрия' },
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
+920
View File
@@ -0,0 +1,920 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
HeatEngineSim — тепловые двигатели
Поддерживаемые циклы: Carnot, Otto, Diesel, Brayton
n = 1 моль, R = 8.314 Дж/(моль·К), γ = 1.4
Левая часть: PV-диаграмма (Canvas 2D) — замкнутый цикл A→B→C→D→A
Правая часть: анимация поршня + частицы газа
══════════════════════════════════════════════════════════════ */
class HeatEngineSim {
constructor(pvCanvas, pistonCanvas) {
this._pv = pvCanvas;
this._pis = pistonCanvas;
this._pvCtx = pvCanvas.getContext('2d');
this._pisCtx = pistonCanvas.getContext('2d');
/* physics constants */
this.n = 1;
this.R = 8.314;
this.gamma = 1.4;
/* parameters */
this.cycleType = 'carnot'; // 'carnot' | 'otto' | 'diesel' | 'brayton'
this.Th = 800; // K
this.Tc = 300; // K
this.cr = 8; // compression ratio (Otto/Diesel)
/* animation state */
this._running = false;
this._t = 0; // 0..1 progress through cycle
this._speed = 0.004; // dt per frame
this._raf = null;
/* PV viewport */
this._ML = 58; this._MB = 46; this._MT = 28; this._MR = 22;
this._pvW = 0; this._pvH = 0;
this._Vmin = 0; this._Vmax = 1;
this._Pmin = 0; this._Pmax = 1;
/* particles for piston view */
this._particles = [];
this._initParticles(40);
/* hover state */
this._hoverSeg = -1;
this._pv.addEventListener('mousemove', e => this._onPvMove(e));
this._pv.addEventListener('mouseleave', () => { this._hoverSeg = -1; this._drawPv(); });
/* tooltip */
this._tooltip = null;
/* callbacks */
this.onStats = null; // called with stats object
new ResizeObserver(() => { this.fit(); this._drawPv(); this._drawPiston(); }).observe(pvCanvas.parentElement || pvCanvas);
}
/* ── public API ─────────────────────────────────────── */
setCycle(type) { this.cycleType = type; this._recompute(); }
setTh(v) { this.Th = Math.max(this.Tc + 10, +v); this._recompute(); }
setTc(v) { this.Tc = Math.max(200, Math.min(this.Th - 10, +v)); this._recompute(); }
setCR(v) { this.cr = Math.max(2, Math.min(20, +v)); this._recompute(); }
start() { if (!this._running) { this._running = true; this._loop(); } }
pause() { this._running = false; cancelAnimationFrame(this._raf); }
stop() { this.pause(); this._t = 0; this._drawPv(); this._drawPiston(); }
step() { this._t = (this._t + this._speed * 10) % 1; this._drawPv(); this._drawPiston(); }
reset() { this.stop(); this._recompute(); }
fit() {
const dpr = window.devicePixelRatio || 1;
for (const [cv, ctx, side] of [
[this._pv, this._pvCtx, 'pv'],
[this._pis, this._pisCtx, 'pis'],
]) {
const w = cv.offsetWidth || 400;
const h = cv.offsetHeight || 300;
cv.width = w * dpr;
cv.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (side === 'pv') { this._pvW = w; this._pvH = h; }
}
}
/* ── cycle computation ───────────────────────────────── */
_recompute() {
this._nodes = this['_nodes_' + this.cycleType]();
this._updateViewport();
this._emitStats();
this._drawPv();
this._drawPiston();
}
/* Returns array of {V,P,T,label,phase} nodes.
Segments: nodes[i] → nodes[(i+1)%n], phase type on segment start. */
_nodes_carnot() {
const { n, R, gamma, Th, Tc } = this;
/* choose V_A so diagarm looks good; derive B,C,D from Carnot chain */
const VA = 0.01; // m³ — 10 L
const PA = n * R * Th / VA;
/* A→B: isothermal at Th; pick V_B = 3*VA */
const VB = 3 * VA;
const PB = n * R * Th / VB;
/* B→C: adiabatic expansion to Tc
Tc/Th = (VB/VC)^(γ-1) → VC = VB*(Th/Tc)^(1/(γ-1)) */
const VC = VB * Math.pow(Th / Tc, 1 / (gamma - 1));
const PC = n * R * Tc / VC;
/* C→D: isothermal at Tc, pick VD so D→A is adiabatic
PD*VD^γ = PA*VA^γ and PD = nRTc/VD → VD = VA*(Th/Tc)^(1/(γ-1)) */
const VD = VA * Math.pow(Th / Tc, 1 / (gamma - 1));
const PD = n * R * Tc / VD;
return [
{ V: VA, P: PA, T: Th, label: 'A', phase: 'isotherm_hot' },
{ V: VB, P: PB, T: Th, label: 'B', phase: 'adiabat_exp' },
{ V: VC, P: PC, T: Tc, label: 'C', phase: 'isotherm_cold' },
{ V: VD, P: PD, T: Tc, label: 'D', phase: 'adiabat_comp' },
];
}
_nodes_otto() {
const { n, R, gamma, Th, Tc, cr } = this;
/* Otto: 1→2 adiabatic comp, 2→3 isochoric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
const V1 = 0.02; // m³ — BDC
const V2 = V1 / cr; // TDC
const T1 = Tc;
const P1 = n * R * T1 / V1;
/* adiabatic compression 1→2 */
const T2 = T1 * Math.pow(cr, gamma - 1);
const P2 = P1 * Math.pow(cr, gamma);
/* isochoric heat 2→3: peak temp = Th */
const T3 = Th;
const P3 = P2 * (T3 / T2);
const V3 = V2;
/* adiabatic expansion 3→4 */
const T4 = T3 / Math.pow(cr, gamma - 1);
const P4 = P3 / Math.pow(cr, gamma);
const V4 = V1;
return [
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isochoric_hot' },
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
];
}
_nodes_diesel() {
const { n, R, gamma, Th, Tc, cr } = this;
/* Diesel: 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
const V1 = 0.02;
const V2 = V1 / cr;
const T1 = Tc;
const P1 = n * R * T1 / V1;
/* adiabatic comp 1→2 */
const T2 = T1 * Math.pow(cr, gamma - 1);
const P2 = P1 * Math.pow(cr, gamma);
/* isobaric heat 2→3 to Th */
const T3 = Th;
const V3 = V2 * (T3 / T2);
const P3 = P2;
/* cutoff ratio */
const rc = V3 / V2;
/* adiabatic exp 3→4 */
const V4 = V1;
const P4 = P3 * Math.pow(V3 / V4, gamma);
const T4 = P4 * V4 / (n * R);
return [
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
];
}
_nodes_brayton() {
const { n, R, gamma, Th, Tc, cr } = this;
/* Brayton (gas turbine): 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isobaric cool */
const V1 = 0.025;
const T1 = Tc;
const P1 = n * R * T1 / V1;
const P2 = P1 * cr; // pressure ratio
/* adiabatic comp */
const T2 = T1 * Math.pow(cr, (gamma - 1) / gamma);
const V2 = n * R * T2 / P2;
/* isobaric heat to Th */
const T3 = Th;
const V3 = n * R * T3 / P2;
const P3 = P2;
/* adiabatic exp */
const P4 = P1;
const T4 = T3 / Math.pow(cr, (gamma - 1) / gamma);
const V4 = n * R * T4 / P4;
return [
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
];
}
/* ── viewport & stats ─────────────────────────────── */
_updateViewport() {
const ns = this._nodes;
if (!ns || !ns.length) return;
const Vs = ns.map(n => n.V), Ps = ns.map(n => n.P);
const Vmin = Math.min(...Vs), Vmax = Math.max(...Vs);
const Pmin = Math.min(...Ps), Pmax = Math.max(...Ps);
const dV = Vmax - Vmin || 0.001;
const dP = Pmax - Pmin || 1;
this._Vmin = Vmin - dV * 0.18;
this._Vmax = Vmax + dV * 0.18;
this._Pmin = Pmin - dP * 0.18;
this._Pmax = Pmax + dP * 0.18;
}
_emitStats() {
if (!this.onStats) return;
const ns = this._nodes;
if (!ns) return;
/* calculate Q_hot, Q_cold, W_net by summing segment works */
let W = 0, Qh = 0, Qc = 0;
const { n: nn, R, gamma } = this;
for (let i = 0; i < ns.length; i++) {
const a = ns[i], b = ns[(i + 1) % ns.length];
const seg = a.phase;
const dW = this._segWork(a, b, seg);
const dQ = this._segHeat(a, b, seg, dW);
W += dW;
if (dQ > 0) Qh += dQ; else Qc += dQ;
}
const etaCarnot = 1 - this.Tc / this.Th;
const eta = Qh > 0 ? W / Qh : 0;
this.onStats({
Th: Math.round(this.Th),
Tc: Math.round(this.Tc),
etaCarnot: (etaCarnot * 100).toFixed(1),
eta: (Math.max(0, eta) * 100).toFixed(1),
Qh: Math.round(Qh),
Qc: Math.round(Math.abs(Qc)),
W: Math.round(W),
});
}
_segWork(a, b, phase) {
const { n, R, gamma } = this;
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
return n * R * a.T * Math.log(b.V / a.V);
}
if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
return (a.P * a.V - b.P * b.V) / (gamma - 1);
}
if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
return 0;
}
if (phase === 'isobar_hot' || phase === 'isobar_cold' || phase === 'isochoric_cold') {
return a.P * (b.V - a.V);
}
return 0;
}
_segHeat(a, b, phase, W) {
const { n, R, gamma } = this;
const Cv = R / (gamma - 1);
const dU = n * Cv * (b.T - a.T);
if (phase === 'adiabat_exp' || phase === 'adiabat_comp') return 0;
return dU + W;
}
/* ── animation loop ──────────────────────────────── */
_loop() {
if (!this._running) return;
this._t = (this._t + this._speed) % 1;
this._updateParticles();
this._drawPv();
this._drawPiston();
this._raf = requestAnimationFrame(() => this._loop());
}
/* ── interpolated state ──────────────────────────── */
_stateAt(t) {
const ns = this._nodes;
if (!ns || ns.length < 2) return null;
const N = ns.length;
const seg = Math.floor(t * N);
const u = (t * N) - seg;
const a = ns[seg % N];
const b = ns[(seg + 1) % N];
const phase = a.phase;
let V, P, T;
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
/* isothermal: PV = const → V = Va + u*(Vb-Va), P = const/V */
V = a.V + u * (b.V - a.V);
P = a.P * a.V / V;
T = a.T;
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
/* adiabatic: PV^γ = const → parametric by V */
V = a.V * Math.pow(b.V / a.V, u);
P = a.P * Math.pow(a.V / V, this.gamma);
T = P * V / (this.n * this.R);
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
/* isochoric: V = const */
V = a.V;
P = a.P + u * (b.P - a.P);
T = P * V / (this.n * this.R);
} else {
/* isobaric */
V = a.V + u * (b.V - a.V);
P = a.P;
T = P * V / (this.n * this.R);
}
return { V, P, T, phase, seg };
}
/* ── PV diagram ──────────────────────────────────── */
_vx(v) {
const pw = this._pvW - this._ML - this._MR;
return this._ML + (v - this._Vmin) / (this._Vmax - this._Vmin) * pw;
}
_py(p) {
const ph = this._pvH - this._MT - this._MB;
return this._MT + (1 - (p - this._Pmin) / (this._Pmax - this._Pmin)) * ph;
}
_drawPv() {
const ctx = this._pvCtx;
const W = this._pvW, H = this._pvH;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
if (!this._nodes) return;
this._pvDrawGrid(ctx, W, H);
this._pvDrawCycle(ctx);
this._pvDrawCurrentPoint(ctx);
this._pvDrawPhaseLabel(ctx, W);
this._pvDrawHoverTooltip(ctx, W, H);
}
_pvDrawGrid(ctx, W, H) {
const { _ML: ML, _MT: MT, _MB: MB, _MR: MR } = this;
const pw = W - ML - MR, ph = H - MT - MB;
ctx.fillStyle = 'rgba(255,255,255,0.018)';
ctx.fillRect(ML, MT, pw, ph);
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1; ctx.setLineDash([]);
const vSteps = 6, pSteps = 5;
for (let i = 0; i <= vSteps; i++) {
const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
const x = this._vx(v);
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
}
for (let i = 0; i <= pSteps; i++) {
const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
const y = this._py(p);
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
}
/* axes */
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
ctx.stroke();
/* axis labels */
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('V, м³', ML + pw / 2, MT + ph + 32);
ctx.save();
ctx.translate(13, MT + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P, Па', 0, 0);
ctx.restore();
/* tick labels */
ctx.font = '9px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= pSteps; i++) {
const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
const y = this._py(p);
if (y < MT + 2 || y > MT + ph - 2) continue;
ctx.fillText(this._fmtSci(p), ML - 5, y);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let i = 0; i <= vSteps; i++) {
const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
const x = this._vx(v);
ctx.fillText(this._fmtSci(v), x, MT + ph + 5);
}
}
_pvDrawCycle(ctx) {
const ns = this._nodes;
const N = ns.length;
const segColors = {
isotherm_hot: '#EF476F',
adiabat_exp: '#FFD166',
isotherm_cold: '#06D6E0',
adiabat_comp: '#7BF5A4',
isochoric_hot: '#F15BB5',
isochoric_cold: '#4CC9F0',
isobar_hot: '#F15BB5',
isobar_cold: '#4CC9F0',
};
/* filled area — net work */
ctx.beginPath();
for (let i = 0; i < N; i++) {
const a = ns[i], b = ns[(i + 1) % N];
this._pvSegPath(ctx, a, b, i === 0);
}
ctx.closePath();
ctx.fillStyle = 'rgba(155,93,229,0.12)';
ctx.fill();
/* W label inside */
const cV = ns.reduce((s, nd) => s + nd.V, 0) / N;
const cP = ns.reduce((s, nd) => s + nd.P, 0) / N;
const cx = this._vx(cV), cy = this._py(cP);
ctx.fillStyle = 'rgba(155,93,229,0.75)';
ctx.font = 'bold 10px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('Wцикл', cx, cy - 6);
/* segments */
for (let i = 0; i < N; i++) {
const a = ns[i], b = ns[(i + 1) % N];
const col = segColors[a.phase] || '#9B5DE5';
const hovered = this._hoverSeg === i;
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = hovered ? 3.5 : 2.2;
ctx.globalAlpha = hovered ? 1 : 0.82;
ctx.setLineDash([]);
ctx.beginPath();
this._pvSegPath(ctx, a, b, true);
ctx.stroke();
ctx.restore();
/* arrow at midpoint */
this._pvArrow(ctx, a, b, col);
}
/* node circles & labels */
for (let i = 0; i < N; i++) {
const nd = ns[i];
const x = this._vx(nd.V), y = this._py(nd.P);
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = segColors[nd.phase] || '#9B5DE5';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
/* offset label away from center */
const dv = nd.V - (this._Vmin + this._Vmax) / 2;
const dp = nd.P - (this._Pmin + this._Pmax) / 2;
const ox = dv > 0 ? 13 : -13;
const oy = dp > 0 ? -12 : 12;
ctx.fillText(nd.label, x + ox, y + oy);
}
}
_pvSegPath(ctx, a, b, isFirst) {
const STEPS = 80;
const phase = a.phase;
for (let i = 0; i <= STEPS; i++) {
const u = i / STEPS;
let V, P;
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
V = a.V + u * (b.V - a.V);
P = a.P * a.V / V;
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
V = a.V * Math.pow(b.V / a.V, u);
P = a.P * Math.pow(a.V / V, this.gamma);
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
V = a.V;
P = a.P + u * (b.P - a.P);
} else {
V = a.V + u * (b.V - a.V);
P = a.P;
}
const x = this._vx(V), y = this._py(P);
if (i === 0 && isFirst) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
}
_pvArrow(ctx, a, b, col) {
/* midpoint at u=0.55 */
const phase = a.phase;
const u0 = 0.52, u1 = 0.56;
const [x0, y0] = this._pvPoint(a, b, phase, u0);
const [x1, y1] = this._pvPoint(a, b, phase, u1);
const ang = Math.atan2(y1 - y0, x1 - x0);
const sz = 7;
ctx.save();
ctx.fillStyle = col;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.translate(x1, y1);
ctx.rotate(ang);
ctx.moveTo(sz, 0);
ctx.lineTo(-sz * 0.6, -sz * 0.4);
ctx.lineTo(-sz * 0.6, sz * 0.4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
_pvPoint(a, b, phase, u) {
let V, P;
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
V = a.V + u * (b.V - a.V); P = a.P * a.V / V;
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
V = a.V * Math.pow(b.V / a.V, u); P = a.P * Math.pow(a.V / V, this.gamma);
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
V = a.V; P = a.P + u * (b.P - a.P);
} else {
V = a.V + u * (b.V - a.V); P = a.P;
}
return [this._vx(V), this._py(P)];
}
_pvDrawCurrentPoint(ctx) {
if (!this._running && this._t === 0) return;
const st = this._stateAt(this._t);
if (!st) return;
const x = this._vx(st.V), y = this._py(st.P);
ctx.save();
ctx.beginPath();
ctx.arc(x, y, 7, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.strokeStyle = '#9B5DE5';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
_pvDrawPhaseLabel(ctx, W) {
const st = this._stateAt(this._t);
if (!st) return;
const names = {
isotherm_hot: 'Изотермическое расширение (Tгор)',
adiabat_exp: 'Адиабатическое расширение',
isotherm_cold: 'Изотермическое сжатие (Tхол)',
adiabat_comp: 'Адиабатическое сжатие',
isochoric_hot: 'Изохорное нагревание',
isochoric_cold: 'Изохорное охлаждение',
isobar_hot: 'Изобарное нагревание',
isobar_cold: 'Изобарное охлаждение',
};
const label = names[st.phase] || '';
if (!label) return;
ctx.save();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fillText(label, W / 2, this._MT - 18);
ctx.restore();
}
_pvDrawHoverTooltip(ctx, W, H) {
if (this._hoverSeg < 0 || !this._tooltipX) return;
const ns = this._nodes;
const i = this._hoverSeg;
const a = ns[i];
const phase = a.phase;
/* formulas per phase */
const formulas = {
isotherm_hot: 'W = nRTгор · ln(V₂/V₁)',
isotherm_cold: 'W = nRTхол · ln(V₂/V₁)',
adiabat_exp: 'W = (P₁V₁ - P₂V₂) / (γ - 1)',
adiabat_comp: 'W = (P₁V₁ - P₂V₂) / (γ - 1)',
isochoric_hot: 'W = 0, Q = νCᵥΔT',
isochoric_cold: 'W = 0, Q = νCᵥΔT',
isobar_hot: 'W = PΔV, Q = νCₚΔT',
isobar_cold: 'W = PΔV, Q = νCₚΔT',
};
const txt = formulas[phase] || '';
if (!txt) return;
const tx = Math.min(this._tooltipX, W - 180);
const ty = Math.max(this._MT + 4, this._tooltipY - 36);
ctx.save();
ctx.fillStyle = 'rgba(13,13,26,0.92)';
ctx.strokeStyle = 'rgba(155,93,229,0.5)';
ctx.lineWidth = 1;
const tw = ctx.measureText(txt).width + 20;
const th = 24;
ctx.beginPath();
ctx.roundRect(tx, ty, tw, th, 6);
ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(txt, tx + 10, ty + th / 2);
ctx.restore();
}
/* ── hover detection ─────────────────────────────── */
_onPvMove(e) {
const rect = this._pv.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this._tooltipX = mx;
this._tooltipY = my;
const ns = this._nodes;
if (!ns) return;
let found = -1;
for (let i = 0; i < ns.length; i++) {
const a = ns[i], b = ns[(i + 1) % ns.length];
if (this._nearSeg(mx, my, a, b)) { found = i; break; }
}
if (found !== this._hoverSeg) {
this._hoverSeg = found;
this._drawPv();
}
}
_nearSeg(mx, my, a, b) {
const STEPS = 40, THRESH = 10;
const phase = a.phase;
for (let i = 0; i <= STEPS; i++) {
const [x, y] = this._pvPoint(a, b, phase, i / STEPS);
if (Math.hypot(x - mx, y - my) < THRESH) return true;
}
return false;
}
/* ── piston animation ───────────────────────────── */
_initParticles(N) {
this._particles = [];
for (let i = 0; i < N; i++) {
this._particles.push({
x: Math.random(), y: Math.random(),
vx: (Math.random() - 0.5), vy: (Math.random() - 0.5),
});
}
}
_updateParticles() {
const st = this._stateAt(this._t);
const speedScale = st ? Math.sqrt(st.T / 300) * 0.012 : 0.008;
for (const p of this._particles) {
p.x += p.vx * speedScale;
p.y += p.vy * speedScale;
if (p.x < 0) { p.x = 0; p.vx = Math.abs(p.vx); }
if (p.x > 1) { p.x = 1; p.vx = -Math.abs(p.vx); }
if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy); }
if (p.y > 1) { p.y = 1; p.vy = -Math.abs(p.vy); }
}
}
_drawPiston() {
const ctx = this._pisCtx;
const W = this._pis.offsetWidth || 300;
const H = this._pis.offsetHeight || 300;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const ns = this._nodes;
if (!ns) return;
const st = this._stateAt(this._t) || { V: ns[0].V, T: ns[0].T, phase: ns[0].phase };
/* cylinder geometry */
const cylX = W * 0.2, cylW = W * 0.6;
const cylTop = H * 0.12, cylBot = H * 0.92;
const cylH = cylBot - cylTop;
/* piston position from volume */
const Vmin = Math.min(...ns.map(n => n.V));
const Vmax = Math.max(...ns.map(n => n.V));
const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
const pistonY = cylTop + cylH * (1 - Vfrac); // bottom = max V, top = min V
/* heat source color indicator */
const phase = st.phase;
const isHot = phase === 'isotherm_hot' || phase === 'isochoric_hot' || phase === 'isobar_hot';
const isCold = phase === 'isotherm_cold' || phase === 'isochoric_cold' || phase === 'isobar_cold';
const isAdia = phase === 'adiabat_exp' || phase === 'adiabat_comp';
/* reservoir strip on left */
const resW = W * 0.12;
const resX = W * 0.05;
if (isHot) {
ctx.fillStyle = 'rgba(239,71,111,0.25)';
ctx.beginPath();
ctx.roundRect(resX, cylTop, resW, cylH, 5);
ctx.fill();
ctx.strokeStyle = '#EF476F';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#EF476F';
ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Tгор', resX + resW / 2, cylTop + 14);
/* heat flow arrows */
this._drawHeatArrows(ctx, resX + resW, pistonY, cylX, '#EF476F', true);
} else if (isCold) {
ctx.fillStyle = 'rgba(6,214,224,0.18)';
ctx.beginPath();
ctx.roundRect(resX, cylTop, resW, cylH, 5);
ctx.fill();
ctx.strokeStyle = '#06D6E0';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Tхол', resX + resW / 2, cylTop + 14);
this._drawHeatArrows(ctx, cylX, pistonY, resX + resW, '#06D6E0', false);
} else if (isAdia) {
/* insulation hatching */
ctx.save();
ctx.strokeStyle = 'rgba(255,200,0,0.35)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
for (let y = cylTop; y < cylBot; y += 8) {
ctx.beginPath(); ctx.moveTo(resX, y); ctx.lineTo(resX + resW, y); ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
ctx.strokeStyle = 'rgba(255,200,0,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(resX, cylTop, resW, cylH, 5);
ctx.stroke();
ctx.fillStyle = 'rgba(255,200,0,0.7)';
ctx.font = 'bold 8px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Q=0', resX + resW / 2, cylTop + 14);
}
/* cylinder walls */
ctx.strokeStyle = 'rgba(200,200,220,0.5)';
ctx.lineWidth = 2.5;
ctx.setLineDash([]);
/* left wall */
ctx.beginPath(); ctx.moveTo(cylX, cylTop); ctx.lineTo(cylX, cylBot); ctx.stroke();
/* right wall */
ctx.beginPath(); ctx.moveTo(cylX + cylW, cylTop); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();
/* bottom */
ctx.beginPath(); ctx.moveTo(cylX, cylBot); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();
/* gas region */
const gasGrad = ctx.createLinearGradient(cylX, pistonY, cylX, cylBot);
const hotness = Math.min(1, (st.T - this.Tc) / Math.max(1, this.Th - this.Tc));
const r = Math.round(13 + hotness * 220);
const g = Math.round(13 + (1 - hotness) * 100);
const b = Math.round(26 + (1 - hotness) * 180);
gasGrad.addColorStop(0, `rgba(${r},${g},${b},0.12)`);
gasGrad.addColorStop(1, `rgba(${r},${g},${b},0.28)`);
ctx.fillStyle = gasGrad;
ctx.fillRect(cylX, pistonY, cylW, cylBot - pistonY);
/* particles */
const gasH = cylBot - pistonY;
for (const p of this._particles) {
const px = cylX + p.x * cylW;
const py = pistonY + p.y * gasH;
ctx.beginPath();
ctx.arc(px, py, 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.globalAlpha = 0.7;
ctx.fill();
ctx.globalAlpha = 1;
}
/* piston */
const pisH = Math.max(12, H * 0.05);
ctx.fillStyle = 'rgba(100,120,180,0.85)';
ctx.strokeStyle = 'rgba(160,180,255,0.7)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(cylX + 2, pistonY - pisH, cylW - 4, pisH, 4);
ctx.fill(); ctx.stroke();
/* piston rod */
const rodW = cylW * 0.1;
ctx.fillStyle = 'rgba(130,140,200,0.7)';
ctx.fillRect(cylX + cylW / 2 - rodW / 2, cylTop, rodW, pistonY - pisH - cylTop);
/* labels */
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('T = ' + Math.round(st.T) + ' K', W * 0.75, cylTop + 16);
ctx.fillText('V = ' + this._fmtSci(st.V) + ' m³', W * 0.75, cylTop + 30);
}
_drawHeatArrows(ctx, x0, y0, x1, col, rightward) {
const N = 3;
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.7;
for (let i = 0; i < N; i++) {
const yo = y0 - 15 + i * 15;
const dx = rightward ? 8 : -8;
ctx.beginPath();
ctx.moveTo(x0, yo);
ctx.lineTo(x1, yo);
/* arrowhead */
const ax = x1, ay = yo;
ctx.moveTo(ax, ay);
ctx.lineTo(ax - dx, ay - 4);
ctx.moveTo(ax, ay);
ctx.lineTo(ax - dx, ay + 4);
ctx.stroke();
}
ctx.restore();
}
/* ── util ────────────────────────────────────────── */
_fmtSci(v) {
if (Math.abs(v) >= 1e4 || (Math.abs(v) < 0.01 && v !== 0)) {
return v.toExponential(1);
}
if (Math.abs(v) >= 1000) return Math.round(v).toString();
if (Math.abs(v) >= 10) return v.toFixed(1);
return v.toFixed(3);
}
}
/* ─── lab UI init ─────────────────────────────────── */
var heSim = null;
function _openHeatEngine() {
document.getElementById('sim-topbar-title').textContent = 'Тепловые двигатели';
_simShow('sim-heatengine');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!heSim) {
heSim = new HeatEngineSim(
document.getElementById('he-pv-canvas'),
document.getElementById('he-piston-canvas')
);
heSim.onStats = _heUpdateUI;
}
heSim.fit();
heSim._recompute();
}));
}
function heSetCycle(type, el) {
document.querySelectorAll('.he-cycle-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
/* show/hide CR slider */
const crRow = document.getElementById('he-cr-row');
if (crRow) crRow.style.display = (type === 'carnot') ? 'none' : '';
if (heSim) heSim.setCycle(type);
}
function heParam(name, val) {
const v = parseFloat(val);
if (name === 'Th') {
document.getElementById('he-th-val').textContent = Math.round(v);
if (heSim) heSim.setTh(v);
} else if (name === 'Tc') {
document.getElementById('he-tc-val').textContent = Math.round(v);
if (heSim) heSim.setTc(v);
} else if (name === 'cr') {
document.getElementById('he-cr-val').textContent = Math.round(v);
if (heSim) heSim.setCR(v);
}
}
function heStart() { if (heSim) heSim.start(); }
function hePause() { if (heSim) heSim.pause(); }
function heStep() { if (heSim) heSim.step(); }
function heReset() { if (heSim) heSim.reset(); }
function _heUpdateUI(s) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('hebar-th', s.Th + ' K');
v('hebar-tc', s.Tc + ' K');
v('hebar-eta', s.eta + '%');
v('hebar-qh', s.Qh + ' Дж');
v('hebar-qc', s.Qc + ' Дж');
v('hebar-w', s.W + ' Дж');
}
+114 -13
View File
@@ -429,6 +429,35 @@
<circle cx="124" cy="121" r="3" fill="#4CC9F0"/><circle cx="159" cy="121" r="3" fill="#9BB8CC"/>
<circle cx="194" cy="121" r="3" fill="#FFD166"/>`);
const P_STOICHIOMETRY = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${_grid()}
<rect x="18" y="28" width="52" height="68" rx="5" fill="none" stroke="rgba(155,184,204,0.5)" stroke-width="1.5"/>
<rect x="20" y="58" width="48" height="36" rx="3" fill="rgba(155,184,204,0.12)"/>
<circle cx="32" cy="74" r="4" fill="rgba(155,184,204,0.7)"/>
<circle cx="45" cy="70" r="4" fill="rgba(155,184,204,0.7)"/>
<circle cx="58" cy="76" r="4" fill="rgba(155,184,204,0.7)"/>
<text x="44" y="46" font-size="9" fill="rgba(155,184,204,0.9)" font-family="Manrope,sans-serif" text-anchor="middle">Zn</text>
<rect x="78" y="28" width="60" height="68" rx="5" fill="none" stroke="rgba(120,210,120,0.5)" stroke-width="1.5"/>
<rect x="80" y="58" width="56" height="36" rx="3" fill="rgba(120,210,120,0.12)"/>
<circle cx="92" cy="72" r="3.5" fill="rgba(120,210,120,0.7)"/>
<circle cx="103" cy="76" r="3.5" fill="rgba(120,210,120,0.7)"/>
<circle cx="114" cy="70" r="3.5" fill="rgba(120,210,120,0.7)"/>
<circle cx="125" cy="74" r="3.5" fill="rgba(120,210,120,0.7)"/>
<text x="108" y="46" font-size="9" fill="rgba(120,210,120,0.9)" font-family="Manrope,sans-serif" text-anchor="middle">2HCl</text>
<text x="150" y="68" font-size="14" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif" text-anchor="middle">&#8594;</text>
<rect x="162" y="28" width="52" height="68" rx="5" fill="none" stroke="rgba(76,201,240,0.5)" stroke-width="1.5"/>
<rect x="164" y="58" width="48" height="36" rx="3" fill="rgba(76,201,240,0.12)"/>
<circle cx="176" cy="74" r="4" fill="rgba(76,201,240,0.7)"/>
<circle cx="189" cy="70" r="4" fill="rgba(76,201,240,0.7)"/>
<circle cx="202" cy="76" r="4" fill="rgba(76,201,240,0.7)"/>
<text x="184" y="46" font-size="9" fill="rgba(76,201,240,0.9)" font-family="Manrope,sans-serif" text-anchor="middle">ZnCl&#8322;</text>
<rect x="222" y="28" width="36" height="68" rx="5" fill="none" stroke="rgba(255,209,102,0.5)" stroke-width="1.5"/>
<circle cx="235" cy="68" r="3" fill="rgba(255,209,102,0.7)"/>
<circle cx="248" cy="74" r="3" fill="rgba(255,209,102,0.7)"/>
<text x="240" y="46" font-size="9" fill="rgba(255,209,102,0.9)" font-family="Manrope,sans-serif" text-anchor="middle">H&#8322;</text>
<text x="135" y="118" font-size="8" fill="rgba(239,71,111,0.7)" font-family="Manrope,sans-serif" text-anchor="middle">&#9679; лимит</text>`);
const P_CRYSTAL = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${[
@@ -520,6 +549,43 @@
stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/>
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b</text>`);
/* Radioactive decay preview */
const P_RADIOACTIVE = _svg(`${_grid()}
<circle cx="55" cy="45" r="5" fill="#9B5DE5" opacity="0.9"/>
<circle cx="90" cy="65" r="5" fill="#9B5DE5" opacity="0.9"/>
<circle cx="38" cy="80" r="5" fill="#9B5DE5" opacity="0.7"/>
<circle cx="75" cy="95" r="5" fill="#EF476F" opacity="0.9"/>
<circle cx="110" cy="50" r="5" fill="#EF476F" opacity="0.85"/>
<circle cx="130" cy="85" r="5" fill="#4CAF50" opacity="0.85"/>
<circle cx="155" cy="55" r="5" fill="#9B5DE5" opacity="0.8"/>
<circle cx="170" cy="90" r="5" fill="#4CAF50" opacity="0.75"/>
<circle cx="200" cy="45" r="5" fill="#4CAF50" opacity="0.9"/>
<circle cx="215" cy="80" r="5" fill="#4CAF50" opacity="0.85"/>
<circle cx="240" cy="60" r="5" fill="#9B5DE5" opacity="0.7"/>
<path d="M 20,115 Q 67,42 135,52 Q 200,62 270,110"
fill="none" stroke="#9B5DE5" stroke-width="2" opacity="0.55" stroke-dasharray="5,3"/>
<path d="M 20,115 Q 100,110 175,100 Q 230,92 270,85"
fill="none" stroke="#4CAF50" stroke-width="1.5" opacity="0.5"/>
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">N(t) = N₀·e⁻λt · T½ · цепочки распада</text>`);
/* Heat Engines preview */
const P_HEATENGINE = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="30" y1="10" x2="30" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<line x1="30" y1="125" x2="265" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<path d="M 55,18 Q 100,30 140,75 Q 160,100 190,115" fill="none" stroke="#EF476F" stroke-width="2.2" opacity="0.85"/>
<path d="M 190,115 Q 205,118 215,110 Q 230,90 225,60" fill="none" stroke="#FFD166" stroke-width="2" opacity="0.8"/>
<path d="M 225,60 Q 200,40 160,32 Q 110,22 55,18" fill="none" stroke="#06D6E0" stroke-width="2.2" opacity="0.85"/>
<path d="M 55,18 Q 48,16 44,22 Q 38,38 45,60 Q 50,80 55,18" fill="rgba(155,93,229,0.12)" stroke="#9B5DE5" stroke-width="1" opacity="0.5"/>
<circle cx="55" cy="18" r="4" fill="#EF476F"/>
<circle cx="190" cy="115" r="4" fill="#FFD166"/>
<circle cx="225" cy="60" r="4" fill="#06D6E0"/>
<text x="44" y="14" font-size="9" fill="#EF476F" font-family="Manrope,sans-serif">A</text>
<text x="195" y="126" font-size="9" fill="#FFD166" font-family="Manrope,sans-serif">B</text>
<text x="230" y="58" font-size="9" fill="#06D6E0" font-family="Manrope,sans-serif">C</text>
<text x="255" y="128" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">V</text>
<text x="18" y="12" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">P</text>
<text x="135" y="115" font-size="8" fill="rgba(155,93,229,0.7)" font-family="Manrope,sans-serif" text-anchor="middle">η = 1 Tc/Th</text>`);
/* Geometry (planimetry) preview */
const P_GEOMETRY = _svg(`${_grid('rgba(255,255,255,0.04)')}
<circle cx="135" cy="70" r="50" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.5"/>
@@ -535,6 +601,29 @@
<text x="188" y="111" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">B</text>
<text x="131" y="16" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">C</text>`);
/* Logic Circuits preview */
const P_LOGIC = _svg(`${_grid('rgba(255,255,255,0.04)')}
<rect x="20" y="38" width="60" height="30" fill="rgba(155,93,229,0.12)" stroke="#9B5DE5" stroke-width="1.5" rx="4"/>
<text x="50" y="57" font-size="9" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">AND</text>
<rect x="130" y="20" width="60" height="30" fill="rgba(6,214,224,0.12)" stroke="#06D6E0" stroke-width="1.5" rx="4"/>
<text x="160" y="39" font-size="9" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">XOR</text>
<rect x="130" y="60" width="60" height="30" fill="rgba(241,91,181,0.12)" stroke="#F15BB5" stroke-width="1.5" rx="4"/>
<text x="160" y="79" font-size="9" fill="#F15BB5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">AND</text>
<circle cx="18" cy="45" r="4" fill="#4ADE80"/>
<circle cx="18" cy="58" r="4" fill="rgba(255,255,255,0.35)"/>
<line x1="22" y1="45" x2="80" y2="40" stroke="#4ADE80" stroke-width="1.5"/>
<line x1="22" y1="58" x2="80" y2="55" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<line x1="80" y1="53" x2="112" y2="35" stroke="#4ADE80" stroke-width="1.5"/>
<line x1="80" y1="53" x2="112" y2="75" stroke="#4ADE80" stroke-width="1.5"/>
<line x1="190" y1="35" x2="230" y2="35" stroke="#4ADE80" stroke-width="1.5"/>
<line x1="190" y1="75" x2="230" y2="75" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<circle cx="234" cy="35" r="5" fill="#4ADE80" opacity="0.9"/>
<circle cx="234" cy="75" r="5" fill="rgba(255,255,255,0.25)"/>
<text x="240" y="39" font-size="8" fill="#4ADE80" font-family="Manrope,sans-serif" font-weight="700">S</text>
<text x="240" y="79" font-size="8" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif" font-weight="700">C</text>
<text x="135" y="130" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">S = A⊕B · C = A∧B · Таблица истинности`);
const SIMS = [
/* ── Математика ── */
{ id: 'graph', cat: 'math',
@@ -602,18 +691,10 @@
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
preview: P_SANDBOX },
{ id: 'thinlens', cat: 'phys',
title: 'Тонкая линза',
desc: 'Двигай объект относительно линзы — формула линзы, мнимое и действительное изображение.',
{ id: 'opticsbench', cat: 'phys',
title: 'Оптическая скамья',
desc: 'Линза, зеркала и преломление в одной симуляции: формула линзы, зеркальное отражение, закон Снеллиуса, ПВО, дисперсия.',
preview: P_LENS },
{ id: 'refraction', cat: 'phys',
title: 'Преломление света',
desc: 'Луч на границе двух сред: закон Снеллиуса, угол Брюстера, полное внутреннее отражение.',
preview: P_REFRACTION },
{ id: 'mirrors', cat: 'phys',
title: 'Зеркала',
desc: 'Плоское, вогнутое и выпуклое зеркало: построение изображения тремя главными лучами.',
preview: P_MIRROR },
{ id: 'isoprocess', cat: 'phys',
title: 'Изопроцессы',
desc: 'PV-диаграмма для четырёх изопроцессов идеального газа. Расчёт работы, теплоты и внутренней энергии.',
@@ -622,6 +703,18 @@
title: 'Волны и звук',
desc: 'Поперечные и продольные волны, суперпозиция, стоячие волны. Частота, амплитуда, фаза, гармоники.',
preview: P_WAVES },
{ id: 'radioactive', cat: 'phys',
title: 'Радиоактивный распад',
desc: 'Период полураспада, цепочки распадов, активность. Визуализация ядер + кривая N(t). Радиоуглеродное датирование.',
preview: P_RADIOACTIVE },
{ id: 'heatengine', cat: 'phys',
title: 'Тепловые двигатели',
desc: 'Циклы Карно, Отто, Дизеля, Брайтона. PV-диаграмма, поршень, КПД.',
preview: P_HEATENGINE },
{ id: 'logic', cat: 'phys',
title: 'Логические схемы',
desc: 'Конструктор цифровых схем: И/ИЛИ/НЕ/XOR, триггеры, сумматоры. Авто-таблица истинности.',
preview: P_LOGIC },
/* ── Химия / Молекулярная физика ── */
{ id: 'molphys', cat: 'chem',
title: 'Молекулярная физика',
@@ -657,6 +750,10 @@
title: 'Химическая песочница',
desc: 'Смешивай реагенты, наблюдай реакции: осадки, газы, изменение цвета. Свободное экспериментирование.',
preview: P_CHEMSANDBOX },
{ id: 'stoichiometry', cat: 'chem',
title: 'Стехиометрия',
desc: 'Расчёты по уравнениям: масса, моль, объём. Лимитирующий реагент, выход. 10 реакций.',
preview: P_STOICHIOMETRY },
{ id: 'crystal', cat: 'chem',
title: 'Кристаллическая решётка',
desc: 'NaCl, алмаз, металл — интерактивная 3D-решётка, типы связей, вращение структуры.',
@@ -832,8 +929,12 @@
const _SIM_HASH_MAP = {};
SIMS.forEach(function(s) { if (s.id) { _SIM_HASH_MAP[s.id] = s.id; } });
// backward-compat aliases: old URLs redirect to unified emfield sim
_SIM_HASH_MAP['magnetic'] = 'magnetic';
_SIM_HASH_MAP['coulomb'] = 'coulomb';
_SIM_HASH_MAP['magnetic'] = 'magnetic';
_SIM_HASH_MAP['coulomb'] = 'coulomb';
// backward-compat aliases: old optics sims redirect to opticsbench
_SIM_HASH_MAP['thinlens'] = 'opticsbench';
_SIM_HASH_MAP['mirrors'] = 'opticsbench';
_SIM_HASH_MAP['refraction'] = 'opticsbench';
var _routerNavigating = false;
+79 -11
View File
@@ -22,9 +22,7 @@
var photosynSim = null;
var quadSim = null;
var eqSim = null;
var lensSim = null;
var titrSim = null;
var refrSim = null;
var probSim = null;
var bohrSim = null;
var elecSim = null;
@@ -34,17 +32,17 @@
var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield',
'sim-molphys',
'sim-circuit','sim-chemistry','sim-dynamics',
'sim-crystal','sim-orbitals','sim-stereo','sim-chemsandbox',
'sim-crystal','sim-orbitals','sim-stereo','sim-chemsandbox','sim-stoichiometry',
'sim-celldivision','sim-photosynthesis','sim-angrybirds',
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves','sim-hydro','sim-geometry'];
'sim-pendulum','sim-equilibrium','sim-opticsbench','sim-titration',
'sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic'];
var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-emfield',
'ctrl-molphys',
'ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro',
'ctrl-geometry'];
'ctrl-radioactive','ctrl-geometry'];
/* ── sim routing ── */
@@ -88,18 +86,24 @@
if (id === 'graphtransform') _openGraphTransform();
if (id === 'pendulum') _openPendulum();
if (id === 'equilibrium') _openEquilibrium();
if (id === 'thinlens') _openThinLens();
if (id === 'mirrors') _openMirror();
if (id === 'opticsbench') _openOpticsBench('lens');
if (id.startsWith('opticsbench:')) _openOpticsBench(id.split(':')[1]);
if (id === 'thinlens') _openOpticsBench('lens'); // backward compat
if (id === 'mirrors') _openOpticsBench('mirror'); // backward compat
if (id === 'refraction') _openOpticsBench('refraction'); // backward compat
if (id === 'isoprocess') _openIsoprocess();
if (id === 'titration') _openTitration();
if (id === 'refraction') _openRefraction();
if (id === 'probability') _openProbability();
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
if (id === 'radioactive') _openRadioactive();
if (id === 'geometry') _openGeometry();
if (id === 'logic') _openLogic();
if (id === 'heatengine') _openHeatEngine();
if (id === 'stoichiometry') _openStoich();
}
function _simShow(elId) {
@@ -181,7 +185,11 @@
if (probSim) probSim.stop();
if (bohrSim) bohrSim.stop();
if (elecSim) elecSim.stop();
if (wavesSim) wavesSim.stop();
if (wavesSim) wavesSim.stop();
if (radioactiveSim) radioactiveSim.stop();
if (heSim) heSim.stop();
if (mirrorSim && mirrorSim._playing) mirrorSim._stopAnim();
if (mirrorSim && mirrorSim._photonRaf) mirrorSim._stopPhotons();
// tSim, csSim, quadSim, ndSim, gtSim, lensSim, refrSim have no animation loops — nothing to stop
document.getElementById('stereo-stats').style.display = 'none';
document.getElementById('lab-sim').classList.remove('open');
@@ -452,6 +460,17 @@
{ head: 'Окислительное фосфорилирование', text: 'Электрон-транспортная цепь на внутренней мембране митохондрий. Основной выход АТФ (~34).' },
]
},
stoichiometry: {
title: 'Стехиометрия',
sections: [
{ head: 'Молярная масса', formula: 'M = \\frac{m}{n}', vars: [['m','масса вещества, г'],['n','количество моль'],['M','молярная масса, г/моль']] },
{ head: 'Количество вещества', formula: 'n = \\frac{m}{M}', text: 'Основная формула перехода от массы к молям.' },
{ head: 'Стехиометрический расчёт', formula: 'n_2 = \\frac{b}{a} \\cdot n_1', vars: [['a','коэффициент реагента'],['b','коэффициент продукта'],['n₁','моли реагента'],['n₂','моли продукта']] },
{ head: 'Объём газа (н.у.)', formula: 'V = n \\cdot 22{,}4\\text{ л/моль}', text: 'При нормальных условиях (0°C, 101.3 кПа) 1 моль любого газа занимает 22.4 л.' },
{ head: 'Лимитирующий реагент', text: 'Для каждого реагента вычислить n_i / a_i. Наименьшее значение — лимитирующий реагент (определяет выход).' },
{ head: 'Избыток реагента', formula: 'n_{\\text{изб}} = n_{\\text{дано}} - n_{\\text{израсх}}', text: 'После реакции остаётся непрореагировавший избыток нелимитирующего реагента.' },
]
},
chemsandbox: {
title: 'Химическая песочница',
sections: [
@@ -482,6 +501,17 @@
{ head: 'Энергия активации', formula: 'k = A \\cdot e^{-E_a / RT}', text: 'Уравнение Аррениуса. Чем ниже Ea, тем быстрее реакция.' },
]
},
opticsbench: {
title: 'Оптическая скамья',
sections: [
{ head: 'Формула тонкой линзы', formula: '\\frac{1}{f} = \\frac{1}{d} + \\frac{1}{d\'}', vars: [['f','фокусное расстояние'],['d','расстояние до предмета'],["d'",'расстояние до изображения']] },
{ head: 'Увеличение', formula: 'M = -\\frac{d\'}{d} = \\frac{h\'}{h}', text: '|M| > 1 — увеличенное, |M| < 1 — уменьшенное. M < 0 — перевёрнутое.' },
{ head: 'Формула зеркала', formula: '\\frac{1}{f} = \\frac{1}{d} + \\frac{1}{d\'}', text: 'Аналогична линзе. Вогнутое: f > 0, выпуклое: f < 0. Плоское: f = ∞.' },
{ head: 'Закон Снеллиуса', formula: 'n_1 \\sin\\theta_1 = n_2 \\sin\\theta_2', text: 'Угол преломления зависит от соотношения показателей преломления двух сред.' },
{ head: 'Полное внутреннее отражение', formula: '\\theta_c = \\arcsin\\frac{n_2}{n_1}', text: 'При n₁ > n₂ и θ₁ > θc — свет полностью отражается.' },
{ head: 'Показатель преломления', formula: 'n = \\frac{c}{v}', text: 'Воздух ≈ 1.00, вода = 1.33, стекло ≈ 1.5, алмаз = 2.42.' },
]
},
thinlens: {
title: 'Тонкая линза',
sections: [
@@ -564,6 +594,20 @@
{ head: 'Электролит CuSO₄', text: 'Катод: Cu²⁺ + 2e⁻ <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> Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> (осадок). Анод: 2H₂O 4e⁻ <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> O₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + 4H⁺.' },
]
},
logic: {
title: 'Логические схемы',
sections: [
{ head: 'Конъюнкция (AND)', formula: 'A \land B', text: 'Истина, когда оба операнда истинны. Таблица: 0∆0, 0∆0, 1∆0, 1∆1 = 0,0,0,1.' },
{ head: 'Дизъюнкция (OR)', formula: 'A \lor B', text: 'Истина, когда хотя бы один операнд истинен. Результат 0 только при A=B=0.' },
{ head: 'Инверсия (NOT)', formula: '\lnot A', text: 'Меняет значение: NOT 0 = 1, NOT 1 = 0.' },
{ head: 'XOR', formula: 'A \oplus B', text: 'Исключающее ИЛИ: истина, когда операнды различны. A XOR A = 0 всегда.' },
{ head: 'NAND / NOR', text: 'NAND = NOT(AND). NOR = NOT(OR). Функционально полные базисы — любую схему можно собрать только на NAND-вентилях.' },
{ head: 'Полусумматор', formula: 'S = A \oplus B,\; C = A \land B', text: 'Складывает два бита. S — сумма, C — перенос.' },
{ head: 'Полный сумматор', text: 'Три входа: A, B, Cin. Выходы: S = A⊕B⊕Cin, Cout = (A∧B) (Cin∧(A⊕B)).' },
{ head: 'RS-триггер', text: 'Два перекрёстных NOR-вентиля. S=1: Q→1. R=1: Q→0. S=R=0: состояние хранится. S=R=1: запрещено.' },
{ head: 'D-триггер', text: 'Q = D при CLK=1 (прозрачный режим). При CLK=0 состояние хранится.' },
]
},
waves: {
title: 'Волны и звук',
sections: [
@@ -575,6 +619,30 @@
{ head: 'Биения', text: 'Если f\u2081 \u2260 f\u2082, результирующая амплитуда периодически меняется с частотой |f\u2081\u2212f\u2082|. Применяется в акустике для настройки инструментов.' },
]
},
radioactive: {
title: 'Радиоактивный распад',
sections: [
{ head: 'Закон радиоактивного распада', formula: 'N(t) = N_0 \cdot e^{-\lambda t}', vars: [['N_0','начальное число ядер'],['\lambda','постоянная распада (с⁻¹)'],['t','время']] },
{ head: 'Период полураспада', formula: 'T_{1/2} = rac{\ln 2}{\lambda}', text: 'Время, за которое распадается половина ядер.' },
{ head: 'Активность', formula: 'A = \lambda N = rac{\ln 2}{T_{1/2}} N', text: 'Число распадов в единицу времени. Единица — беккерель (Бк = 1 распад/с).' },
{ head: 'Радиоуглеродное датирование', formula: 't = rac{\ln(N_0 / N)}{\lambda}', text: 'По остаточной активности ¹⁴C определяется возраст органического образца (до ~50 000 лет).' },
{ head: 'Цепочки распадов', text: '²³⁸U → ²³⁴Th → ... → ²⁰⁶Pb (14 шагов). В симуляции используются 4-5 основных нуклидов цепочки.' },
{ head: 'Типы распадов', text: 'α-распад: ядро теряет ⁴He (масса -4, заряд -2). β-распад: нейтрон → протон + e⁻ + ν̅. γ-излучение: энергетический переход без изменения нуклидов.' },
]
},
heatengine: {
title: 'Тепловые двигатели',
sections: [
{ head: 'Первое начало термодинамики', formula: 'Q = \\Delta U + W', text: 'Теплота Q идёт на изменение внутренней энергии ΔU и совершение работы W.' },
{ head: 'КПД цикла Карно', formula: '\\eta = 1 - \\frac{T_c}{T_h}', text: 'Максимальный КПД теплового двигателя. Не зависит от рабочего тела, только от температур резервуаров.' },
{ head: 'Связь теплот и работы', formula: 'W = Q_h - |Q_c|,\\quad \\eta = \\frac{W}{Q_h}', vars: [['Q_h','теплота от горячего резервуара'],['Q_c','теплота, отданная холодному'],['W','работа за цикл']] },
{ head: 'Изотермический процесс', formula: 'W = nRT\\ln\\frac{V_2}{V_1}', text: 'T = const, ΔU = 0, Q = W. Рабочее тело в тепловом контакте с резервуаром.' },
{ head: 'Адиабатический процесс', formula: 'PV^\\gamma = \\text{const},\\quad W = \\frac{P_1V_1 - P_2V_2}{\\gamma - 1}', text: 'Q = 0 — нет теплообмена. γ = 1.4 для двухатомного идеального газа.' },
{ head: 'Цикл Отто (ДВС)', formula: '\\eta_{\\text{Отто}} = 1 - r^{1-\\gamma}', vars: [['r','степень сжатия'],['\\gamma = 1.4','показатель адиабаты']], text: '2 адиабаты + 2 изохоры. Типичная η ≈ 25–40%.' },
{ head: 'Цикл Дизеля', text: '2 адиабаты + 1 изобара + 1 изохора. Более высокая степень сжатия, чем Отто. η ≈ 3545%.' },
{ head: 'Цикл Брайтона (ГТД)', text: '2 адиабаты + 2 изобары. Основа авиадвигателей и газовых турбин. η зависит от степени повышения давления.' },
]
},
};
/* ══════════════════════════════════════════════
+823
View File
@@ -0,0 +1,823 @@
'use strict';
/* ══════════════════════════════════════════════════════════
LogicSim — Логические схемы
Canvas-based digital logic circuit builder.
Exports: LogicSim class, logicTool(), logicPreset(), logicClearAll(), _openLogic()
══════════════════════════════════════════════════════════ */
/* ── Gate definitions ── */
const GATE_DEFS = {
INPUT: { ins: 0, outs: 1, label: 'IN', w: 56, h: 36 },
CLOCK: { ins: 0, outs: 1, label: 'CLK', w: 56, h: 36 },
OUTPUT: { ins: 1, outs: 0, label: 'OUT', w: 56, h: 36 },
AND: { ins: 2, outs: 1, label: 'AND', w: 64, h: 44 },
OR: { ins: 2, outs: 1, label: 'OR', w: 64, h: 44 },
NOT: { ins: 1, outs: 1, label: 'NOT', w: 56, h: 36 },
XOR: { ins: 2, outs: 1, label: 'XOR', w: 64, h: 44 },
NAND: { ins: 2, outs: 1, label: 'NAND', w: 64, h: 44 },
NOR: { ins: 2, outs: 1, label: 'NOR', w: 64, h: 44 },
XNOR: { ins: 2, outs: 1, label: 'XNOR', w: 64, h: 44 },
BUFFER: { ins: 1, outs: 1, label: 'BUF', w: 56, h: 36 },
};
const PORT_R = 5; // port dot radius
const GRID = 20; // snap grid size
/* ── evaluate a single gate ── */
function evalGate(type, inputs) {
const a = inputs[0] || 0;
const b = inputs[1] || 0;
switch (type) {
case 'AND': return a & b;
case 'OR': return a | b;
case 'NOT': return a ? 0 : 1;
case 'XOR': return a ^ b;
case 'NAND': return (a & b) ? 0 : 1;
case 'NOR': return (a | b) ? 0 : 1;
case 'XNOR': return (a ^ b) ? 0 : 1;
case 'BUFFER': return a;
case 'INPUT': return a; // value from state.value
case 'CLOCK': return a;
case 'OUTPUT': return a;
default: return 0;
}
}
/* ═══════════════════════════════════════════════════════════
LogicSim
═══════════════════════════════════════════════════════════ */
class LogicSim {
constructor(canvas, exprEl, tableEl) {
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
this._exprEl = exprEl; // element for boolean expression display
this._tableEl = tableEl; // element for truth table
this._gates = []; // { id, type, x, y, value, label, freq, _phase }
this._wires = []; // { from: {gateId, port:'out'|'in0'|'in1'}, to: {gateId, port} }
this._nextId = 1;
this._tool = 'select'; // 'select' | type key
this._drag = null;
this._wireStart = null; // { gateId, side:'out', px, py }
this._history = [];
this._histIdx = -1;
this._raf = null;
this._clockRaf = null;
this._bindEvents();
this._startClock();
}
/* ── port pixel positions ── */
_portPx(gate, port) {
const def = GATE_DEFS[gate.type];
const hw = def.w / 2, hh = def.h / 2;
const cx = gate.x, cy = gate.y;
if (port === 'out') return { x: cx + hw, y: cy };
if (port === 'in0') {
if (def.ins === 1) return { x: cx - hw, y: cy };
return { x: cx - hw, y: cy - hh / 2 };
}
if (port === 'in1') return { x: cx - hw, y: cy + hh / 2 };
return { x: cx, y: cy };
}
/* ── snap to grid ── */
_snap(v) { return Math.round(v / GRID) * GRID; }
/* ── find gate near point ── */
_hitGate(px, py) {
for (let i = this._gates.length - 1; i >= 0; i--) {
const g = this._gates[i];
const def = GATE_DEFS[g.type];
if (Math.abs(px - g.x) <= def.w / 2 + 2 && Math.abs(py - g.y) <= def.h / 2 + 2) return g;
}
return null;
}
/* ── find port near point; returns { gateId, port, px, py } or null ── */
_hitPort(px, py) {
for (const g of this._gates) {
const def = GATE_DEFS[g.type];
const ports = [];
if (def.outs > 0) ports.push('out');
if (def.ins >= 1) ports.push('in0');
if (def.ins >= 2) ports.push('in1');
for (const port of ports) {
const p = this._portPx(g, port);
if (Math.hypot(px - p.x, py - p.y) <= 10) {
return { gateId: g.id, port, px: p.x, py: p.y };
}
}
}
return null;
}
/* ── canvas coordinates from event ── */
_pos(e) {
const r = this._canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
/* ══ Event binding ══ */
_bindEvents() {
const c = this._canvas;
c.addEventListener('mousedown', this._onDown.bind(this));
c.addEventListener('mousemove', this._onMove.bind(this));
c.addEventListener('mouseup', this._onUp.bind(this));
c.addEventListener('dblclick', this._onDbl.bind(this));
c.addEventListener('contextmenu', e => { e.preventDefault(); this._onRightClick(e); });
window.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); this.undo(); }
if (e.ctrlKey && e.key === 'y') { e.preventDefault(); this.redo(); }
});
}
_onDown(e) {
if (e.button !== 0) return;
const { x, y } = this._pos(e);
if (this._tool !== 'select') {
// place a new gate
const type = this._tool;
const def = GATE_DEFS[type];
if (!def) return;
this._pushHistory();
const g = this._addGate(type, this._snap(x), this._snap(y));
this._propagate();
this._updatePanels();
this.draw();
return;
}
// select tool: check port first (wire drawing)
const hitP = this._hitPort(x, y);
if (hitP && (hitP.port === 'out')) {
this._wireStart = hitP;
this._mouseX = x; this._mouseY = y;
return;
}
// check gate drag
const g = this._hitGate(x, y);
if (g) {
this._drag = { gate: g, ox: x - g.x, oy: y - g.y };
}
}
_onMove(e) {
const { x, y } = this._pos(e);
this._mouseX = x; this._mouseY = y;
if (this._drag) {
this._drag.gate.x = this._snap(x - this._drag.ox);
this._drag.gate.y = this._snap(y - this._drag.oy);
this._propagate();
this._updatePanels();
this.draw();
return;
}
if (this._wireStart) {
this.draw();
return;
}
// hover cursor
const hp = this._hitPort(x, y);
this._canvas.style.cursor = hp ? 'crosshair' : (this._hitGate(x, y) ? 'grab' : 'default');
}
_onUp(e) {
if (this._drag) {
this._pushHistory();
this._drag = null;
return;
}
if (this._wireStart) {
const { x, y } = this._pos(e);
const hitP = this._hitPort(x, y);
if (hitP && (hitP.port === 'in0' || hitP.port === 'in1') && hitP.gateId !== this._wireStart.gateId) {
// check not already wired
const exists = this._wires.some(w => w.to.gateId === hitP.gateId && w.to.port === hitP.port);
if (!exists) {
this._pushHistory();
this._wires.push({ from: { gateId: this._wireStart.gateId, port: this._wireStart.port }, to: { gateId: hitP.gateId, port: hitP.port } });
this._propagate();
this._updatePanels();
}
}
this._wireStart = null;
this.draw();
}
}
_onDbl(e) {
const { x, y } = this._pos(e);
const g = this._hitGate(x, y);
if (g && (g.type === 'INPUT')) {
this._pushHistory();
g.value = g.value ? 0 : 1;
this._propagate();
this._updatePanels();
this.draw();
}
if (g && g.type === 'OUTPUT') {
// rename label cycle: OUT → OUT₁ → OUT₂ → OUT (no-op, just show)
}
}
_onRightClick(e) {
const { x, y } = this._pos(e);
// delete wire near click
for (let i = this._wires.length - 1; i >= 0; i--) {
const w = this._wires[i];
const g1 = this._gateById(w.from.gateId); if (!g1) continue;
const g2 = this._gateById(w.to.gateId); if (!g2) continue;
const p1 = this._portPx(g1, w.from.port);
const p2 = this._portPx(g2, w.to.port);
if (this._distToSeg(x, y, p1.x, p1.y, p2.x, p2.y) < 8) {
this._pushHistory();
this._wires.splice(i, 1);
this._propagate();
this._updatePanels();
this.draw();
return;
}
}
// delete gate
const g = this._hitGate(x, y);
if (g) {
this._pushHistory();
this._wires = this._wires.filter(w => w.from.gateId !== g.id && w.to.gateId !== g.id);
this._gates = this._gates.filter(gg => gg.id !== g.id);
this._propagate();
this._updatePanels();
this.draw();
}
}
_distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/* ══ Gate management ══ */
_addGate(type, x, y) {
const id = this._nextId++;
const def = GATE_DEFS[type];
const g = { id, type, x, y, value: 0, label: def.label };
if (type === 'INPUT') { g.label = String.fromCharCode(64 + this._gates.filter(gg => gg.type === 'INPUT').length + 1); }
if (type === 'OUTPUT') { g.label = 'OUT' + (this._gates.filter(gg => gg.type === 'OUTPUT').length + 1 > 1 ? (this._gates.filter(gg => gg.type === 'OUTPUT').length + 1) : ''); }
if (type === 'CLOCK') { g.freq = 1; g._phase = 0; }
this._gates.push(g);
return g;
}
_gateById(id) { return this._gates.find(g => g.id === id) || null; }
/* ══ Logic propagation (topological sort + eval) ══ */
_propagate() {
// Build in-degree map
const inDeg = {};
this._gates.forEach(g => { inDeg[g.id] = 0; });
const deps = {}; // id -> [id of gates this gate depends on]
this._gates.forEach(g => { deps[g.id] = []; });
this._wires.forEach(w => {
inDeg[w.to.gateId]++;
deps[w.to.gateId].push(w.from.gateId);
});
// Kahn's algorithm
const queue = this._gates.filter(g => inDeg[g.id] === 0).map(g => g.id);
const sorted = [];
const visited = new Set();
while (queue.length) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
sorted.push(id);
// find wires going FROM this gate
this._wires.forEach(w => {
if (w.from.gateId === id) {
inDeg[w.to.gateId]--;
if (inDeg[w.to.gateId] === 0) queue.push(w.to.gateId);
}
});
}
// any unvisited (loops): add them to sorted anyway
this._gates.forEach(g => { if (!visited.has(g.id)) sorted.push(g.id); });
for (const id of sorted) {
const g = this._gateById(id);
if (!g) continue;
if (g.type === 'INPUT' || g.type === 'CLOCK') continue; // value set externally
const ins = this._getInputValues(g);
g.value = evalGate(g.type, ins);
}
}
_getInputValues(gate) {
const def = GATE_DEFS[gate.type];
const vals = new Array(def.ins).fill(0);
this._wires.forEach(w => {
if (w.to.gateId !== gate.id) return;
const src = this._gateById(w.from.gateId);
if (!src) return;
const idx = w.to.port === 'in0' ? 0 : 1;
vals[idx] = src.value;
});
return vals;
}
/* ══ Clock ══ */
_startClock() {
let last = 0;
const tick = (now) => {
this._clockRaf = requestAnimationFrame(tick);
const dt = (now - last) / 1000;
last = now;
let changed = false;
this._gates.forEach(g => {
if (g.type !== 'CLOCK') return;
g._phase = (g._phase || 0) + dt * (g.freq || 1);
const newVal = g._phase % 1 < 0.5 ? 1 : 0;
if (newVal !== g.value) { g.value = newVal; changed = true; }
});
if (changed) {
this._propagate();
this._updatePanels();
this.draw();
}
};
this._clockRaf = requestAnimationFrame(tick);
}
/* ══ Undo / Redo ══ */
_pushHistory() {
const snap = JSON.stringify({ gates: this._gates, wires: this._wires, nextId: this._nextId });
this._history = this._history.slice(0, this._histIdx + 1);
this._history.push(snap);
if (this._history.length > 50) this._history.shift();
this._histIdx = this._history.length - 1;
}
undo() {
if (this._histIdx <= 0) return;
this._histIdx--;
this._restoreHistory(this._history[this._histIdx]);
}
redo() {
if (this._histIdx >= this._history.length - 1) return;
this._histIdx++;
this._restoreHistory(this._history[this._histIdx]);
}
_restoreHistory(snap) {
const s = JSON.parse(snap);
this._gates = s.gates;
this._wires = s.wires;
this._nextId = s.nextId;
this._propagate();
this._updatePanels();
this.draw();
}
/* ══ Fit canvas to element ══ */
fit() {
const el = this._canvas.parentElement || this._canvas;
const dpr = window.devicePixelRatio || 1;
const w = el.clientWidth || 800;
const h = el.clientHeight || 500;
this._canvas.width = w * dpr;
this._canvas.height = h * dpr;
this._canvas.style.width = w + 'px';
this._canvas.style.height = h + 'px';
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.draw();
}
/* ══ Drawing ══ */
draw() {
const ctx = this._ctx;
const W = this._canvas.width / (window.devicePixelRatio || 1);
const H = this._canvas.height / (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, W, H);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += GRID) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += GRID) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
// wires
this._wires.forEach(w => this._drawWire(ctx, w));
// ghost wire while dragging
if (this._wireStart) {
const p = this._wireStart;
ctx.beginPath();
ctx.moveTo(p.px, p.py);
ctx.lineTo(this._mouseX || p.px, this._mouseY || p.py);
ctx.strokeStyle = 'rgba(255,255,100,0.7)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
// gates
this._gates.forEach(g => this._drawGate(ctx, g));
}
_drawWire(ctx, w) {
const g1 = this._gateById(w.from.gateId);
const g2 = this._gateById(w.to.gateId);
if (!g1 || !g2) return;
const p1 = this._portPx(g1, w.from.port);
const p2 = this._portPx(g2, w.to.port);
const val = g1.value;
ctx.beginPath();
// L-route
const mx = (p1.x + p2.x) / 2;
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(mx, p1.y);
ctx.lineTo(mx, p2.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = val ? '#00ff88' : 'rgba(255,255,255,0.25)';
ctx.lineWidth = val ? 2.2 : 1.5;
ctx.stroke();
}
_drawGate(ctx, g) {
const def = GATE_DEFS[g.type];
const hw = def.w / 2, hh = def.h / 2;
const x = g.x, y = g.y;
// gate body
const isHigh = g.value === 1;
ctx.beginPath();
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
let fill = 'rgba(30,30,60,0.9)';
if (g.type === 'INPUT') fill = isHigh ? 'rgba(0,220,100,0.35)' : 'rgba(60,60,100,0.8)';
if (g.type === 'CLOCK') fill = isHigh ? 'rgba(0,180,255,0.35)' : 'rgba(40,40,100,0.8)';
if (g.type === 'OUTPUT') fill = isHigh ? 'rgba(255,80,80,0.55)' : 'rgba(60,30,30,0.8)';
ctx.fillStyle = fill;
ctx.fill();
const borderCol = g.type === 'OUTPUT' ? (isHigh ? '#ff6060' : 'rgba(255,255,255,0.2)')
: g.type === 'INPUT' ? (isHigh ? '#00cc66' : 'rgba(255,255,255,0.2)')
: g.type === 'CLOCK' ? (isHigh ? '#00aaff' : 'rgba(255,255,255,0.2)')
: 'rgba(155,93,229,0.6)';
ctx.strokeStyle = borderCol;
ctx.lineWidth = 1.5;
ctx.stroke();
// label
ctx.fillStyle = isHigh ? '#fff' : 'rgba(255,255,255,0.75)';
ctx.font = `bold ${def.ins <= 1 ? 10 : 9}px Manrope,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lbl = (g.type === 'INPUT' || g.type === 'OUTPUT') ? g.label : def.label;
ctx.fillText(lbl, x, y);
// value badge (INPUT / OUTPUT / CLOCK)
if (g.type === 'INPUT' || g.type === 'OUTPUT' || g.type === 'CLOCK') {
ctx.fillStyle = isHigh ? '#00ff88' : 'rgba(255,255,255,0.3)';
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.fillText(isHigh ? '1' : '0', x + hw - 10, y - hh + 9);
}
// ports
this._drawPorts(ctx, g);
}
_drawPorts(ctx, g) {
const def = GATE_DEFS[g.type];
const ports = [];
if (def.outs > 0) ports.push('out');
if (def.ins >= 1) ports.push('in0');
if (def.ins >= 2) ports.push('in1');
for (const port of ports) {
const p = this._portPx(g, port);
const isOut = port === 'out';
const srcGate = isOut ? g : null;
const val = isOut ? g.value : this._getInputValues(g)[port === 'in0' ? 0 : 1];
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = val ? '#00ff88' : 'rgba(255,255,255,0.3)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
/* ══ Boolean expression panel ══ */
_updatePanels() {
this._updateExprPanel();
this._updateTruthTable();
}
_buildExpr(gateId, depth) {
if (depth > 20) return '…';
const g = this._gateById(gateId);
if (!g) return '?';
if (g.type === 'INPUT') return g.label;
if (g.type === 'CLOCK') return g.label || 'CLK';
const srcOf = (port) => {
const w = this._wires.find(ww => ww.to.gateId === gateId && ww.to.port === port);
return w ? this._buildExpr(w.from.gateId, depth + 1) : '0';
};
const a = g.type !== 'NOT' && g.type !== 'BUFFER' ? srcOf('in0') : srcOf('in0');
const b = srcOf('in1');
switch (g.type) {
case 'AND': return `(${a}${b})`;
case 'OR': return `(${a} ${b})`;
case 'NOT': return `¬${a}`;
case 'XOR': return `(${a}${b})`;
case 'NAND': return `¬(${a}${b})`;
case 'NOR': return `¬(${a} ${b})`;
case 'XNOR': return `¬(${a}${b})`;
case 'BUFFER': return a;
default: return '?';
}
}
_updateExprPanel() {
if (!this._exprEl) return;
const outputs = this._gates.filter(g => g.type === 'OUTPUT');
if (outputs.length === 0) {
this._exprEl.textContent = 'Добавьте OUTPUT для вывода выражения';
return;
}
const lines = outputs.map(out => {
const w = this._wires.find(ww => ww.to.gateId === out.id);
if (!w) return `${out.label} = ?`;
const expr = this._buildExpr(w.from.gateId, 0);
return `${out.label} = ${expr}`;
});
this._exprEl.textContent = lines.join(' | ');
}
_updateTruthTable() {
if (!this._tableEl) return;
const inputs = this._gates.filter(g => g.type === 'INPUT');
const outputs = this._gates.filter(g => g.type === 'OUTPUT');
if (inputs.length === 0 || outputs.length === 0) {
this._tableEl.innerHTML = '<span style="color:rgba(255,255,255,0.35)">Добавьте INPUT и OUTPUT</span>';
return;
}
const n = inputs.length;
if (n > 6) {
this._tableEl.innerHTML = '<span style="color:rgba(255,255,255,0.35)">Слишком много входов (макс 6)</span>';
return;
}
const rows = 1 << n;
// save current values
const savedVals = inputs.map(g => g.value);
let html = '<table class="logic-tt"><thead><tr>';
inputs.forEach(g => { html += `<th>${g.label}</th>`; });
outputs.forEach(g => { html += `<th>${g.label}</th>`; });
html += '</tr></thead><tbody>';
// determine current row
const curRow = savedVals.reduce((acc, v, i) => acc | (v << (n - 1 - i)), 0);
for (let r = 0; r < rows; r++) {
inputs.forEach((g, i) => { g.value = (r >> (n - 1 - i)) & 1; });
this._propagate();
const isCur = r === curRow;
html += `<tr${isCur ? ' class="logic-tt-cur"' : ''}>`;
inputs.forEach((g, i) => { html += `<td>${(r >> (n - 1 - i)) & 1}</td>`; });
outputs.forEach(g => { html += `<td style="color:${g.value ? '#00ff88' : 'rgba(255,255,255,0.4)'}">${g.value}</td>`; });
html += '</tr>';
}
html += '</tbody></table>';
this._tableEl.innerHTML = html;
// restore
inputs.forEach((g, i) => { g.value = savedVals[i]; });
this._propagate();
}
/* ══ Presets ══ */
preset(name) {
this._pushHistory();
this._gates = [];
this._wires = [];
this._nextId = 1;
const add = (type, x, y) => this._addGate(type, x, y);
const wire = (a, ap, b, bp) => this._wires.push({ from: { gateId: a.id, port: ap }, to: { gateId: b.id, port: bp } });
switch (name) {
case 'half-adder': {
const A = add('INPUT', 80, 120); A.label = 'A';
const B = add('INPUT', 80, 200); B.label = 'B';
const xor = add('XOR', 200, 120);
const and = add('AND', 200, 200);
const S = add('OUTPUT', 320, 120); S.label = 'S';
const C = add('OUTPUT', 320, 200); C.label = 'C';
wire(A, 'out', xor, 'in0'); wire(B, 'out', xor, 'in1');
wire(A, 'out', and, 'in0'); wire(B, 'out', and, 'in1');
wire(xor, 'out', S, 'in0');
wire(and, 'out', C, 'in0');
break;
}
case 'full-adder': {
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 180); B.label = 'B';
const Cin = add('INPUT', 60, 260); Cin.label = 'Cin';
const xr1 = add('XOR', 180, 140);
const xr2 = add('XOR', 300, 140);
const an1 = add('AND', 180, 220);
const an2 = add('AND', 300, 220);
const or1 = add('OR', 400, 220);
const S = add('OUTPUT', 420, 140); S.label = 'S';
const Cout = add('OUTPUT', 520, 220); Cout.label = 'Cout';
wire(A, 'out', xr1, 'in0'); wire(B, 'out', xr1, 'in1');
wire(xr1, 'out', xr2, 'in0'); wire(Cin, 'out', xr2, 'in1');
wire(A, 'out', an1, 'in0'); wire(B, 'out', an1, 'in1');
wire(xr1, 'out', an2, 'in0'); wire(Cin, 'out', an2, 'in1');
wire(an1, 'out', or1, 'in0'); wire(an2, 'out', or1, 'in1');
wire(xr2, 'out', S, 'in0');
wire(or1, 'out', Cout, 'in0');
break;
}
case 'rs-latch': {
// Cross-coupled NOR gates: Q=NOR(R,Qbar), Qbar=NOR(S,Q)
// We simplify: R→NOR1, S→NOR2 cross-coupled; initial state stabilised
const R = add('INPUT', 80, 120); R.label = 'R';
const S = add('INPUT', 80, 220); S.label = 'S';
const nr1 = add('NOR', 220, 120);
const nr2 = add('NOR', 220, 220);
const Q = add('OUTPUT', 340, 120); Q.label = 'Q';
const Qb = add('OUTPUT', 340, 220); Qb.label = 'Q̅';
wire(R, 'out', nr1, 'in0');
wire(S, 'out', nr2, 'in1');
// Cross connections — we add them and propagate will stabilise
wire(nr2, 'out', nr1, 'in1');
wire(nr1, 'out', nr2, 'in0');
wire(nr1, 'out', Q, 'in0');
wire(nr2, 'out', Qb, 'in0');
// run propagation twice to stabilise
this._propagate(); this._propagate();
break;
}
case 'd-latch': {
// D latch: Q = D when CLK=1, holds otherwise
// SR from D: S=D∧CLK, R=¬D∧CLK
const D = add('INPUT', 60, 140); D.label = 'D';
const CLK = add('CLOCK', 60, 220); CLK.label = 'CLK'; CLK.freq = 1;
const notD = add('NOT', 160, 180);
const an1 = add('AND', 260, 120);
const an2 = add('AND', 260, 220);
const nr1 = add('NOR', 360, 120);
const nr2 = add('NOR', 360, 220);
const Q = add('OUTPUT', 480, 120); Q.label = 'Q';
const Qb = add('OUTPUT', 480, 220); Qb.label = 'Q̅';
wire(D, 'out', notD, 'in0');
wire(D, 'out', an1, 'in0'); wire(CLK, 'out', an1, 'in1');
wire(notD, 'out', an2, 'in0'); wire(CLK, 'out', an2, 'in1');
wire(an1, 'out', nr1, 'in0'); wire(nr2, 'out', nr1, 'in1');
wire(an2, 'out', nr2, 'in1'); wire(nr1, 'out', nr2, 'in0');
wire(nr1, 'out', Q, 'in0');
wire(nr2, 'out', Qb, 'in0');
break;
}
case 'decoder-2to4': {
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 200); B.label = 'B';
const nA = add('NOT', 160, 100);
const nB = add('NOT', 160, 200);
const g0 = add('AND', 280, 80);
const g1 = add('AND', 280, 160);
const g2 = add('AND', 280, 240);
const g3 = add('AND', 280, 320);
const o0 = add('OUTPUT', 400, 80); o0.label = 'Y0';
const o1 = add('OUTPUT', 400, 160); o1.label = 'Y1';
const o2 = add('OUTPUT', 400, 240); o2.label = 'Y2';
const o3 = add('OUTPUT', 400, 320); o3.label = 'Y3';
wire(A, 'out', nA, 'in0');
wire(B, 'out', nB, 'in0');
wire(nA, 'out', g0, 'in0'); wire(nB, 'out', g0, 'in1');
wire(A, 'out', g1, 'in0'); wire(nB, 'out', g1, 'in1');
wire(nA, 'out', g2, 'in0'); wire(B, 'out', g2, 'in1');
wire(A, 'out', g3, 'in0'); wire(B, 'out', g3, 'in1');
wire(g0, 'out', o0, 'in0');
wire(g1, 'out', o1, 'in0');
wire(g2, 'out', o2, 'in0');
wire(g3, 'out', o3, 'in0');
break;
}
case 'mux-2to1': {
// Y = (A ∧ ¬S) (B ∧ S)
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 200); B.label = 'B';
const Sel= add('INPUT', 60, 300); Sel.label = 'S';
const nS = add('NOT', 160, 300);
const an1= add('AND', 280, 120);
const an2= add('AND', 280, 240);
const or1= add('OR', 380, 180);
const Y = add('OUTPUT', 480, 180); Y.label = 'Y';
wire(Sel, 'out', nS, 'in0');
wire(A, 'out', an1, 'in0'); wire(nS, 'out', an1, 'in1');
wire(B, 'out', an2, 'in0'); wire(Sel, 'out', an2, 'in1');
wire(an1, 'out', or1, 'in0'); wire(an2, 'out', or1, 'in1');
wire(or1, 'out', Y, 'in0');
break;
}
default: {
const A = add('INPUT', 100, 160); A.label = 'A';
const B = add('INPUT', 100, 240); B.label = 'B';
const g = add('AND', 240, 200);
const O = add('OUTPUT', 360, 200);
wire(A, 'out', g, 'in0'); wire(B, 'out', g, 'in1');
wire(g, 'out', O, 'in0');
}
}
this._propagate();
this._updatePanels();
this.draw();
}
/* ── Clear ── */
clear() {
this._pushHistory();
this._gates = [];
this._wires = [];
this._propagate();
this._updatePanels();
this.draw();
}
/* ── Destroy ── */
destroy() {
if (this._clockRaf) cancelAnimationFrame(this._clockRaf);
if (this._raf) cancelAnimationFrame(this._raf);
}
/* ── Set tool ── */
setTool(t) { this._tool = t; }
}
/* ═══════════════════════════════════════════════════════════
Global helpers called from HTML
═══════════════════════════════════════════════════════════ */
var logicSim = null;
var _logicTableOpen = true;
function logicTool(t, el) {
if (logicSim) logicSim.setTool(t);
document.querySelectorAll('.lgc-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
}
function logicPreset(name) {
if (logicSim) logicSim.preset(name);
}
function logicClearAll() {
if (logicSim) logicSim.clear();
}
function logicToggleTable() {
_logicTableOpen = !_logicTableOpen;
const panel = document.getElementById('logic-tt-panel');
if (panel) panel.style.display = _logicTableOpen ? '' : 'none';
const btn = document.getElementById('btn-logic-tt');
if (btn) btn.classList.toggle('active', _logicTableOpen);
}
function _openLogic() {
document.getElementById('sim-topbar-title').textContent = 'Логические схемы';
_simShow('sim-logic');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('logic-canvas');
const exprEl = document.getElementById('logic-expr');
const tableEl = document.getElementById('logic-tt-body');
if (!logicSim) {
logicSim = new LogicSim(canvas, exprEl, tableEl);
} else {
// re-attach panels in case DOM was re-created
logicSim._exprEl = exprEl;
logicSim._tableEl = tableEl;
}
logicSim.fit();
if (logicSim._gates.length === 0) logicSim.preset('half-adder');
logicSim._updatePanels();
logicSim.draw();
// select tool active by default
logicTool('select', null);
}));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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) + '% осталось';
}
-541
View File
@@ -1,541 +0,0 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
RefractionSim — light refraction simulation (Snell's law)
n₁·sin(θ₁) = n₂·sin(θ₂)
Total internal reflection · Fresnel coefficients · Dispersion
Interactive incident ray drag · Presets
══════════════════════════════════════════════════════════════ */
class RefractionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n1 = 1.0; // refractive index of top medium
this.n2 = 1.5; // refractive index of bottom medium
this.angle = 30; // incidence angle in degrees
/* dispersion mode */
this.dispersion = false;
/* drag state */
this._drag = false;
/* callback */
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 { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
if (dispersion !== undefined) this.dispersion = !!dispersion;
this.draw();
this._emit();
}
reset() {
this.n1 = 1.0; this.n2 = 1.5; this.angle = 30;
this.dispersion = false;
this.draw();
this._emit();
}
info() {
const { n1, n2, angle } = this;
const theta1Rad = angle * Math.PI / 180;
const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
const criticalAngle = n1 > n2
? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1)
: null;
let angle2;
if (isTIR) {
angle2 = 'ПВО';
} else {
angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
}
return {
n1: +n1.toFixed(2),
n2: +n2.toFixed(2),
angle1: +angle.toFixed(1),
angle2,
criticalAngle,
isTIR,
};
}
/* ── presets ────────────────────────────────── */
static PRESETS = {
air_glass: { n1: 1.0, n2: 1.5, angle: 30 },
glass_air: { n1: 1.5, n2: 1.0, angle: 30 },
water_glass: { n1: 1.33, n2: 1.5, angle: 30 },
diamond: { n1: 1.0, n2: 2.42, angle: 45 },
};
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const midY = H / 2;
const hitX = W / 2;
const hitY = midY;
/* --- background: two media --- */
// top medium (lighter)
const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
gradTop.addColorStop(0, '#131328');
gradTop.addColorStop(1, '#1a1a3a');
ctx.fillStyle = gradTop;
ctx.fillRect(0, 0, W, midY);
// bottom medium (darker, denser feel)
const gradBot = ctx.createLinearGradient(0, midY, 0, H);
gradBot.addColorStop(0, '#0e1a2e');
gradBot.addColorStop(1, '#0D0D1A');
ctx.fillStyle = gradBot;
ctx.fillRect(0, midY, W, H - midY);
/* --- interface line with glow --- */
ctx.save();
ctx.shadowColor = 'rgba(155, 93, 229, 0.4)';
ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
ctx.restore();
/* --- normal line (dashed vertical) --- */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
ctx.setLineDash([]);
/* --- physics --- */
const theta1Rad = this.angle * Math.PI / 180;
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
/* Fresnel reflectance (simplified) */
let R = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const cosT1 = Math.cos(theta1Rad);
const cosT2 = Math.cos(theta2Rad);
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
R = rs * rs;
}
/* ray length (from edge to hit point) */
const rayLen = Math.max(W, H) * 0.6;
/* --- critical angle indicator --- */
if (this.n1 > this.n2) {
const critRad = Math.asin(this.n2 / this.n1);
const critDx = Math.sin(critRad);
const critDy = Math.cos(critRad);
ctx.strokeStyle = 'rgba(255,209,102,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
// critical angle ray in top medium
ctx.beginPath();
ctx.moveTo(hitX, hitY);
ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5);
ctx.stroke();
ctx.setLineDash([]);
// label
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,209,102,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const lblX = hitX - critDx * rayLen * 0.35 + 6;
const lblY = hitY - critDy * rayLen * 0.35;
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', lblX, lblY);
}
if (this.dispersion && !isTIR) {
this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
} else {
this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
}
/* --- angle arcs --- */
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
/* --- medium labels --- */
this._drawMediumLabels(ctx, W, H, midY);
/* --- info box --- */
this._drawInfoBox(ctx, isTIR, R);
/* --- drag handle indicator (incident ray endpoint) --- */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const handleX = hitX - incDx * rayLen * 0.55;
const handleY = hitY - incDy * rayLen * 0.55;
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)');
grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
/* incident ray */
const incStartX = hitX - incDx * rayLen;
const incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5');
/* reflected ray */
const refDx = incDx; // same x component
const refDy = -incDy; // flipped y
const refEndX = hitX + refDx * rayLen;
const refEndY = hitY + refDy * rayLen; // goes up (refDy is negative of incDy)
const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
ctx.globalAlpha = refAlpha;
this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
ctx.globalAlpha = 1;
/* refracted ray */
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const refracDx = Math.sin(theta2Rad);
const refracDy = Math.cos(theta2Rad);
const refracEndX = hitX + refracDx * rayLen;
const refracEndY = hitY + refracDy * rayLen;
const T = 1 - R;
ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5);
this._drawArrowhead(ctx, refracEndX, refracEndY,
Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0');
ctx.globalAlpha = 1;
}
}
_drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
/* Cauchy dispersion: n(λ) = A + B/λ² */
const spectral = [
{ name: 'red', color: '#FF0000', wave: 656 },
{ name: 'orange', color: '#FF7F00', wave: 589 },
{ name: 'yellow', color: '#FFFF00', wave: 550 },
{ name: 'green', color: '#00FF00', wave: 510 },
{ name: 'cyan', color: '#00FFFF', wave: 475 },
{ name: 'blue', color: '#0000FF', wave: 450 },
{ name: 'violet', color: '#8B00FF', wave: 400 },
];
/* incident white ray */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen;
const incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
/* Cauchy coefficients derived from base n2 */
const A = this.n2 - 4500 / (550 * 550);
const B = 4500;
for (const s of spectral) {
const n2w = A + B / (s.wave * s.wave);
const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) continue;
const t2 = Math.asin(sinT2);
const dx = Math.sin(t2);
const dy = Math.cos(t2);
ctx.globalAlpha = 0.85;
this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
ctx.globalAlpha = 1;
}
/* reflected (white, partial) */
const refDx = incDx;
const refDy = -incDy;
ctx.globalAlpha = 0.35;
this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
ctx.globalAlpha = 1;
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
/* subtle glow */
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
}
_drawArrowhead(ctx, x, y, angle, color) {
const aLen = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
ctx.closePath(); ctx.fill();
}
_drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
const arcR = 50;
const font = '12px Manrope, system-ui, sans-serif';
/* θ₁ arc (incidence angle, measured from normal = vertical up) */
if (this.angle > 1) {
ctx.strokeStyle = 'rgba(155,93,229,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
// normal points up from hit: angle = -π/2 in canvas coords
// incident ray comes from upper-left
// Arc from normal (straight up = -π/2) to incident ray direction
const normAngle = -Math.PI / 2;
const incAngle = -Math.PI / 2 - theta1Rad;
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle));
ctx.stroke();
// label
ctx.font = font;
ctx.fillStyle = '#9B5DE5';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA = normAngle - theta1Rad / 2;
ctx.fillText(
'θ₁=' + this.angle.toFixed(1) + '°',
hitX + (arcR + 20) * Math.cos(midA),
hitY + (arcR + 20) * Math.sin(midA)
);
}
/* θ₂ arc (refraction angle, measured from normal = vertical down) */
if (!isTIR && Math.abs(sinTheta2) <= 1) {
const theta2Rad = Math.asin(sinTheta2);
if (theta2Rad > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
const normDown = Math.PI / 2;
const refAngle = Math.PI / 2 + theta2Rad;
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle));
ctx.stroke();
// label
const angle2Deg = theta2Rad * 180 / Math.PI;
ctx.font = font;
ctx.fillStyle = '#06D6E0';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA2 = normDown + theta2Rad / 2;
ctx.fillText(
'θ₂=' + angle2Deg.toFixed(1) + '°',
hitX + (arcR * 0.8 + 20) * Math.cos(midA2),
hitY + (arcR * 0.8 + 20) * Math.sin(midA2)
);
}
}
}
_drawMediumLabels(ctx, W, H, midY) {
ctx.font = '13px Manrope, system-ui, sans-serif';
ctx.textBaseline = 'middle';
/* top medium */
ctx.fillStyle = 'rgba(155,93,229,0.6)';
ctx.textAlign = 'left';
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
/* bottom medium */
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
/* TIR badge */
const theta1Rad = this.angle * Math.PI / 180;
const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) {
ctx.font = 'bold 14px Manrope, system-ui, sans-serif';
ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center';
ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
}
}
_drawInfoBox(ctx, isTIR, R) {
const boxW = 220, boxH = 72;
const bx = this.W - boxW - 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
const info = this.info();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
const a2str = info.isTIR ? 'ПВО' : info.angle2 + '°';
ctx.fillText(`θ₁ = ${info.angle1}° θ₂ = ${a2str}`, bx + 10, by + 28);
const rPct = (R * 100).toFixed(1);
const tPct = ((1 - R) * 100).toFixed(1);
ctx.fillStyle = '#EF476F';
ctx.fillText(`R = ${rPct}%`, bx + 10, by + 46);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`T = ${isTIR ? '0' : tPct}%`, bx + 90, by + 46);
if (info.criticalAngle !== null) {
ctx.fillStyle = '#FFD166';
ctx.fillText(`θc = ${info.criticalAngle}°`, bx + 160, by + 46);
}
}
/* ── 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 hitTest = (mx, my) => {
/* Check if near the incident ray line (top half only) */
const hitX = this.W / 2;
const hitY = this.H / 2;
if (my >= hitY) return false;
/* distance from mouse to the hit point — if within top half, allow drag */
const dx = mx - hitX;
const dy = my - hitY;
const dist = Math.hypot(dx, dy);
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
};
const angleFromMouse = (mx, my) => {
const hitX = this.W / 2;
const hitY = this.H / 2;
const dx = mx - hitX;
const dy = hitY - my; // flip: canvas y goes down, angle measured from vertical up
// angle from vertical = atan2(|dx|, dy)
const a = Math.atan2(Math.abs(dx), dy) * 180 / Math.PI;
return Math.max(0, Math.min(89, a));
};
const onDown = (e) => {
const { mx, my } = getPos(e);
if (hitTest(mx, my)) this._drag = true;
};
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx, my } = getPos(e);
this.angle = angleFromMouse(mx, my);
this.draw();
this._emit();
};
const onUp = () => { this._drag = false; };
/* mouse */
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
/* touch */
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) onDown(e);
}, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
/* cursor style */
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openRefraction() {
document.getElementById('sim-topbar-title').textContent = 'Преломление света';
_simShow('sim-refraction');
_registerSimState('refraction', () => refrSim?.getParams(), st => refrSim?.setParams(st));
if (_embedMode) _startStateEmit('refraction');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!refrSim) {
refrSim = new RefractionSim(document.getElementById('refraction-canvas'));
refrSim.onUpdate = _refrUpdateUI;
}
refrSim.fit();
refrSim.draw();
refrSim._emit();
}));
}
function refrParam(name, val) {
const v = parseFloat(val);
const ids = { n1: 'refr-n1-val', n2: 'refr-n2-val', angle: 'refr-angle-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = name === 'angle' ? v : v.toFixed(2);
if (refrSim) refrSim.setParams({ [name]: v });
}
function refrPreset(n1, n2, angle) {
document.getElementById('sl-refr-n1').value = n1; document.getElementById('refr-n1-val').textContent = n1.toFixed(2);
document.getElementById('sl-refr-n2').value = n2; document.getElementById('refr-n2-val').textContent = n2.toFixed(2);
document.getElementById('sl-refr-angle').value = angle; document.getElementById('refr-angle-val').textContent = angle;
if (refrSim) refrSim.setParams({ n1, n2, angle });
}
function _refrUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('refrbar-v1', info.angle1 + '°');
v('refrbar-v2', info.isTIR ? 'ПВО' : info.angle2 + '°');
v('refrbar-v3', info.criticalAngle !== null ? info.criticalAngle + '°' : '—');
v('refrbar-v4', info.isTIR ? 'Да' : 'Нет');
}
/* ── probability ── */
+863
View File
@@ -0,0 +1,863 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════════
StoichSim — «Стехиометрия»
Визуальный интерактивный калькулятор стехиометрии с анимацией.
═══════════════════════════════════════════════════════════════════════ */
class StoichSim {
/* ── Рецепты реакций ─────────────────────────────────────────────── */
static RECIPES = [
{
name: 'Zn + 2HCl → ZnCl₂ + H₂↑',
label: 'Zn + HCl',
reactants: [
{ sym: 'Zn', coef: 1, M: 65.38, phase: 's', color: '#9BB8CC' },
{ sym: 'HCl', coef: 2, M: 36.46, phase: 'aq', color: '#78D278' },
],
products: [
{ sym: 'ZnCl₂', coef: 1, M: 136.28, phase: 'aq', color: '#4CC9F0' },
{ sym: 'H₂', coef: 1, M: 2.016, phase: 'g', color: '#FFD166' },
],
},
{
name: '2H₂ + O₂ → 2H₂O',
label: 'H₂ + O₂',
reactants: [
{ sym: 'H₂', coef: 2, M: 2.016, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: 'CH₄ + 2O₂ → CO₂ + 2H₂O',
label: 'Горение метана',
reactants: [
{ sym: 'CH₄', coef: 1, M: 16.043, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 2, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
{
name: 'N₂ + 3H₂ → 2NH₃',
label: 'Синтез аммиака',
reactants: [
{ sym: 'N₂', coef: 1, M: 28.014, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂', coef: 3, M: 2.016, phase: 'g', color: '#FFD166' },
],
products: [
{ sym: 'NH₃', coef: 2, M: 17.031, phase: 'g', color: '#06D6E0' },
],
},
{
name: '2Al + 3CuSO₄ → Al₂(SO₄)₃ + 3Cu',
label: 'Al + CuSO₄',
reactants: [
{ sym: 'Al', coef: 2, M: 26.982, phase: 's', color: '#D6D6D6' },
{ sym: 'CuSO₄',coef: 3, M: 159.60, phase: 'aq', color: '#4CC9F0' },
],
products: [
{ sym: 'Al₂(SO₄)₃', coef: 1, M: 342.15, phase: 'aq', color: '#B8D4F0' },
{ sym: 'Cu', coef: 3, M: 63.546, phase: 's', color: '#C87840' },
],
},
{
name: '2Mg + O₂ → 2MgO',
label: 'Горение магния',
reactants: [
{ sym: 'Mg', coef: 2, M: 24.305, phase: 's', color: '#E8E8E8' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'MgO', coef: 2, M: 40.304, phase: 's', color: '#FFFFFF' },
],
},
{
name: 'CaCO₃ → CaO + CO₂↑',
label: 'Разложение мела',
reactants: [
{ sym: 'CaCO₃', coef: 1, M: 100.086, phase: 's', color: '#F0F0F0' },
],
products: [
{ sym: 'CaO', coef: 1, M: 56.077, phase: 's', color: '#D4C4A0' },
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
],
},
{
name: 'HCl + NaOH → NaCl + H₂O',
label: 'Нейтрализация',
reactants: [
{ sym: 'HCl', coef: 1, M: 36.46, phase: 'aq', color: '#78D278' },
{ sym: 'NaOH', coef: 1, M: 40.0, phase: 'aq', color: '#7BF5A4' },
],
products: [
{ sym: 'NaCl', coef: 1, M: 58.44, phase: 'aq', color: '#FFFFFF' },
{ sym: 'H₂O', coef: 1, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: '2KMnO₄ → K₂MnO₄ + MnO₂ + O₂↑',
label: 'Разложение KMnO₄',
reactants: [
{ sym: 'KMnO₄', coef: 2, M: 158.034, phase: 's', color: '#9B59B6' },
],
products: [
{ sym: 'K₂MnO₄', coef: 1, M: 197.132, phase: 's', color: '#27AE60' },
{ sym: 'MnO₂', coef: 1, M: 86.937, phase: 's', color: '#1A1A2E' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
},
{
name: 'C₂H₅OH + 3O₂ → 2CO₂ + 3H₂O',
label: 'Горение спирта',
reactants: [
{ sym: 'C₂H₅OH', coef: 1, M: 46.068, phase: 'l', color: '#FFD166' },
{ sym: 'O₂', coef: 3, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 2, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 3, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
];
/* ── Конструктор ─────────────────────────────────────────────────── */
constructor(container) {
this._container = container;
this._recipeIdx = 0;
this._amounts = []; // граммы для каждого реагента
this._inputMode = []; // 'mass' | 'mol' | 'vol' для каждого реагента
this._animState = 'idle'; // idle | reacting | done
this._animT = 0;
this._raf = null;
this._computed = null; // результаты последнего расчёта
this._init();
this._setRecipe(0);
}
/* ── Построение DOM ─────────────────────────────────────────────── */
_init() {
const c = this._container;
c.innerHTML = '';
// ── Wrapper layout ──
c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;';
// ── Equation bar ──
this._eqBar = _stEl('div', {
style: 'flex:0 0 auto;padding:10px 16px 6px;background:rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.08);',
});
c.appendChild(this._eqBar);
// ── Main area ──
const main = _stEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' });
c.appendChild(main);
// Left panel (reagent inputs)
this._leftPanel = _stEl('div', {
style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-right:1px solid rgba(255,255,255,0.07);',
});
main.appendChild(this._leftPanel);
// Center canvas area
const centerWrap = _stEl('div', {
style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;',
});
this._canvas = document.createElement('canvas');
this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;';
centerWrap.appendChild(this._canvas);
main.appendChild(centerWrap);
// Right panel (step-by-step)
this._rightPanel = _stEl('div', {
style: 'flex:0 0 240px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-left:1px solid rgba(255,255,255,0.07);',
});
main.appendChild(this._rightPanel);
// ── Bottom HUD ──
this._hud = _stEl('div', {
style: 'flex:0 0 auto;display:flex;gap:12px;flex-wrap:wrap;align-items:center;padding:8px 16px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);font-size:.76rem;',
});
c.appendChild(this._hud);
// Canvas context
this._ctx = this._canvas.getContext('2d');
// ResizeObserver
if (window.ResizeObserver) {
this._ro = new ResizeObserver(() => { this._fitCanvas(); this._draw(); });
this._ro.observe(this._canvas);
}
}
/* ── Выбрать рецепт ─────────────────────────────────────────────── */
_setRecipe(idx) {
this._recipeIdx = idx;
const r = StoichSim.RECIPES[idx];
// Инициализация количеств (начальные значения = 1 г / 1 моль за реагент)
this._amounts = r.reactants.map(re => re.M); // 1 моль в граммах
this._inputMode = r.reactants.map(() => 'mass');
this._animState = 'idle';
this._animT = 0;
this._rebuildLeft();
this._rebuildEquation();
this._compute();
this._rebuildRight();
this._fitCanvas();
this._draw();
}
/* ── Уравнение реакции ──────────────────────────────────────────── */
_rebuildEquation() {
const r = StoichSim.RECIPES[this._recipeIdx];
const eb = this._eqBar;
eb.innerHTML = '';
// Реакции selector
const selWrap = _stEl('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' });
const sel = document.createElement('select');
sel.style.cssText = 'background:#1a1a2e;color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:4px 8px;font-size:.78rem;font-family:Manrope,sans-serif;cursor:pointer;';
StoichSim.RECIPES.forEach((rc, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = rc.label;
if (i === this._recipeIdx) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', () => { this._setRecipe(+sel.value); });
selWrap.appendChild(sel);
// Equation display
const eqText = _stEl('div', {
style: 'font-size:.88rem;color:rgba(255,255,255,0.9);flex:1;min-width:0;word-break:break-word;',
textContent: r.name,
});
selWrap.appendChild(eqText);
// React button
const btn = _stEl('button', {
style: 'margin-left:auto;padding:5px 14px;border-radius:6px;background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff;font-size:.75rem;font-weight:700;border:none;cursor:pointer;white-space:nowrap;',
textContent: 'Реагировать',
});
btn.addEventListener('click', () => this._startAnim());
selWrap.appendChild(btn);
eb.appendChild(selWrap);
// Quantity badges
if (this._computed) this._rebuildBadges(eb, r);
}
_rebuildBadges(eb, r) {
const comp = this._computed;
const badgesRow = _stEl('div', { style: 'display:flex;gap:16px;flex-wrap:wrap;margin-top:6px;' });
const all = [
...r.reactants.map((s, i) => ({ s, q: comp.reactantQ[i], isReactant: true, idx: i })),
...r.products.map((s, i) => ({ s, q: comp.productQ[i], isReactant: false, idx: i })),
];
all.forEach(({ s, q, isReactant, idx }) => {
const wrap = _stEl('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:2px;' });
const coefSpan = _stEl('span', {
style: `font-size:.72rem;color:rgba(255,255,255,0.5);`,
textContent: (s.coef > 1 ? s.coef : '') + s.sym,
});
wrap.appendChild(coefSpan);
const mBadge = _stEl('span', {
style: `font-size:.7rem;padding:2px 6px;border-radius:4px;background:rgba(255,255,255,0.08);color:#FFD166;font-weight:600;`,
textContent: q.m.toFixed(2) + ' г',
});
wrap.appendChild(mBadge);
const nBadge = _stEl('span', {
style: `font-size:.68rem;color:rgba(255,255,255,0.5);`,
textContent: q.n.toFixed(4) + ' моль',
});
wrap.appendChild(nBadge);
if (s.phase === 'g') {
const vBadge = _stEl('span', {
style: `font-size:.68rem;color:var(--cyan,#4CC9F0);`,
textContent: q.v.toFixed(3) + ' л',
});
wrap.appendChild(vBadge);
}
// Highlight limiting reagent
if (isReactant && this._computed.limitIdx === idx) {
wrap.style.outline = '2px solid #EF476F';
wrap.style.borderRadius = '6px';
wrap.style.padding = '2px 4px';
}
badgesRow.appendChild(wrap);
// Arrow between reactants and products
if (isReactant && idx === r.reactants.length - 1) {
badgesRow.appendChild(_stEl('div', {
style: 'font-size:1rem;align-self:center;color:rgba(255,255,255,0.4);',
textContent: '→',
}));
}
});
eb.appendChild(badgesRow);
}
/* ── Левая панель: inputs ───────────────────────────────────────── */
_rebuildLeft() {
const lp = this._leftPanel;
lp.innerHTML = '';
const r = StoichSim.RECIPES[this._recipeIdx];
const title = _stEl('div', {
style: 'font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;',
textContent: 'Реагенты',
});
lp.appendChild(title);
r.reactants.forEach((re, i) => {
const block = _stEl('div', {
style: 'margin-bottom:14px;padding:8px;background:rgba(255,255,255,0.04);border-radius:8px;',
});
// Name
const nameRow = _stEl('div', {
style: `font-size:.8rem;font-weight:700;color:${re.color};margin-bottom:6px;`,
textContent: re.sym,
});
block.appendChild(nameRow);
// Mode toggle
const modeRow = _stEl('div', { style: 'display:flex;gap:3px;margin-bottom:7px;' });
const modes = [['mass', 'г'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л']] : [])];
modes.forEach(([m, label]) => {
const btn = _stEl('button', {
style: `flex:1;padding:2px 0;border-radius:4px;font-size:.65rem;border:1px solid rgba(255,255,255,0.15);cursor:pointer;font-family:Manrope,sans-serif;transition:background .15s;`,
textContent: label,
});
btn.style.background = this._inputMode[i] === m ? 'rgba(155,93,229,0.4)' : 'rgba(255,255,255,0.05)';
btn.style.color = this._inputMode[i] === m ? '#fff' : 'rgba(255,255,255,0.6)';
btn.addEventListener('click', () => {
this._inputMode[i] = m;
this._rebuildLeft();
this._compute();
this._updateAll();
});
modeRow.appendChild(btn);
});
block.appendChild(modeRow);
// Slider + value
const mode = this._inputMode[i];
let sliderMin, sliderMax, sliderStep, sliderVal, unit;
if (mode === 'mass') {
sliderMin = +(re.M * 0.1).toFixed(2);
sliderMax = +(re.M * 10).toFixed(0);
sliderStep = +(re.M * 0.01).toFixed(2);
sliderVal = +this._amounts[i].toFixed(4);
unit = 'г';
} else if (mode === 'mol') {
sliderMin = 0.01;
sliderMax = 10;
sliderStep = 0.01;
sliderVal = +(this._amounts[i] / re.M).toFixed(4);
unit = 'моль';
} else {
sliderMin = 0.1;
sliderMax = 100;
sliderStep = 0.1;
sliderVal = +(this._amounts[i] / re.M * 22.4).toFixed(3);
unit = 'л';
}
const valSpan = _stEl('span', {
style: 'font-size:.76rem;font-weight:700;color:#FFD166;min-width:52px;text-align:right;',
textContent: sliderVal.toFixed(3) + ' ' + unit,
});
const sl = document.createElement('input');
sl.type = 'range';
sl.min = sliderMin;
sl.max = sliderMax;
sl.step = sliderStep;
sl.value = sliderVal;
sl.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer;';
sl.addEventListener('input', () => {
const v = parseFloat(sl.value);
if (mode === 'mass') this._amounts[i] = v;
else if (mode === 'mol') this._amounts[i] = v * re.M;
else this._amounts[i] = v / 22.4 * re.M;
valSpan.textContent = v.toFixed(3) + ' ' + unit;
this._compute();
this._updateAll();
});
const slRow = _stEl('div', { style: 'display:flex;align-items:center;gap:6px;' });
slRow.appendChild(sl);
block.appendChild(slRow);
block.appendChild(valSpan);
lp.appendChild(block);
});
// Reset button
const resetBtn = _stEl('button', {
style: 'width:100%;padding:6px;border-radius:6px;background:rgba(255,255,255,0.07);color:rgba(255,255,255,0.7);font-size:.73rem;border:1px solid rgba(255,255,255,0.12);cursor:pointer;margin-top:4px;',
textContent: 'Сброс',
});
resetBtn.addEventListener('click', () => {
const r2 = StoichSim.RECIPES[this._recipeIdx];
this._amounts = r2.reactants.map(re => re.M);
this._inputMode = r2.reactants.map(() => 'mass');
this._animState = 'idle';
this._animT = 0;
this._rebuildLeft();
this._compute();
this._updateAll();
});
lp.appendChild(resetBtn);
}
/* ── Расчёт стехиометрии ─────────────────────────────────────────── */
_compute() {
const r = StoichSim.RECIPES[this._recipeIdx];
// n_i / coef_i для каждого реагента
const ratios = r.reactants.map((re, i) => (this._amounts[i] / re.M) / re.coef);
const limitVal = Math.min(...ratios);
const limitIdx = ratios.indexOf(limitVal);
// Количество реагентов фактически израсходованных
const reactantQ = r.reactants.map((re, i) => {
const nConsumed = limitVal * re.coef;
const mConsumed = nConsumed * re.M;
const nActual = this._amounts[i] / re.M;
const nExcess = nActual - nConsumed;
return {
n: nConsumed,
m: mConsumed,
v: nConsumed * 22.4,
nExcess,
mExcess: nExcess * re.M,
vExcess: nExcess * 22.4,
};
});
// Продукты
const productQ = r.products.map(pr => {
const nProd = limitVal * pr.coef;
return {
n: nProd,
m: nProd * pr.M,
v: nProd * 22.4,
};
});
this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ };
}
/* ── Правая панель: пошаговый расчёт ───────────────────────────── */
_rebuildRight() {
const rp = this._rightPanel;
rp.innerHTML = '';
if (!this._computed) return;
const comp = this._computed;
const r = StoichSim.RECIPES[this._recipeIdx];
const title = _stEl('div', {
style: 'font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;',
textContent: 'Решение',
});
rp.appendChild(title);
// Для каждого реагента показываем шаг n = m/M
r.reactants.forEach((re, i) => {
const m = this._amounts[i];
const n = m / re.M;
const block = _stEl('div', {
style: 'margin-bottom:10px;padding:7px 8px;background:rgba(255,255,255,0.04);border-radius:7px;',
});
const head = _stEl('div', {
style: `font-size:.75rem;font-weight:700;color:${re.color};margin-bottom:4px;`,
textContent: re.sym + ' (реагент):',
});
block.appendChild(head);
// n = m/M rendered with katex if available
const step1 = `n = \\frac{m}{M} = \\frac{${m.toFixed(2)}}{${re.M}} = ${n.toFixed(4)}\\text{ моль}`;
const step1El = _stEl('div', { style: 'margin-bottom:3px;' });
_stKatex(step1El, step1);
block.appendChild(step1El);
rp.appendChild(block);
});
// Лимитирующий реагент → расчёт продуктов
const limRe = r.reactants[comp.limitIdx];
const limN = this._amounts[comp.limitIdx] / limRe.M;
const limBlock = _stEl('div', {
style: 'margin-bottom:10px;padding:7px 8px;background:rgba(239,71,111,0.1);border-radius:7px;border:1px solid rgba(239,71,111,0.3);',
});
limBlock.appendChild(_stEl('div', {
style: 'font-size:.73rem;font-weight:700;color:#EF476F;margin-bottom:4px;',
textContent: 'Лимитирующий: ' + limRe.sym,
}));
const limFormula = `n_{\\text{лим}} = ${comp.limitVal.toFixed(4)}\\text{ моль}`;
const limEl = _stEl('div', { style: 'margin-bottom:2px;' });
_stKatex(limEl, limFormula);
limBlock.appendChild(limEl);
rp.appendChild(limBlock);
// Продукты
r.products.forEach((pr, i) => {
const q = comp.productQ[i];
const block = _stEl('div', {
style: 'margin-bottom:10px;padding:7px 8px;background:rgba(255,255,255,0.04);border-radius:7px;',
});
const head = _stEl('div', {
style: `font-size:.75rem;font-weight:700;color:${pr.color};margin-bottom:4px;`,
textContent: pr.sym + ' (продукт):',
});
block.appendChild(head);
// n₂ = (b/a)·n_lim
const ratio = pr.coef + '/' + limRe.coef;
const step1El = _stEl('div', { style: 'margin-bottom:3px;' });
_stKatex(step1El, `n = \\frac{${pr.coef}}{${limRe.coef}} \\cdot ${comp.limitVal.toFixed(4)} = ${q.n.toFixed(4)}\\text{ моль}`);
block.appendChild(step1El);
const step2El = _stEl('div', { style: 'margin-bottom:3px;' });
_stKatex(step2El, `m = n \\cdot M = ${q.n.toFixed(4)} \\cdot ${pr.M} = ${q.m.toFixed(3)}\\text{ г}`);
block.appendChild(step2El);
if (pr.phase === 'g') {
const step3El = _stEl('div');
_stKatex(step3El, `V = n \\cdot 22{,}4 = ${q.v.toFixed(3)}\\text{ л}\\,(\\text{н.у.})`);
block.appendChild(step3El);
}
rp.appendChild(block);
});
}
/* ── HUD ─────────────────────────────────────────────────────────── */
_rebuildHud() {
const hud = this._hud;
hud.innerHTML = '';
if (!this._computed) return;
const comp = this._computed;
const r = StoichSim.RECIPES[this._recipeIdx];
const limRe = r.reactants[comp.limitIdx];
const limQ = comp.reactantQ[comp.limitIdx];
const chip = (label, val, color) => {
const c = _stEl('div', { style: 'display:flex;flex-direction:column;gap:1px;' });
c.appendChild(_stEl('span', { style: 'color:rgba(255,255,255,0.4);font-size:.67rem;', textContent: label }));
c.appendChild(_stEl('span', { style: `color:${color};font-weight:700;font-size:.8rem;`, textContent: val }));
return c;
};
hud.appendChild(chip('Лимитирующий реагент', limRe.sym, '#EF476F'));
hud.appendChild(_stEl('div', { style: 'width:1px;height:28px;background:rgba(255,255,255,0.1);' }));
const excessN = limQ.nExcess;
const otherExcesses = r.reactants
.map((re, i) => ({ re, q: comp.reactantQ[i], i }))
.filter(({ i }) => i !== comp.limitIdx);
otherExcesses.forEach(({ re, q }) => {
hud.appendChild(chip('Избыток ' + re.sym, q.mExcess.toFixed(2) + ' г', '#FFD166'));
});
hud.appendChild(_stEl('div', { style: 'width:1px;height:28px;background:rgba(255,255,255,0.1);' }));
const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0);
hud.appendChild(chip('Выход (теор.)', totalProdM.toFixed(3) + ' г', '#06D6E0'));
const totalGasV = r.products
.map((pr, i) => pr.phase === 'g' ? comp.productQ[i].v : 0)
.reduce((a, b) => a + b, 0);
if (totalGasV > 0.0001) {
hud.appendChild(chip('Газов (н.у.)', totalGasV.toFixed(3) + ' л', '#9B5DE5'));
}
}
/* ── Обновить всё кроме левой панели (слайдеры уже обновлены) ──── */
_updateAll() {
this._rebuildEquation();
this._rebuildRight();
this._rebuildHud();
this._draw();
}
/* ── Canvas: размеры ─────────────────────────────────────────────── */
_fitCanvas() {
const cv = this._canvas;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth;
const h = cv.clientHeight;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._W = w;
this._H = h;
}
/* ── Canvas: рисование ──────────────────────────────────────────── */
_draw() {
const ctx = this._ctx;
const W = this._W || this._canvas.clientWidth;
const H = this._H || this._canvas.clientHeight;
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
const allSubs = [
...r.reactants.map((s, i) => ({ s, i, isReactant: true, q: comp.reactantQ[i] })),
...r.products.map((s, i) => ({ s, i, isReactant: false, q: comp.productQ[i] })),
];
const N = allSubs.length;
const boxW = Math.min(Math.floor((W - (N + 1) * 10) / N), 110);
const boxH = Math.min(H - 40, 130);
const totalW = N * boxW + (N - 1) * 10;
const startX = (W - totalW) / 2;
const topY = (H - boxH) / 2 - 10;
// Стрелка-разделитель между реагентами и продуктами
const sepIdx = r.reactants.length;
const animT = this._animState === 'reacting' ? this._animT : (this._animState === 'done' ? 1 : 0);
allSubs.forEach(({ s, i, isReactant, q }, k) => {
const x = startX + k * (boxW + 10);
// Стрелка → перед первым продуктом
if (k === sepIdx) {
ctx.save();
ctx.strokeStyle = `rgba(255,255,255,${0.2 + animT * 0.5})`;
ctx.lineWidth = 2;
const ax = x - 10;
ctx.beginPath();
ctx.moveTo(ax - 12, topY + boxH / 2);
ctx.lineTo(ax - 2, topY + boxH / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ax - 7, topY + boxH / 2 - 5);
ctx.lineTo(ax - 2, topY + boxH / 2);
ctx.lineTo(ax - 7, topY + boxH / 2 + 5);
ctx.stroke();
ctx.restore();
}
// Highlight лимитирующего реагента
const isLimit = isReactant && i === comp.limitIdx;
this._drawBeaker(ctx, x, topY, boxW, boxH, s, q, isReactant, isLimit, animT);
});
}
_drawBeaker(ctx, x, y, bw, bh, sub, q, isReactant, isLimit, animT) {
const r = 6;
ctx.save();
// Border
const borderColor = isLimit
? `rgba(239,71,111,${0.4 + animT * 0.4})`
: 'rgba(255,255,255,0.1)';
ctx.strokeStyle = borderColor;
ctx.lineWidth = isLimit ? 2 : 1;
ctx.beginPath();
_stRoundRect(ctx, x, y, bw, bh, r);
ctx.stroke();
// Background
ctx.fillStyle = 'rgba(255,255,255,0.03)';
ctx.fill();
// Label
ctx.fillStyle = sub.color;
ctx.font = 'bold 11px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(sub.sym, x + bw / 2, y + 16);
// Particles
const maxParticles = 20;
const nParticles = isReactant
? Math.max(1, Math.round((q.n / (q.n + q.nExcess || q.n)) * maxParticles))
: Math.max(1, Math.round(Math.min(q.n / 0.2, 1) * maxParticles));
const areaX = x + 8;
const areaY = y + 24;
const areaW = bw - 16;
const areaH = bh - 40;
// Seed deterministic positions from sub.sym
const seed = sub.sym.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const pts = [];
for (let p = 0; p < maxParticles; p++) {
const px = areaX + _stLcg(seed + p * 7) * areaW;
const py = areaY + _stLcg(seed + p * 7 + 3) * areaH;
pts.push([px, py]);
}
const alpha = isReactant
? Math.max(0, 1 - animT * 1.2)
: Math.min(1, animT * 1.5);
ctx.globalAlpha = alpha;
for (let p = 0; p < nParticles; p++) {
const [px, py] = pts[p];
const jx = isReactant && animT > 0
? (x + bw / 2 - px) * animT
: 0;
const jy = isReactant && animT > 0
? (y + bh / 2 - py) * animT * 0.5
: 0;
ctx.beginPath();
ctx.arc(px + jx, py + jy, 4, 0, Math.PI * 2);
ctx.fillStyle = sub.color;
ctx.fill();
ctx.globalAlpha = alpha * 0.5;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.globalAlpha = alpha;
}
ctx.globalAlpha = 1;
// Phase label
const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '9px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(phaseText, x + bw / 2, y + bh - 6);
// Mass badge bottom-right
ctx.fillStyle = 'rgba(255,214,102,0.8)';
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.textAlign = 'right';
ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6);
ctx.restore();
}
/* ── Анимация реакции ───────────────────────────────────────────── */
_startAnim() {
if (this._animState === 'reacting') return;
this._animState = 'reacting';
this._animT = 0;
const dur = 1200; // ms
const start = performance.now();
const tick = (now) => {
this._animT = Math.min(1, (now - start) / dur);
this._draw();
if (this._animT < 1) {
this._raf = requestAnimationFrame(tick);
} else {
this._animState = 'done';
this._rebuildHud();
this._draw();
}
};
this._raf = requestAnimationFrame(tick);
}
/* ── Public API для _openStoich ─────────────────────────────────── */
fit() {
this._fitCanvas();
this._draw();
}
destroy() {
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
}
}
/* ── helpers (stoichiometry-local, prefixed _st to avoid collisions) ─ */
function _stEl(tag, props) {
const el = document.createElement(tag);
Object.entries(props || {}).forEach(([k, v]) => {
if (k === 'textContent') el.textContent = v;
else if (k === 'style') el.style.cssText = v;
else el[k] = v;
});
return el;
}
function _stRoundRect(ctx, x, y, w, h, r) {
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
// Simple deterministic pseudo-random [0,1) from seed
function _stLcg(seed) {
const a = 1664525, c = 1013904223, m = 2 ** 32;
return ((a * seed + c) % m) / m;
}
function _stKatex(el, formula) {
if (window.katex) {
try {
el.innerHTML = katex.renderToString(formula, { throwOnError: false, displayMode: false });
return;
} catch(e) { /* fallback */ }
}
// plain text fallback
el.textContent = formula;
el.style.fontFamily = 'monospace';
el.style.fontSize = '.75rem';
el.style.color = 'rgba(255,255,255,0.7)';
}
/* ═══════════════════════════════════════════════════════════════════
lab UI init — следует паттерну _openChemSandbox / _openEquilibrium
═══════════════════════════════════════════════════════════════════ */
var _stoichSim = null;
function _openStoich() {
document.getElementById('sim-topbar-title').textContent = 'Стехиометрия';
_simShow('sim-stoichiometry');
requestAnimationFrame(() => requestAnimationFrame(() => {
const container = document.getElementById('stoichiometry-wrap');
if (!_stoichSim) {
_stoichSim = new StoichSim(container);
} else {
_stoichSim.fit();
}
}));
}
-489
View File
@@ -1,489 +0,0 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
ThinLensSim — thin lens ray tracing simulation
1/f = 1/d + 1/d' M = -d'/d
Three principal rays · draggable object & focal point
Converging (f>0) and diverging (f<0) lenses
══════════════════════════════════════════════════════════════ */
class ThinLensSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics (px units) */
this.f = 100; // focal length
this.d = 200; // object distance (positive, measured from lens)
this.h = 50; // object height
/* drag state */
this._drag = null; // 'object' | 'focus' | null
/* callback */
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 { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
this.draw();
this._emit();
}
reset() {
this.f = 100; this.d = 200; this.h = 50;
this.draw();
this._emit();
}
info() {
const { f, d, h } = this;
const denom = d - f;
const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
const hPrime = M === Infinity ? Infinity : M * h;
const isVirtual = dPrime < 0;
return {
f: +f.toFixed(1),
d: +d.toFixed(1),
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
M: M === Infinity ? Infinity : +M.toFixed(3),
imageType: isVirtual ? 'мнимое' : 'действительное',
h: +h.toFixed(1),
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/** Convert simulation coords to canvas coords.
* Origin = lens center; +x right, +y up.
* Canvas: lensX = W/2, axisY = H/2 */
_toCanvas(sx, sy) {
return { cx: this.W / 2 + sx, cy: this.H / 2 - sy };
}
_fromCanvas(cx, cy) {
return { sx: cx - this.W / 2, sy: this.H / 2 - cy };
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const { f, d, h } = this;
const lensX = W / 2;
const axisY = H / 2;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* optical axis */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
ctx.setLineDash([]);
/* lens */
this._drawLens(ctx, lensX, axisY, f);
/* focal & 2F points */
this._drawFocalPoints(ctx, lensX, axisY, f);
/* object arrow */
const objX = lensX - d;
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
/* compute image */
const denom = d - f;
let dPrime, hPrime;
if (Math.abs(denom) < 0.5) {
/* object at focal point — rays parallel, no image */
dPrime = null;
hPrime = null;
} else {
dPrime = (f * d) / denom;
const M = -dPrime / d;
hPrime = M * h;
}
/* principal rays */
this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
/* image arrow */
if (dPrime !== null && isFinite(dPrime)) {
const isVirtual = dPrime < 0;
const imgX = lensX + dPrime;
const imgTop = axisY - hPrime;
this._drawArrow(ctx, imgX, axisY, imgX, imgTop,
isVirtual ? '#FFD166' : '#EF476F', isVirtual);
}
/* labels */
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
}
_drawLens(ctx, lx, ay, f) {
const lensH = Math.min(this.H * 0.38, 140);
const converging = f > 0;
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2.5;
if (converging) {
/* biconvex shape */
const bulge = Math.min(18, Math.abs(f) * 0.12);
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH);
ctx.stroke();
/* arrowheads (converging) */
this._lensArrow(ctx, lx, ay - lensH, -1);
this._lensArrow(ctx, lx, ay + lensH, 1);
} else {
/* biconcave shape */
const bulge = Math.min(14, Math.abs(f) * 0.1);
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH);
ctx.stroke();
/* arrowheads (diverging) */
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
}
/* center line */
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
}
_lensArrow(ctx, x, y, dir) {
const sz = 7;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - sz, y + dir * sz * 1.2);
ctx.lineTo(x + sz, y + dir * sz * 1.2);
ctx.closePath(); ctx.fill();
}
_lensArrowDiv(ctx, x, y, dir) {
const sz = 6;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath();
ctx.moveTo(x - sz, y);
ctx.lineTo(x, y - dir * sz);
ctx.lineTo(x + sz, y);
ctx.closePath(); ctx.fill();
}
_drawFocalPoints(ctx, lx, ay, f) {
const pts = [
{ sx: f, label: "F'" },
{ sx: -f, label: 'F' },
{ sx: 2 * f, label: "2F'" },
{ sx: -2 * f, label: '2F' },
];
for (const p of pts) {
const px = lx + p.sx;
if (px < 10 || px > this.W - 10) continue;
const isFocal = !p.label.startsWith('2');
const r = isFocal ? 5 : 3.5;
const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(p.label, px, ay + 10);
}
}
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
if (dashed) ctx.setLineDash([]);
/* arrowhead */
const angle = Math.atan2(y2 - y1, x2 - x1);
const aLen = 10;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
ctx.closePath(); ctx.fill();
}
_drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
const objX = lx - d;
const objY = ay - h;
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
/* Ray 1: parallel to axis <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> through F' (converging) or from F' (diverging) */
{
ctx.strokeStyle = colors[0];
ctx.setLineDash([]);
/* incoming: object tip <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> lens, parallel */
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
/* outgoing */
if (hasImage) {
const imgX = lx + dPrime;
const imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
/* extend past image */
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
} else {
/* diverging outgoing ray + dashed virtual extension */
const outSlope = (objY - ay) / f;
ctx.beginPath(); ctx.moveTo(lx, objY);
ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
ctx.setLineDash([]);
}
}
}
/* Ray 2: through center <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> straight */
{
ctx.strokeStyle = colors[1];
ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350;
const farY = ay + slope * 350;
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
if (isVirtual) {
/* extend behind lens too */
const backX = lx - 350;
const backY = ay - slope * 350;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
}
/* Ray 3: through F <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> parallel after lens */
{
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f;
const slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
if (hasImage && isVirtual) {
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
}
}
_extendRay(ctx, x1, y1, x2, y2, color) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ex = x2 + (dx / len) * 80;
const ey = y2 + (dy / len) * 80;
ctx.globalAlpha = 0.3;
ctx.strokeStyle = color;
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(ex, ey); ctx.stroke();
ctx.globalAlpha = 1;
}
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textBaseline = 'top';
/* d label */
const objX = lx - d;
ctx.fillStyle = '#9B5DE5';
ctx.textAlign = 'center';
ctx.fillText(`d = ${d.toFixed(0)}`, (objX + lx) / 2, ay + 26);
/* f label */
ctx.fillStyle = '#06D6E0';
ctx.fillText(`f = ${f.toFixed(0)}`, lx, ay + 42);
/* d' label */
if (dPrime !== null && isFinite(dPrime)) {
const imgX = lx + dPrime;
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
ctx.textAlign = 'center';
ctx.fillText(`d' = ${dPrime.toFixed(1)}`, (lx + imgX) / 2, ay + 26);
}
/* formula box */
const info = this.info();
const boxW = 200, boxH = 52;
const bx = 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
ctx.fillText(`1/f = 1/d + 1/d'`, bx + 10, by + 10);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(`M = ${mStr} d' = ${dpStr} ${info.imageType}`, bx + 10, by + 30);
}
/* ── 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 hitTest = (mx, my) => {
const lx = this.W / 2, ay = this.H / 2;
/* object tip */
const objX = lx - this.d;
const objY = ay - this.h;
if (Math.hypot(mx - objX, my - objY) < 20) return 'object';
/* focal point F (front) */
const fx = lx - this.f;
if (Math.hypot(mx - fx, my - ay) < 16) return 'focus';
return null;
};
const onDown = (e) => {
const { mx, my } = getPos(e);
this._drag = hitTest(mx, my);
};
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e);
const lx = this.W / 2;
if (this._drag === 'object') {
this.d = Math.max(30, Math.min(400, lx - mx));
} else if (this._drag === 'focus') {
const newF = lx - mx;
this.f = Math.max(-200, Math.min(200, newF));
}
this.draw();
this._emit();
};
const onUp = () => { this._drag = null; };
/* mouse */
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
/* touch */
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) onDown(e);
}, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
/* cursor style */
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openThinLens() {
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
_simShow('sim-thinlens');
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
if (_embedMode) _startStateEmit('thinlens');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!lensSim) {
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
lensSim.onUpdate = _lensUpdateUI;
}
lensSim.fit();
lensSim.draw();
lensSim._emit();
}));
}
function lensParam(name, val) {
const v = parseFloat(val);
const ids = { f: 'lens-f-val', d: 'lens-d-val', h: 'lens-h-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
if (lensSim) lensSim.setParams({ [name]: v });
}
function lensPreset(f, d, h) {
document.getElementById('sl-lens-f').value = f; document.getElementById('lens-f-val').textContent = f;
document.getElementById('sl-lens-d').value = d; document.getElementById('lens-d-val').textContent = d;
document.getElementById('sl-lens-h').value = h; document.getElementById('lens-h-val').textContent = h;
if (lensSim) lensSim.setParams({ f, d, h });
}
function _lensUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('lensbar-v1', info.f);
v('lensbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime);
v('lensbar-v3', info.M === Infinity ? '∞' : info.M);
v('lensbar-v4', info.imageType);
}
/* ── mirrors ── */
+347 -76
View File
@@ -328,6 +328,16 @@
</div>
</div>
<!-- radioactive controls -->
<div id="ctrl-radioactive" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" id="rd-ctrl-play" onclick="radioactivePlay()" title="Старт / Пауза">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polygon points="5,3 19,12 5,21"/></svg>
</button>
<button class="zoom-btn" onclick="radioactiveReset()" title="Сброс">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
</div>
<!-- theory toggle (all sims) -->
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
@@ -1235,6 +1245,85 @@
</div>
</div><!-- /#sim-circuit -->
<!-- ══════════════════════════════════════════════
ЛОГИЧЕСКИЕ СХЕМЫ
══════════════════════════════════════════════ -->
<div id="sim-logic" class="sim-proj-wrap" style="display:none">
<!-- left panel: palette + presets -->
<div class="sim-body-wrap" style="flex-direction:column">
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
<div class="proj-panel" style="width:220px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:8px">Инструмент</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip lgc-tool-btn active" data-tool="select" onclick="logicTool('select',this)">Выбор</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="INPUT" onclick="logicTool('INPUT',this)">INPUT</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="CLOCK" onclick="logicTool('CLOCK',this)">CLOCK</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="OUTPUT" onclick="logicTool('OUTPUT',this)">OUTPUT</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="AND" onclick="logicTool('AND',this)">AND</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="OR" onclick="logicTool('OR',this)">OR</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="NOT" onclick="logicTool('NOT',this)">NOT</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="XOR" onclick="logicTool('XOR',this)">XOR</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="NAND" onclick="logicTool('NAND',this)">NAND</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="NOR" onclick="logicTool('NOR',this)">NOR</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="XNOR" onclick="logicTool('XNOR',this)">XNOR</button>
<button class="proj-preset-chip lgc-tool-btn" data-tool="BUFFER" onclick="logicTool('BUFFER',this)">BUF</button>
</div>
<div class="gp-section-title" style="margin-bottom:8px;margin-top:4px">Пресеты</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="logicPreset('half-adder')">Полусумматор</button>
<button class="proj-preset-chip" onclick="logicPreset('full-adder')">Полный сумматор</button>
<button class="proj-preset-chip" onclick="logicPreset('rs-latch')">RS-триггер</button>
<button class="proj-preset-chip" onclick="logicPreset('d-latch')">D-триггер</button>
<button class="proj-preset-chip" onclick="logicPreset('decoder-2to4')">Декодер 2-в-4</button>
<button class="proj-preset-chip" onclick="logicPreset('mux-2to1')">Мультиплексор 2-в-1</button>
<button class="proj-preset-chip" onclick="logicPreset('and-gate')">AND (пример)</button>
</div>
<button class="proj-preset-chip" style="margin-top:auto;background:rgba(239,71,111,0.12);border-color:rgba(239,71,111,0.35);color:#EF476F" onclick="logicClearAll()">Очистить</button>
<div style="margin-top:10px;font-size:0.67rem;color:var(--text-3);text-align:center;line-height:1.7;padding-top:4px">
Клик = добавить элемент<br>
Перетащи выход (кружок) на вход<br>
2×клик по INPUT — переключить 0/1<br>
ПКМ — удалить &nbsp;|&nbsp; Ctrl+Z отмена
</div>
</div><!-- /.proj-panel -->
<div class="proj-canvas-outer" style="flex:1;position:relative">
<canvas id="logic-canvas" style="display:block;position:absolute;top:0;left:0;width:100%;height:100%;cursor:default"></canvas>
</div>
<!-- right: boolean expression panel -->
<div class="proj-panel" style="width:200px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:8px">Выражение</div>
<div id="logic-expr" style="font-size:0.82rem;color:rgba(255,255,255,0.8);line-height:1.7;word-break:break-all;min-height:40px">
Добавьте OUTPUT для вывода выражения
</div>
</div>
</div><!-- /.flex row -->
<!-- truth table panel (collapsible) -->
<div id="logic-tt-panel" style="max-height:200px;overflow:auto;border-top:1px solid rgba(255,255,255,0.08);padding:8px 12px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:0.75rem;font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:.08em;text-transform:uppercase">Таблица истинности</span>
<button id="btn-logic-tt" class="zoom-btn active" onclick="logicToggleTable()" style="font-size:0.65rem;padding:2px 7px">скрыть</button>
</div>
<div id="logic-tt-body" style="font-size:0.78rem">
<span style="color:rgba(255,255,255,0.35)">Добавьте INPUT и OUTPUT</span>
</div>
</div>
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-logic -->
<!-- ══════════════════════════════════════════════
КИНЕТИКА РЕАКЦИЙ
══════════════════════════════════════════════ -->
@@ -2419,10 +2508,18 @@
</div>
</div>
<!-- ── THIN LENS sim body ── -->
<div id="sim-thinlens" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<div class="proj-panel" style="width:220px;gap:0">
<!-- ── OPTICSBENCH sim body ── -->
<div id="sim-opticsbench" class="sim-proj-wrap" style="display:none;flex-direction:column">
<!-- Tab bar -->
<div style="display:flex;gap:0;border-bottom:1px solid #2a2a3e;background:#12121e;flex-shrink:0">
<button id="ob-tab-lens" onclick="obSwitchMode('lens')" class="ob-tab active" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Тонкая линза</button>
<button id="ob-tab-mirror" onclick="obSwitchMode('mirror')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Зеркала</button>
<button id="ob-tab-refraction" onclick="obSwitchMode('refraction')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Преломление</button>
</div>
<!-- Body row: control panels + shared canvas -->
<div style="display:flex;flex:1;min-height:0;overflow:hidden">
<!-- ── Lens control panel ── -->
<div id="ob-ctrl-lens" class="proj-panel" style="width:220px;gap:0;flex-shrink:0">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">f = <span id="lens-f-val" style="color:var(--cyan);font-weight:700">100</span></label>
@@ -2446,22 +2543,8 @@
</div>
<div class="pp-hint">Тащи стрелку-предмет или фокус мышью</div>
</div>
<div class="proj-canvas-outer">
<canvas id="thinlens-canvas"></canvas>
</div>
</div>
<div class="proj-stats-bar" id="lensbar">
<div class="pstat"><div class="pstat-label">f</div><div class="pstat-val" id="lensbar-v1" style="color:var(--cyan)">100</div></div>
<div class="pstat"><div class="pstat-label">d'</div><div class="pstat-val" id="lensbar-v2" style="color:#EF476F"></div></div>
<div class="pstat"><div class="pstat-label">M</div><div class="pstat-val" id="lensbar-v3" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">Тип</div><div class="pstat-val" id="lensbar-v4" style="color:var(--violet)"></div></div>
</div>
</div>
<!-- ── MIRRORS sim body ── -->
<div id="sim-mirrors" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<div class="proj-panel" style="width:264px;gap:0">
<!-- ── Mirror control panel ── -->
<div id="ob-ctrl-mirror" class="proj-panel" style="width:264px;gap:0;flex-shrink:0;display:none">
<div class="gp-section-title" style="margin-bottom:8px">Тип зеркала</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px">
<button class="preset-btn mirror-type-btn" id="mtype-flat" onclick="mirrorType('flat',this)" style="font-size:.72rem">Плоское</button>
@@ -2482,24 +2565,24 @@
<input type="range" id="sl-mirror-h" min="20" max="80" step="2" value="60" oninput="mirrorParam('h',this.value)" style="flex:1">
</div>
<div style="display:flex;gap:6px;margin-top:8px;margin-bottom:8px;align-items:center">
<button id="mirror-play-btn" onclick="mirrorTogglePlay(this)" style="flex:1;padding:6px 0;border-radius:8px;border:none;background:linear-gradient(135deg,var(--cyan),var(--violet));color:#fff;font-size:.78rem;font-weight:700;cursor:pointer"> Анимация</button>
<button id="mirror-play-btn" onclick="mirrorTogglePlay(this)" style="flex:1;padding:6px 0;border-radius:8px;border:none;background:linear-gradient(135deg,var(--cyan),var(--violet));color:#fff;font-size:.78rem;font-weight:700;cursor:pointer">&#9654; Анимация</button>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px">
<span style="font-size:.62rem;color:#888">скорость</span>
<select id="mirror-speed-sel" onchange="mirrorSetSpeed(this.value)" style="background:#1a1a2e;color:#ccc;border:1px solid #333;border-radius:4px;font-size:.7rem;padding:2px 4px">
<option value="0.25">×¼</option><option value="0.5">×½</option>
<option value="1" selected>×1</option><option value="2">×2</option>
<option value="0.25">&#215;&#188;</option><option value="0.5">&#215;&#189;</option>
<option value="1" selected>&#215;1</option><option value="2">&#215;2</option>
</select>
</div>
</div>
<div style="display:flex;gap:4px;margin-bottom:10px">
<button onclick="mirrorStepNext()" style="flex:1;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#7BF5A4;font-size:.73rem;cursor:pointer" title="Показать следующий луч"> Пошагово</button>
<button onclick="mirrorStepReset()" style="padding:5px 9px;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Показать все лучи"></button>
<button onclick="mirrorStepNext()" style="flex:1;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#7BF5A4;font-size:.73rem;cursor:pointer" title="Показать следующий луч">&#9312; Пошагово</button>
<button onclick="mirrorStepReset()" style="padding:5px 9px;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Показать все лучи">&#8634;</button>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Отображение</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;margin-bottom:10px">
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-normals" checked onchange="mirrorToggle('normals',this.checked)"> Нормали</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-dims" checked onchange="mirrorToggle('dims',this.checked)"> Размеры</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-angles" checked onchange="mirrorToggle('angles',this.checked)"> Углы θ</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-angles" checked onchange="mirrorToggle('angles',this.checked)"> Углы &#952;</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-photons" checked onchange="mirrorToggle('photons',this.checked)"> Фотоны</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-grid" onchange="mirrorToggle('grid',this.checked)"> Сетка</label>
<label style="display:flex;align-items:center;gap:4px;font-size:.72rem;color:#ccc;cursor:pointer"><input type="checkbox" id="mtog-zones" checked onchange="mirrorToggle('zones',this.checked)"> Зоны</label>
@@ -2517,16 +2600,59 @@
</div>
<div class="pp-hint">Тащи предмет, фокус или изображение мышью</div>
</div>
<div class="proj-canvas-outer">
<canvas id="mirror-canvas"></canvas>
<!-- ── Refraction control panel ── -->
<div id="ob-ctrl-refraction" class="proj-panel" style="width:220px;gap:0;flex-shrink:0;display:none">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">n&#8321; = <span id="refr-n1-val" style="color:var(--violet);font-weight:700">1.00</span></label>
<input type="range" id="sl-refr-n1" min="1" max="2.5" step="0.01" value="1" oninput="refrParam('n1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">n&#8322; = <span id="refr-n2-val" style="color:var(--cyan);font-weight:700">1.50</span></label>
<input type="range" id="sl-refr-n2" min="1" max="2.5" step="0.01" value="1.5" oninput="refrParam('n2',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">&#952; = <span id="refr-angle-val" style="color:#FFD166;font-weight:700">30</span>&#176;</label>
<input type="range" id="sl-refr-angle" min="0" max="89" step="1" value="30" oninput="refrParam('angle',this.value)" style="flex:1">
</div>
<div style="margin-top:8px"></div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="refrPreset(1,1.5,30)">Воздух&#8594;Стекло</button>
<button class="preset-btn" onclick="refrPreset(1.5,1,30)">Стекло&#8594;Воздух</button>
<button class="preset-btn" onclick="refrPreset(1.33,1.5,30)">Вода&#8594;Стекло</button>
<button class="preset-btn" onclick="refrPreset(1,2.42,45)">Алмаз</button>
</div>
<div class="pp-hint">Тащи луч мышью для изменения угла</div>
</div>
<!-- ── Shared canvas area (all 3 canvases stacked) ── -->
<div class="proj-canvas-outer" style="position:relative;flex:1;min-width:0">
<canvas id="ob-lens-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%"></canvas>
<canvas id="ob-mirror-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
<canvas id="ob-refr-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>
</div>
</div>
<div class="proj-stats-bar" id="mirrorbar">
<div class="pstat"><div class="pstat-label">f</div><div class="pstat-val" id="mirrorbar-v1" style="color:var(--cyan)">120</div></div>
<div class="pstat"><div class="pstat-label">d</div><div class="pstat-val" id="mirrorbar-v5" style="color:var(--violet)">240</div></div>
<div class="pstat"><div class="pstat-label">d'</div><div class="pstat-val" id="mirrorbar-v2" style="color:#EF476F"></div></div>
<div class="pstat"><div class="pstat-label">M</div><div class="pstat-val" id="mirrorbar-v3" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">Тип изобр.</div><div class="pstat-val" id="mirrorbar-v4" style="color:var(--violet)"></div></div>
<!-- Stats bar -->
<div class="proj-stats-bar" id="ob-statsbar" style="padding:0">
<div id="ob-stats-lens" style="display:flex;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">f</div><div class="pstat-val" id="lensbar-v1" style="color:var(--cyan)">100</div></div>
<div class="pstat"><div class="pstat-label">d&#8217;</div><div class="pstat-val" id="lensbar-v2" style="color:#EF476F">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">M</div><div class="pstat-val" id="lensbar-v3" style="color:#FFD166">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Тип</div><div class="pstat-val" id="lensbar-v4" style="color:var(--violet)">&#8212;</div></div>
</div>
<div id="ob-stats-mirror" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">f</div><div class="pstat-val" id="mirrorbar-v1" style="color:var(--cyan)">120</div></div>
<div class="pstat"><div class="pstat-label">d</div><div class="pstat-val" id="mirrorbar-v5" style="color:var(--violet)">240</div></div>
<div class="pstat"><div class="pstat-label">d&#8217;</div><div class="pstat-val" id="mirrorbar-v2" style="color:#EF476F">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">M</div><div class="pstat-val" id="mirrorbar-v3" style="color:#FFD166">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Тип изобр.</div><div class="pstat-val" id="mirrorbar-v4" style="color:var(--violet)">&#8212;</div></div>
</div>
<div id="ob-stats-refr" style="display:none;flex:1;gap:0">
<div class="pstat"><div class="pstat-label">&#952;&#8321;</div><div class="pstat-val" id="refrbar-v1" style="color:var(--violet)">30&#176;</div></div>
<div class="pstat"><div class="pstat-label">&#952;&#8322;</div><div class="pstat-val" id="refrbar-v2" style="color:var(--cyan)">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Крит. угол</div><div class="pstat-val" id="refrbar-v3" style="color:#FFD166">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">ПВО</div><div class="pstat-val" id="refrbar-v4" style="color:#EF476F">Нет</div></div>
</div>
</div>
</div>
@@ -2628,45 +2754,6 @@
</div>
</div>
<!-- ── REFRACTION sim body ── -->
<div id="sim-refraction" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<div class="proj-panel" style="width:220px;gap:0">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">n₁ = <span id="refr-n1-val" style="color:var(--violet);font-weight:700">1.00</span></label>
<input type="range" id="sl-refr-n1" min="1" max="2.5" step="0.01" value="1" oninput="refrParam('n1',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">n₂ = <span id="refr-n2-val" style="color:var(--cyan);font-weight:700">1.50</span></label>
<input type="range" id="sl-refr-n2" min="1" max="2.5" step="0.01" value="1.5" oninput="refrParam('n2',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" style="margin-bottom:8px">
<label style="font-size:.78rem;color:#ccc;width:55px">θ = <span id="refr-angle-val" style="color:#FFD166;font-weight:700">30</span>°</label>
<input type="range" id="sl-refr-angle" min="0" max="89" step="1" value="30" oninput="refrParam('angle',this.value)" style="flex:1">
</div>
<div style="margin-top:8px"></div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
<button class="preset-btn" onclick="refrPreset(1,1.5,30)">Воздух<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>Стекло</button>
<button class="preset-btn" onclick="refrPreset(1.5,1,30)">Стекло<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>Воздух</button>
<button class="preset-btn" onclick="refrPreset(1.33,1.5,30)">Вода<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>Стекло</button>
<button class="preset-btn" onclick="refrPreset(1,2.42,45)">Алмаз</button>
</div>
<div class="pp-hint">Тащи луч мышью для изменения угла</div>
</div>
<div class="proj-canvas-outer">
<canvas id="refraction-canvas"></canvas>
</div>
</div>
<div class="proj-stats-bar" id="refrbar">
<div class="pstat"><div class="pstat-label">θ₁</div><div class="pstat-val" id="refrbar-v1" style="color:var(--violet)">30°</div></div>
<div class="pstat"><div class="pstat-label">θ₂</div><div class="pstat-val" id="refrbar-v2" style="color:var(--cyan)"></div></div>
<div class="pstat"><div class="pstat-label">Крит. угол</div><div class="pstat-val" id="refrbar-v3" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">ПВО</div><div class="pstat-val" id="refrbar-v4" style="color:#EF476F">Нет</div></div>
</div>
</div>
<!-- ── PROBABILITY sim body ── -->
<div id="sim-probability" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
@@ -3326,6 +3413,99 @@
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-hydro -->
<!-- ══════════════════════════════════════════════
РАДИОАКТИВНЫЙ РАСПАД
══════════════════════════════════════════════ -->
<div id="sim-radioactive" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<!-- left panel: controls -->
<div class="proj-panel" style="width:228px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:6px">Изотоп</div>
<select id="rd-isotope-sel" onchange="radioactiveIsotope(this.value)"
style="width:100%;background:#1a1a2e;color:#ccc;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:5px 8px;font-size:.82rem;margin-bottom:10px;cursor:pointer">
<option value="C-14">¹⁴C — углерод (T½ = 5730 лет)</option>
<option value="I-131">¹³¹I — йод (T½ = 8.0 сут)</option>
<option value="Cs-137">¹³⁷Cs — цезий (T½ = 30.2 г)</option>
<option value="Ra-226">²²⁶Ra — радий → цепочка</option>
<option value="K-40">⁴⁰K — калий (T½ = 1.25·10⁹ г)</option>
<option value="U-238">²³⁸U → цепочка → ²⁰⁶Pb</option>
<option value="U-235">²³⁵U → цепочка → ²⁰⁷Pb</option>
</select>
<div class="gp-section-title" style="margin-bottom:6px">Начальное N₀</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Частиц</span>
<span class="param-val" id="rd-n0-val" style="color:var(--violet)">500</span>
</div>
<input type="range" id="sl-rd-n0" class="param-slider" min="50" max="2000" step="50" value="500"
oninput="radioactiveN0(this.value)">
</div>
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Скорость симуляции</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Масштаб</span>
<span class="param-val" id="rd-speed-val" style="color:var(--cyan)">×10</span>
</div>
<input type="range" id="sl-rd-speed" class="param-slider" min="1" max="1000" step="1" value="10"
oninput="radioactiveSpeed(this.value)">
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<button id="rd-play-btn" class="preset-btn" onclick="radioactivePlay()" style="flex:1;min-width:70px">Старт</button>
<button class="preset-btn" onclick="radioactiveReset()" style="flex:1;min-width:70px">Сброс</button>
</div>
<!-- Dating mode -->
<div style="margin-top:16px;border-top:1px solid rgba(255,255,255,0.08);padding-top:12px">
<div class="gp-section-title" style="margin-bottom:6px">Радиоуглеродное датирование</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Осталось ¹⁴C</span>
<span class="param-val" id="rd-dating-pct-val" style="color:#FFD166">50% осталось</span>
</div>
<input type="range" id="sl-rd-dating" class="param-slider" min="1" max="99" step="1" value="50"
oninput="radioactiveDating(this.value)">
</div>
<div id="rd-dating-result" style="font-size:.8rem;color:var(--cyan);text-align:center;margin-top:4px"></div>
<div class="pp-hint">Только для ¹⁴C — другие изотопы игнорируют</div>
</div>
</div><!-- /.proj-panel -->
<!-- particle canvas -->
<div class="proj-canvas-outer" style="flex:1;min-width:0">
<canvas id="radioactive-canvas"></canvas>
</div>
<!-- graph canvas -->
<div class="proj-canvas-outer" style="width:280px;flex-shrink:0">
<canvas id="radioactive-graph"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
<!-- HUD bar -->
<div class="proj-stats-bar" id="rd-hud">
<div class="pstat">
<div class="pstat-label">Прошло периодов</div>
<div class="pstat-val" id="rd-hud-periods" style="color:var(--violet)">0 T½</div>
</div>
<div class="pstat">
<div class="pstat-label">Распалось</div>
<div class="pstat-val" id="rd-hud-decayed" style="color:#EF476F">0%</div>
</div>
<div class="pstat">
<div class="pstat-label">Активность</div>
<div class="pstat-val" id="rd-hud-activity" style="color:var(--cyan)">0 Бк</div>
</div>
</div>
</div><!-- /#sim-radioactive -->
<!-- ══════════════════════════════════════════════
ПЛАНИМЕТРИЯ
══════════════════════════════════════════════ -->
@@ -3658,6 +3838,95 @@
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-geometry -->
<!-- ── STOICHIOMETRY sim body ── -->
<div id="sim-stoichiometry" class="sim-proj-wrap" style="display:none">
<div id="stoichiometry-wrap" style="flex:1;min-height:0;overflow:hidden;"></div>
</div>
<!-- ── HEAT ENGINE sim body ── -->
<div id="sim-heatengine" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap" style="flex-direction:column">
<!-- top: two canvases side by side -->
<div style="display:flex;flex:1;min-height:0;gap:0">
<!-- left: PV diagram -->
<div class="proj-canvas-outer" style="flex:1">
<canvas id="he-pv-canvas"></canvas>
</div>
<!-- right: piston animation -->
<div class="proj-canvas-outer" style="width:220px;border-left:1px solid var(--border)">
<canvas id="he-piston-canvas"></canvas>
</div>
</div>
<!-- bottom: control panel -->
<div style="display:flex;gap:12px;padding:10px 14px;border-top:1px solid var(--border);flex-wrap:wrap;align-items:flex-start;background:rgba(255,255,255,0.015)">
<!-- cycle selector -->
<div style="display:flex;flex-direction:column;gap:4px;min-width:140px">
<div class="gp-section-title" style="margin-bottom:4px">Цикл</div>
<div style="display:flex;flex-wrap:wrap;gap:3px">
<button class="preset-btn he-cycle-btn active" onclick="heSetCycle('carnot',this)" style="font-size:.72rem">Карно</button>
<button class="preset-btn he-cycle-btn" onclick="heSetCycle('otto',this)" style="font-size:.72rem">Отто</button>
<button class="preset-btn he-cycle-btn" onclick="heSetCycle('diesel',this)" style="font-size:.72rem">Дизель</button>
<button class="preset-btn he-cycle-btn" onclick="heSetCycle('brayton',this)" style="font-size:.72rem">Брайтон</button>
</div>
</div>
<!-- sliders -->
<div style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:180px">
<div class="gp-section-title" style="margin-bottom:2px">Параметры</div>
<div class="proj-slider-row">
<label style="font-size:.78rem;color:#ccc;width:90px">T<sub>гор</sub> = <span id="he-th-val" style="color:#EF476F;font-weight:700">800</span> K</label>
<input type="range" id="sl-he-th" min="300" max="1500" step="10" value="800" oninput="heParam('Th',this.value)" style="flex:1">
</div>
<div class="proj-slider-row">
<label style="font-size:.78rem;color:#ccc;width:90px">T<sub>хол</sub> = <span id="he-tc-val" style="color:#06D6E0;font-weight:700">300</span> K</label>
<input type="range" id="sl-he-tc" min="200" max="500" step="10" value="300" oninput="heParam('Tc',this.value)" style="flex:1">
</div>
<div class="proj-slider-row" id="he-cr-row" style="display:none">
<label style="font-size:.78rem;color:#ccc;width:90px">r<sub>сж</sub> = <span id="he-cr-val" style="color:#FFD166;font-weight:700">8</span></label>
<input type="range" id="sl-he-cr" min="2" max="20" step="1" value="8" oninput="heParam('cr',this.value)" style="flex:1">
</div>
</div>
<!-- buttons -->
<div style="display:flex;flex-direction:column;gap:5px;min-width:120px">
<div class="gp-section-title" style="margin-bottom:2px">Управление</div>
<div style="display:flex;gap:4px">
<button onclick="heStart()" style="flex:1;padding:6px 0;border-radius:7px;border:none;background:linear-gradient(135deg,#EF476F,#9B5DE5);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer" title="Старт">
<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button onclick="hePause()" style="flex:1;padding:6px 0;border-radius:7px;border:1px solid #444;background:#1a1a2e;color:#ccc;font-size:.78rem;cursor:pointer" title="Пауза">
<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
<button onclick="heStep()" style="flex:1;padding:6px 0;border-radius:7px;border:1px solid #444;background:#1a1a2e;color:#ccc;font-size:.78rem;cursor:pointer" title="Шаг">
<svg class="ic" viewBox="0 0 24 24"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
<button onclick="heReset()" style="flex:1;padding:6px 0;border-radius:7px;border:1px solid #444;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Сброс">
<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
</button>
</div>
</div>
</div><!-- /controls -->
</div><!-- /.sim-body-wrap -->
<!-- stats bar -->
<div class="proj-stats-bar">
<div class="pstat"><div class="pstat-label">T<sub>гор</sub></div><div class="pstat-val" id="hebar-th" style="color:#EF476F">800 K</div></div>
<div class="pstat"><div class="pstat-label">T<sub>хол</sub></div><div class="pstat-val" id="hebar-tc" style="color:#06D6E0">300 K</div></div>
<div class="pstat"><div class="pstat-label">η</div><div class="pstat-val" id="hebar-eta" style="color:#7BF5A4">—%</div></div>
<div class="pstat"><div class="pstat-label">Q<sub>гор</sub>, Дж</div><div class="pstat-val" id="hebar-qh" style="color:#FFD166"></div></div>
<div class="pstat"><div class="pstat-label">Q<sub>хол</sub>, Дж</div><div class="pstat-val" id="hebar-qc" style="color:var(--cyan)"></div></div>
<div class="pstat"><div class="pstat-label">W, Дж</div><div class="pstat-val" id="hebar-w" style="color:var(--violet)"></div></div>
</div>
</div><!-- /#sim-heatengine -->
<!-- ── Theory panel (overlay right) ── -->
<div class="theory-panel" id="theory-panel">
<div class="theory-panel-inner" id="theory-content"></div>
@@ -3697,6 +3966,7 @@
<script src="/js/labs/angrybirds.js"></script>
<script src="/js/labs/waves.js"></script>
<script src="/js/labs/chemsandbox.js"></script>
<script src="/js/labs/stoichiometry.js"></script>
<script src="/js/labs/celldivision.js"></script>
<script src="/js/labs/photosynthesis.js"></script>
<script src="/js/labs/crystal.js"></script>
@@ -3708,15 +3978,16 @@
<script src="/js/labs/graphtransform.js"></script>
<script src="/js/labs/pendulum.js"></script>
<script src="/js/labs/equilibrium.js"></script>
<script src="/js/labs/thinlens.js"></script>
<script src="/js/labs/mirror.js"></script>
<script src="/js/labs/opticsbench.js"></script>
<script src="/js/labs/isoprocess.js"></script>
<script src="/js/labs/titration.js"></script>
<script src="/js/labs/refraction.js"></script>
<script src="/js/labs/probability.js"></script>
<script src="/js/labs/bohratom.js"></script>
<script src="/js/labs/electrolysis.js"></script>
<script src="/js/labs/hydrostatics.js"></script>
<script src="/js/labs/radioactive.js"></script>
<script src="/js/labs/geometry.js"></script>
<script src="/js/labs/logic.js"></script>
<script src="/js/labs/heatengine.js"></script>
</body>
</html>