6afe928c0d
ФУНДАМЕНТ (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>
898 lines
27 KiB
JavaScript
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 ── */
|