'use strict'; /* ══════════════════════════════════════════════════════════════════ OrganicSim — «Органическая химия» 3 sub-modes: 1. «Конструктор молекул» — canvas 2D drag-drop builder, valence check, auto-class detection, IUPAC naming for alkanes 2. «Гомологические ряды» — preset series with property table 3. «Качественные реакции» — drag-drop reagent into test-tube, animations ══════════════════════════════════════════════════════════════════ */ class OrganicSim { /* ── Atom valences ─────────────────────────────────────────────── */ static VALENCE = { C: 4, H: 1, O: 2, N: 3, Cl: 1, S: 2 }; /* ── Atom display colors ───────────────────────────────────────── */ static ATOM_COLOR = { C: '#9B5DE5', H: '#E0E0E0', O: '#EF476F', N: '#4CC9F0', Cl: '#34d399', S: '#FFD166', }; /* ── Atom radii on canvas ──────────────────────────────────────── */ static ATOM_R = { C: 20, H: 13, O: 17, N: 16, Cl: 17, S: 17 }; /* ── Homologous series data ────────────────────────────────────── */ static HOMOLOG_SERIES = { alkanes: { name: 'Алканы', formula: (n) => `C${n}H${2*n+2}`, minC: 1, tboil: [-161.5,-88.6,-42.1,-0.5,36.1,68.7,98.4,125.6,150.8,174.1], tmelt: [-182.5,-183.3,-187.7,-138.3,-129.7,-95.3,-90.6,-56.8,-53.5,-29.7], state: (n) => n<=4 ? 'Газ' : n<=17 ? 'Жидкость' : 'Твёрдое', M: (n) => 12*n + (2*n+2), notable: { 1: { name: 'Метан', info: 'Природный газ, топливо, 87% в составе природного газа.' }, 2: { name: 'Этан', info: 'Компонент природного газа, сырьё для производства этилена.' }, 6: { name: 'Гексан', info: 'Растворитель для экстракции масел.' }, } }, alkenes: { name: 'Алкены', formula: (n) => `C${n}H${2*n}`, minC: 2, tboil: [null,-103.7,-47.6,-6.3,30,63.5,93.6,121.3,146.9,170.5], tmelt: [null,-169.1,-185.2,-185.3,-138.9,-119.7,-101.7,-101.7,-86.9,-75.6], state: (n) => n<=4 ? 'Газ' : 'Жидкость', M: (n) => 12*n + 2*n, notable: { 2: { name: 'Этилен', info: 'Фитогормон, сырьё для полиэтилена. Участвует в созревании плодов.' }, 3: { name: 'Пропилен', info: 'Мономер полипропилена. Производство пластмасс и каучука.' }, } }, alkynes: { name: 'Алкины', formula: (n) => `C${n}H${2*n-2}`, minC: 2, tboil: [null,-84,23.2,8.1,26.1,71.4,99.7,125.2,150.8,174], tmelt: [null,-80.8,-101.5,-123.1,-130,-90,-81,-79.3,-65,-36], state: (n) => n<=4 ? 'Газ' : 'Жидкость', M: (n) => 12*n + (2*n-2), notable: { 2: { name: 'Ацетилен', info: 'Сварка металлов, синтез уксусной кислоты, ПВХ.' }, } }, alcohols: { name: 'Спирты', formula: (n) => `C${n}H${2*n+1}OH`, minC: 1, tboil: [64.7,78.4,97.2,117.7,137.8,157.9,176,194,213,231], tmelt: [-97.8,-114.1,-126,-89.5,-79,-51.6,-34.5,-17,21,6], state: (n) => n<=11 ? 'Жидкость' : 'Твёрдое', M: (n) => 12*n + (2*n+1) + 17, notable: { 1: { name: 'Метанол', info: 'Метиловый спирт. Топливо, растворитель. Ядовит!' }, 2: { name: 'Этанол', info: 'Этиловый спирт. Напитки, медицина, растворитель, биотопливо.' }, 3: { name: 'Пропанол', info: 'Растворитель, дезинфектант.' }, } }, aldehydes: { name: 'Альдегиды', formula: (n) => `C${n}H${2*n}O`, minC: 1, tboil: [-21,20.2,48.8,74.8,103,128,152,173,191,208], tmelt: [-92,-123,-81,-99,-91.5,-66,-45,-26,-12,4], state: (n) => n<=4 ? 'Газ' : 'Жидкость', M: (n) => 12*n + 2*n + 16, notable: { 1: { name: 'Формальдегид', info: 'Консервант, производство смол. Ядовит.' }, 2: { name: 'Уксусный альдегид', info: 'Сырьё для уксусной кислоты.' }, } }, acids: { name: 'Карб. кислоты', formula: (n) => `C${n}H${2*n}O₂`, minC: 1, tboil: [100.7,118.1,141.2,163.7,186.3,205.3,223.1,239.3,253.9,268.5], tmelt: [8.3,16.6,-20.5,-7.9,-33.8,-8,16.5,12,15,31.5], state: (n) => n<=3 ? 'Жидкость (смеш)' : 'Жидкость', M: (n) => 12*n + 2*n + 32, notable: { 1: { name: 'Муравьиная', info: 'Укусы муравьёв, пчёл. Кожное средство.' }, 2: { name: 'Уксусная', info: '3–9% в столовом уксусе. Консервация, растворитель, синтез.' }, 3: { name: 'Пропионовая', info: 'Консервант (E280). Производство гербицидов.' }, } }, amines: { name: 'Амины', formula: (n) => `C${n}H${2*n+3}N`, minC: 1, tboil: [-6.3,16.6,48.7,77.8,104.4,130.5,154,177,199,220], tmelt: [-93.5,-81,-83,-50,-55,-23,-12,0,17,30], state: (n) => n<=2 ? 'Газ' : 'Жидкость', M: (n) => 12*n + (2*n+3) + 14, notable: { 1: { name: 'Метиламин', info: 'Рыбный запах, синтез красителей, фармацевтика.' }, 2: { name: 'Этиламин', info: 'Растворитель, производство каучука.' }, } }, }; /* ── Qualitative reactions ─────────────────────────────────────── */ static QUAL_REACTIONS = [ { id: 'bromine', reagent: 'Br₂(водн)', reagentColor: '#A0520020', reagentLiquid: '#B85C00', desc: 'Бромная вода (Br₂(aq))', compounds: [ { name: 'Алкен (C=C)', result: 'Обесцвечивание', equation: 'R-CH=CH-R + Br₂ → R-CHBr-CHBr-R', color: '#ffffff10', resultColor: '#F5E8CC20', symbol: '+' }, { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с Br₂(водн) при н.у.',color: '#B85C0080', resultColor: '#B85C0080', symbol: '−' }, { name: 'Фенол', result: 'Белый осадок', equation: 'C₆H₅OH + 3Br₂ → C₆H₂Br₃OH↓ + 3HBr', color: '#ffffff20', resultColor: '#ffffff80', symbol: '+' }, { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2Br₂ → CHBr₂-CHBr₂', color: '#B85C0080', resultColor: '#ffffff10', symbol: '+' }, ], }, { id: 'kmno4', reagent: 'KMnO₄', reagentColor: '#7B2FBE80', reagentLiquid: '#9B3FDE', desc: 'Перманганат калия KMnO₄', compounds: [ { name: 'Алкен', result: 'Обесцвечивание', equation: '3R-CH=CH₂ + 2KMnO₄ + 4H₂O → 3R-CH(OH)-CH₂OH + 2MnO₂↓ + 2KOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, { name: 'Альдегид', result: 'Обесцвечивание', equation: 'R-CHO + [O] → R-COOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы устойчивы к KMnO₄ при н.у.', color: '#9B3FDE80', resultColor: '#9B3FDE80', symbol: '−' }, { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2KMnO₄ → 2CO₂ + 2KOH + 2MnO₂↓', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, ], }, { id: 'silver', reagent: 'Ag₂O/NH₃', reagentColor: '#C0C0C020', reagentLiquid: '#C8C8C8', desc: 'Реакция серебряного зеркала', compounds: [ { name: 'Альдегид', result: 'Серебристый налёт', equation: 'R-CHO + Ag₂O → R-COOH + 2Ag↓', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true }, { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не реагируют с реактивом Толленса', color: '#C8C8C820', resultColor: '#C8C8C820', symbol: '−' }, { name: 'Сахар (альд)', result: 'Серебристый налёт', equation: 'Глюкоза (альдегид) → серебристый налёт', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true }, ], }, { id: 'cuoh2', reagent: 'Cu(OH)₂', reagentColor: '#4CC9F030', reagentLiquid: '#3aaad0', desc: 'Гидроксид меди(II)', compounds: [ { name: 'Многоатом. спирт', result: 'Ярко-синий р-р', equation: 'Глицерин + Cu(OH)₂ → ярко-синий раствор', color: '#3aaad080', resultColor: '#1E90FF80', symbol: '+', heat: false }, { name: 'Альдегид (нагрев)', result: 'Красный Cu₂O↓', equation: 'R-CHO + 2Cu(OH)₂ →(нагрев) R-COOH + Cu₂O↓(красный) + 2H₂O', color: '#3aaad080', resultColor: '#CC440080', symbol: '+', heat: true }, { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не восстанавливают Cu(OH)₂', color: '#3aaad080', resultColor: '#3aaad080', symbol: '−' }, ], }, { id: 'fecl3', reagent: 'FeCl₃', reagentColor: '#D4A04040', reagentLiquid: '#C88020', desc: 'Хлорид железа(III)', compounds: [ { name: 'Фенол', result: 'Фиолетовый цвет', equation: 'C₆H₅OH + FeCl₃ → [Fe(OC₆H₅)₃]Cl₃ (фиолетовый)', color: '#C8802060', resultColor: '#8000FF80', symbol: '+' }, { name: 'Спирт', result: 'Нет реакции', equation: 'Спирты не дают фиолетовой окраски с FeCl₃', color: '#C8802060', resultColor: '#C8802060', symbol: '−' }, ], }, { id: 'sodium', reagent: 'Na (металл)', reagentColor: '#F5F0C820', reagentLiquid: '#F0EAB0', desc: 'Натрий металлический', compounds: [ { name: 'Спирт', result: 'H₂↑ (пузырьки)', equation: '2R-OH + 2Na → 2R-ONa + H₂↑', color: '#F0EAB060', resultColor: '#6EB4D780', symbol: '+', gas: true }, { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с натрием', color: '#F0EAB060', resultColor: '#F0EAB060', symbol: '−' }, ], }, ]; /* ── Constructor ───────────────────────────────────────────────── */ constructor(wrap) { this._wrap = wrap; this._mode = 'constructor'; // 'constructor' | 'homologs' | 'qualitative' this._raf = null; this._dirty = false; // constructor state this._atoms = []; this._bonds = []; this._drag = null; this._pendingBond = null; // first atom of bond being drawn this._toolAtom = 'C'; this._toolBond = 1; // 1,2,3 // homologs state this._homSeries = 'alkanes'; this._homN = 1; // qualitative state this._qualReaction = OrganicSim.QUAL_REACTIONS[0]; this._qualCompound = null; this._qualAnim = null; this._initDOM(); } /* ── DOM bootstrap ─────────────────────────────────────────────── */ _initDOM() { this._wrap.innerHTML = ''; this._wrap.style.display = 'flex'; this._wrap.style.flexDirection = 'column'; this._wrap.style.height = '100%'; this._wrap.style.background = '#0D0D1A'; this._wrap.style.fontFamily = "'Manrope', sans-serif"; this._wrap.style.color = '#e0e0e0'; this._wrap.style.overflow = 'hidden'; // ── top mode bar const modeBar = document.createElement('div'); modeBar.style.cssText = 'display:flex;gap:8px;padding:12px 16px 0;flex-shrink:0'; [ ['constructor', 'Конструктор молекул'], ['homologs', 'Гомологические ряды'], ['qualitative', 'Качественные реакции'], ].forEach(([id, label]) => { const btn = document.createElement('button'); btn.textContent = label; btn.dataset.mode = id; btn.style.cssText = 'padding:6px 14px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);' + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.78rem;font-family:inherit;' + 'transition:all .15s'; btn.addEventListener('click', () => this._setMode(id)); modeBar.appendChild(btn); }); this._modeBar = modeBar; this._wrap.appendChild(modeBar); // ── content area this._content = document.createElement('div'); this._content.style.cssText = 'flex:1;display:flex;overflow:hidden;min-height:0'; this._wrap.appendChild(this._content); this._buildConstructor(); this._setMode('constructor'); } /* ── Mode switch ───────────────────────────────────────────────── */ _setMode(mode) { this._mode = mode; // update button styles this._modeBar.querySelectorAll('button').forEach(b => { const active = b.dataset.mode === mode; b.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.04)'; b.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.12)'; b.style.color = active ? '#C9A0FF' : '#c0c0c0'; b.style.fontWeight = active ? '700' : '400'; }); // show correct panel if (this._consPanel) this._consPanel.style.display = mode === 'constructor' ? 'flex' : 'none'; if (this._homoPanel) this._homoPanel.style.display = mode === 'homologs' ? 'flex' : 'none'; if (this._qualPanel) this._qualPanel.style.display = mode === 'qualitative' ? 'flex' : 'none'; if (mode === 'constructor') { this._drawMolecule(); } if (mode === 'homologs') { this._drawHomologs(); } if (mode === 'qualitative') { this._drawQual(); } } /* ══════════════════════════════════════════════════════════════ MODE 1 — CONSTRUCTOR ══════════════════════════════════════════════════════════════ */ _buildConstructor() { const panel = document.createElement('div'); panel.style.cssText = 'display:flex;width:100%;height:100%'; this._consPanel = panel; this._content.appendChild(panel); // left toolbar const left = document.createElement('div'); left.style.cssText = 'width:160px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; panel.appendChild(left); // atom palette const atomTitle = document.createElement('div'); atomTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + 'color:rgba(255,255,255,0.4);margin-bottom:2px'; atomTitle.textContent = 'Атомы'; left.appendChild(atomTitle); this._atomBtns = {}; const atomWrap = document.createElement('div'); atomWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px'; ['C','H','O','N','Cl','S'].forEach(sym => { const btn = document.createElement('button'); btn.textContent = sym; const col = OrganicSim.ATOM_COLOR[sym]; btn.style.cssText = `width:38px;height:32px;border-radius:6px;border:1.5px solid ${col}44;` + `background:${col}1A;color:${col};cursor:pointer;font-size:.8rem;font-weight:700;font-family:inherit;transition:all .12s`; btn.addEventListener('click', () => this._selectAtom(sym)); atomWrap.appendChild(btn); this._atomBtns[sym] = btn; }); left.appendChild(atomWrap); // bond order const bondTitle = document.createElement('div'); bondTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + 'color:rgba(255,255,255,0.4);margin-bottom:2px'; bondTitle.textContent = 'Связь'; left.appendChild(bondTitle); this._bondBtns = {}; const bondWrap = document.createElement('div'); bondWrap.style.cssText = 'display:flex;gap:4px;margin-bottom:8px'; [[1,'─'],[2,'═'],[3,'≡']].forEach(([n, sym]) => { const btn = document.createElement('button'); btn.textContent = sym; btn.style.cssText = 'width:36px;height:28px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.15);' + 'background:rgba(255,255,255,0.05);color:#e0e0e0;cursor:pointer;font-size:.9rem;font-family:inherit;transition:all .12s'; btn.addEventListener('click', () => this._selectBond(n)); bondWrap.appendChild(btn); this._bondBtns[n] = btn; }); left.appendChild(bondWrap); // separator const sep = document.createElement('div'); sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; left.appendChild(sep); // actions const clearBtn = document.createElement('button'); clearBtn.textContent = 'Очистить'; clearBtn.style.cssText = 'padding:6px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' + 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit'; clearBtn.addEventListener('click', () => { this._atoms = []; this._bonds = []; this._pendingBond = null; this._drawMolecule(); this._updateFormula(); }); left.appendChild(clearBtn); // hint const hint = document.createElement('div'); hint.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4;margin-top:4px'; hint.textContent = 'Клик на холст — добавить атом. Клик на 2 атома — нарисовать связь. ПКМ — удалить.'; left.appendChild(hint); // center canvas const canvasWrap = document.createElement('div'); canvasWrap.style.cssText = 'flex:1;position:relative;overflow:hidden;background:#080810'; panel.appendChild(canvasWrap); const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%'; this._molCanvas = canvas; canvasWrap.appendChild(canvas); // right info panel const right = document.createElement('div'); right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' + 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto'; panel.appendChild(right); const infoTitle = document.createElement('div'); infoTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + 'color:rgba(255,255,255,0.4)'; infoTitle.textContent = 'Анализ молекулы'; right.appendChild(infoTitle); this._formulaEl = this._infoBox(right, 'Молекулярная формула', '—'); this._structEl = this._infoBox(right, 'Структурная формула', '—'); this._classEl = this._infoBox(right, 'Класс соединения', '—'); this._iupacEl = this._infoBox(right, 'Название (ИЮПАК)', '—'); this._valenceEl = this._infoBox(right, 'Валентность', '—'); // canvas events canvas.addEventListener('click', e => this._molClick(e)); canvas.addEventListener('mousedown', e => this._molMouseDown(e)); canvas.addEventListener('mousemove', e => this._molMouseMove(e)); canvas.addEventListener('mouseup', e => this._molMouseUp(e)); canvas.addEventListener('contextmenu', e => { e.preventDefault(); this._molRightClick(e); }); this._selectAtom('C'); this._selectBond(1); this._updateFormula(); } _infoBox(parent, label, value) { const wrap = document.createElement('div'); wrap.style.cssText = 'padding:8px 10px;border-radius:8px;background:rgba(255,255,255,0.04);' + 'border:1px solid rgba(255,255,255,0.07)'; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;' + 'color:rgba(255,255,255,0.35);margin-bottom:3px'; lbl.textContent = label; const val = document.createElement('div'); val.style.cssText = 'font-size:.9rem;font-weight:700;color:#e8e8e8;word-break:break-all'; val.textContent = value; wrap.appendChild(lbl); wrap.appendChild(val); parent.appendChild(wrap); return val; } _selectAtom(sym) { this._toolAtom = sym; Object.entries(this._atomBtns).forEach(([s, btn]) => { const col = OrganicSim.ATOM_COLOR[s]; const active = s === sym; btn.style.background = active ? `${col}40` : `${col}1A`; btn.style.borderColor = active ? col : `${col}44`; btn.style.boxShadow = active ? `0 0 8px ${col}60` : 'none'; }); } _selectBond(n) { this._toolBond = n; Object.entries(this._bondBtns).forEach(([k, btn]) => { const active = parseInt(k) === n; btn.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.05)'; btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.15)'; btn.style.color = active ? '#C9A0FF' : '#e0e0e0'; }); } /* ── Canvas size sync ─────────────────────────────────────────── */ _fitMolCanvas() { const c = this._molCanvas; if (!c) return; const rect = c.getBoundingClientRect(); if (rect.width === 0) return; c.width = Math.round(rect.width * devicePixelRatio); c.height = Math.round(rect.height * devicePixelRatio); c.getContext('2d').setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); } /* ── Canvas pointer helpers ─────────────────────────────────────── */ _molXY(e) { const r = this._molCanvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } _atomAt(x, y) { return this._atoms.find(a => Math.hypot(a.x - x, a.y - y) <= OrganicSim.ATOM_R[a.sym] + 4); } /* ── Mouse/click handlers ─────────────────────────────────────── */ _molClick(e) { const { x, y } = this._molXY(e); const hit = this._atomAt(x, y); if (hit) { // bond mode: select second atom if (this._pendingBond) { if (this._pendingBond !== hit) { const existing = this._bonds.find(b => (b.a === this._pendingBond && b.b === hit) || (b.a === hit && b.b === this._pendingBond)); if (existing) { existing.order = this._toolBond; } else { this._bonds.push({ a: this._pendingBond, b: hit, order: this._toolBond }); } } this._pendingBond = null; } else { this._pendingBond = hit; } } else { this._pendingBond = null; // place new atom const atom = { id: Date.now() + Math.random(), sym: this._toolAtom, x, y }; this._atoms.push(atom); } this._drawMolecule(); this._updateFormula(); } _molMouseDown(e) { if (e.button !== 0) return; const { x, y } = this._molXY(e); const hit = this._atomAt(x, y); if (hit && !this._pendingBond) { this._drag = { atom: hit, ox: hit.x - x, oy: hit.y - y }; } } _molMouseMove(e) { const { x, y } = this._molXY(e); if (this._drag) { this._drag.atom.x = x + this._drag.ox; this._drag.atom.y = y + this._drag.oy; this._drawMolecule(); } this._molCanvas.style.cursor = this._atomAt(x, y) ? 'grab' : 'crosshair'; } _molMouseUp(e) { if (this._drag) { this._drag = null; this._updateFormula(); } } _molRightClick(e) { const { x, y } = this._molXY(e); const hit = this._atomAt(x, y); if (hit) { this._bonds = this._bonds.filter(b => b.a !== hit && b.b !== hit); this._atoms = this._atoms.filter(a => a !== hit); if (this._pendingBond === hit) this._pendingBond = null; this._drawMolecule(); this._updateFormula(); } } /* ── Draw molecule ─────────────────────────────────────────────── */ _drawMolecule() { const c = this._molCanvas; if (!c) return; this._fitMolCanvas(); const ctx = c.getContext('2d'); const W = c.getBoundingClientRect().width; const H = c.getBoundingClientRect().height; ctx.clearRect(0, 0, W, H); // subtle grid ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); } for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke(); } // bad valence set const badAtoms = this._getBadValenceAtoms(); // bonds this._bonds.forEach(b => { const x1 = b.a.x, y1 = b.a.y, x2 = b.b.x, y2 = b.b.y; const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy) || 1; const px = -dy / len, py = dx / len; ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 2; const offsets = b.order === 1 ? [0] : b.order === 2 ? [-3, 3] : [-5, 0, 5]; offsets.forEach(o => { ctx.beginPath(); ctx.moveTo(x1 + px * o, y1 + py * o); ctx.lineTo(x2 + px * o, y2 + py * o); ctx.stroke(); }); }); // pending bond line if (this._pendingBond) { const pa = this._pendingBond; ctx.strokeStyle = 'rgba(155,93,229,0.4)'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.arc(pa.x, pa.y, OrganicSim.ATOM_R[pa.sym] + 6, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } // atoms this._atoms.forEach(a => { const r = OrganicSim.ATOM_R[a.sym]; const col = OrganicSim.ATOM_COLOR[a.sym]; const bad = badAtoms.has(a); const sel = this._pendingBond === a; // glow if (sel) { ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 16; } else if (bad) { ctx.shadowColor = '#EF476F'; ctx.shadowBlur = 12; } ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fillStyle = bad ? '#EF476F30' : (sel ? 'rgba(155,93,229,0.35)' : `${col}28`); ctx.fill(); ctx.strokeStyle = bad ? '#EF476F' : col; ctx.lineWidth = bad ? 2 : 1.8; ctx.stroke(); ctx.shadowBlur = 0; ctx.shadowColor = 'transparent'; ctx.fillStyle = bad ? '#EF476F' : col; ctx.font = `700 ${a.sym.length > 1 ? '11' : '13'}px Manrope, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(a.sym, a.x, a.y); }); } /* ── Valence check ─────────────────────────────────────────────── */ _getBondCount(atom) { let count = 0; this._bonds.forEach(b => { if (b.a === atom || b.b === atom) count += b.order; }); return count; } _getBadValenceAtoms() { const bad = new Set(); this._atoms.forEach(a => { const v = OrganicSim.VALENCE[a.sym] || 1; const used = this._getBondCount(a); if (used > v) bad.add(a); }); return bad; } /* ── Formula + class detection ─────────────────────────────────── */ _updateFormula() { if (!this._formulaEl) return; const counts = {}; this._atoms.forEach(a => { counts[a.sym] = (counts[a.sym] || 0) + 1; }); // hill order: C first, H second, rest alphabetically let formula = ''; if (counts['C']) { formula += 'C'; if (counts['C'] > 1) formula += this._sub(counts['C']); } if (counts['H']) { formula += 'H'; if (counts['H'] > 1) formula += this._sub(counts['H']); } ['N','O','S','Cl'].forEach(s => { if (counts[s]) { formula += s; if (counts[s] > 1) formula += this._sub(counts[s]); } }); const klass = this._detectClass(); const struct = this._buildStructural(counts); const iupac = this._iupacName(counts, klass); const bad = this._getBadValenceAtoms(); const valMsg = bad.size > 0 ? Array.from(bad).map(a => `${a.sym}(${this._getBondCount(a)}/${OrganicSim.VALENCE[a.sym]})`).join(', ') + ' — превышена валентность' : (this._atoms.length > 0 ? 'OK' : '—'); this._formulaEl.textContent = formula || '—'; this._structEl.textContent = struct || '—'; this._classEl.textContent = klass || '—'; this._iupacEl.textContent = iupac || '—'; this._valenceEl.textContent = valMsg; this._valenceEl.style.color = bad.size > 0 ? '#EF476F' : '#34d399'; } _sub(n) { const map = '₀₁₂₃₄₅₆₇₈₉'; return String(n).split('').map(d => map[d]).join(''); } _buildStructural(counts) { if (!counts['C'] && !counts['H']) return ''; const parts = []; if (counts['C']) parts.push(`C${counts['C'] > 1 ? counts['C'] : ''}`); if (counts['H']) parts.push(`H${counts['H'] > 1 ? counts['H'] : ''}`); if (counts['O']) parts.push(`O${counts['O'] > 1 ? counts['O'] : ''}`); if (counts['N']) parts.push(`N${counts['N'] > 1 ? counts['N'] : ''}`); if (counts['Cl']) parts.push(`Cl${counts['Cl'] > 1 ? counts['Cl'] : ''}`); if (counts['S']) parts.push(`S${counts['S'] > 1 ? counts['S'] : ''}`); return parts.join('-'); } /* ── Class detection ───────────────────────────────────────────── */ _detectClass() { if (this._atoms.length === 0) return '—'; const hasDoubleCO = this._bonds.some(b => b.order === 2 && ((b.a.sym === 'C' && b.b.sym === 'O') || (b.a.sym === 'O' && b.b.sym === 'C'))); const hasDoubleCC = this._bonds.some(b => b.order === 2 && b.a.sym === 'C' && b.b.sym === 'C'); const hasTripleCC = this._bonds.some(b => b.order === 3 && b.a.sym === 'C' && b.b.sym === 'C'); // detect -OH group: O connected to C (sp3) and H const hasOH = this._atoms.some(a => { if (a.sym !== 'O') return false; const neighbors = this._getNeighbors(a); return neighbors.some(n => n.sym === 'H') && neighbors.some(n => n.sym === 'C'); }); // detect -CHO: C with double bond to O and single bond to H const hasCHO = this._atoms.some(a => { if (a.sym !== 'C') return false; const nb = this._getNeighbors(a); const hasHNeighbor = nb.some(n => n.sym === 'H'); const hasDoubleOBond = this._bonds.some(b => b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); return hasHNeighbor && hasDoubleOBond; }); // detect -COOH: C with double O and single O (which has H) const hasCOOH = this._atoms.some(a => { if (a.sym !== 'C') return false; const dblO = this._bonds.filter(b => b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length; const sngO = this._bonds.filter(b => b.order === 1 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length; return dblO >= 1 && sngO >= 1; }); // ketone: C with two double-bond O neighbors via C-C bonds with no H on the carbonyl C const hasKetone = hasCOOH ? false : (this._atoms.some(a => { if (a.sym !== 'C') return false; const nb = this._getNeighbors(a); const hasHNeighbor = nb.some(n => n.sym === 'H'); const hasDoubleOBond = this._bonds.some(b => b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); const hasCCBond = nb.filter(n => n.sym === 'C').length >= 2; return hasDoubleOBond && hasCCBond && !hasHNeighbor; })); // ester: -COO- (C with dbl O and O linked to C) const hasEster = this._atoms.some(a => { if (a.sym !== 'C') return false; const dblOBond = this._bonds.find(b => b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); if (!dblOBond) return false; const sngOs = this._bonds.filter(b => b.order === 1 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); return sngOs.some(b => { const oAtom = b.a === a ? b.b : b.a; const oNb = this._getNeighbors(oAtom); return oNb.some(n => n.sym === 'C' && n !== a); }); }); // ether: O linked to two C (no H on O) const hasEther = this._atoms.some(a => { if (a.sym !== 'O') return false; const nb = this._getNeighbors(a); return nb.filter(n => n.sym === 'C').length >= 2 && !nb.some(n => n.sym === 'H'); }); // amine: N with H or N between carbons const hasAmine = this._atoms.some(a => a.sym === 'N'); // halide const hasCl = this._atoms.some(a => a.sym === 'Cl'); const hasS = this._atoms.some(a => a.sym === 'S'); // only C & H present const onlyCH = this._atoms.every(a => a.sym === 'C' || a.sym === 'H'); // benzene ring detection (6 C in ring with alternating bonds) const hasBenzene = this._detectBenzene(); if (hasCOOH) return 'Карбоновая кислота (-COOH)'; if (hasEster) return 'Сложный эфир (-COO-)'; if (hasCHO) return 'Альдегид (-CHO)'; if (hasKetone) return 'Кетон (C=O между C)'; if (hasOH) return 'Спирт (-OH)'; if (hasAmine) return 'Амин (-NHₓ)'; if (hasBenzene) return 'Ароматическое (бензольное кольцо)'; if (hasTripleCC) return 'Алкин (C≡C)'; if (hasDoubleCC) return 'Алкен (C=C)'; if (hasCl) return 'Галогеналкан (-Cl)'; if (hasS) return 'Серосодержащее'; if (hasDoubleCO) return 'Карбонильное соединение (C=O)'; if (onlyCH) return this._detectCHClass(); return 'Органическое соединение'; } _detectCHClass() { // check if cycle present if (this._detectCycle()) return 'Циклоалкан'; return 'Алкан (только C-C + H)'; } _detectCycle() { if (this._atoms.length < 3) return false; // simple DFS cycle detection const visited = new Set(); const adj = new Map(); this._atoms.forEach(a => adj.set(a, [])); this._bonds.forEach(b => { adj.get(b.a).push(b.b); adj.get(b.b).push(b.a); }); let hasCycle = false; const dfs = (node, parent) => { if (hasCycle) return; visited.add(node); for (const nb of adj.get(node)) { if (nb === parent) continue; if (visited.has(nb)) { hasCycle = true; return; } dfs(nb, node); } }; if (this._atoms.length > 0) dfs(this._atoms[0], null); return hasCycle; } _detectBenzene() { // look for 6 C-atoms forming a ring with alternating single/double bonds const cAtoms = this._atoms.filter(a => a.sym === 'C'); if (cAtoms.length < 6) return false; // build adjacency among C atoms const adj = new Map(); cAtoms.forEach(a => adj.set(a, [])); this._bonds.forEach(b => { if (b.a.sym === 'C' && b.b.sym === 'C') { adj.get(b.a).push(b.b); adj.get(b.b).push(b.a); } }); // find if any C is in a 6-membered ring for (const start of cAtoms) { const path = [start]; const found = this._findRing(start, start, path, adj, 6); if (found) return true; } return false; } _findRing(start, cur, path, adj, targetLen) { if (path.length === targetLen) { return adj.get(cur).includes(start); } for (const nb of adj.get(cur)) { if (path.length > 1 && nb === path[path.length - 2]) continue; if (path.includes(nb)) continue; path.push(nb); if (this._findRing(start, nb, path, adj, targetLen)) return true; path.pop(); } return false; } _getNeighbors(atom) { const nb = []; this._bonds.forEach(b => { if (b.a === atom) nb.push(b.b); if (b.b === atom) nb.push(b.a); }); return nb; } /* ── IUPAC name for simple alkanes ─────────────────────────────── */ _iupacName(counts, klass) { if (!klass || !klass.includes('Алкан') || klass.includes('Цикло')) { if (!klass || !counts['C']) return '—'; } const prefixes = ['','мет','эт','проп','бут','пент','гекс','гепт','окт','нон','дек']; const n = counts['C'] || 0; if (n < 1 || n > 10) return n > 10 ? `алкан C${n}` : '—'; if (klass.includes('Циклоалкан')) return `цикло${prefixes[n]}ан`; if (klass.includes('Алкен')) return `${prefixes[n]}ен`; if (klass.includes('Алкин')) return `${prefixes[n]}ин`; if (klass.includes('Алкан')) return `${prefixes[n]}ан`; if (klass.includes('Спирт')) return `${prefixes[n]}анол-1`; if (klass.includes('Альдегид')) return `${prefixes[n]}аналь`; if (klass.includes('Кислота')) return `${prefixes[n]}ановая кислота`; if (klass.includes('Амин')) return `${prefixes[n]}иламин`; return '—'; } /* ══════════════════════════════════════════════════════════════ MODE 2 — HOMOLOGS ══════════════════════════════════════════════════════════════ */ _buildHomologs() { const panel = document.createElement('div'); panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0'; this._homoPanel = panel; this._content.appendChild(panel); // left controls const left = document.createElement('div'); left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:8px;' + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; panel.appendChild(left); const serLabel = document.createElement('div'); serLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; serLabel.textContent = 'Гомологический ряд'; left.appendChild(serLabel); this._serBtns = {}; Object.entries(OrganicSim.HOMOLOG_SERIES).forEach(([key, s]) => { const btn = document.createElement('button'); btn.textContent = s.name; btn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.75rem;font-family:inherit;text-align:left'; btn.addEventListener('click', () => this._selectSeries(key)); left.appendChild(btn); this._serBtns[key] = btn; }); const sep2 = document.createElement('div'); sep2.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; left.appendChild(sep2); const nLabel = document.createElement('div'); nLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; nLabel.textContent = 'Число атомов C'; left.appendChild(nLabel); const sliderRow = document.createElement('div'); sliderRow.style.cssText = 'display:flex;align-items:center;gap:8px'; const slider = document.createElement('input'); slider.type = 'range'; slider.min = 1; slider.max = 10; slider.value = 1; slider.style.cssText = 'flex:1;accent-color:#9B5DE5'; const nVal = document.createElement('span'); nVal.style.cssText = 'font-size:1rem;font-weight:700;color:#C9A0FF;min-width:18px;text-align:center'; nVal.textContent = '1'; slider.addEventListener('input', () => { this._homN = parseInt(slider.value); nVal.textContent = this._homN; this._drawHomologs(); }); sliderRow.appendChild(slider); sliderRow.appendChild(nVal); left.appendChild(sliderRow); this._homSlider = slider; this._homNVal = nVal; // center + right const center = document.createElement('div'); center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden'; panel.appendChild(center); // molecule sketch area const sketch = document.createElement('canvas'); sketch.style.cssText = 'width:100%;height:260px;flex-shrink:0;display:block'; this._homCanvas = sketch; center.appendChild(sketch); // properties table const tableWrap = document.createElement('div'); tableWrap.style.cssText = 'flex:1;overflow-y:auto;padding:12px 16px'; this._homTableWrap = tableWrap; center.appendChild(tableWrap); this._selectSeries('alkanes'); } _selectSeries(key) { this._homSeries = key; const s = OrganicSim.HOMOLOG_SERIES[key]; // adjust slider this._homSlider.min = s.minC; if (this._homN < s.minC) { this._homN = s.minC; this._homSlider.value = s.minC; this._homNVal.textContent = s.minC; } Object.entries(this._serBtns).forEach(([k, btn]) => { const active = k === key; btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)'; btn.style.color = active ? '#C9A0FF' : '#c0c0c0'; btn.style.fontWeight = active ? '700' : '400'; }); this._drawHomologs(); } _drawHomologs() { if (!this._homCanvas) return; const s = OrganicSim.HOMOLOG_SERIES[this._homSeries]; const n = this._homN; const idx = n - 1; // fit canvas const c = this._homCanvas; const rect = c.getBoundingClientRect(); if (!rect.width) return; c.width = Math.round(rect.width * devicePixelRatio); c.height = Math.round(rect.height * devicePixelRatio); const ctx = c.getContext('2d'); ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); const W = rect.width, H = rect.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#080810'; ctx.fillRect(0, 0, W, H); // draw 2D skeletal formula this._drawSkeletal(ctx, W, H, s, n); // properties table const tboil = s.tboil[idx]; const tmelt = s.tmelt[idx]; const state = s.state(n); const M = s.M(n); const formula = s.formula(n); const notable = s.notable && s.notable[n]; let html = `
${s.name} — ${formula}
${this._propRow('Молярная масса', `${M} г/моль`)} ${this._propRow('Т. кипения', tboil != null ? `${tboil} °C` : '—')} ${this._propRow('Т. плавления', tmelt != null ? `${tmelt} °C` : '—')} ${this._propRow('Агрегатное состояние (20°C)', state)}
Параметр Значение
`; if (notable) { html += `
${notable.name}
${notable.info}
`; } this._homTableWrap.innerHTML = html; } _propRow(label, value) { return ` ${label} ${value} `; } /* ── Skeletal formula 2D auto-drawing ─────────────────────────── */ _drawSkeletal(ctx, W, H, s, n) { const key = this._homSeries; const CX = W / 2, CY = H / 2; const bond = Math.min(55, (W - 80) / Math.max(n, 1)); const R = 13; ctx.fillStyle = '#080810'; ctx.fillRect(0, 0, W, H); // subtle grid ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); } for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); } // positions: zigzag for chain const positions = []; const totalW = bond * (n - 1); let startX = CX - totalW / 2; for (let i = 0; i < n; i++) { const y = CY + (i % 2 === 0 ? -12 : 12); positions.push({ x: startX + i * bond, y }); } // determine bond order for main chain let mainBondOrder = 1; if (key === 'alkenes') mainBondOrder = 2; if (key === 'alkynes') mainBondOrder = 3; const drawBond = (x1, y1, x2, y2, order) => { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy) || 1; const px = -dy/len, py = dx/len; ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.8; const offs = order === 1 ? [0] : order === 2 ? [-2.5,2.5] : [-4,0,4]; offs.forEach(o => { ctx.beginPath(); ctx.moveTo(x1 + px*o, y1 + py*o); ctx.lineTo(x2 + px*o, y2 + py*o); ctx.stroke(); }); }; // main chain bonds for (let i = 0; i < n - 1; i++) { const p1 = positions[i], p2 = positions[i+1]; const bo = (key === 'alkenes' && i === 0) ? 2 : (key === 'alkynes' && i === 0) ? 3 : 1; drawBond(p1.x, p1.y, p2.x, p2.y, bo); } // side groups: draw H or functional group atoms const colC = OrganicSim.ATOM_COLOR['C']; const colH = OrganicSim.ATOM_COLOR['H']; const colO = OrganicSim.ATOM_COLOR['O']; const colN = OrganicSim.ATOM_COLOR['N']; positions.forEach((p, i) => { let hs = 0; if (key === 'alkanes') hs = (i === 0 || i === n-1) ? 3 : 2; if (key === 'alkenes') hs = (i === 0) ? 2 : (i === n-1) ? 3 : 2; if (key === 'alkynes') hs = (i === 0 || i === 1) ? 1 : (i === n-1) ? 3 : 2; if (key === 'alcohols') { hs = (i === 0 || i === n-1) ? 3 : 2; if (i === n-1) { // draw -OH drawBond(p.x, p.y, p.x + 30, p.y - 20, 1); ctx.beginPath(); ctx.arc(p.x+30, p.y-20, R, 0, Math.PI*2); ctx.fillStyle = OrganicSim.ATOM_COLOR['O']+'30'; ctx.fill(); ctx.strokeStyle = OrganicSim.ATOM_COLOR['O']; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle = OrganicSim.ATOM_COLOR['O']; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('O', p.x+30, p.y-20); // H on O drawBond(p.x+30, p.y-20, p.x+44, p.y-30, 1); ctx.beginPath(); ctx.arc(p.x+44, p.y-30, R-4, 0, Math.PI*2); ctx.fillStyle = colH+'30'; ctx.fill(); ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke(); ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x+44, p.y-30); hs = 2; } } if (key === 'aldehydes') { hs = i === 0 ? 1 : (i === n-1) ? 3 : 2; if (i === 0) { // draw =O drawBond(p.x, p.y, p.x - 28, p.y - 22, 2); ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2); ctx.fillStyle = colO+'30'; ctx.fill(); ctx.strokeStyle = colO; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle = colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('O', p.x-28, p.y-22); // H on C-1 drawBond(p.x, p.y, p.x - 28, p.y + 22, 1); ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R-4, 0, Math.PI*2); ctx.fillStyle = colH+'30'; ctx.fill(); ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke(); ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-28, p.y+22); } } if (key === 'acids') { hs = i === 0 ? 0 : (i === n-1) ? 3 : 2; if (i === 0) { // draw -COOH drawBond(p.x, p.y, p.x-28, p.y-22, 2); ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2); ctx.fillStyle = colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle=colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('O', p.x-28, p.y-22); drawBond(p.x, p.y, p.x-28, p.y+22, 1); ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R, 0, Math.PI*2); ctx.fillStyle=colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle=colO; ctx.fillText('O', p.x-28, p.y+22); // H on single O drawBond(p.x-28, p.y+22, p.x-42, p.y+32, 1); ctx.beginPath(); ctx.arc(p.x-42, p.y+32, R-4, 0, Math.PI*2); ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke(); ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-42, p.y+32); } } if (key === 'amines') { hs = i === n-1 ? 2 : (i === 0 ? 1 : 2); if (i === n-1) { // draw -NH₂ drawBond(p.x, p.y, p.x+28, p.y-22, 1); ctx.beginPath(); ctx.arc(p.x+28, p.y-22, R, 0, Math.PI*2); ctx.fillStyle=colN+'30'; ctx.fill(); ctx.strokeStyle=colN; ctx.lineWidth=1.5; ctx.stroke(); ctx.fillStyle=colN; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('N', p.x+28, p.y-22); // 2 H on N [[p.x+42,p.y-32],[p.x+42,p.y-12]].forEach(([hx,hy]) => { drawBond(p.x+28, p.y-22, hx, hy, 1); ctx.beginPath(); ctx.arc(hx,hy,R-4,0,Math.PI*2); ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke(); ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H',hx,hy); }); } } // draw H on main C const hPositions = [[0,-1],[0,1],[-1,0]].slice(0, hs); hPositions.forEach(([hx0, hy0], hi) => { const angle = (hi / Math.max(hs,1)) * Math.PI + (i%2===0 ? Math.PI*1.1 : Math.PI*0.1); const hx = p.x + Math.cos(angle) * (bond*0.55); const hy = p.y + Math.sin(angle) * (bond*0.55); drawBond(p.x, p.y, hx, hy, 1); ctx.beginPath(); ctx.arc(hx, hy, R-3, 0, Math.PI*2); ctx.fillStyle = colH+'25'; ctx.fill(); ctx.strokeStyle = colH; ctx.lineWidth = 1.2; ctx.stroke(); ctx.fillStyle = colH; ctx.font = 'bold 10px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('H', hx, hy); }); // main C atom ctx.beginPath(); ctx.arc(p.x, p.y, R, 0, Math.PI*2); ctx.fillStyle = colC+'30'; ctx.fill(); ctx.strokeStyle = colC; ctx.lineWidth = 1.8; ctx.stroke(); ctx.fillStyle = colC; ctx.font = 'bold 12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('C', p.x, p.y); }); // formula label const formula = s.formula(n); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.fillText(formula, 12, H - 12); } /* ══════════════════════════════════════════════════════════════ MODE 3 — QUALITATIVE REACTIONS ══════════════════════════════════════════════════════════════ */ _buildQualitative() { const panel = document.createElement('div'); panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0'; this._qualPanel = panel; this._content.appendChild(panel); // left reagent selector const left = document.createElement('div'); left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; panel.appendChild(left); const rl = document.createElement('div'); rl.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4);margin-bottom:2px'; rl.textContent = 'Реагент в пробирке'; left.appendChild(rl); this._qualBtns = {}; OrganicSim.QUAL_REACTIONS.forEach(rxn => { const btn = document.createElement('button'); btn.textContent = rxn.desc; btn.style.cssText = 'padding:6px 8px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.72rem;font-family:inherit;text-align:left;line-height:1.3'; btn.addEventListener('click', () => this._selectQual(rxn)); left.appendChild(btn); this._qualBtns[rxn.id] = btn; }); const sep3 = document.createElement('div'); sep3.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; left.appendChild(sep3); const hint3 = document.createElement('div'); hint3.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4'; hint3.textContent = 'Выберите реагент → затем кликните на вещество для реакции'; left.appendChild(hint3); // center: test tube canvas const center = document.createElement('div'); center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative'; panel.appendChild(center); const qualCanvas = document.createElement('canvas'); qualCanvas.style.cssText = 'width:100%;flex:1;display:block'; this._qualCanvas = qualCanvas; center.appendChild(qualCanvas); // compounds area const compArea = document.createElement('div'); compArea.style.cssText = 'padding:8px 12px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);' + 'display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0'; this._compArea = compArea; center.appendChild(compArea); // right: result panel const right = document.createElement('div'); right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' + 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto'; panel.appendChild(right); const rt = document.createElement('div'); rt.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; rt.textContent = 'Результат'; right.appendChild(rt); this._qualResultEl = this._infoBox(right, 'Наблюдение', '—'); this._qualEqEl = this._infoBox(right, 'Уравнение реакции', '—'); this._qualEqEl.style.fontSize = '.72rem'; this._qualEqEl.style.lineHeight = '1.4'; const resetBtn = document.createElement('button'); resetBtn.textContent = 'Сбросить'; resetBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' + 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit;margin-top:auto'; resetBtn.addEventListener('click', () => { this._qualCompound = null; this._qualAnim = null; this._qualResultEl.textContent = '—'; this._qualEqEl.textContent = '—'; this._drawQual(); }); right.appendChild(resetBtn); this._selectQual(OrganicSim.QUAL_REACTIONS[0]); } _selectQual(rxn) { this._qualReaction = rxn; this._qualCompound = null; this._qualAnim = null; this._qualResultEl.textContent = '—'; this._qualEqEl.textContent = '—'; Object.entries(this._qualBtns).forEach(([id, btn]) => { const active = id === rxn.id; btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)'; btn.style.color = active ? '#C9A0FF' : '#c0c0c0'; btn.style.fontWeight = active ? '700' : '400'; }); // build compound buttons this._compArea.innerHTML = ''; rxn.compounds.forEach(comp => { const btn = document.createElement('button'); btn.textContent = comp.name; btn.style.cssText = 'padding:5px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);' + 'background:rgba(255,255,255,0.06);color:#e0e0e0;cursor:pointer;font-size:.78rem;font-family:inherit;' + 'transition:all .15s'; btn.addEventListener('click', () => this._runQualReaction(comp)); btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(155,93,229,0.2)'; btn.style.borderColor = '#9B5DE5'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.06)'; btn.style.borderColor = 'rgba(255,255,255,0.12)'; }); this._compArea.appendChild(btn); }); this._drawQual(); } _runQualReaction(comp) { this._qualCompound = comp; this._qualResultEl.textContent = comp.result; this._qualEqEl.textContent = comp.equation; this._qualResultEl.style.color = comp.symbol === '+' ? '#34d399' : '#EF476F'; // start animation this._qualAnim = { compound: comp, t: 0, maxT: 120, }; this._animQual(); } _animQual() { if (!this._qualAnim) return; this._qualAnim.t++; this._drawQual(); if (this._qualAnim.t < this._qualAnim.maxT) { this._raf = requestAnimationFrame(() => this._animQual()); } } _drawQual() { const c = this._qualCanvas; if (!c) return; const rect = c.getBoundingClientRect(); if (!rect.width) return; c.width = Math.round(rect.width * devicePixelRatio); c.height = Math.round(rect.height * devicePixelRatio); const ctx = c.getContext('2d'); ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); const W = rect.width, H = rect.height; ctx.fillStyle = '#080810'; ctx.fillRect(0, 0, W, H); // subtle grid ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); } for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); } const rxn = this._qualReaction; const comp = this._qualCompound; const anim = this._qualAnim; // draw multiple test tubes const tubes = rxn.compounds; const tubeW = 56, tubeH = 150, gap = 20; const totalW = tubes.length * (tubeW + gap) - gap; let startX = (W - totalW) / 2; tubes.forEach((tube, i) => { const tx = startX + i * (tubeW + gap); const ty = (H - tubeH) / 2 - 10; const isActive = comp && comp === tube; const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0; this._drawTestTube(ctx, tx, ty, tubeW, tubeH, rxn, tube, progress, isActive); // label ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)'; ctx.font = `${isActive ? '700' : '400'} 10px Manrope,sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const label = tube.name.length > 12 ? tube.name.substring(0,11)+'…' : tube.name; ctx.fillText(label, tx + tubeW/2, ty + tubeH + 8); }); // reagent label ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, H - 4); } _drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) { const liqH = h * 0.55; const liqY = y + h - liqH; // glass tube outline ctx.save(); ctx.strokeStyle = isActive ? '#9B5DE5' : 'rgba(255,255,255,0.25)'; ctx.lineWidth = isActive ? 2 : 1.5; // tube shape: rect top + rounded bottom ctx.beginPath(); ctx.moveTo(x + 4, y); ctx.lineTo(x + 4, y + h - w/2 + 4); ctx.arcTo(x + 4, y + h, x + w/2, y + h, w/2 - 4); ctx.arcTo(x + w - 4, y + h, x + w - 4, y + h - w/2 + 4, w/2 - 4); ctx.lineTo(x + w - 4, y); ctx.stroke(); // clip to tube for liquid ctx.beginPath(); ctx.rect(x + 4, liqY, w - 8, liqH - 8); ctx.arc(x + w/2, y + h - (w/2 - 4), w/2 - 4, 0, Math.PI); ctx.clip(); // base liquid (reagent color) const baseColor = rxn.reagentLiquid; ctx.fillStyle = baseColor + 'A0'; ctx.fillRect(x + 4, liqY, w - 8, liqH); // result color overlay animated if (progress > 0) { const resColor = comp.resultColor; ctx.globalAlpha = progress; ctx.fillStyle = resColor; ctx.fillRect(x + 4, liqY, w - 8, liqH); ctx.globalAlpha = 1; // precipitate settling if (comp.symbol === '+' && progress > 0.4) { const precH = (progress - 0.4) / 0.6 * 20; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillRect(x + 4, y + h - (w/2-4) - precH, w - 8, precH); } // silver mirror effect if (comp.mirror && progress > 0.3) { const mirrorA = (progress - 0.3) / 0.7; const grad = ctx.createLinearGradient(x+4, liqY, x+w-4, liqY); grad.addColorStop(0, `rgba(220,220,220,${mirrorA*0.3})`); grad.addColorStop(0.5, `rgba(240,240,240,${mirrorA*0.8})`); grad.addColorStop(1, `rgba(200,200,200,${mirrorA*0.3})`); ctx.fillStyle = grad; ctx.fillRect(x + 4, liqY, w - 8, 8); } // gas bubbles if (comp.gas && progress > 0.2) { const numBubbles = Math.floor((progress - 0.2) / 0.8 * 8); for (let b = 0; b < numBubbles; b++) { const bx = x + 8 + (b * 7) % (w - 16); const by = y + h - 30 - (((this._qualAnim ? this._qualAnim.t : 0) * 3 + b * 15) % 80); ctx.beginPath(); ctx.arc(bx, by, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(200,220,255,0.6)'; ctx.fill(); } } // heat shimmer lines if (comp.heat && progress > 0.5) { ctx.strokeStyle = 'rgba(255,120,50,0.4)'; ctx.lineWidth = 1; for (let ln = 0; ln < 3; ln++) { ctx.beginPath(); const lx = x + 10 + ln * 12; ctx.moveTo(lx, liqY + 10); ctx.quadraticCurveTo(lx + 4, liqY + 20, lx, liqY + 30); ctx.stroke(); } } } ctx.restore(); // result symbol badge if (comp.symbol) { ctx.fillStyle = comp.symbol === '+' ? '#34d399' : '#EF476F'; ctx.font = 'bold 14px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(comp.symbol, x + w/2, y - 12); } else { // pending indicator ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('?', x + w/2, y - 12); } } /* ── Lifecycle ─────────────────────────────────────────────────── */ start() { this._buildHomologs(); this._buildQualitative(); this._setMode(this._mode); } stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } fit() { if (this._mode === 'constructor') this._drawMolecule(); if (this._mode === 'homologs') this._drawHomologs(); if (this._mode === 'qualitative') this._drawQual(); } } /* ─── lab UI init ─────────────────────────────────── */ var organicSim = null; function _openOrganic() { document.getElementById('sim-topbar-title').textContent = 'Органическая химия'; _simShow('sim-organic'); requestAnimationFrame(() => requestAnimationFrame(() => { const wrap = document.getElementById('sim-organic'); if (!organicSim) { organicSim = new OrganicSim(wrap); organicSim.start(); } else { organicSim.fit(); } })); }