be4d43105e
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>
816 lines
32 KiB
JavaScript
816 lines
32 KiB
JavaScript
'use strict';
|
|
/* ════════════════════════════════════════════════════════════════
|
|
CellDivisionSim v2 — интерактивное деление клетки
|
|
Митоз и мейоз · анимация · частицы · скрабинг · клик
|
|
════════════════════════════════════════════════════════════════ */
|
|
|
|
class CellDivisionSim {
|
|
|
|
static MITOSIS_PHASES = [
|
|
{ id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 6000,
|
|
desc: 'G1+S+G2: клетка растёт, ДНК удваивается в S-периоде' },
|
|
{ id: 'prophase', label: 'Профаза', chromN: '2n = 46', dna: '4C', dur: 4500,
|
|
desc: 'Хромосомы конденсируются · ядерная оболочка разрушается · формируется веретено' },
|
|
{ id: 'metaphase', label: 'Метафаза', chromN: '2n = 46', dna: '4C', dur: 3500,
|
|
desc: 'Хромосомы на метафазной пластинке · нити веретена у кинетохор' },
|
|
{ id: 'anaphase', label: 'Анафаза', chromN: '4n = 92', dna: '4C', dur: 3000,
|
|
desc: 'Хроматиды расходятся к полюсам · клетка вытягивается' },
|
|
{ id: 'telophase', label: 'Телофаза', chromN: '2n = 46', dna: '4C', dur: 3000,
|
|
desc: 'Два ядра восстанавливаются · хромосомы деконденсируются' },
|
|
{ id: 'cytokinesis', label: 'Цитокинез', chromN: '2n = 46', dna: '2C', dur: 3500,
|
|
desc: 'Цитоплазма делится · 2 дочерних диплоидных клетки (2n = 46)' },
|
|
];
|
|
|
|
static MEIOSIS_PHASES = [
|
|
{ id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 4000,
|
|
desc: 'Репликация ДНК перед делением' },
|
|
{ id: 'prophase1', label: 'Профаза I', chromN: '2n = 46', dna: '4C', dur: 5000,
|
|
desc: 'Конъюгация гомологов · кроссинговер — рекомбинация генов' },
|
|
{ id: 'metaphase1', label: 'Метафаза I', chromN: '2n = 46', dna: '4C', dur: 3000,
|
|
desc: 'Биваленты (пары гомологов) выстраиваются по экватору' },
|
|
{ id: 'anaphase1', label: 'Анафаза I', chromN: '2n = 46', dna: '4C', dur: 3000,
|
|
desc: 'Гомологичные хромосомы расходятся к полюсам' },
|
|
{ id: 'telophase1', label: 'Телофаза I', chromN: 'n = 23', dna: '2C', dur: 2500,
|
|
desc: 'Два гаплоидных ядра · хромосомы ещё с сестринскими хроматидами' },
|
|
{ id: 'prophase2', label: 'Профаза II', chromN: 'n = 23', dna: '2C', dur: 2000,
|
|
desc: 'Без репликации ДНК · начало второго деления' },
|
|
{ id: 'metaphase2', label: 'Метафаза II', chromN: 'n = 23', dna: '2C', dur: 2500,
|
|
desc: 'Хромосомы на экваторе · нити веретена к хроматидам' },
|
|
{ id: 'anaphase2', label: 'Анафаза II', chromN: 'n = 23', dna: '2C', dur: 2500,
|
|
desc: 'Хроматиды расходятся к полюсам' },
|
|
{ id: 'telophase2', label: 'Телофаза II', chromN: 'n = 23', dna: 'C', dur: 2500,
|
|
desc: 'Четыре гаплоидных ядра формируются' },
|
|
{ id: 'cytokinesis', label: 'Цитокинез', chromN: 'n = 23', dna: 'C', dur: 3000,
|
|
desc: '4 гаплоидные клетки — гаметы (n = 23)' },
|
|
];
|
|
|
|
static C = {
|
|
bg: '#070711',
|
|
cell: 'rgba(34,211,153,0.055)',
|
|
cellStr: '#22d399',
|
|
nucFill: 'rgba(122,77,210,0.09)',
|
|
nucStr: '#9B5DE5',
|
|
chromatin:'#06D6E0',
|
|
ch: ['#EF476F','#FF9F1C','#9B5DE5','#06D6E0','#F15BB5','#7BF5A4'],
|
|
spindle: 'rgba(255,214,0,0.55)',
|
|
pole: '#FFD166',
|
|
furrow: '#22d399',
|
|
crossing: '#FFD166',
|
|
progress: '#22d399',
|
|
};
|
|
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.mode = 'mitosis';
|
|
this._phaseIdx = 0;
|
|
this._phaseT = 0;
|
|
this._autoPlay = true;
|
|
this._speed = 1.0;
|
|
this._raf = null;
|
|
this._last = 0;
|
|
this._time = 0;
|
|
this.W = 0; this.H = 0;
|
|
this.onUpdate = null;
|
|
this._chromatinDots = [];
|
|
this._particles = [];
|
|
this._draggingBar = false;
|
|
this._bindEvents();
|
|
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._genChromatinDots();
|
|
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._phaseIdx = 0;
|
|
this._phaseT = 0;
|
|
this._particles = [];
|
|
this._emitUpdate();
|
|
if (!this._raf) this._draw();
|
|
}
|
|
|
|
setMode(mode) { this.mode = mode; this.reset(); }
|
|
setSpeed(s) { this._speed = s; }
|
|
|
|
nextPhase() {
|
|
const phases = this._phases();
|
|
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
|
|
this._phaseT = 0;
|
|
this._particles = [];
|
|
this._emitUpdate();
|
|
if (!this._raf) this._draw();
|
|
}
|
|
|
|
prevPhase() {
|
|
const phases = this._phases();
|
|
this._phaseIdx = (this._phaseIdx - 1 + phases.length) % phases.length;
|
|
this._phaseT = 0;
|
|
this._particles = [];
|
|
this._emitUpdate();
|
|
if (!this._raf) this._draw();
|
|
}
|
|
|
|
jumpToPhase(idx) {
|
|
const phases = this._phases();
|
|
this._phaseIdx = Math.max(0, Math.min(phases.length - 1, idx));
|
|
this._phaseT = 0;
|
|
this._particles = [];
|
|
this._emitUpdate();
|
|
if (!this._raf) this._draw();
|
|
}
|
|
|
|
toggleAutoPlay() {
|
|
this._autoPlay = !this._autoPlay;
|
|
if (this._autoPlay && !this._raf) this.start();
|
|
return this._autoPlay;
|
|
}
|
|
|
|
info() {
|
|
const phases = this._phases();
|
|
const p = phases[this._phaseIdx];
|
|
return { phase: p.label, chromN: p.chromN, dna: p.dna,
|
|
index: this._phaseIdx, total: phases.length,
|
|
progress: this._phaseT, mode: this.mode };
|
|
}
|
|
|
|
/* ── Events ─────────────────────────────────────────────────── */
|
|
|
|
_bindEvents() {
|
|
const c = this.canvas;
|
|
const getBarT = e => {
|
|
const rect = c.getBoundingClientRect();
|
|
return Math.max(0, Math.min(1, (e.clientX - rect.left - 14) / (this.W - 28)));
|
|
};
|
|
|
|
c.addEventListener('click', e => {
|
|
const rect = c.getBoundingClientRect();
|
|
if (e.clientY - rect.top < this.H - 28) this.nextPhase();
|
|
});
|
|
|
|
c.addEventListener('mousedown', e => {
|
|
const rect = c.getBoundingClientRect();
|
|
if (e.clientY - rect.top >= this.H - 28) {
|
|
this._draggingBar = true;
|
|
this._phaseT = getBarT(e);
|
|
if (!this._raf) this._draw();
|
|
}
|
|
});
|
|
c.addEventListener('mousemove', e => {
|
|
const rect = c.getBoundingClientRect();
|
|
c.style.cursor = (e.clientY - rect.top >= this.H - 28) ? 'col-resize' : 'pointer';
|
|
if (this._draggingBar) { this._phaseT = getBarT(e); if (!this._raf) this._draw(); }
|
|
});
|
|
c.addEventListener('mouseup', () => { this._draggingBar = false; });
|
|
c.addEventListener('mouseleave', () => { this._draggingBar = false; });
|
|
|
|
c.setAttribute('tabindex', '0');
|
|
c.addEventListener('keydown', e => {
|
|
if (e.code === 'Space') { e.preventDefault(); this.toggleAutoPlay(); }
|
|
if (e.key === 'ArrowRight') { e.preventDefault(); this.nextPhase(); }
|
|
if (e.key === 'ArrowLeft') { e.preventDefault(); this.prevPhase(); }
|
|
});
|
|
}
|
|
|
|
/* ── Internals ──────────────────────────────────────────────── */
|
|
|
|
_phases() {
|
|
return this.mode === 'meiosis'
|
|
? CellDivisionSim.MEIOSIS_PHASES
|
|
: CellDivisionSim.MITOSIS_PHASES;
|
|
}
|
|
|
|
_emitUpdate() {
|
|
if (this.onUpdate) this.onUpdate(this.info());
|
|
}
|
|
|
|
_ease(t) {
|
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
}
|
|
|
|
_genChromatinDots() {
|
|
const { W, H } = this;
|
|
const cx = W / 2, cy = H / 2;
|
|
const nr = Math.min(W, H) * 0.17;
|
|
this._chromatinDots = Array.from({ length: 52 }, (_, i) => {
|
|
// stable seeded positions
|
|
const seed = i * 1337 + 42;
|
|
const a = ((seed * 9301 + 49297) % 233280) / 233280 * Math.PI * 2;
|
|
const r = ((seed * 4321 + 12345) % 233280) / 233280 * nr * 0.88;
|
|
const sz = 1.4 + ((seed * 2341 + 7777) % 233280) / 233280 * 2.5;
|
|
const ph = ((seed * 6543 + 3210) % 233280) / 233280 * Math.PI * 2;
|
|
return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.85, size: sz, phase: ph };
|
|
});
|
|
}
|
|
|
|
_spawnEnvelopeParticles(cx, cy, nucR) {
|
|
for (let i = 0; i < 32; i++) {
|
|
const a = Math.random() * Math.PI * 2;
|
|
const r = nucR * (0.88 + Math.random() * 0.18);
|
|
this._particles.push({
|
|
x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.9,
|
|
vx: Math.cos(a) * (0.8 + Math.random() * 1.8),
|
|
vy: Math.sin(a) * (0.8 + Math.random() * 1.8),
|
|
life: 1, decay: 0.016 + Math.random() * 0.018,
|
|
size: 2 + Math.random() * 3, color: '#9B5DE5',
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── Tick ───────────────────────────────────────────────────── */
|
|
|
|
_tick(t) {
|
|
const dt = Math.min(t - this._last, 80);
|
|
this._last = t;
|
|
this._time += dt;
|
|
|
|
if (this._autoPlay && !this._draggingBar) {
|
|
const phases = this._phases();
|
|
const phase = phases[this._phaseIdx];
|
|
this._phaseT += (dt / phase.dur) * this._speed;
|
|
|
|
// nuclear envelope breakdown particles
|
|
if ((phase.id === 'prophase' || phase.id === 'prophase1') &&
|
|
this._phaseT > 0.34 && this._phaseT < 0.38 && this._particles.length < 5) {
|
|
const cellR = Math.min(this.W, this.H) * 0.37;
|
|
this._spawnEnvelopeParticles(this.W / 2, this.H / 2, cellR * 0.46);
|
|
}
|
|
|
|
if (this._phaseT >= 1) {
|
|
this._phaseT = 0;
|
|
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
|
|
this._particles = [];
|
|
this._emitUpdate();
|
|
}
|
|
}
|
|
|
|
this._particles = this._particles.filter(p => {
|
|
p.x += p.vx; p.y += p.vy; p.vx *= 0.94; p.vy *= 0.94;
|
|
p.life -= p.decay; return p.life > 0;
|
|
});
|
|
|
|
this._emitUpdate();
|
|
this._draw();
|
|
}
|
|
|
|
/* ── Drawing ────────────────────────────────────────────────── */
|
|
|
|
_draw() {
|
|
const { ctx, W, H } = this;
|
|
const C = CellDivisionSim.C;
|
|
const t = this._phaseT;
|
|
const phases = this._phases();
|
|
const phase = phases[this._phaseIdx];
|
|
const cx = W / 2, cy = H / 2;
|
|
const cellR = Math.min(W, H) * 0.37;
|
|
const nucR = cellR * 0.46;
|
|
|
|
ctx.fillStyle = C.bg;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// subtle radial bg
|
|
const bg2 = ctx.createRadialGradient(cx, cy, 0, cx, cy, cellR * 1.5);
|
|
bg2.addColorStop(0, 'rgba(34,211,153,0.022)');
|
|
bg2.addColorStop(1, 'transparent');
|
|
ctx.fillStyle = bg2;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
switch (phase.id) {
|
|
case 'interphase': this._drawInterphase(cx, cy, cellR, nucR, t); break;
|
|
case 'prophase':
|
|
case 'prophase1': this._drawProphase(cx, cy, cellR, nucR, t, phase.id === 'prophase1'); break;
|
|
case 'metaphase':
|
|
case 'metaphase1': this._drawMetaphase(cx, cy, cellR, t, phase.id === 'metaphase1', false); break;
|
|
case 'prophase2': this._drawProphase2(cx, cy, cellR, nucR, t); break;
|
|
case 'metaphase2': this._drawMetaphase(cx, cy, cellR, t, false, true); break;
|
|
case 'anaphase': this._drawAnaphase(cx, cy, cellR, t, false, false); break;
|
|
case 'anaphase1': this._drawAnaphase(cx, cy, cellR, t, true, false); break;
|
|
case 'anaphase2': this._drawAnaphase(cx, cy, cellR, t, false, true); break;
|
|
case 'telophase': this._drawTelophase(cx, cy, cellR, nucR, t, false, false); break;
|
|
case 'telophase1': this._drawTelophase(cx, cy, cellR, nucR, t, true, false); break;
|
|
case 'telophase2': this._drawTelophase(cx, cy, cellR, nucR, t, false, true); break;
|
|
case 'cytokinesis': this._drawCytokinesis(cx, cy, cellR, nucR, t); break;
|
|
}
|
|
|
|
this._drawParticles();
|
|
this._drawOverlay(phase);
|
|
this._drawProgressBar();
|
|
this._drawHint();
|
|
}
|
|
|
|
/* ── Cell / nucleus ─────────────────────────────────────────── */
|
|
|
|
_cellPath(cx, cy, rx, ry, wobble) {
|
|
const ctx = this.ctx, N = 48;
|
|
ctx.beginPath();
|
|
for (let i = 0; i <= N; i++) {
|
|
const a = (i / N) * Math.PI * 2;
|
|
const w = (wobble || 0) * (Math.sin(a * 3 + this._time * 0.00075) * 0.6 +
|
|
Math.sin(a * 5 + this._time * 0.00055) * 0.4);
|
|
ctx.lineTo(cx + Math.cos(a) * (rx + rx * w),
|
|
cy + Math.sin(a) * ((ry || rx * 0.88) + (ry || rx * 0.88) * w));
|
|
}
|
|
ctx.closePath();
|
|
}
|
|
|
|
_drawCell(cx, cy, rx, ry, wobble, alpha) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha !== undefined ? alpha : 1;
|
|
this._cellPath(cx, cy, rx, ry, wobble !== undefined ? wobble : 0.013);
|
|
ctx.shadowColor = C.cellStr; ctx.shadowBlur = 20;
|
|
ctx.fillStyle = C.cell; ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2;
|
|
ctx.globalAlpha *= 0.65; ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawNucleus(cx, cy, rx, ry, alpha) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
ctx.save(); ctx.globalAlpha = alpha;
|
|
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry || rx * 0.9, 0, 0, Math.PI * 2);
|
|
ctx.shadowColor = C.nucStr; ctx.shadowBlur = 16;
|
|
ctx.fillStyle = C.nucFill; ctx.fill();
|
|
ctx.shadowBlur = 0; ctx.setLineDash([3, 3]);
|
|
ctx.strokeStyle = C.nucStr; ctx.lineWidth = 1.6; ctx.stroke();
|
|
ctx.setLineDash([]); ctx.restore();
|
|
}
|
|
|
|
/* ── Chromosome ─────────────────────────────────────────────── */
|
|
|
|
_drawChromosome(x, y, size, angle, color, sister, alpha) {
|
|
const ctx = this.ctx;
|
|
ctx.save();
|
|
if (alpha !== undefined) ctx.globalAlpha = alpha;
|
|
ctx.translate(x, y); ctx.rotate(angle);
|
|
const aw = size * 0.20, ah = size * 0.50, gap = size * 0.11;
|
|
const offsets = sister ? [-gap, gap] : [0];
|
|
ctx.shadowColor = color; ctx.shadowBlur = 10;
|
|
for (const ox of offsets) {
|
|
ctx.save(); ctx.translate(ox, 0);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -gap * 0.5);
|
|
ctx.bezierCurveTo(-aw * 1.1, -ah * 0.35, -aw * 1.3, -ah * 0.75, 0, -ah);
|
|
ctx.bezierCurveTo( aw * 1.3, -ah * 0.75, aw * 1.1, -ah * 0.35, 0, -gap * 0.5);
|
|
ctx.moveTo(0, gap * 0.5);
|
|
ctx.bezierCurveTo(-aw * 1.1, ah * 0.35, -aw * 1.3, ah * 0.75, 0, ah);
|
|
ctx.bezierCurveTo( aw * 1.3, ah * 0.75, aw * 1.1, ah * 0.35, 0, gap * 0.5);
|
|
ctx.fillStyle = color; ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
ctx.shadowColor = '#fff'; ctx.shadowBlur = 6;
|
|
ctx.beginPath(); ctx.arc(0, 0, size * 0.12, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#fff'; ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
_chrPairs(cx, cy, r) {
|
|
const ch = CellDivisionSim.C.ch;
|
|
return [
|
|
{ x: cx - r * 0.50, y: cy - r * 0.28, angle: -0.20, color: ch[0] },
|
|
{ x: cx - r * 0.15, y: cy - r * 0.35, angle: 0.15, color: ch[1] },
|
|
{ x: cx + r * 0.28, y: cy - r * 0.20, angle: 0.28, color: ch[2] },
|
|
{ x: cx - r * 0.38, y: cy + r * 0.22, angle: 0.18, color: ch[3] },
|
|
{ x: cx + r * 0.12, y: cy + r * 0.32, angle: -0.22, color: ch[4] },
|
|
{ x: cx + r * 0.48, y: cy + r * 0.18, angle: -0.10, color: ch[5] },
|
|
];
|
|
}
|
|
|
|
_chrPairsHaploid(cx, cy, r) {
|
|
const ch = CellDivisionSim.C.ch;
|
|
return [
|
|
{ x: cx - r * 0.36, y: cy - r * 0.24, angle: -0.18, color: ch[0] },
|
|
{ x: cx + r * 0.08, y: cy - r * 0.08, angle: 0.12, color: ch[2] },
|
|
{ x: cx + r * 0.32, y: cy + r * 0.26, angle: 0.28, color: ch[4] },
|
|
];
|
|
}
|
|
|
|
/* ── Spindle ────────────────────────────────────────────────── */
|
|
|
|
_drawSpindle(cx, cy, cellR, alpha, chrs) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
ctx.save(); ctx.globalAlpha = alpha;
|
|
const poleY = cellR * 0.72;
|
|
const poles = [{ x: cx, y: cy - poleY }, { x: cx, y: cy + poleY }];
|
|
|
|
// aster rays
|
|
for (const pole of poles) {
|
|
for (let i = 0; i < 10; i++) {
|
|
const a = (i / 10) * Math.PI * 2;
|
|
ctx.beginPath(); ctx.moveTo(pole.x, pole.y);
|
|
ctx.lineTo(pole.x + Math.cos(a) * 16, pole.y + Math.sin(a) * 16);
|
|
ctx.strokeStyle = 'rgba(255,214,0,0.28)'; ctx.lineWidth = 0.8; ctx.stroke();
|
|
}
|
|
}
|
|
// fibers
|
|
if (chrs) {
|
|
for (const ch of chrs) {
|
|
for (const pole of poles) {
|
|
ctx.beginPath(); ctx.moveTo(pole.x, pole.y);
|
|
ctx.quadraticCurveTo(cx + (ch.x - cx) * 0.18, (pole.y + ch.y) / 2, ch.x, ch.y);
|
|
ctx.strokeStyle = C.spindle; ctx.lineWidth = 0.9; ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
// pole dots
|
|
for (const p of poles) {
|
|
ctx.shadowColor = C.pole; ctx.shadowBlur = 14;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
|
|
ctx.fillStyle = C.pole; ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ── Phase renderers ────────────────────────────────────────── */
|
|
|
|
_drawInterphase(cx, cy, cellR, nucR, t) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
this._drawCell(cx, cy, cellR);
|
|
this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1);
|
|
|
|
const dots = this._chromatinDots;
|
|
ctx.save();
|
|
for (let i = 0; i < dots.length; i++) {
|
|
const d = dots[i];
|
|
const pulse = 0.35 + 0.25 * Math.sin(d.phase + this._time * 0.0015);
|
|
ctx.globalAlpha = pulse;
|
|
ctx.beginPath(); ctx.arc(d.x, d.y, d.size * 0.72, 0, Math.PI * 2);
|
|
ctx.shadowColor = C.chromatin; ctx.shadowBlur = 5;
|
|
ctx.fillStyle = C.chromatin; ctx.fill();
|
|
// replication copies
|
|
if (te > 0.45 && i < Math.floor(((te - 0.45) / 0.55) * dots.length)) {
|
|
ctx.globalAlpha = ((te - 0.45) / 0.55) * 0.5;
|
|
ctx.beginPath(); ctx.arc(d.x + 3.5, d.y + 2, d.size * 0.62, 0, Math.PI * 2);
|
|
ctx.shadowColor = '#FFD166'; ctx.fillStyle = '#FFD166'; ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
if (t > 0.42) {
|
|
const a = Math.min(1, (t - 0.42) * 7);
|
|
ctx.save(); ctx.globalAlpha = a;
|
|
ctx.font = 'bold 11px Manrope,sans-serif';
|
|
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
|
|
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
|
|
ctx.fillText('S-период: репликация ДНК', cx, cy + nucR + 26);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
_drawProphase(cx, cy, cellR, nucR, t, isMeiosis1) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
this._drawCell(cx, cy, cellR);
|
|
this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1 - te * 0.95);
|
|
if (te > 0.28) {
|
|
this._drawSpindle(cx, cy, cellR, (te - 0.28) / 0.72 * 0.6);
|
|
}
|
|
const chrs = this._chrPairs(cx, cy, cellR * 0.27);
|
|
const size = 11 + te * 15;
|
|
if (isMeiosis1) {
|
|
for (let i = 0; i < chrs.length; i++) {
|
|
const ch = chrs[i];
|
|
const off = (1 - te) * 9 * (i % 2 === 0 ? 1 : -1);
|
|
this._drawChromosome(ch.x + off, ch.y, size, ch.angle, ch.color, true);
|
|
if (te > 0.52) {
|
|
const ca = (te - 0.52) / 0.48;
|
|
ctx.save(); ctx.globalAlpha = ca * 0.88;
|
|
ctx.shadowColor = C.crossing; ctx.shadowBlur = 10;
|
|
ctx.beginPath(); ctx.arc(ch.x, ch.y, size * 0.55, 0, Math.PI * 2);
|
|
ctx.strokeStyle = C.crossing; ctx.lineWidth = 2; ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
if (te > 0.52) {
|
|
const ca = (te - 0.52) / 0.48;
|
|
ctx.save(); ctx.globalAlpha = ca;
|
|
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
|
|
ctx.font = 'bold 11px Manrope,sans-serif';
|
|
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
|
|
ctx.fillText('Кроссинговер — рекомбинация', cx, cy + cellR * 0.72);
|
|
ctx.restore();
|
|
}
|
|
} else {
|
|
for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true);
|
|
}
|
|
}
|
|
|
|
_drawProphase2(cx, cy, cellR, nucR, t) {
|
|
const te = this._ease(t);
|
|
this._drawCell(cx, cy, cellR * 0.9);
|
|
this._drawNucleus(cx, cy, nucR * 0.75, nucR * 0.67, 1 - te * 0.85);
|
|
const chrs = this._chrPairsHaploid(cx, cy, cellR * 0.22);
|
|
const size = 14 + te * 9;
|
|
if (te > 0.3) this._drawSpindle(cx, cy, cellR * 0.9, (te - 0.3) / 0.7 * 0.55);
|
|
for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true);
|
|
}
|
|
|
|
_drawMetaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
this._drawCell(cx, cy, cellR);
|
|
|
|
ctx.save();
|
|
ctx.setLineDash([5, 5]);
|
|
ctx.strokeStyle = `rgba(255,255,255,${0.14 + te * 0.12})`;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - cellR * 0.82, cy); ctx.lineTo(cx + cellR * 0.82, cy);
|
|
ctx.stroke(); ctx.setLineDash([]); ctx.restore();
|
|
|
|
const ch = C.ch;
|
|
const sp = cellR * 0.55;
|
|
|
|
if (isHaploid) {
|
|
const pos = [
|
|
{ x: cx - sp * 0.42, y: cy, angle: -0.05 },
|
|
{ x: cx, y: cy, angle: 0.0 },
|
|
{ x: cx + sp * 0.42, y: cy, angle: 0.05 },
|
|
];
|
|
this._drawSpindle(cx, cy, cellR, 0.75 + te * 0.25, pos);
|
|
for (let i = 0; i < pos.length; i++)
|
|
this._drawChromosome(pos[i].x, pos[i].y, 21, pos[i].angle, ch[i * 2], true);
|
|
} else if (isMeiosis1) {
|
|
const pos = [
|
|
{ x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.17, y: cy, angle: 0.04 },
|
|
{ x: cx + sp * 0.17, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 },
|
|
{ x: cx - sp * 0.35, y: cy, angle: 0.12 }, { x: cx + sp * 0.35, y: cy, angle: -0.12 },
|
|
];
|
|
this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos);
|
|
for (let i = 0; i < pos.length; i++) {
|
|
this._drawChromosome(pos[i].x - 5, pos[i].y, 19, pos[i].angle, ch[i % 6], true);
|
|
this._drawChromosome(pos[i].x + 5, pos[i].y, 19, pos[i].angle + 0.25, ch[(i + 3) % 6], true);
|
|
}
|
|
} else {
|
|
const pos = [
|
|
{ x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.28, y: cy, angle: 0.04 },
|
|
{ x: cx - sp * 0.04, y: cy, angle: -0.02 }, { x: cx + sp * 0.04, y: cy, angle: 0.02 },
|
|
{ x: cx + sp * 0.28, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 },
|
|
];
|
|
this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos);
|
|
for (let i = 0; i < 6; i++)
|
|
this._drawChromosome(pos[i].x, pos[i].y, 22, pos[i].angle, ch[i], true);
|
|
}
|
|
}
|
|
|
|
_drawAnaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
const stretchY = 1 + te * 0.38;
|
|
ctx.save();
|
|
ctx.translate(cx, cy); ctx.scale(1, stretchY); ctx.translate(-cx, -cy);
|
|
this._drawCell(cx, cy, cellR * (1 - te * 0.04));
|
|
ctx.restore();
|
|
|
|
const poleOffset = cellR * 0.7 * te;
|
|
const topY = cy - poleOffset, botY = cy + poleOffset;
|
|
this._drawSpindle(cx, cy, cellR, 1 - te * 0.3);
|
|
|
|
const ch = C.ch, sp = cellR * (isHaploid ? 0.32 : 0.44);
|
|
const n = isHaploid ? 3 : 6;
|
|
for (let i = 0; i < n; i++) {
|
|
const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.34);
|
|
const ang = (i - (n - 1) / 2) * 0.07;
|
|
if (isMeiosis1) {
|
|
this._drawChromosome(cx + ox, topY, 20, ang, ch[i], true);
|
|
this._drawChromosome(cx + ox, botY, 20, ang, ch[(i + 3) % 6], true);
|
|
} else {
|
|
const ci = isHaploid ? i * 2 : i;
|
|
this._drawChromosome(cx + ox, topY, isHaploid ? 17 : 18, ang, ch[ci % 6], false);
|
|
this._drawChromosome(cx + ox, botY, isHaploid ? 17 : 18, ang, ch[ci % 6], false);
|
|
}
|
|
}
|
|
}
|
|
|
|
_drawTelophase(cx, cy, cellR, nucR, t, isMeiosis1, isHaploid) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
const sep = cellR * 0.44;
|
|
|
|
ctx.save();
|
|
ctx.translate(cx, cy); ctx.scale(1, 1.30 - te * 0.12); ctx.translate(-cx, -cy);
|
|
this._drawCell(cx, cy, cellR);
|
|
ctx.restore();
|
|
|
|
for (const s of [-1, 1])
|
|
this._drawNucleus(cx, cy + s * sep, nucR * 0.72, nucR * 0.65, te);
|
|
|
|
const ch = C.ch, size = 20 * (1 - te * 0.78);
|
|
const alpha = 1 - te * 0.88, sp = cellR * (isHaploid ? 0.22 : 0.34);
|
|
const n = isHaploid ? 3 : 6;
|
|
for (let i = 0; i < n; i++) {
|
|
const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.26);
|
|
const ci = isHaploid ? i * 2 : i;
|
|
ctx.save(); ctx.globalAlpha = alpha;
|
|
if (isMeiosis1) {
|
|
this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], true);
|
|
this._drawChromosome(cx + ox, cy + sep, size, 0, ch[(ci + 3) % 6], true);
|
|
} else {
|
|
this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], false);
|
|
this._drawChromosome(cx + ox, cy + sep, size, 0, ch[ci % 6], false);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
if (te > 0.45) {
|
|
const fa = (te - 0.45) / 0.55;
|
|
ctx.save(); ctx.globalAlpha = fa * 0.6;
|
|
ctx.setLineDash([7, 5]);
|
|
ctx.shadowColor = C.furrow; ctx.shadowBlur = 10;
|
|
ctx.strokeStyle = C.furrow; ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - cellR * 0.7, cy); ctx.lineTo(cx + cellR * 0.7, cy);
|
|
ctx.stroke(); ctx.setLineDash([]); ctx.restore();
|
|
}
|
|
}
|
|
|
|
_drawCytokinesis(cx, cy, cellR, nucR, t) {
|
|
const ctx = this.ctx, C = CellDivisionSim.C;
|
|
const te = this._ease(t);
|
|
|
|
if (te < 0.80) {
|
|
const pinch = te;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
for (let i = 0; i <= 48; i++) {
|
|
const a = (i / 48) * Math.PI * 2;
|
|
const s2 = Math.abs(Math.cos(a));
|
|
const waist = 1 - pinch * Math.max(0, 1 - s2 * 2.2) * 0.90;
|
|
ctx.lineTo(cx + Math.cos(a) * cellR * waist,
|
|
cy + Math.sin(a) * cellR * 0.88 * (1 + pinch * 0.09));
|
|
}
|
|
ctx.closePath();
|
|
ctx.shadowColor = C.cellStr; ctx.shadowBlur = 16;
|
|
ctx.fillStyle = C.cell; ctx.fill(); ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2;
|
|
ctx.globalAlpha = 0.68; ctx.stroke(); ctx.restore();
|
|
|
|
ctx.save(); ctx.globalAlpha = pinch * 0.85;
|
|
ctx.shadowColor = C.furrow; ctx.shadowBlur = 14;
|
|
ctx.strokeStyle = C.furrow; ctx.lineWidth = 2.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - cellR * (1 - pinch * 0.92), cy);
|
|
ctx.lineTo(cx + cellR * (1 - pinch * 0.92), cy);
|
|
ctx.stroke(); ctx.restore();
|
|
} else {
|
|
const appear = (te - 0.80) / 0.20;
|
|
const sepY = cellR * 0.52;
|
|
for (const s of [-1, 1])
|
|
this._drawCell(cx, cy + s * sepY * 0.54, cellR * 0.65, cellR * 0.57, 0.01, 0.5 + appear * 0.5);
|
|
}
|
|
|
|
const sep = cellR * 0.50;
|
|
for (const s of [-1, 1])
|
|
this._drawNucleus(cx, cy + s * sep * 0.52, nucR * 0.68, nucR * 0.61, Math.min(1, te * 1.5));
|
|
|
|
if (te > 0.72) {
|
|
const a = (te - 0.72) / 0.28;
|
|
ctx.save(); ctx.globalAlpha = a;
|
|
ctx.shadowColor = '#22d399'; ctx.shadowBlur = 12;
|
|
ctx.font = 'bold 12px Manrope,sans-serif';
|
|
ctx.fillStyle = '#22d399'; ctx.textAlign = 'center';
|
|
ctx.fillText(
|
|
this.mode === 'meiosis' ? '4 гаплоидные клетки (n = 23)' : '2 диплоидные клетки (2n = 46)',
|
|
cx, cy + cellR * 0.88);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/* ── Particles ──────────────────────────────────────────────── */
|
|
|
|
_drawParticles() {
|
|
const ctx = this.ctx;
|
|
for (const p of this._particles) {
|
|
ctx.save();
|
|
ctx.globalAlpha = p.life * 0.82;
|
|
ctx.shadowColor = p.color; ctx.shadowBlur = 8;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fillStyle = p.color; ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/* ── UI overlays ────────────────────────────────────────────── */
|
|
|
|
_drawOverlay(phase) {
|
|
const ctx = this.ctx;
|
|
const { W, H } = this;
|
|
|
|
// phase name pill — top right
|
|
ctx.save();
|
|
ctx.font = 'bold 14px Manrope,sans-serif';
|
|
const tw = ctx.measureText(phase.label).width;
|
|
_cdRRect(ctx, W - tw - 30, 12, tw + 22, 28, 8);
|
|
ctx.shadowColor = '#22d399'; ctx.shadowBlur = 14;
|
|
ctx.fillStyle = 'rgba(34,211,153,0.12)'; ctx.fill(); ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = 'rgba(34,211,153,0.38)'; ctx.lineWidth = 1; ctx.stroke();
|
|
ctx.fillStyle = '#22d399'; ctx.textAlign = 'left';
|
|
ctx.fillText(phase.label, W - tw - 19, 30);
|
|
ctx.restore();
|
|
|
|
// chromN + DNA — bottom left
|
|
ctx.save();
|
|
ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'left';
|
|
ctx.fillStyle = 'rgba(6,214,224,0.78)';
|
|
ctx.fillText('n: ' + phase.chromN, 14, H - 46);
|
|
ctx.fillStyle = 'rgba(255,214,0,0.78)';
|
|
ctx.fillText('ДНК: ' + phase.dna, 14, H - 32);
|
|
ctx.restore();
|
|
|
|
// description — bottom right
|
|
ctx.save();
|
|
ctx.font = '10.5px Manrope,sans-serif';
|
|
ctx.fillStyle = 'rgba(255,255,255,0.36)';
|
|
ctx.textAlign = 'right';
|
|
const maxW = W * 0.5;
|
|
const words = phase.desc.split(' ');
|
|
let line = '', lines = [];
|
|
for (const w of words) {
|
|
const test = line + (line ? ' ' : '') + w;
|
|
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; }
|
|
else line = test;
|
|
}
|
|
if (line) lines.push(line);
|
|
lines.forEach((l, i) => ctx.fillText(l, W - 14, H - 46 + i * 14));
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawProgressBar() {
|
|
const ctx = this.ctx;
|
|
const { W, H } = this;
|
|
const C = CellDivisionSim.C;
|
|
const phases = this._phases();
|
|
const bx = 14, bw = W - 28, by = H - 14, bh = 4;
|
|
const total = (this._phaseIdx + this._phaseT) / phases.length;
|
|
|
|
_cdRRect(ctx, bx, by - bh / 2, bw, bh, 2);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
|
|
|
|
if (total > 0) {
|
|
_cdRRect(ctx, bx, by - bh / 2, bw * total, bh, 2);
|
|
ctx.shadowColor = C.progress; ctx.shadowBlur = 8;
|
|
ctx.fillStyle = C.progress; ctx.fill(); ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// phase tick marks
|
|
for (let i = 1; i < phases.length; i++) {
|
|
const tx = bx + bw * (i / phases.length);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
|
ctx.fillRect(tx - 0.5, by - bh, 1, bh * 2);
|
|
}
|
|
|
|
// status
|
|
ctx.save();
|
|
ctx.font = '10px Manrope,sans-serif';
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(this._autoPlay ? '> авто' : '|| пауза', bx, H - 22);
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawHint() {
|
|
if (this._time >= 4500) return;
|
|
const a = Math.min(1, this._time / 600) * Math.max(0, 1 - (this._time - 3200) / 1300);
|
|
const ctx = this.ctx;
|
|
ctx.save();
|
|
ctx.globalAlpha = a * 0.42;
|
|
ctx.font = '10.5px Manrope,sans-serif';
|
|
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
|
|
ctx.fillText('Клик — следующая фаза · тяни полосу внизу · Space — пауза', this.W / 2, this.H - 26);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function _cdRRect(ctx, x, y, w, h, r) {
|
|
if (w <= 0 || h <= 0) return;
|
|
r = Math.min(r, w / 2, h / 2);
|
|
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();
|
|
}
|