'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 = `
| Параметр | Значение |
|---|