LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
'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;
|
||||
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 <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> stroma
|
||||
this._atpTimer += dt;
|
||||
if (this._atpTimer > 400 / (rate + 0.1)) {
|
||||
this._atpTimer = 0;
|
||||
if (L > 0.05) this._spawnATP();
|
||||
}
|
||||
|
||||
// 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 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();
|
||||
}
|
||||
|
||||
/* ── 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();
|
||||
}
|
||||
Reference in New Issue
Block a user