Files
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

898 lines
27 KiB
JavaScript

'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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> pyruvate (glycolysis)
this._glucoseTimer += dt;
if (this._glucoseTimer > 900) {
this._glucoseTimer = 0;
this._spawnGlucose();
}
// pyruvate <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 ── */