Files
Learn_System/frontend/js/labs/celldivision.js
T
Maxim Dolgolyov be4d43105e 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>
2026-04-12 10:10:37 +03:00

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();
}