'use strict'; /* ════════════════════════════════════════════════════════════════ PhotosynthesisSim — Фотосинтез и клеточное дыхание Световые реакции · цикл Кальвина · митохондриальное дыхание Молекулярная анимация · частицы · статистика ════════════════════════════════════════════════════════════════ */ class PhotosynthesisSim { static C = { bg: '#0a0e14', // хлоропласт chlorBg: 'rgba(34,211,153,0.07)', chlorStroke: 'rgba(34,211,153,0.5)', thylBg: 'rgba(34,211,153,0.18)', thylStroke: 'rgba(34,211,153,0.6)', stroma: 'rgba(34,211,153,0.04)', // митохондрия mitoBg: 'rgba(239,71,111,0.08)', mitoStroke: 'rgba(239,71,111,0.5)', cristaeBg: 'rgba(239,71,111,0.18)', cristaeStroke:'rgba(239,71,111,0.55)', matrix: 'rgba(239,71,111,0.04)', // молекулы photon: '#FFD166', water: '#06D6E0', co2: '#EF476F', o2: '#4CC9F0', atp: '#9B5DE5', nadph: '#7BF5A4', g3p: '#22d399', glucose: '#FFD166', pyruvate: '#FF6B35', electron: '#4CC9F0', // text label: 'rgba(255,255,255,0.35)', labelBright: 'rgba(255,255,255,0.8)', }; constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.mode = 'photo'; // 'photo' | 'resp' this._light = 70; // 0..100 this._co2 = 50; // 0..100 this._particles = []; this._time = 0; this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 }; this._atpAccum = 0; this._atpSmoothR = 0; // spawn timers this._photonTimer = 0; this._waterTimer = 0; this._co2Timer = 0; this._glucoseTimer = 0; this._atpTimer = 0; this._pyrTimer = 0; this._krebsAngle = 0; this._etcOffset = 0; // LabFX throttle timers this._fxPhotonThrottle = 0; this._fxAtpSound = 0; this._fxGlucoseSound = 0; // layout (computed in fit) this._layout = {}; this._raf = null; this._last = 0; this.W = 0; this.H = 0; this.onUpdate = null; this.fit(); } /* ── Lifecycle ────────────────────────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; const W = this.canvas.offsetWidth || 700; const H = this.canvas.offsetHeight || 440; this.canvas.width = Math.round(W * dpr); this.canvas.height = Math.round(H * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = W; this.H = H; this._calcLayout(); if (!this._raf) this._draw(); } start() { if (this._raf) return; this._last = performance.now(); const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; this._raf = requestAnimationFrame(loop); } stop() { cancelAnimationFrame(this._raf); this._raf = null; } reset() { this._particles = []; this._time = 0; this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 }; this._atpAccum = 0; this._atpSmoothR = 0; if (!this._raf) this._draw(); this._emitUpdate(); } setMode(mode) { this.mode = mode; if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 }); this.reset(); } setLightIntensity(v) { this._light = v; } setCO2(v) { this._co2 = v; } /* ── Layout ───────────────────────────────────────────────── */ _calcLayout() { const { W, H } = this; const cx = W / 2, cy = H / 2; const ow = Math.min(W * 0.82, 560), oh = Math.min(H * 0.72, 300); if (this.mode === 'photo' || this.mode !== 'resp') { // chloroplast outer this._layout = { cx, cy, outerRx: ow / 2, outerRy: oh / 2, // thylakoid band (horizontal, middle third) thylY: cy - oh * 0.06, thylH: oh * 0.28, thylX1: cx - ow * 0.4, thylX2: cx + ow * 0.4, // stroma top / bottom stromaTopY: cy - oh / 2, stromaBotY: cy + oh / 2, // label positions thylLabelY: cy + oh * 0.08, stromaLabelY: cy - oh * 0.34, }; } else { // mitochondria this._layout = { cx, cy, outerRx: ow / 2, outerRy: oh / 2, // inner membrane (cristae zone: inner 60% of organelle) innerRx: ow * 0.3, innerRy: oh * 0.3, // zones matrixCx: cx + ow * 0.12, cytoCx: cx - ow * 0.32, etcY: cy, }; } } /* ── Tick ─────────────────────────────────────────────────── */ _tick(t) { const dt = Math.min(t - this._last, 80); this._last = t; this._time += dt; if (this.mode === 'photo') { this._updatePhoto(dt); } else { this._updateResp(dt); } if (window.LabFX) LabFX.particles.update(dt); this._updateParticles(dt); this._draw(); this._emitUpdate(); } /* ── Photosynthesis update ────────────────────────────────── */ _updatePhoto(dt) { const L = this._light / 100; const CO = this._co2 / 100; const rate = L * 0.8 + 0.2; // min rate even at low light // фотоны (rain from top) this._photonTimer += dt; const photonInterval = 300 / (L * 3 + 0.5); while (this._photonTimer > photonInterval) { this._photonTimer -= photonInterval; this._spawnPhoton(); // throttled photon absorption sound (~5/sec max) this._fxPhotonThrottle += photonInterval; if (this._fxPhotonThrottle >= 200 && window.LabFX) { LabFX.sound.play('tick', { pitch: 1.8, volume: 0.1 }); this._fxPhotonThrottle = 0; } } // H2O splitting (thylakoid) this._waterTimer += dt; if (this._waterTimer > 600 / (rate + 0.2)) { this._waterTimer = 0; if (L > 0.1) this._spawnWaterSplit(); } // CO2 into stroma this._co2Timer += dt; if (this._co2Timer > 500 / (CO * 2 + 0.3)) { this._co2Timer = 0; if (CO > 0.05) this._spawnCO2(); } // ATP from thylakoid → stroma this._atpTimer += dt; if (this._atpTimer > 400 / (rate + 0.1)) { this._atpTimer = 0; if (L > 0.05) { this._spawnATP(); // subtle chime on ATP formation (throttled) this._fxAtpSound += 400 / (rate + 0.1); if (this._fxAtpSound >= 1200 && window.LabFX) { LabFX.sound.play('chime', { pitch: 1.2, volume: 0.15 }); this._fxAtpSound = 0; } } } // G3P output this._glucoseTimer += dt; if (this._glucoseTimer > 800 / (rate * CO + 0.1)) { this._glucoseTimer = 0; if (L > 0.1 && CO > 0.05) { this._spawnG3P(); // Calvin cycle complete — glucose sparkle + chime (throttled) this._fxGlucoseSound = (this._fxGlucoseSound || 0) + 1; if (this._fxGlucoseSound >= 3 && window.LabFX) { this._fxGlucoseSound = 0; LabFX.sound.play('chime', { pitch: 0.8, volume: 0.3 }); const L2 = this._layout; if (L2.cx) { LabFX.particles.emit({ ctx: this.ctx, x: L2.cx, y: L2.cy - (L2.thylH || 0) * 0.8, count: 8, color: '#FFD166', speed: 28, spread: Math.PI * 2, angle: 0, gravity: -8, life: 700, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true }); } } } } // Calvin cycle rotation this._krebsAngle += dt * 0.0004 * rate; // stats const atpR = L * CO * 18; this._atpSmoothR += (atpR - this._atpSmoothR) * 0.05; this._atpAccum += atpR * dt / 1000; this._stats.atpRate = this._atpSmoothR; this._stats.atp = this._atpAccum; this._stats.o2 += L * 0.4 * dt / 1000; this._stats.co2Out = CO; this._stats.efficiency = Math.round(L * CO * 100 * 0.38); } /* ── Respiration update ───────────────────────────────────── */ _updateResp(dt) { const rate = 0.8; // glucose pyruvate (glycolysis) this._glucoseTimer += dt; if (this._glucoseTimer > 900) { this._glucoseTimer = 0; this._spawnGlucose(); } // pyruvate krebs cycle this._pyrTimer += dt; if (this._pyrTimer > 600) { this._pyrTimer = 0; this._spawnPyruvate(); } // ATP bursts (ETC) this._atpTimer += dt; if (this._atpTimer > 350) { this._atpTimer = 0; this._spawnATPResp(); } // CO2 from krebs this._co2Timer += dt; if (this._co2Timer > 500) { this._co2Timer = 0; this._spawnCO2Resp(); } // electron flow along ETC this._etcOffset = (this._etcOffset + dt * 0.0008) % 1; this._krebsAngle += dt * 0.0005; // stats const atpR = 38 * 0.5; this._atpSmoothR += (atpR - this._atpSmoothR) * 0.04; this._atpAccum += atpR * dt / 1000; this._stats.atpRate = this._atpSmoothR; this._stats.atp = this._atpAccum; this._stats.o2 += 0.3 * dt / 1000; this._stats.co2Out += 0.5 * dt / 1000; this._stats.efficiency = 38; } /* ── Particle spawners ────────────────────────────────────── */ _spawnPhoton() { const L = this._layout; const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1); this._particles.push({ type: 'photon', x, y: this.H * 0.02, vx: (Math.random() - 0.5) * 15, vy: 60 + Math.random() * 30, life: 1, maxLife: 1, targetY: L.thylY - L.thylH / 2, }); } _spawnWaterSplit() { const L = this._layout; const x = L.cx - L.outerRx * 0.35 + Math.random() * L.outerRx * 0.15; const y = L.thylY; // O2 bubbles rise for (let i = 0; i < 2; i++) { this._particles.push({ type: 'o2', x: x + i * 12, y, vx: (Math.random() - 0.5) * 20, vy: -(40 + Math.random() * 30), life: 1, maxLife: 1, }); } } _spawnCO2() { const L = this._layout; const x = L.cx + L.outerRx * 0.3; const y = L.stromaTopY + Math.random() * (L.cy - L.stromaTopY); this._particles.push({ type: 'co2', x, y, vx: -(30 + Math.random() * 20), vy: (Math.random() - 0.5) * 15, life: 1, maxLife: 1, }); } _spawnATP() { const L = this._layout; const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1); const y = L.thylY - L.thylH * 0.1; this._particles.push({ type: 'atp', x, y, vx: (Math.random() - 0.5) * 25, vy: -(35 + Math.random() * 25), life: 1, maxLife: 1, }); } _spawnG3P() { const L = this._layout; const angle = Math.random() * Math.PI * 2; const r = 30 + Math.random() * 20; this._particles.push({ type: 'g3p', x: L.cx + Math.cos(angle) * r, y: L.cy - L.thylH * 0.8 + Math.sin(angle) * r * 0.5, vx: (Math.random() - 0.5) * 20, vy: -(20 + Math.random() * 15), life: 1, maxLife: 1, }); } _spawnGlucose() { const L = this._layout; this._particles.push({ type: 'glucose', x: L.cx - L.outerRx * 0.6, y: L.cy + (Math.random() - 0.5) * 40, vx: 35, vy: (Math.random() - 0.5) * 10, life: 1, maxLife: 1, }); } _spawnPyruvate() { const L = this._layout; this._particles.push({ type: 'pyruvate', x: L.cx - 20, y: L.cy + (Math.random() - 0.5) * 30, vx: 20, vy: (Math.random() - 0.5) * 15, life: 1, maxLife: 1, }); } _spawnATPResp() { const L = this._layout; const angle = this._krebsAngle + Math.random() * 0.5; const r = L.innerRx * 0.7; this._particles.push({ type: 'atp', x: L.cx + Math.cos(angle) * r, y: L.cy + Math.sin(angle) * r * 0.75, vx: (Math.random() - 0.5) * 30, vy: -(25 + Math.random() * 20), life: 1, maxLife: 1, }); } _spawnCO2Resp() { const L = this._layout; const angle = this._krebsAngle + Math.PI * (0.5 + Math.random() * 0.5); const r = L.innerRx * 0.5; this._particles.push({ type: 'co2', x: L.cx + Math.cos(angle) * r, y: L.cy + Math.sin(angle) * r * 0.75, vx: (Math.random() - 0.5) * 20, vy: -(30 + Math.random() * 20), life: 1, maxLife: 1, }); } /* ── Particle update ──────────────────────────────────────── */ _updateParticles(dt) { const s = dt / 1000; for (const p of this._particles) { if (p.targetY !== undefined && p.y > p.targetY) { p.y += p.vy * s; p.x += p.vx * s; } else { p.x += p.vx * s; p.y += p.vy * s; p.life -= s * (0.5 + Math.random() * 0.3); } if (p.type === 'photon' && p.y >= (p.targetY || 0)) { p.targetY = undefined; p.vy = -15; p.vx = (Math.random() - 0.5) * 30; p.life -= 0.4; } } // cap at 120 particles this._particles = this._particles.filter(p => p.life > 0).slice(-120); } /* ── Draw ─────────────────────────────────────────────────── */ _draw() { const { ctx, W, H } = this; ctx.clearRect(0, 0, W, H); ctx.fillStyle = PhotosynthesisSim.C.bg; ctx.fillRect(0, 0, W, H); if (this.mode === 'photo') { this._drawChloroplast(); } else { this._drawMitochondria(); } this._drawParticles(); this._drawEquation(); if (window.LabFX) LabFX.particles.draw(this.ctx); } /* ── Chloroplast ──────────────────────────────────────────── */ _drawChloroplast() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; if (!L.outerRx) return; // outer envelope ctx.beginPath(); ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2); ctx.fillStyle = C.chlorBg; ctx.fill(); ctx.strokeStyle = C.chlorStroke; ctx.lineWidth = 2.5; ctx.stroke(); // stroma label ctx.save(); ctx.font = '11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(34,211,153,0.45)'; ctx.textAlign = 'center'; ctx.fillText('Строма (цикл Кальвина)', L.cx, L.stromaTopY + 22); ctx.restore(); // thylakoid membrane band const tY = L.thylY, tH = L.thylH; const tX1 = L.thylX1, tX2 = L.thylX2; const tW = tX2 - tX1; ctx.beginPath(); _psRRect(ctx, tX1, tY - tH / 2, tW, tH, tH / 2); ctx.fillStyle = C.thylBg; ctx.fill(); ctx.strokeStyle = C.thylStroke; ctx.lineWidth = 2; ctx.stroke(); // thylakoid label ctx.save(); ctx.font = '11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(34,211,153,0.6)'; ctx.textAlign = 'center'; ctx.fillText('Тилакоид (световые реакции)', L.cx, L.thylY + tH / 2 + 16); ctx.restore(); // Calvin cycle rotating wheel in stroma this._drawCalvinCycle(); // light arrows this._drawLightArrows(); } _drawCalvinCycle() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; const cx = L.cx, cy = L.cy - L.thylH * 0.85; const r = Math.min(L.outerRy * 0.28, 42); const a = this._krebsAngle; ctx.save(); ctx.globalAlpha = 0.6; // circle arrow (rotating) ctx.beginPath(); ctx.arc(cx, cy, r, a, a + Math.PI * 1.7); ctx.strokeStyle = C.g3p; ctx.lineWidth = 2.5; ctx.stroke(); // arrowhead const ex = cx + Math.cos(a + Math.PI * 1.7) * r; const ey = cy + Math.sin(a + Math.PI * 1.7) * r; const da = 0.4; ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 - da) * 8, ey - Math.sin(a + Math.PI * 1.7 - da) * 8); ctx.moveTo(ex, ey); ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 + da) * 8, ey - Math.sin(a + Math.PI * 1.7 + da) * 8); ctx.strokeStyle = C.g3p; ctx.lineWidth = 2; ctx.stroke(); // center label ctx.globalAlpha = 0.55; ctx.font = 'bold 10px Manrope,sans-serif'; ctx.fillStyle = C.g3p; ctx.textAlign = 'center'; ctx.fillText('цикл', cx, cy - 2); ctx.fillText('Кальвина', cx, cy + 11); ctx.restore(); } _drawLightArrows() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; const tX1 = L.thylX1, tX2 = L.thylX2; const topY = L.stromaTopY + 8; const botY = L.thylY - L.thylH / 2; const L_norm = this._light / 100; ctx.save(); ctx.globalAlpha = 0.25 + L_norm * 0.55; const n = 5; for (let i = 0; i < n; i++) { const x = tX1 + (i + 0.5) / n * (tX2 - tX1); ctx.beginPath(); ctx.moveTo(x, topY); ctx.lineTo(x, botY - 4); ctx.strokeStyle = C.photon; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); ctx.stroke(); ctx.setLineDash([]); // arrowhead ctx.beginPath(); ctx.moveTo(x, botY - 4); ctx.lineTo(x - 5, botY - 14); ctx.moveTo(x, botY - 4); ctx.lineTo(x + 5, botY - 14); ctx.strokeStyle = C.photon; ctx.lineWidth = 1.5; ctx.stroke(); // sun dot ctx.beginPath(); ctx.arc(x, topY - 5, 4, 0, Math.PI * 2); ctx.fillStyle = C.photon; ctx.fill(); } ctx.restore(); } /* ── Mitochondria ─────────────────────────────────────────── */ _drawMitochondria() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; if (!L.outerRx) return; // outer membrane ctx.beginPath(); ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2); ctx.fillStyle = C.mitoBg; ctx.fill(); ctx.strokeStyle = C.mitoStroke; ctx.lineWidth = 2.5; ctx.stroke(); // inner membrane / cristae (zigzag folds) this._drawCristae(); // zone labels ctx.save(); ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(239,71,111,0.5)'; ctx.fillText('Матрикс (цикл Кребса)', L.cx + L.innerRx * 0.1, L.cy + 12); ctx.fillStyle = 'rgba(239,71,111,0.35)'; ctx.fillText('Цитоплазма (гликолиз)', L.cx - L.outerRx * 0.62, L.cy); ctx.restore(); // Krebs cycle arrow this._drawKrebsWheel(); // ETC along inner membrane this._drawETC(); } _drawCristae() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; const iRx = L.innerRx || 110, iRy = L.innerRy || 80; ctx.beginPath(); ctx.ellipse(L.cx, L.cy, iRx, iRy, 0, 0, Math.PI * 2); ctx.fillStyle = C.cristaeBg; ctx.fill(); ctx.strokeStyle = C.cristaeStroke; ctx.lineWidth = 1.8; ctx.stroke(); // cristae folds (vertical zigzag lines inside) ctx.save(); ctx.globalAlpha = 0.45; ctx.strokeStyle = C.cristaeStroke; ctx.lineWidth = 1.5; for (let i = -2; i <= 2; i++) { const x = L.cx + i * iRx * 0.32; const h = Math.sqrt(Math.max(0, 1 - (i * 0.32) ** 2)) * iRy * 0.7; ctx.beginPath(); ctx.moveTo(x, L.cy - h); ctx.bezierCurveTo(x - 12, L.cy - h * 0.3, x + 12, L.cy + h * 0.3, x, L.cy + h); ctx.stroke(); } ctx.restore(); } _drawKrebsWheel() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; const cx = L.cx, cy = L.cy; const r = (L.innerRx || 110) * 0.42; const a = this._krebsAngle; ctx.save(); ctx.globalAlpha = 0.55; ctx.beginPath(); ctx.arc(cx, cy, r, a, a + Math.PI * 1.65); ctx.strokeStyle = C.pyruvate; ctx.lineWidth = 2.5; ctx.stroke(); // arrowhead const ex = cx + Math.cos(a + Math.PI * 1.65) * r; const ey = cy + Math.sin(a + Math.PI * 1.65) * r; const da = 0.45; ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 - da) * 8, ey - Math.sin(a + Math.PI * 1.65 - da) * 8); ctx.moveTo(ex, ey); ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 + da) * 8, ey - Math.sin(a + Math.PI * 1.65 + da) * 8); ctx.strokeStyle = C.pyruvate; ctx.lineWidth = 2; ctx.stroke(); ctx.globalAlpha = 0.45; ctx.font = 'bold 10px Manrope,sans-serif'; ctx.fillStyle = C.pyruvate; ctx.textAlign = 'center'; ctx.fillText('цикл', cx, cy - 3); ctx.fillText('Кребса', cx, cy + 11); ctx.restore(); } _drawETC() { const { ctx } = this; const C = PhotosynthesisSim.C; const L = this._layout; const iRx = L.innerRx || 110, iRy = L.innerRy || 80; const n = 8; ctx.save(); ctx.globalAlpha = 0.7; for (let i = 0; i < n; i++) { const frac = ((i / n) + this._etcOffset) % 1; const a = frac * Math.PI * 2 - Math.PI / 2; const x = L.cx + Math.cos(a) * iRx; const y = L.cy + Math.sin(a) * iRy; const size = 4 + 2 * Math.sin(frac * Math.PI * 4); ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fillStyle = C.electron; ctx.shadowColor = C.electron; ctx.shadowBlur = 6; ctx.fill(); ctx.shadowBlur = 0; } ctx.restore(); } /* ── Particle rendering ───────────────────────────────────── */ _drawParticles() { const { ctx } = this; const C = PhotosynthesisSim.C; const colorMap = { photon: C.photon, o2: C.o2, co2: C.co2, atp: C.atp, nadph: C.nadph, g3p: C.g3p, glucose: C.glucose, pyruvate: C.pyruvate, electron: C.electron, }; const labelMap = { photon: '*', o2: 'O₂', co2: 'CO₂', atp: 'ATP', nadph: 'NADPH', g3p: 'G3P', glucose: 'Глк', pyruvate: 'Пир', electron: 'e⁻', }; for (const p of this._particles) { const alpha = Math.min(1, p.life * 2) * 0.9; if (alpha <= 0) continue; ctx.save(); ctx.globalAlpha = alpha; const col = colorMap[p.type] || '#fff'; const lbl = labelMap[p.type] || ''; // glow ctx.beginPath(); ctx.arc(p.x, p.y, 9, 0, Math.PI * 2); ctx.fillStyle = col + '28'; ctx.fill(); // circle ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fillStyle = col; ctx.fill(); // label ctx.font = 'bold 8px Manrope,sans-serif'; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(lbl, p.x, p.y + 18); ctx.restore(); } } /* ── Equation footer ──────────────────────────────────────── */ _drawEquation() { const { ctx, W, H } = this; const eq = this.mode === 'photo' ? '6CO₂ + 6H₂O + свет → C₆H₁₂O₆ + 6O₂' : 'C₆H₁₂O₆ + 6O₂ → 6CO₂ + 6H₂O + 38 ATP'; ctx.save(); ctx.font = '12px Manrope,sans-serif'; const tw = ctx.measureText(eq).width; const px = W / 2 - tw / 2 - 12, py = H - 28; ctx.fillStyle = 'rgba(255,255,255,0.06)'; _psRRect(ctx, px, py - 2, tw + 24, 20, 6); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.textAlign = 'center'; ctx.fillText(eq, W / 2, py + 13); ctx.restore(); } /* ── Stats emit ───────────────────────────────────────────── */ _emitUpdate() { if (!this.onUpdate) return; this.onUpdate({ mode: this.mode, atpRate: this._stats.atpRate.toFixed(1), o2: Math.floor(this._stats.o2), co2: Math.floor(this._stats.co2Out), efficiency: this._stats.efficiency.toFixed ? this._stats.efficiency.toFixed(0) : this._stats.efficiency, light: this._light, co2Level: this._co2, }); } } /* helper */ function _psRRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /* ─── lab UI init ─────────────────────────────────── */ function _openPhotosynthesis(mode) { document.getElementById('sim-topbar-title').textContent = 'Фотосинтез и дыхание'; _simShow('sim-photosynthesis'); _simShow('ctrl-photosynthesis'); requestAnimationFrame(() => requestAnimationFrame(() => { const canvas = document.getElementById('photosyn-canvas'); if (!photosynSim) { photosynSim = new PhotosynthesisSim(canvas); photosynSim.onUpdate = _psUpdateUI; } photosynSim.fit(); photosynSim.setMode(mode || 'photo'); photosynSim.start(); })); } function psSetMode(mode, btn) { document.querySelectorAll('.ps-mode-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); if (photosynSim) photosynSim.setMode(mode); } function psLightChange() { const v = +document.getElementById('sl-ps-light').value; document.getElementById('ps-light-val').textContent = v + '%'; if (photosynSim) photosynSim.setLightIntensity(v); } function psCO2Change() { const v = +document.getElementById('sl-ps-co2').value; document.getElementById('ps-co2-val').textContent = v + '%'; if (photosynSim) photosynSim.setCO2(v); } function psReset() { if (photosynSim) photosynSim.reset(); } function _psUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('psbar-v1', info.atpRate || '0'); v('psbar-v2', info.o2 || '0'); v('psbar-v3', info.co2 || '0'); v('psbar-v4', info.efficiency ? info.efficiency + '%' : '—'); v('psbar-v5', info.mode === 'photo' ? 'Фотосинтез' : 'Дыхание'); } /* ── Angry Birds ── */