Files
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

941 lines
38 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;
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 ? '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Пауза' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Авто'; }
_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) =>
`<div class="cd-phase-dot${i === activeIdx ? ' active' : ''}" onclick="cdJumpPhase(${i})" title="${p.label}"></div>`
).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 ? '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Пауза' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Авто';
}
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 ── */