diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 81072a6..e049eb1 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -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; + } diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js index 6210ef9..ffa0623 100644 --- a/frontend/js/admin/sections/sims.js +++ b/frontend/js/admin/sections/sims.js @@ -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: 'Фотосинтез и дыхание' }, diff --git a/frontend/js/labs/heatengine.js b/frontend/js/labs/heatengine.js new file mode 100644 index 0000000..74a21d1 --- /dev/null +++ b/frontend/js/labs/heatengine.js @@ -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 + ' Дж'); +} diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 39cdb3c..fe239a6 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -429,6 +429,35 @@ `); + const P_STOICHIOMETRY = _svg(` + + ${_grid()} + + + + + + Zn + + + + + + + 2HCl + + + + + + + ZnCl₂ + + + + H₂ + ● лимит`); + const P_CRYSTAL = _svg(` ${[ @@ -520,6 +549,43 @@ stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/> v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b`); + /* Radioactive decay preview */ + const P_RADIOACTIVE = _svg(`${_grid()} + + + + + + + + + + + + + + N(t) = N₀·e⁻λt · T½ · цепочки распада`); + + /* Heat Engines preview */ + const P_HEATENGINE = _svg(`${_grid('rgba(255,255,255,0.04)')} + + + + + + + + + + A + B + C + V + P + η = 1 − Tc/Th`); + /* Geometry (planimetry) preview */ const P_GEOMETRY = _svg(`${_grid('rgba(255,255,255,0.04)')} @@ -535,6 +601,29 @@ B C`); + + /* Logic Circuits preview */ + const P_LOGIC = _svg(`${_grid('rgba(255,255,255,0.04)')} + + AND + + XOR + + AND + + + + + + + + + + + S + C + 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; diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index 0584b57..de3a86a 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -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⁻ Cu (осадок). Анод: 2H₂O − 4e⁻ O₂ + 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 изохора. Более высокая степень сжатия, чем Отто. η ≈ 35–45%.' }, + { head: 'Цикл Брайтона (ГТД)', text: '2 адиабаты + 2 изобары. Основа авиадвигателей и газовых турбин. η зависит от степени повышения давления.' }, + ] + }, }; /* ══════════════════════════════════════════════ diff --git a/frontend/js/labs/logic.js b/frontend/js/labs/logic.js new file mode 100644 index 0000000..8e75d76 --- /dev/null +++ b/frontend/js/labs/logic.js @@ -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 = 'Добавьте INPUT и OUTPUT'; + return; + } + const n = inputs.length; + if (n > 6) { + this._tableEl.innerHTML = 'Слишком много входов (макс 6)'; + return; + } + const rows = 1 << n; + // save current values + const savedVals = inputs.map(g => g.value); + + let html = ''; + inputs.forEach(g => { html += ``; }); + outputs.forEach(g => { html += ``; }); + html += ''; + + // 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 += ``; + inputs.forEach((g, i) => { html += ``; }); + outputs.forEach(g => { html += ``; }); + html += ''; + } + html += '
${g.label}${g.label}
${(r >> (n - 1 - i)) & 1}${g.value}
'; + 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); + })); +} diff --git a/frontend/js/labs/mirror.js b/frontend/js/labs/mirror.js deleted file mode 100644 index e9fbfe6..0000000 --- a/frontend/js/labs/mirror.js +++ /dev/null @@ -1,1099 +0,0 @@ -'use strict'; -/* ══════════════════════════════════════════════════════════════ - MirrorSim v3 - Flat / Concave / Convex · 1/f = 1/d + 1/d' · M = -d'/d - Features: fan rays, normals, angle arcs, ray labels ①②③, - center C, zones, grid, photon animation, step mode, - speed control, point mode, drag image, hover tooltips, - mirror transition, unified infobox, legend, export PNG - ══════════════════════════════════════════════════════════════ */ - -class MirrorSim { - constructor(canvas) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - this.W = 0; this.H = 0; - - // physics - this.type = 'concave'; - this.f = 120; - this.d = 240; - this.h = 60; - - // object animation - this._playing = false; - this._animT = 1.4; - this._animSpeed = 1; - this._raf = null; - - // step mode (-1 = all, 0..3 = progressive) - this._step = -1; - - // display toggles - this._showGrid = false; - this._showZones = true; - this._showNormals = true; - this._showDims = true; - this._showAngles = true; - this._showPhotons = true; - this._pointMode = false; - - // photon system - this._photons = []; - this._photonRaf = null; - this._photonTimer = 0; - this._lastPhoTime = 0; - this._photonPaths = []; - - // mirror transition - this._prevType = 'concave'; - this._transT = 1.0; - this._transRaf = null; - - // drag & hover - this._drag = null; - this._hoverX = -999; - this._hoverY = -999; - - // callbacks - this.onUpdate = null; - this.onAnimate = 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; - } - - setType(type) { - if (type === this.type) return; - this._prevType = this.type; - this.type = type; - if (this._playing) this._stopAnim(); - this._startTransition(); - this.draw(); this._emit(); - } - - getParams() { return { f: this.f, d: this.d, h: this.h }; } - setParams({ f, d, h } = {}) { - if (f !== undefined) this.f = Math.max(30, Math.min(300, +f)); - if (d !== undefined) this.d = Math.max(30, Math.min(490, +d)); - if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); - this.draw(); this._emit(); - } - - setAnimSpeed(s) { this._animSpeed = +s || 1; } - togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); } - stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); } - stepReset() { this._step = -1; this.draw(); } - setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); } - - setToggle(name, val) { - const map = { - grid:'_showGrid', zones:'_showZones', normals:'_showNormals', - dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', - }; - if (map[name]) this[map[name]] = !!val; - if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); } - this.draw(); - } - - exportPng() { - const a = document.createElement('a'); - a.href = this.canvas.toDataURL('image/png'); - a.download = `mirror_${this.type}_d${Math.round(this.d)}.png`; - a.click(); - } - - /* ── Physics ─────────────────────────────────── */ - - _fSigned() { - if (this.type === 'flat') return Infinity; - return this.type === 'convex' ? -this.f : this.f; - } - - info() { - const { type, d, h } = this; - const f = this._fSigned(); - let dPrime, M; - if (type === 'flat') { - dPrime = -d; M = 1; - } else { - const den = d - f; - if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; } - else { dPrime = f * d / den; M = -dPrime / d; } - } - const hPrime = M === Infinity ? Infinity : M * h; - const isReal = dPrime > 0 && dPrime !== Infinity; - const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое'; - const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое'; - const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное'; - return { - f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0), - d: +d.toFixed(1), - dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), - M: M === Infinity ? Infinity : +M.toFixed(3), - imageType, orient, sizeStr, - hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), - isReal, - }; - } - - _emit() { if (this.onUpdate) this.onUpdate(this.info()); } - - /* ── Mirror transition ───────────────────────── */ - - _getBulge(type) { - if (type === 'flat') return 0; - if (type === 'concave') return -Math.min(30, this.f * 0.18); - return Math.min(24, this.f * 0.16); - } - - _startTransition() { - this._transT = 0; - if (this._transRaf) cancelAnimationFrame(this._transRaf); - const step = () => { - this._transT = Math.min(1, this._transT + 0.07); - this.draw(); - if (this._transT < 1) this._transRaf = requestAnimationFrame(step); - else this._transRaf = null; - }; - this._transRaf = requestAnimationFrame(step); - } - - /* ── Object animation ────────────────────────── */ - - _startAnim() { this._playing = true; this._animLoop(); } - - _stopAnim() { - this._playing = false; - if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } - } - - _animLoop() { - if (!this._playing) return; - this._animT += 0.013 * this._animSpeed; - const t = 0.5 - 0.5 * Math.cos(this._animT); - if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t))); - else this.d = 40 + 400 * t; - if (this.onAnimate) this.onAnimate(this.d); - this.draw(); this._emit(); - this._raf = requestAnimationFrame(() => this._animLoop()); - } - - /* ── Photon system ───────────────────────────── */ - - _getRayPaths(mx, ay, f, dPrime, hPrime) { - const { d, h, type } = this; - const hasImage = dPrime !== null && isFinite(dPrime); - const isReal = hasImage && dPrime > 0; - const imgX = hasImage ? mx - dPrime : null; - const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null; - const objX = mx - d; - const objY = ay - (this._pointMode ? 0 : h); - const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166']; - - if (type === 'flat') { - return [objY, ay, ay - h * 0.5].map((hy, i) => ({ - pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])], - color: COLORS[i], - })); - } - - const hit1Y = ay - (this._pointMode ? 0 : h); - const hit2Y = ay; - const denom3 = d - f; - const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3; - - const rays = []; - const add = (hitY, color) => { - if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return; - const pts = [[objX, objY], [mx, hitY]]; - if (hasImage) { - if (isReal) { - pts.push([imgX, imgY]); - const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy); - if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]); - } else { - const dx = imgX - mx, dy = imgY - hitY; - if (Math.abs(dx) > 1) { - const tL = (mx - 5) / dx; - let endX = 5, endY = hitY - dy * tL; - if (endY < 5 || endY > this.H - 5) { - endY = endY < 5 ? 5 : this.H - 5; - const tE = (hitY - endY) / dy; - endX = Math.max(5, mx - dx * tE); - } - pts.push([endX, endY]); - } - } - } - rays.push({ pts, color }); - }; - add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]); - return rays; - } - - _startPhotons() { - if (this._photonRaf) return; - this._lastPhoTime = performance.now(); - this._photonLoop(); - } - - _stopPhotons() { - if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; } - this._photons = []; - this.draw(); - } - - _photonLoop() { - const now = performance.now(); - const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1); - this._lastPhoTime = now; - - const spd = 200; - for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len); - this._photons = this._photons.filter(p => p.t < 1); - - this._photonTimer += dt; - if (this._photonTimer > 0.75 && this._photonPaths.length) { - this._photonTimer = 0; - for (const path of this._photonPaths) { - if (path.pts.length < 2) continue; - let len = 0; - for (let i = 1; i < path.pts.length; i++) - len += Math.hypot(path.pts[i][0]-path.pts[i-1][0], path.pts[i][1]-path.pts[i-1][1]); - if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len }); - } - } - - if (!this._playing) this.draw(); // animation loop handles draw when playing - this._photonRaf = requestAnimationFrame(() => this._photonLoop()); - } - - /* ── Main draw ───────────────────────────────── */ - - draw() { - const { ctx, W, H } = this; - if (!W || !H) return; - - const f = this._fSigned(); - const mx = Math.round(W * 0.62); - const ay = H / 2; - - let dPrime = null, hPrime = null; - if (this.type === 'flat') { - dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; - } else { - const den = this.d - f; - if (Math.abs(den) >= 0.5) { - dPrime = f * this.d / den; - hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h; - } - } - - const step = this._step; - const showRay = i => step === -1 || i <= step; - const showFill = step === -1 || step >= 3; - - this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime); - - /* bg */ - ctx.fillStyle = '#0D0D1A'; - ctx.fillRect(0, 0, W, H); - - if (this._showGrid) this._drawGrid(ctx); - if (this._showZones) this._drawZones(ctx, mx); - - /* axis */ - ctx.strokeStyle = 'rgba(255,255,255,0.12)'; - ctx.lineWidth = 1; ctx.setLineDash([6, 4]); - ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); - ctx.setLineDash([]); - - /* fan rays */ - this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill); - - /* mirror */ - this._drawMirror(ctx, mx, ay); - - /* focal pts + C */ - if (this.type !== 'flat') { - this._drawFocalPoints(ctx, mx, ay, f); - this._drawCenterC(ctx, mx, ay, f); - } - - /* normals */ - if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3)) - this._drawNormals(ctx, mx, ay, f); - - /* angle arcs (only in full view) */ - if (this._showAngles && this.type !== 'flat' && step === -1) - this._drawAngleArcs(ctx, mx, ay, f); - - /* ray labels */ - if (step === -1 || step >= 1) - this._drawRayLabels(ctx, mx, ay, f, step); - - /* object */ - const objX = mx - this.d; - if (this._pointMode) { - ctx.save(); ctx.shadowColor='#9B5DE5'; ctx.shadowBlur=10; - ctx.fillStyle = '#9B5DE5'; - ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); - ctx.restore(); - } else { - this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); - } - - /* image */ - if (dPrime !== null && isFinite(dPrime)) { - const imgX = mx - dPrime; - const imgY = ay - (this._pointMode ? 0 : hPrime); - if (this._pointMode) { - ctx.save(); - ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; - if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); } - ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2); - dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })(); - ctx.restore(); - } else { - this._drawArrow(ctx, imgX, ay, imgX, imgY, - dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0); - } - } - - /* dims */ - if (this._showDims && (step === -1 || step >= 3)) - this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime); - - /* infobox */ - this._drawInfoBox(ctx, f, dPrime); - - /* badge */ - if ((step === -1 || step >= 3) && dPrime !== null) - this._drawImageBadge(ctx, dPrime, hPrime); - - /* critical marker */ - this._drawCriticalMarker(ctx, f); - - /* legend */ - if (this._showDims) this._drawLegend(ctx); - - /* photons */ - if (this._showPhotons && this._photons.length) - this._drawPhotons(ctx); - - /* tooltip */ - this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); - - /* step overlay */ - if (step >= 0) this._drawStepOverlay(ctx, step); - } - - /* ── Grid & Zones ────────────────────────────── */ - - _drawGrid(ctx) { - ctx.strokeStyle = 'rgba(255,255,255,0.03)'; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); } - for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); } - ctx.stroke(); - } - - _drawZones(ctx, mx) { - const g1 = ctx.createLinearGradient(0,0,mx,0); - g1.addColorStop(0, 'rgba(6,214,224,0.0)'); - g1.addColorStop(1, 'rgba(6,214,224,0.03)'); - ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H); - - const g2 = ctx.createLinearGradient(mx,0,this.W,0); - g2.addColorStop(0, 'rgba(239,71,111,0.04)'); - g2.addColorStop(1, 'rgba(239,71,111,0.0)'); - ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H); - } - - /* ── Mirror surface ──────────────────────────── */ - - _drawMirror(ctx, mx, ay) { - const mH = Math.min(this.H * 0.4, 150); - ctx.save(); - - const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t; - const bulge = this._getBulge(this._prevType) + - (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT); - - ctx.strokeStyle = 'rgba(6,214,224,0.92)'; - ctx.lineWidth = 3; - ctx.shadowColor = 'rgba(6,214,224,0.45)'; - ctx.shadowBlur = 8; - - ctx.beginPath(); - ctx.moveTo(mx, ay - mH); - ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); - ctx.stroke(); - ctx.shadowBlur = 0; - - ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5; - for (let i = 0; i <= 10; i++) { - const y = ay - mH + i * mH * 2 / 10; - ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke(); - } - ctx.restore(); - } - - /* ── Focal points ────────────────────────────── */ - - _drawFocalPoints(ctx, mx, ay, f) { - const behind = f < 0; - const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }]; - ctx.font = '11px Manrope, system-ui, sans-serif'; - for (const p of pts) { - if (p.px < 4 || p.px > this.W-4) continue; - const col = behind ? 'rgba(255,209,102,0.7)' : '#06D6E0'; - ctx.fillStyle = col; - ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill(); - ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText(p.lbl, p.px, ay+9); - } - } - - /* ── Center of curvature C ───────────────────── */ - - _drawCenterC(ctx, mx, ay, f) { - if (!isFinite(f)) return; - const cx = mx - 2*f; - if (cx < 4 || cx > this.W-4) return; - const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06; - ctx.save(); - if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; } - ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)'; - ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill(); - ctx.shadowBlur = 0; - ctx.font = '11px Manrope, system-ui, sans-serif'; - ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)'; - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText('C', cx, ay+9); - ctx.restore(); - } - - /* ── Fan rays ────────────────────────────────── */ - - _drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) { - const { d, h, type } = this; - const hasImg = dPrime !== null && isFinite(dPrime); - const isReal = hasImg && dPrime > 0; - const imgX = hasImg ? mx - dPrime : null; - const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null; - const objX = mx - d; - const objY = ay - (this._pointMode ? 0 : h); - const COLS = ['#06D6E0','#7BF5A4','#FFD166']; - const FAN = 'rgba(255,255,255,0.18)'; - - if (type === 'flat') { - const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5]; - hits.forEach((hy, i) => { - if (!showRay(i)) return; - this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg); - }); - return; - } - - const hit1 = ay - (this._pointMode ? 0 : h); - const hit2 = ay; - const den3 = d - f; - const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3; - - if (showFill) { - const fills = [(hit1+hit2)/2]; - if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2); - for (const hy of fills) - this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY); - } - - if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY); - if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY); - if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY); - } - - _oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) { - if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return; - ctx.save(); ctx.globalAlpha = alpha; - ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); - ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); - if (!hasImg) { ctx.restore(); return; } - - if (isReal) { - ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); - const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy); - if (l > 1) { - ctx.globalAlpha = alpha * 0.22; - ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); - } - } else { - const dx = imgX-mx, dy = imgY-hitY; - if (Math.abs(dx) < 1) { ctx.restore(); return; } - const tL = (mx-5)/dx; - let ex = 5, ey = hitY - dy*tL; - if (ey < 5 || ey > this.H-5) { - ey = ey < 5 ? 5 : this.H-5; - ex = Math.max(5, mx - dx*(hitY-ey)/dy); - } - ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke(); - ctx.globalAlpha = alpha * 0.4; - ctx.setLineDash([4,4]); - ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); - ctx.setLineDash([]); - } - ctx.restore(); - } - - _flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) { - ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); - ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); - const slope = (hitY-oy)/(mx-ox); - const farX = Math.max(5, ox-50); - const farY = hitY - slope*(mx-farX); - ctx.globalAlpha = 0.3; - ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke(); - ctx.globalAlpha = 1; - if (hasImg) { - ctx.setLineDash([4,4]); - ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); - ctx.setLineDash([]); - } - ctx.restore(); - } - - /* ── Normals ─────────────────────────────────── */ - - _drawNormals(ctx, mx, ay, f) { - if (!isFinite(f)) return; - const { d, h } = this; - const cX = mx - 2*f; - const hits = [ay-h, ay]; - const d3 = d-f; - if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); } - - ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]); - for (const hy of hits) { - if (hy < -this.H || hy > 2*this.H) continue; - const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny); - if (nl < 1) continue; - const ux=nx/nl*28, uy=ny/nl*28; - ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke(); - } - ctx.setLineDash([]); ctx.restore(); - } - - /* ── Angle arcs ──────────────────────────────── */ - - _drawAngleArcs(ctx, mx, ay, f) { - if (!isFinite(f)) return; - const { d, h } = this; - const hitY = ay - h; // use ray 1 hit point - if (hitY < 5 || hitY > this.H-5) return; - - const cX = mx - 2*f; - const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny); - if (nl < 1) return; - - const normInward = Math.atan2(ny, nx); // toward C - const normOuter = normInward + Math.PI; // outward normal - const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d)); // incident FROM object - const incFrom = incDir + Math.PI; // direction FROM mirror to object - - const r = 14; - ctx.save(); - ctx.lineWidth = 1; - - // arc on incident side - ctx.strokeStyle = 'rgba(6,214,224,0.45)'; - ctx.beginPath(); - let a1 = normOuter, a2 = incFrom; - // normalize so arc goes the short way - ctx.arc(mx, hitY, r, a1, a2, false); - ctx.stroke(); - - // θ label - ctx.fillStyle = 'rgba(6,214,224,0.7)'; - ctx.font = '9px Manrope, system-ui, sans-serif'; - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const mid = (a1+a2)/2; - ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9)); - - ctx.restore(); - } - - /* ── Ray labels ①②③ ──────────────────────────── */ - - _drawRayLabels(ctx, mx, ay, f, step) { - if (this.type === 'flat' || !isFinite(f)) return; - const { d, h } = this; - const hits = [ay-h, ay, null]; - const den3 = d-f; - if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; } - const COLS = ['#06D6E0','#7BF5A4','#FFD166']; - const LBLS = ['①','②','③']; - - ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; - ctx.textAlign = 'left'; - hits.forEach((hy, i) => { - if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return; - if (step !== -1 && i > step) return; - ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; - ctx.fillText(LBLS[i], mx+8, hy); - }); - } - - /* ── Arrow ───────────────────────────────────── */ - - _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([]); - const a = Math.atan2(y2-y1, x2-x1), s=10; - ctx.beginPath(); - ctx.moveTo(x2,y2); - ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35)); - ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35)); - ctx.closePath(); ctx.fill(); - } - - /* ── Dimension annotations ───────────────────── */ - - _drawDimensions(ctx, mx, ay, f, dPrime, hPrime) { - const { d, h } = this; - const objX = mx - d; - const yBase = ay + Math.min(this.H*0.22, 60); - ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1; - - const bracket = (x1, x2, y, lbl, col) => { - if (x1 === x2 || x1 < 4 || x2 > this.W-4) return; - ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); - ctx.moveTo(x1, y); ctx.lineTo(x2, y); - ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); - ctx.stroke(); - ctx.textAlign='center'; ctx.textBaseline='top'; - ctx.fillText(lbl, (x1+x2)/2, y+3); - }; - - bracket(objX, mx, yBase, `d=${d.toFixed(0)}`, 'rgba(155,93,229,0.65)'); - if (isFinite(f) && Math.abs(f) > 5) { - const fX = mx-f; - if (fX > 4 && fX < this.W-4) - bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, - `f=${Math.abs(f).toFixed(0)}`, 'rgba(6,214,224,0.55)'); - } - if (dPrime !== null && isFinite(dPrime)) { - const ix = mx-dPrime; - if (ix > 4 && ix < this.W-4) - bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, - `d'=${Math.abs(dPrime).toFixed(0)}`, - dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)'); - } - - const xl = objX-18; - if (xl > 4 && h > 6 && !this._pointMode) { - ctx.strokeStyle='rgba(155,93,229,0.4)'; - ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke(); - ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle'; - ctx.fillText(`h=${h.toFixed(0)}`, xl-3, ay-h/2); - } - - if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) { - const ix = mx-dPrime; - const xil = ix + (dPrime > 0 ? -18 : 18); - if (xil > 4 && xil < this.W-4) { - const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,'; - ctx.strokeStyle = col+'0.4)'; - ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke(); - ctx.fillStyle = col+'0.7)'; - ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle'; - ctx.fillText(`h'=${Math.abs(hPrime).toFixed(0)}`, ix+(dPrime>0?-3:3), ay-hPrime/2); - } - } - } - - /* ── Unified info box ────────────────────────── */ - - _drawInfoBox(ctx, f, dPrime) { - const info = this.info(); - const bx=12, by=12, bw=230, bh=76; - ctx.fillStyle='rgba(13,13,26,0.9)'; - ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); - ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; - ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); - - ctx.font='11px Manrope, system-ui, sans-serif'; - ctx.textAlign='left'; ctx.textBaseline='top'; - ctx.fillStyle='rgba(255,255,255,0.42)'; - ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8); - - if (isFinite(f) && dPrime !== null && isFinite(dPrime)) { - ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText(`1/${Math.abs(+info.f)}`, bx+10, by+28); - ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28); - ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText(`1/${info.d}`, bx+78, by+28); - ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28); - ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)'; - ctx.fillText(`${dPrime>0?'':'−'}1/${Math.abs(+info.dPrime).toFixed(0)}`, bx+136, by+28); - } else { - ctx.fillStyle='rgba(255,209,102,0.75)'; - ctx.fillText('d = f → изображение на ∞', bx+10, by+28); - } - - if (info.M !== Infinity) { - ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText(`M = ${info.M}`, bx+10, by+48); - if (isFinite(dPrime)) { - ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; - ctx.textAlign = 'right'; - ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48); - ctx.textAlign = 'left'; - } - } - } - - /* ── Image badge ─────────────────────────────── */ - - _drawImageBadge(ctx, dPrime, hPrime) { - const info = this.info(); - const bw=160, bh=58, bx=this.W-bw-12, by=12; - ctx.fillStyle='rgba(13,13,26,0.88)'; - ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); - ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; - ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); - - const isInf = !isFinite(dPrime); - ctx.font='10px Manrope, system-ui, sans-serif'; - ctx.textAlign='left'; ctx.textBaseline='top'; - const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166'; - ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8); - ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8); - if (!isInf) { - ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26); - ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)'; - ctx.fillText(info.orient, bx+62, by+26); - if (info.sizeStr) { - const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)'; - ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42); - ctx.fillStyle=sc; ctx.fillText(`${info.sizeStr} ×${Math.abs(+info.M).toFixed(2)}`, bx+57, by+42); - } - } - } - - /* ── Critical marker ─────────────────────────── */ - - _drawCriticalMarker(ctx, f) { - if (!isFinite(f) || f <= 0) return; - const eps = f*0.06; - let text = null; - if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет'; - else if (Math.abs(this.d-2*f) 0 — предмет перед зеркалом' }, - { c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" }, - { c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" }, - ]; - const bx=12, lh=14, by=this.H - items.length*lh - 16; - ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top'; - items.forEach(({ c, t }, i) => { - const y = by+i*lh; - ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8); - ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y); - }); - ctx.restore(); - } - - /* ── Photon drawing ──────────────────────────── */ - - _drawPhotons(ctx) { - for (const p of this._photons) { - const pos = this._photonPos(p.pts, p.t); - if (!pos) continue; - ctx.save(); - ctx.shadowColor = p.color; ctx.shadowBlur = 8; - ctx.fillStyle = p.color; - ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill(); - ctx.restore(); - } - } - - _photonPos(pts, t) { - if (pts.length < 2) return null; - let total = 0; - const lens = []; - for (let i=1; i { - if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; - }; - if (isFinite(f)) { - chk(mx-f, ay, 'Главный фокус F', `f = ${Math.abs(f).toFixed(0)}`); - chk(mx-2*f, ay, 'Центр кривизны C', `R = 2f = ${(2*Math.abs(f)).toFixed(0)}`); - } - chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', `d = ${this.d.toFixed(0)}, h = ${this.h.toFixed(0)}`); - if (dPrime !== null && isFinite(dPrime)) { - const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime); - chk(ix, iy, 'Изображение', `d' = ${Math.abs(dPrime).toFixed(0)}, M = ${this.info().M}`); - } - if (!tip) return; - - ctx.save(); - ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; - const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width); - const bw=tw+20, bh=34; - let tx=hx+14, ty=hy-bh-6; - if (tx+bw > this.W-4) tx = hx-bw-14; - if (ty < 4) ty = hy+10; - ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1; - ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke(); - ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; - ctx.fillText(tip.lbl, tx+10, ty+6); - ctx.font='10px Manrope, system-ui, sans-serif'; - ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20); - ctx.restore(); - } - - /* ── Step overlay ────────────────────────────── */ - - _drawStepOverlay(ctx, step) { - const lbls = [ - '① Луч параллельно оси → отражается через F', - '② Луч через вершину → отражается симметрично', - '③ Луч через F → отражается параллельно', - ' Изображение — пересечение всех отражённых лучей', - ]; - const text = lbls[Math.min(step, lbls.length-1)]; - ctx.save(); - ctx.font = '11px Manrope, system-ui, sans-serif'; - const tw = ctx.measureText(text).width; - const bx = this.W/2-tw/2-12, by = this.H-34; - ctx.fillStyle='rgba(13,13,26,0.9)'; - ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill(); - ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillText(text, this.W/2, by+12); - ctx.restore(); - } - - /* ── Events ──────────────────────────────────── */ - - _bindEvents() { - const cv = this.canvas; - const getPos = e => { - const r = cv.getBoundingClientRect(); - const t = e.touches ? e.touches[0] : e; - return { - px: (t.clientX-r.left)*(this.W/r.width), - py: (t.clientY-r.top) *(this.H/r.height), - }; - }; - const mX = () => Math.round(this.W*0.62); - const aY = () => this.H/2; - - const hitTest = (px, py) => { - if (this._playing) return null; - const mx=mX(), ay=aY(), f=this._fSigned(); - if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object'; - if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus'; - const info = this.info(); - if (info.dPrime !== Infinity && isFinite(info.dPrime)) { - const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0)); - if (Math.hypot(px-ix, py-iy) < 18) return 'image'; - } - return null; - }; - - cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); }); - - window.addEventListener('mousemove', e => { - const {px,py} = getPos(e); - this._hoverX = px; this._hoverY = py; - if (this._drag) { - if (e.cancelable) e.preventDefault(); - const mx=mX(), f=this._fSigned(); - if (this._drag === 'object') { - this.d = Math.max(30, Math.min(490, mx-px)); - } else if (this._drag === 'focus') { - this.f = Math.max(30, Math.min(300, Math.abs(mx-px))); - } else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') { - const dp = mx-px; - if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f))); - } - if (this.onAnimate) this.onAnimate(this.d); - this.draw(); this._emit(); - } else if (!this._photonRaf && !this._playing) { - this.draw(); // redraw for tooltip - } - }); - - window.addEventListener('mouseup', () => { this._drag = null; }); - - cv.addEventListener('mousemove', e => { - if (this._drag) { cv.style.cursor='grabbing'; return; } - const {px,py}=getPos(e); - cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default'; - }); - - cv.addEventListener('touchstart', e => { - if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); } - }, { passive: true }); - - cv.addEventListener('touchmove', e => { - if (!this._drag) return; - if (e.cancelable) e.preventDefault(); - const {px}=getPos(e), mx=mX(), f=this._fSigned(); - if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px)); - else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px))); - else if (this._drag==='image' && isFinite(f) && this.type!=='flat') { - const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f))); - } - if (this.onAnimate) this.onAnimate(this.d); - this.draw(); this._emit(); - }, { passive: false }); - - cv.addEventListener('touchend', () => { this._drag=null; }); - } -} - -/* ─── lab UI init ─────────────────────────────────── */ - var mirrorSim = null; - - function _openMirror() { - document.getElementById('sim-topbar-title').textContent = 'Зеркала'; - _simShow('sim-mirrors'); - _registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st)); - if (_embedMode) _startStateEmit('mirrors'); - requestAnimationFrame(() => requestAnimationFrame(() => { - if (!mirrorSim) { - mirrorSim = new MirrorSim(document.getElementById('mirror-canvas')); - mirrorSim.onUpdate = _mirrorUpdateUI; - mirrorSim.onAnimate = (d) => { - const sl = document.getElementById('sl-mirror-d'); - const lbl = document.getElementById('mirror-d-val'); - if (sl) sl.value = Math.round(d); - if (lbl) lbl.textContent = Math.round(d); - }; - } - mirrorSim.fit(); - mirrorSim.draw(); - mirrorSim._emit(); - if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons(); - })); - } - - function mirrorType(type, el) { - document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); - if (el) el.classList.add('active'); - const fRow = document.getElementById('mirror-f-row'); - if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex'; - if (mirrorSim) mirrorSim.setType(type); - const pb = document.getElementById('mirror-play-btn'); - if (pb) { pb.textContent = '▶ Анимация'; } - const sl = document.getElementById('sl-mirror-d'); - if (sl) sl.disabled = false; - } - - function mirrorParam(name, val) { - const v = parseFloat(val); - const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' }; - const el = document.getElementById(ids[name]); - if (el) el.textContent = v; - if (mirrorSim) mirrorSim.setParams({ [name]: v }); - } - - function mirrorPreset(name) { - const P = { - flat: { type: 'flat', f: 120, d: 200, h: 60 }, - far: { type: 'concave', f: 100, d: 280, h: 60 }, - '2f': { type: 'concave', f: 100, d: 200, h: 60 }, - between: { type: 'concave', f: 100, d: 140, h: 60 }, - near: { type: 'concave', f: 100, d: 60, h: 60 }, - convex: { type: 'convex', f: 100, d: 200, h: 60 }, - }; - const p = P[name]; if (!p) return; - document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); - const tb = document.getElementById(`mtype-${p.type}`); - if (tb) tb.classList.add('active'); - const fRow = document.getElementById('mirror-f-row'); - if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex'; - document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f; - document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d; - document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h; - if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); } - } - - function mirrorTogglePlay(btn) { - if (!mirrorSim) return; - mirrorSim.togglePlay(); - const playing = mirrorSim._playing; - if (btn) btn.textContent = playing ? '⏸ Стоп' : '▶ Анимация'; - const sl = document.getElementById('sl-mirror-d'); - if (sl) sl.disabled = playing; - } - - function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); } - function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); } - function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); } - function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); } - function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); } - - function _mirrorUpdateUI(info) { - const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; - v('mirrorbar-v1', info.f); - v('mirrorbar-v5', Math.round(info.d)); - v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime); - v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M); - v('mirrorbar-v4', info.imageType); - } - - /* ── isoprocesses ── */ - diff --git a/frontend/js/labs/opticsbench.js b/frontend/js/labs/opticsbench.js new file mode 100644 index 0000000..eb35bd2 --- /dev/null +++ b/frontend/js/labs/opticsbench.js @@ -0,0 +1,1583 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + OpticsBenchSim — unified optical bench simulation + Merges: ThinLensSim (thinlens.js) + MirrorSim (mirror.js) + RefractionSim (refraction.js) + + Modes: + 'lens' — thin lens: 1/f = 1/d + 1/d', M = -d'/d + 'mirror' — curved / flat mirrors, same formula + 'refraction'— Snell's law: n₁sin θ₁ = n₂sin θ₂, TIR, dispersion + + Physics preserved verbatim from original sims. + ══════════════════════════════════════════════════════════════ */ + +/* ───────────────────────────────────────────────────────────── + 1. THIN LENS ENGINE (from thinlens.js) +───────────────────────────────────────────────────────────────*/ +class ThinLensSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.f = 100; + this.d = 200; + this.h = 50; + + this._drag = null; + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + 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), + }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _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() { + 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; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + 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([]); + + this._drawLens(ctx, lensX, axisY, f); + this._drawFocalPoints(ctx, lensX, axisY, f); + + const objX = lensX - d; + this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false); + + const denom = d - f; + let dPrime, hPrime; + if (Math.abs(denom) < 0.5) { + dPrime = null; hPrime = null; + } else { + dPrime = (f * d) / denom; + hPrime = (-dPrime / d) * h; + } + + this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime); + + if (dPrime !== null && isFinite(dPrime)) { + const isVirtual = dPrime < 0; + const imgX = lensX + dPrime; + this._drawArrow(ctx, imgX, axisY, imgX, axisY - hPrime, + isVirtual ? '#FFD166' : '#EF476F', isVirtual); + } + + 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) { + 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(); + this._lensArrow(ctx, lx, ay - lensH, -1); + this._lensArrow(ctx, lx, ay + lensH, 1); + } else { + 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(); + this._lensArrowDiv(ctx, lx, ay - lensH, -1); + this._lensArrowDiv(ctx, lx, ay + lensH, 1); + } + 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([]); + const angle = Math.atan2(y2 - y1, x2 - x1), 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, 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 + { + ctx.strokeStyle = colors[0]; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke(); + if (hasImage) { + const imgX = lx + dPrime, imgY = ay - hPrime; + if (!isVirtual) { + ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); + this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]); + } else { + 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 + { + ctx.strokeStyle = colors[1]; ctx.setLineDash([]); + const slope = (objY - ay) / (objX - lx); + const farX = lx + 350, farY = ay + slope * 350; + ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke(); + if (isVirtual) { + const backX = lx - 350, 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 + { + ctx.strokeStyle = colors[2]; ctx.setLineDash([]); + const fx = lx - f, 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, len = Math.hypot(dx, dy); + if (len < 1) return; + ctx.globalAlpha = 0.3; ctx.strokeStyle = color; + ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 + (dx / len) * 80, y2 + (dy / len) * 80); ctx.stroke(); + ctx.globalAlpha = 1; + } + + _drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) { + ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'top'; + const objX = lx - d; + ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; + ctx.fillText('d = ' + d.toFixed(0), (objX + lx) / 2, ay + 26); + ctx.fillStyle = '#06D6E0'; + ctx.fillText('f = ' + f.toFixed(0), lx, ay + 42); + 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); + } + const info = this.info(); + const boxW = 200, boxH = 52, 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'; + ctx.fillText("1/f = 1/d + 1/d'", bx + 10, by + 10); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + const mStr = info.M === Infinity ? '---' : info.M.toFixed(2); + const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1); + ctx.fillText('M = ' + mStr + " d' = " + dpStr + ' ' + info.imageType, bx + 10, by + 30); + } + + _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; + if (Math.hypot(mx - (lx - this.d), my - (ay - this.h)) < 20) return 'object'; + if (Math.hypot(mx - (lx - this.f), 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), lx = this.W / 2; + if (this._drag === 'object') this.d = Math.max(30, Math.min(400, lx - mx)); + else if (this._drag === 'focus') this.f = Math.max(-200, Math.min(200, lx - mx)); + this.draw(); this._emit(); + }; + const onUp = () => { this._drag = null; }; + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + 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); + 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'; + }); + } +} + +/* ───────────────────────────────────────────────────────────── + 2. MIRROR ENGINE (from mirror.js) +───────────────────────────────────────────────────────────────*/ +class MirrorSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.type = 'concave'; + this.f = 120; + this.d = 240; + this.h = 60; + + this._playing = false; + this._animT = 1.4; + this._animSpeed = 1; + this._raf = null; + + this._step = -1; + + this._showGrid = false; + this._showZones = true; + this._showNormals = true; + this._showDims = true; + this._showAngles = true; + this._showPhotons = true; + this._pointMode = false; + + this._photons = []; + this._photonRaf = null; + this._photonTimer = 0; + this._lastPhoTime = 0; + this._photonPaths = []; + + this._prevType = 'concave'; + this._transT = 1.0; + this._transRaf = null; + + this._drag = null; + this._hoverX = -999; + this._hoverY = -999; + + this.onUpdate = null; + this.onAnimate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + 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; + } + + setType(type) { + if (type === this.type) return; + this._prevType = this.type; + this.type = type; + if (this._playing) this._stopAnim(); + this._startTransition(); + this.draw(); this._emit(); + } + + getParams() { return { f: this.f, d: this.d, h: this.h }; } + setParams({ f, d, h } = {}) { + if (f !== undefined) this.f = Math.max(30, Math.min(300, +f)); + if (d !== undefined) this.d = Math.max(30, Math.min(490, +d)); + if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); + this.draw(); this._emit(); + } + + setAnimSpeed(s) { this._animSpeed = +s || 1; } + togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); } + stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); } + stepReset() { this._step = -1; this.draw(); } + setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); } + + setToggle(name, val) { + const map = { grid:'_showGrid', zones:'_showZones', normals:'_showNormals', dims:'_showDims', angles:'_showAngles', photons:'_showPhotons' }; + if (map[name]) this[map[name]] = !!val; + if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); } + this.draw(); + } + + exportPng() { + const a = document.createElement('a'); + a.href = this.canvas.toDataURL('image/png'); + a.download = 'mirror_' + this.type + '_d' + Math.round(this.d) + '.png'; + a.click(); + } + + _fSigned() { + if (this.type === 'flat') return Infinity; + return this.type === 'convex' ? -this.f : this.f; + } + + info() { + const { type, d, h } = this; + const f = this._fSigned(); + let dPrime, M; + if (type === 'flat') { dPrime = -d; M = 1; } + else { + const den = d - f; + if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; } + else { dPrime = f * d / den; M = -dPrime / d; } + } + const hPrime = M === Infinity ? Infinity : M * h; + const isReal = dPrime > 0 && dPrime !== Infinity; + const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое'; + const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое'; + const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное'; + return { + f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0), + d: +d.toFixed(1), + dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), + M: M === Infinity ? Infinity : +M.toFixed(3), + imageType, orient, sizeStr, + hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), + isReal, + }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _getBulge(type) { + if (type === 'flat') return 0; + if (type === 'concave') return -Math.min(30, this.f * 0.18); + return Math.min(24, this.f * 0.16); + } + + _startTransition() { + this._transT = 0; + if (this._transRaf) cancelAnimationFrame(this._transRaf); + const step = () => { + this._transT = Math.min(1, this._transT + 0.07); + this.draw(); + if (this._transT < 1) this._transRaf = requestAnimationFrame(step); + else this._transRaf = null; + }; + this._transRaf = requestAnimationFrame(step); + } + + _startAnim() { this._playing = true; this._animLoop(); } + _stopAnim() { + this._playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + _animLoop() { + if (!this._playing) return; + this._animT += 0.013 * this._animSpeed; + const t = 0.5 - 0.5 * Math.cos(this._animT); + if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t))); + else this.d = 40 + 400 * t; + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + this._raf = requestAnimationFrame(() => this._animLoop()); + } + + _getRayPaths(mx, ay, f, dPrime, hPrime) { + const { d, h, type } = this; + const hasImage = dPrime !== null && isFinite(dPrime); + const isReal = hasImage && dPrime > 0; + const imgX = hasImage ? mx - dPrime : null; + const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null; + const objX = mx - d; + const objY = ay - (this._pointMode ? 0 : h); + const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166']; + if (type === 'flat') { + return [objY, ay, ay - h * 0.5].map((hy, i) => ({ + pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])], + color: COLORS[i], + })); + } + const hit1Y = ay - (this._pointMode ? 0 : h); + const hit2Y = ay; + const denom3 = d - f; + const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3; + const rays = []; + const add = (hitY, color) => { + if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return; + const pts = [[objX, objY], [mx, hitY]]; + if (hasImage) { + if (isReal) { + pts.push([imgX, imgY]); + const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy); + if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]); + } else { + const dx = imgX - mx, dy = imgY - hitY; + if (Math.abs(dx) > 1) { + const tL = (mx - 5) / dx; + let endX = 5, endY = hitY - dy * tL; + if (endY < 5 || endY > this.H - 5) { + endY = endY < 5 ? 5 : this.H - 5; + const tE = (hitY - endY) / dy; + endX = Math.max(5, mx - dx * tE); + } + pts.push([endX, endY]); + } + } + } + rays.push({ pts, color }); + }; + add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]); + return rays; + } + + _startPhotons() { + if (this._photonRaf) return; + this._lastPhoTime = performance.now(); + this._photonLoop(); + } + + _stopPhotons() { + if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; } + this._photons = []; + this.draw(); + } + + _photonLoop() { + const now = performance.now(); + const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1); + this._lastPhoTime = now; + const spd = 200; + for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len); + this._photons = this._photons.filter(p => p.t < 1); + this._photonTimer += dt; + if (this._photonTimer > 0.75 && this._photonPaths.length) { + this._photonTimer = 0; + for (const path of this._photonPaths) { + if (path.pts.length < 2) continue; + let len = 0; + for (let i = 1; i < path.pts.length; i++) + len += Math.hypot(path.pts[i][0] - path.pts[i-1][0], path.pts[i][1] - path.pts[i-1][1]); + if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len }); + } + } + if (!this._playing) this.draw(); + this._photonRaf = requestAnimationFrame(() => this._photonLoop()); + } + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + const f = this._fSigned(); + const mx = Math.round(W * 0.62); + const ay = H / 2; + let dPrime = null, hPrime = null; + if (this.type === 'flat') { dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; } + else { + const den = this.d - f; + if (Math.abs(den) >= 0.5) { + dPrime = f * this.d / den; + hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h; + } + } + const step = this._step; + const showRay = i => step === -1 || i <= step; + const showFill = step === -1 || step >= 3; + this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime); + ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); + if (this._showGrid) this._drawGrid(ctx); + if (this._showZones) this._drawZones(ctx, mx); + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); + ctx.setLineDash([]); + this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill); + this._drawMirror(ctx, mx, ay); + if (this.type !== 'flat') { + this._drawFocalPoints(ctx, mx, ay, f); + this._drawCenterC(ctx, mx, ay, f); + } + if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3)) + this._drawNormals(ctx, mx, ay, f); + if (this._showAngles && this.type !== 'flat' && step === -1) + this._drawAngleArcs(ctx, mx, ay, f); + if (step === -1 || step >= 1) this._drawRayLabels(ctx, mx, ay, f, step); + const objX = mx - this.d; + if (this._pointMode) { + ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 10; + ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); ctx.restore(); + } else { this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); } + if (dPrime !== null && isFinite(dPrime)) { + const imgX = mx - dPrime, imgY = ay - (this._pointMode ? 0 : hPrime); + if (this._pointMode) { + ctx.save(); ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; + if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); } + ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2); + dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })(); + ctx.restore(); + } else { + this._drawArrow(ctx, imgX, ay, imgX, imgY, dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0); + } + } + if (this._showDims && (step === -1 || step >= 3)) + this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime); + this._drawInfoBox(ctx, f, dPrime); + if ((step === -1 || step >= 3) && dPrime !== null) this._drawImageBadge(ctx, dPrime, hPrime); + this._drawCriticalMarker(ctx, f); + if (this._showDims) this._drawLegend(ctx); + if (this._showPhotons && this._photons.length) this._drawPhotons(ctx); + this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); + if (step >= 0) this._drawStepOverlay(ctx, step); + } + + _drawGrid(ctx) { + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; ctx.beginPath(); + for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); } + for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); } + ctx.stroke(); + } + + _drawZones(ctx, mx) { + const g1 = ctx.createLinearGradient(0,0,mx,0); + g1.addColorStop(0, 'rgba(6,214,224,0.0)'); g1.addColorStop(1, 'rgba(6,214,224,0.03)'); + ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H); + const g2 = ctx.createLinearGradient(mx,0,this.W,0); + g2.addColorStop(0, 'rgba(239,71,111,0.04)'); g2.addColorStop(1, 'rgba(239,71,111,0.0)'); + ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H); + } + + _drawMirror(ctx, mx, ay) { + const mH = Math.min(this.H * 0.4, 150); + ctx.save(); + const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t; + const bulge = this._getBulge(this._prevType) + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT); + ctx.strokeStyle = 'rgba(6,214,224,0.92)'; ctx.lineWidth = 3; + ctx.shadowColor = 'rgba(6,214,224,0.45)'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(mx, ay - mH); ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5; + for (let i = 0; i <= 10; i++) { + const y = ay - mH + i * mH * 2 / 10; + ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke(); + } + ctx.restore(); + } + + _drawFocalPoints(ctx, mx, ay, f) { + const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }]; + ctx.font = '11px Manrope, system-ui, sans-serif'; + for (const p of pts) { + if (p.px < 4 || p.px > this.W-4) continue; + const col = f < 0 ? 'rgba(255,209,102,0.7)' : '#06D6E0'; + ctx.fillStyle = col; ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill(); + ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(p.lbl, p.px, ay+9); + } + } + + _drawCenterC(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const cx = mx - 2*f; + if (cx < 4 || cx > this.W-4) return; + const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06; + ctx.save(); + if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; } + ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)'; + ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('C', cx, ay+9); + ctx.restore(); + } + + _drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) { + const { d, h, type } = this; + const hasImg = dPrime !== null && isFinite(dPrime); + const isReal = hasImg && dPrime > 0; + const imgX = hasImg ? mx - dPrime : null; + const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null; + const objX = mx - d, objY = ay - (this._pointMode ? 0 : h); + const COLS = ['#06D6E0','#7BF5A4','#FFD166']; + const FAN = 'rgba(255,255,255,0.18)'; + if (type === 'flat') { + const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5]; + hits.forEach((hy, i) => { + if (!showRay(i)) return; + this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg); + }); + return; + } + const hit1 = ay - (this._pointMode ? 0 : h); + const hit2 = ay; + const den3 = d - f; + const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3; + if (showFill) { + const fills = [(hit1+hit2)/2]; + if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2); + for (const hy of fills) this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY); + } + if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY); + if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY); + if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY); + } + + _oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) { + if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return; + ctx.save(); ctx.globalAlpha = alpha; + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); + if (!hasImg) { ctx.restore(); return; } + if (isReal) { + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); + const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy); + if (l > 1) { ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); } + } else { + const dx = imgX-mx, dy = imgY-hitY; + if (Math.abs(dx) < 1) { ctx.restore(); return; } + const tL = (mx-5)/dx; + let ex = 5, ey = hitY - dy*tL; + if (ey < 5 || ey > this.H-5) { ey = ey < 5 ? 5 : this.H-5; ex = Math.max(5, mx - dx*(hitY-ey)/dy); } + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke(); + ctx.globalAlpha = alpha * 0.4; ctx.setLineDash([4,4]); + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.restore(); + } + + _flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) { + ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); + const slope = (hitY-oy)/(mx-ox); + const farX = Math.max(5, ox-50); + const farY = hitY - slope*(mx-farX); + ctx.globalAlpha = 0.3; + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke(); + ctx.globalAlpha = 1; + if (hasImg) { ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); ctx.setLineDash([]); } + ctx.restore(); + } + + _drawNormals(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const { d, h } = this; + const cX = mx - 2*f; + const hits = [ay-h, ay]; + const d3 = d-f; + if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); } + ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]); + for (const hy of hits) { + if (hy < -this.H || hy > 2*this.H) continue; + const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny); + if (nl < 1) continue; + const ux=nx/nl*28, uy=ny/nl*28; + ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke(); + } + ctx.setLineDash([]); ctx.restore(); + } + + _drawAngleArcs(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const { d, h } = this; + const hitY = ay - h; + if (hitY < 5 || hitY > this.H-5) return; + const cX = mx - 2*f; + const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny); + if (nl < 1) return; + const normInward = Math.atan2(ny, nx); + const normOuter = normInward + Math.PI; + const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d)); + const incFrom = incDir + Math.PI; + const r = 14; + ctx.save(); ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(6,214,224,0.45)'; + ctx.beginPath(); ctx.arc(mx, hitY, r, normOuter, incFrom, false); ctx.stroke(); + ctx.fillStyle = 'rgba(6,214,224,0.7)'; ctx.font = '9px Manrope, system-ui, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const mid = (normOuter+incFrom)/2; + ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9)); + ctx.restore(); + } + + _drawRayLabels(ctx, mx, ay, f, step) { + if (this.type === 'flat' || !isFinite(f)) return; + const { d, h } = this; + const hits = [ay-h, ay, null]; + const den3 = d-f; + if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; } + const COLS = ['#06D6E0','#7BF5A4','#FFD166']; + const LBLS = ['①','②','③']; + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; + hits.forEach((hy, i) => { + if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return; + if (step !== -1 && i > step) return; + ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; ctx.fillText(LBLS[i], mx+8, hy); + }); + } + + _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([]); + const a = Math.atan2(y2-y1, x2-x1), s=10; + ctx.beginPath(); ctx.moveTo(x2,y2); + ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35)); + ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35)); + ctx.closePath(); ctx.fill(); + } + + _drawDimensions(ctx, mx, ay, f, dPrime, hPrime) { + const { d, h } = this; + const objX = mx - d; + const yBase = ay + Math.min(this.H*0.22, 60); + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1; + const bracket = (x1, x2, y, lbl, col) => { + if (x1 === x2 || x1 < 4 || x2 > this.W-4) return; + ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); ctx.moveTo(x1, y); ctx.lineTo(x2, y); + ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); ctx.stroke(); + ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText(lbl, (x1+x2)/2, y+3); + }; + bracket(objX, mx, yBase, 'd=' + d.toFixed(0), 'rgba(155,93,229,0.65)'); + if (isFinite(f) && Math.abs(f) > 5) { + const fX = mx-f; + if (fX > 4 && fX < this.W-4) + bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, 'f=' + Math.abs(f).toFixed(0), 'rgba(6,214,224,0.55)'); + } + if (dPrime !== null && isFinite(dPrime)) { + const ix = mx-dPrime; + if (ix > 4 && ix < this.W-4) + bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, "d'=" + Math.abs(dPrime).toFixed(0), + dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)'); + } + const xl = objX-18; + if (xl > 4 && h > 6 && !this._pointMode) { + ctx.strokeStyle='rgba(155,93,229,0.4)'; + ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke(); + ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle'; + ctx.fillText('h=' + h.toFixed(0), xl-3, ay-h/2); + } + if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) { + const ix = mx-dPrime; + const xil = ix + (dPrime > 0 ? -18 : 18); + if (xil > 4 && xil < this.W-4) { + const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,'; + ctx.strokeStyle = col+'0.4)'; ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke(); + ctx.fillStyle = col+'0.7)'; + ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle'; + ctx.fillText("h'=" + Math.abs(hPrime).toFixed(0), ix+(dPrime>0?-3:3), ay-hPrime/2); + } + } + } + + _drawInfoBox(ctx, f, dPrime) { + const info = this.info(); + const bx=12, by=12, bw=230, bh=76; + ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); + ctx.font='11px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top'; + ctx.fillStyle='rgba(255,255,255,0.42)'; + ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8); + if (isFinite(f) && dPrime !== null && isFinite(dPrime)) { + ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText('1/' + Math.abs(+info.f), bx+10, by+28); + ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28); + ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText('1/' + info.d, bx+78, by+28); + ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28); + ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)'; + ctx.fillText((dPrime>0?'':'−') + '1/' + Math.abs(+info.dPrime).toFixed(0), bx+136, by+28); + } else { + ctx.fillStyle='rgba(255,209,102,0.75)'; + ctx.fillText('d = f → изображение на ∞', bx+10, by+28); + } + if (info.M !== Infinity) { + ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText('M = ' + info.M, bx+10, by+48); + if (isFinite(dPrime)) { + ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; + ctx.textAlign = 'right'; ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48); + ctx.textAlign = 'left'; + } + } + } + + _drawImageBadge(ctx, dPrime, hPrime) { + const info = this.info(); + const bw=160, bh=58, bx=this.W-bw-12, by=12; + ctx.fillStyle='rgba(13,13,26,0.88)'; ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); + const isInf = !isFinite(dPrime); + ctx.font='10px Manrope, system-ui, sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top'; + const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166'; + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8); + ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8); + if (!isInf) { + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26); + ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)'; + ctx.fillText(info.orient, bx+62, by+26); + if (info.sizeStr) { + const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)'; + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42); + ctx.fillStyle=sc; ctx.fillText(info.sizeStr + ' x' + Math.abs(+info.M).toFixed(2), bx+57, by+42); + } + } + } + + _drawCriticalMarker(ctx, f) { + if (!isFinite(f) || f <= 0) return; + const eps = f*0.06; + let text = null; + if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет'; + else if (Math.abs(this.d-2*f) 0 — предмет перед зеркалом' }, + { c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" }, + { c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" }, + ]; + const bx=12, lh=14, by=this.H - items.length*lh - 16; + ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top'; + items.forEach(({ c, t }, i) => { + const y = by+i*lh; + ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8); + ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y); + }); + ctx.restore(); + } + + _drawPhotons(ctx) { + for (const p of this._photons) { + const pos = this._photonPos(p.pts, p.t); + if (!pos) continue; + ctx.save(); ctx.shadowColor = p.color; ctx.shadowBlur = 8; + ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill(); + ctx.restore(); + } + } + + _photonPos(pts, t) { + if (pts.length < 2) return null; + let total = 0; + const lens = []; + for (let i=1; i { if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; }; + if (isFinite(f)) { + chk(mx-f, ay, 'Главный фокус F', 'f = ' + Math.abs(f).toFixed(0)); + chk(mx-2*f, ay, 'Центр кривизны C', 'R = 2f = ' + (2*Math.abs(f)).toFixed(0)); + } + chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', 'd = ' + this.d.toFixed(0) + ', h = ' + this.h.toFixed(0)); + if (dPrime !== null && isFinite(dPrime)) { + const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime); + chk(ix, iy, 'Изображение', "d' = " + Math.abs(dPrime).toFixed(0) + ', M = ' + this.info().M); + } + if (!tip) return; + ctx.save(); + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; + const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width); + const bw=tw+20, bh=34; + let tx=hx+14, ty=hy-bh-6; + if (tx+bw > this.W-4) tx = hx-bw-14; + if (ty < 4) ty = hy+10; + ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke(); + ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText(tip.lbl, tx+10, ty+6); + ctx.font='10px Manrope, system-ui, sans-serif'; + ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20); + ctx.restore(); + } + + _drawStepOverlay(ctx, step) { + const lbls = [ + '① Луч параллельно оси → отражается через F', + '② Луч через вершину → отражается симметрично', + '③ Луч через F → отражается параллельно', + ' Изображение — пересечение всех отражённых лучей', + ]; + const text = lbls[Math.min(step, lbls.length-1)]; + ctx.save(); ctx.font = '11px Manrope, system-ui, sans-serif'; + const tw = ctx.measureText(text).width; + const bx = this.W/2-tw/2-12, by = this.H-34; + ctx.fillStyle='rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill(); + ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText(text, this.W/2, by+12); + ctx.restore(); + } + + _bindEvents() { + const cv = this.canvas; + const getPos = e => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { px: (t.clientX-r.left)*(this.W/r.width), py: (t.clientY-r.top)*(this.H/r.height) }; + }; + const mX = () => Math.round(this.W*0.62); + const aY = () => this.H/2; + const hitTest = (px, py) => { + if (this._playing) return null; + const mx=mX(), ay=aY(), f=this._fSigned(); + if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object'; + if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus'; + const info = this.info(); + if (info.dPrime !== Infinity && isFinite(info.dPrime)) { + const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0)); + if (Math.hypot(px-ix, py-iy) < 18) return 'image'; + } + return null; + }; + cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); }); + window.addEventListener('mousemove', e => { + const {px,py} = getPos(e); + this._hoverX = px; this._hoverY = py; + if (this._drag) { + if (e.cancelable) e.preventDefault(); + const mx=mX(), f=this._fSigned(); + if (this._drag === 'object') this.d = Math.max(30, Math.min(490, mx-px)); + else if (this._drag === 'focus') this.f = Math.max(30, Math.min(300, Math.abs(mx-px))); + else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') { + const dp = mx-px; if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f))); + } + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + } else if (!this._photonRaf && !this._playing) { this.draw(); } + }); + window.addEventListener('mouseup', () => { this._drag = null; }); + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor='grabbing'; return; } + const {px,py}=getPos(e); + cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default'; + }); + cv.addEventListener('touchstart', e => { + if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); } + }, { passive: true }); + cv.addEventListener('touchmove', e => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const {px}=getPos(e), mx=mX(), f=this._fSigned(); + if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px)); + else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px))); + else if (this._drag==='image' && isFinite(f) && this.type!=='flat') { + const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f))); + } + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + }, { passive: false }); + cv.addEventListener('touchend', () => { this._drag=null; }); + } +} + +/* ───────────────────────────────────────────────────────────── + 3. REFRACTION ENGINE (from refraction.js) +───────────────────────────────────────────────────────────────*/ +class RefractionSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.n1 = 1.0; + this.n2 = 1.5; + this.angle = 30; + this.dispersion = false; + + this._drag = false; + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + 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 }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + const midY = H / 2, hitX = W / 2, hitY = midY; + 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); + 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); + 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(); + 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([]); + const theta1Rad = this.angle * Math.PI / 180; + const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad); + const isTIR = Math.abs(sinTheta2) > 1; + let R = 1; + if (!isTIR) { + const theta2Rad = Math.asin(sinTheta2); + const cosT1 = Math.cos(theta1Rad), cosT2 = Math.cos(theta2Rad); + const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2); + R = rs * rs; + } + const rayLen = Math.max(W, H) * 0.6; + if (this.n1 > this.n2) { + const critRad = Math.asin(this.n2 / this.n1); + const critDx = Math.sin(critRad), critDy = Math.cos(critRad); + ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke(); + ctx.setLineDash([]); + ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', hitX - critDx * rayLen * 0.35 + 6, hitY - critDy * rayLen * 0.35); + } + if (this.dispersion && !isTIR) this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen); + else this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen); + this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR); + this._drawMediumLabels(ctx, W, H, midY); + this._drawInfoBox(ctx, isTIR, R); + const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad); + const handleX = hitX - incDx * rayLen * 0.55, 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), incDy = Math.cos(theta1Rad); + const incStartX = hitX - incDx * rayLen, 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'); + const refDx = incDx, refDy = -incDy; + const refEndX = hitX + refDx * rayLen, refEndY = hitY + refDy * rayLen; + 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; + if (!isTIR) { + const theta2Rad = Math.asin(sinTheta2); + const refracDx = Math.sin(theta2Rad), refracDy = Math.cos(theta2Rad); + const refracEndX = hitX + refracDx * rayLen, 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) { + const spectral = [ + { color: '#FF0000', wave: 656 }, { color: '#FF7F00', wave: 589 }, { color: '#FFFF00', wave: 550 }, + { color: '#00FF00', wave: 510 }, { color: '#00FFFF', wave: 475 }, { color: '#0000FF', wave: 450 }, + { color: '#8B00FF', wave: 400 }, + ]; + const incDx = Math.sin(theta1Rad), incDy = Math.cos(theta1Rad); + const incStartX = hitX - incDx * rayLen, 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'); + const A = this.n2 - 4500 / (550 * 550), 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), dx = Math.sin(t2), 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; + } + const refDx = incDx, 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(); + 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, font = '12px Manrope, system-ui, sans-serif'; + if (this.angle > 1) { + ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5; + ctx.beginPath(); + const normAngle = -Math.PI / 2, incAngle = -Math.PI / 2 - theta1Rad; + ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke(); + 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)); + } + 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, refAngle = Math.PI / 2 + theta2Rad; + ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke(); + 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'; + ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left'; + ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30); + ctx.fillStyle = 'rgba(6,214,224,0.6)'; + ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30); + 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, 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)'; + ctx.fillText('θ₁ = ' + info.angle1 + '° θ₂ = ' + (info.isTIR ? 'ПВО' : info.angle2 + '°'), bx + 10, by + 28); + const rPct = (R * 100).toFixed(1), 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); } + } + + _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 hitX = this.W / 2, hitY = this.H / 2; + if (my >= hitY) return false; + const dist = Math.hypot(mx - hitX, my - hitY); + return dist > 20 && dist < Math.max(this.W, this.H) * 0.6; + }; + const angleFromMouse = (mx, my) => { + const hitX = this.W / 2, hitY = this.H / 2; + const dx = mx - hitX, dy = hitY - my; + return Math.max(0, Math.min(89, Math.atan2(Math.abs(dx), dy) * 180 / Math.PI)); + }; + 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; }; + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + 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); + 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'; + }); + } +} + +/* ───────────────────────────────────────────────────────────── + 4. LAB UI INIT — Оптическая скамья +───────────────────────────────────────────────────────────────*/ + +var lensSim = null; +var mirrorSim = null; +var refrSim = null; +var _obMode = 'lens'; // current active mode within opticsbench + +/* Open opticsbench, optionally setting a mode ('lens'|'mirror'|'refraction') */ +function _openOpticsBench(mode) { + mode = mode || 'lens'; + _obMode = mode; + document.getElementById('sim-topbar-title').textContent = 'Оптическая скамья'; + _simShow('sim-opticsbench'); + _registerSimState('opticsbench', () => _obGetState(), st => _obApplyState(st)); + if (_embedMode) _startStateEmit('opticsbench'); + requestAnimationFrame(() => requestAnimationFrame(() => { + obSwitchMode(mode, true); + })); +} + +function _obGetState() { + if (_obMode === 'lens') return { mode: 'lens', ...(lensSim ? lensSim.getParams() : {}) }; + if (_obMode === 'mirror') return { mode: 'mirror', ...(mirrorSim ? mirrorSim.getParams() : {}) }; + if (_obMode === 'refraction') return { mode: 'refraction', ...(refrSim ? refrSim.getParams() : {}) }; + return { mode: _obMode }; +} + +function _obApplyState(st) { + if (!st) return; + const m = st.mode || _obMode; + obSwitchMode(m, true); + const { mode: _m, ...params } = st; + if (m === 'lens' && lensSim) lensSim.setParams(params); + if (m === 'mirror' && mirrorSim) mirrorSim.setParams(params); + if (m === 'refraction' && refrSim) refrSim.setParams(params); +} + +/* Switch between modes — mirrors emSwitchMode pattern */ +function obSwitchMode(mode, silent) { + _obMode = mode; + + /* tab button styling */ + ['lens', 'mirror', 'refraction'].forEach(m => { + const btn = document.getElementById('ob-tab-' + m); + if (btn) btn.classList.toggle('active', m === mode); + }); + + /* show/hide per-mode control panels */ + ['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + const activeCtrl = document.getElementById('ob-ctrl-' + mode); + if (activeCtrl) activeCtrl.style.display = ''; + + /* show/hide stats bar sections */ + ['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + const statsSuffix = mode === 'refraction' ? 'refr' : mode; + const activeStats = document.getElementById('ob-stats-' + statsSuffix); + if (activeStats) activeStats.style.display = 'flex'; + + /* init engine if not yet done, then (re-)draw */ + if (mode === 'lens') { + if (!lensSim) { + const cv = document.getElementById('ob-lens-canvas'); + lensSim = new ThinLensSim(cv); + lensSim.onUpdate = _lensUpdateUI; + } + lensSim.fit(); lensSim.draw(); lensSim._emit(); + document.getElementById('ob-lens-canvas').style.display = ''; + document.getElementById('ob-mirror-canvas').style.display = 'none'; + document.getElementById('ob-refr-canvas').style.display = 'none'; + } else if (mode === 'mirror') { + if (!mirrorSim) { + const cv = document.getElementById('ob-mirror-canvas'); + mirrorSim = new MirrorSim(cv); + mirrorSim.onUpdate = _mirrorUpdateUI; + mirrorSim.onAnimate = (d) => { + const sl = document.getElementById('sl-mirror-d'); + const lbl = document.getElementById('mirror-d-val'); + if (sl) sl.value = Math.round(d); + if (lbl) lbl.textContent = Math.round(d); + }; + } + mirrorSim.fit(); mirrorSim.draw(); mirrorSim._emit(); + if (mirrorSim._showPhotons && !mirrorSim._photonRaf) mirrorSim._startPhotons(); + document.getElementById('ob-lens-canvas').style.display = 'none'; + document.getElementById('ob-mirror-canvas').style.display = ''; + document.getElementById('ob-refr-canvas').style.display = 'none'; + } else if (mode === 'refraction') { + if (!refrSim) { + const cv = document.getElementById('ob-refr-canvas'); + refrSim = new RefractionSim(cv); + refrSim.onUpdate = _refrUpdateUI; + } + refrSim.fit(); refrSim.draw(); refrSim._emit(); + document.getElementById('ob-lens-canvas').style.display = 'none'; + document.getElementById('ob-mirror-canvas').style.display = 'none'; + document.getElementById('ob-refr-canvas').style.display = ''; + } +} + +/* ── Thin Lens controls ── */ +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); +} + +/* ── Mirror controls ── */ +function mirrorType(type, el) { + document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); + if (el) el.classList.add('active'); + const fRow = document.getElementById('mirror-f-row'); + if (fRow) fRow.style.display = type === 'flat' ? 'none' : 'flex'; + if (mirrorSim) mirrorSim.setType(type); + const pb = document.getElementById('mirror-play-btn'); + if (pb) { pb.textContent = 'Анимация'; } + const sl = document.getElementById('sl-mirror-d'); + if (sl) sl.disabled = false; +} + +function mirrorParam(name, val) { + const v = parseFloat(val); + const ids = { f: 'mirror-f-val', d: 'mirror-d-val', h: 'mirror-h-val' }; + const el = document.getElementById(ids[name]); + if (el) el.textContent = v; + if (mirrorSim) mirrorSim.setParams({ [name]: v }); +} + +function mirrorPreset(name) { + const P = { + flat: { type: 'flat', f: 120, d: 200, h: 60 }, + far: { type: 'concave', f: 100, d: 280, h: 60 }, + '2f': { type: 'concave', f: 100, d: 200, h: 60 }, + between: { type: 'concave', f: 100, d: 140, h: 60 }, + near: { type: 'concave', f: 100, d: 60, h: 60 }, + convex: { type: 'convex', f: 100, d: 200, h: 60 }, + }; + const p = P[name]; if (!p) return; + document.querySelectorAll('.mirror-type-btn').forEach(b => b.classList.remove('active')); + const tb = document.getElementById('mtype-' + p.type); + if (tb) tb.classList.add('active'); + const fRow = document.getElementById('mirror-f-row'); + if (fRow) fRow.style.display = p.type === 'flat' ? 'none' : 'flex'; + document.getElementById('sl-mirror-f').value = p.f; document.getElementById('mirror-f-val').textContent = p.f; + document.getElementById('sl-mirror-d').value = p.d; document.getElementById('mirror-d-val').textContent = p.d; + document.getElementById('sl-mirror-h').value = p.h; document.getElementById('mirror-h-val').textContent = p.h; + if (mirrorSim) { mirrorSim.setType(p.type); mirrorSim.setParams({ f: p.f, d: p.d, h: p.h }); } +} + +function mirrorTogglePlay(btn) { + if (!mirrorSim) return; + mirrorSim.togglePlay(); + const playing = mirrorSim._playing; + if (btn) btn.textContent = playing ? 'Стоп' : 'Анимация'; + const sl = document.getElementById('sl-mirror-d'); + if (sl) sl.disabled = playing; +} + +function mirrorSetSpeed(val) { if (mirrorSim) mirrorSim.setAnimSpeed(parseFloat(val)); } +function mirrorToggle(name, val) { if (mirrorSim) mirrorSim.setToggle(name, val); } +function mirrorStepNext() { if (mirrorSim) mirrorSim.stepNext(); } +function mirrorStepReset() { if (mirrorSim) mirrorSim.stepReset(); } +function mirrorSetPointMode(val) { if (mirrorSim) mirrorSim.setPointMode(val); } + +function _mirrorUpdateUI(info) { + const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; + v('mirrorbar-v1', info.f); + v('mirrorbar-v5', Math.round(info.d)); + v('mirrorbar-v2', info.dPrime === Infinity ? '∞' : info.dPrime); + v('mirrorbar-v3', info.M === Infinity ? '∞' : info.M); + v('mirrorbar-v4', info.imageType); +} + +/* ── Refraction controls ── */ +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 ? 'Да' : 'Нет'); +} + +/* ── dispersion toggle ── */ +function refrDispersion(on) { + if (refrSim) refrSim.setParams({ dispersion: on }); +} diff --git a/frontend/js/labs/radioactive.js b/frontend/js/labs/radioactive.js new file mode 100644 index 0000000..98bc8fa --- /dev/null +++ b/frontend/js/labs/radioactive.js @@ -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) + '% осталось'; +} diff --git a/frontend/js/labs/refraction.js b/frontend/js/labs/refraction.js deleted file mode 100644 index f503c43..0000000 --- a/frontend/js/labs/refraction.js +++ /dev/null @@ -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 ── */ - diff --git a/frontend/js/labs/stoichiometry.js b/frontend/js/labs/stoichiometry.js new file mode 100644 index 0000000..46f1fe6 --- /dev/null +++ b/frontend/js/labs/stoichiometry.js @@ -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(); + } + })); +} diff --git a/frontend/js/labs/thinlens.js b/frontend/js/labs/thinlens.js deleted file mode 100644 index 3986102..0000000 --- a/frontend/js/labs/thinlens.js +++ /dev/null @@ -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 through F' (converging) or from F' (diverging) */ - { - ctx.strokeStyle = colors[0]; - ctx.setLineDash([]); - /* incoming: object tip 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 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 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 ── */ - diff --git a/frontend/lab.html b/frontend/lab.html index cc74183..44fb6ab 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -328,6 +328,16 @@ + + + + + + + + + + + + + + + + +
Пресеты
+
+ + + + + + + +
+ + + +
+ Клик = добавить элемент
+ Перетащи выход (кружок) на вход
+ 2×клик по INPUT — переключить 0/1
+ ПКМ — удалить  |  Ctrl+Z отмена +
+ + + +
+ +
+ + +
+
Выражение
+
+ Добавьте OUTPUT для вывода выражения +
+
+ + + + +
+
+ Таблица истинности + +
+
+ Добавьте INPUT и OUTPUT +
+
+ + + + + @@ -2419,10 +2508,18 @@ - -