'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; if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 }); this.reset(); } setSpeed(s) { this._speed = s; } nextPhase() { const phases = this._phases(); this._phaseIdx = (this._phaseIdx + 1) % phases.length; this._phaseT = 0; this._particles = []; if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 }); 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 = []; if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 }); 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 = []; if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.0 + this._phaseIdx * 0.05, volume: 0.3 }); 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); // dust particles from LabFX around condensing chromosomes if (window.LabFX) { const cx = this.W / 2, cy = this.H / 2; LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 6, color: '#9B5DE5', speed: 18, spread: Math.PI * 2, angle: 0, gravity: 0, life: 700, fade: true, glow: false, shape: 'dust', size: 3, sizeFade: true }); } } // anaphase: tick sound at start of separation + pole sparks if ((phase.id === 'anaphase' || phase.id === 'anaphase1' || phase.id === 'anaphase2') && this._phaseT > 0.05 && this._phaseT < 0.10 && !this._anaphaseTickDone) { this._anaphaseTickDone = true; if (window.LabFX) { LabFX.sound.play('tick', { pitch: 1.3 }); const cx = this.W / 2, cellR = Math.min(this.W, this.H) * 0.37; const poleY = cellR * 0.7 * this._phaseT; for (const sy of [-1, 1]) { LabFX.particles.emit({ ctx: this.ctx, x: cx, y: this.H / 2 + sy * poleY, count: 5, color: '#FFD166', speed: 22, spread: Math.PI * 2, angle: 0, gravity: 0, life: 500, fade: true, glow: true, shape: 'spark', size: 3, sizeFade: true }); } } } if (phase.id !== 'anaphase' && phase.id !== 'anaphase1' && phase.id !== 'anaphase2') { this._anaphaseTickDone = false; } if (this._phaseT >= 1) { // cytokinesis complete if (phase.id === 'cytokinesis') { if (window.LabFX) { LabFX.sound.play('chime'); const cx = this.W / 2, cy = this.H / 2; const cellR = Math.min(this.W, this.H) * 0.37; for (const sy of [-1, 1]) { LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy + sy * cellR * 0.5, count: 10, color: '#22d399', speed: 30, spread: Math.PI * 2, angle: 0, gravity: 0, life: 800, fade: true, glow: true, shape: 'ring', size: 5, sizeFade: true }); } } } 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; }); if (window.LabFX) LabFX.particles.update(dt); 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(); if (window.LabFX) LabFX.particles.draw(this.ctx); } /* ── 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(); } /* ─── lab UI init ─────────────────────────────────── */ function _openCellDivision(mode) { document.getElementById('sim-topbar-title').textContent = 'Деление клетки'; _simShow('sim-celldivision'); _simShow('ctrl-celldivision'); requestAnimationFrame(() => requestAnimationFrame(() => { const canvas = document.getElementById('celldiv-canvas'); if (!cellDivSim) { cellDivSim = new CellDivisionSim(canvas); cellDivSim.onUpdate = _cdUpdateUI; } cellDivSim.fit(); cellDivSim.setMode(mode || 'mitosis'); cellDivSim.start(); _cdBuildDots(cellDivSim._phaseIdx); // sync auto button state const autoBtn = document.getElementById('cd-auto-btn'); if (autoBtn) { autoBtn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто'; } _cdUpdateUI(cellDivSim.info()); })); } function _cdBuildDots(activeIdx) { const box = document.getElementById('cd-phase-dots'); if (!box || !cellDivSim) return; const phases = cellDivSim._phases(); box.innerHTML = phases.map((p, i) => `
` ).join(''); } function cdSetMode(mode, btn) { document.querySelectorAll('.cd-mode-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); if (!cellDivSim) return; cellDivSim.setMode(mode); _cdBuildDots(cellDivSim._phaseIdx); _cdUpdateUI(cellDivSim.info()); } function cdAutoPlay(btn) { if (!cellDivSim) return; cellDivSim.toggleAutoPlay(); btn.classList.toggle('active', cellDivSim._autoPlay); btn.innerHTML = cellDivSim._autoPlay ? ' Пауза' : ' Авто'; } function cdPrevPhase() { if (!cellDivSim) return; cellDivSim.prevPhase(); _cdBuildDots(cellDivSim._phaseIdx); } function cdNextPhase() { if (!cellDivSim) return; cellDivSim.nextPhase(); _cdBuildDots(cellDivSim._phaseIdx); } function cdJumpPhase(idx) { if (!cellDivSim) return; cellDivSim.jumpToPhase(idx); _cdBuildDots(idx); } function _cdUpdateUI(info) { const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; v('cdbar-v1', info.phase || '—'); v('cdbar-v2', info.chromN || '—'); v('cdbar-v3', info.dna || '—'); v('cdbar-v4', (info.index + 1) + ' / ' + info.total); v('cdbar-v5', info.mode === 'mitosis' ? 'Митоз' : 'Мейоз'); _cdBuildDots(info.index); } /* ── Photosynthesis / Respiration ── */