Files
Maxim Dolgolyov ea2526dc73 feat(labs): 4 школьные хим. симы + визуальная прокачка лаборатории
4 НОВЫЕ СИМЫ (школьная программа 8-11 классов):

Органика (organic.js, 1545 строк):
- Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds
- Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат
- IUPAC-имена для C1-C10
- Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл
- 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂

Периодическая таблица (periodic.js, 118 элементов):
- Стандартный вид 18×9 + лантаноиды/актиноиды
- Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип
- Боровская модель электронных оболочек (анимированная)
- Подсветка: 11 типов / s/p/d/f-блоки / без подсветки
- Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип)
- Поиск по символу/имени/Z/массе

Качественный анализ (qualanalysis.js, 24 иона):
- 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH
- 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO
- 9 реактивов + пламя
- 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений
- Анимация капли, осадка с цветом, газовых пузырей, пламени

Растворы (solutions.js, 4 режима):
- Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта
- Разбавление с before/after визуализацией
- Смешивание двух растворов с правилом рычага
- Кривые растворимости 8 веществ + задача перекристаллизации
- 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...)

ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file):

12 функций школьной лабораторной графики:
- drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой
- drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя
- animateGasBubbles / animatePrecipitateFall — анимация продуктов
- drawProductLabel — fade-in/out стрелка ↑/↓ с подписью
- drawEduTooltip — bubble с пояснением реакции
- drawDeskBackground / drawVesselShadow — лабораторный фон
- drawPHStrip — pH-индикаторная полоса с маркером

Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox
Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов,
educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask.
pH-полоса в titration.

Каталог теперь: 39 симуляций (было 35 + 4 новых).

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

1546 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 = `
<div style="font-size:1rem;font-weight:700;color:#C9A0FF;margin-bottom:10px">${s.name}${formula}</div>
<table style="width:100%;border-collapse:collapse;font-size:.8rem">
<thead>
<tr style="color:rgba(255,255,255,0.4);font-size:.65rem;text-transform:uppercase;letter-spacing:.05em">
<th style="text-align:left;padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.07)">Параметр</th>
<th style="text-align:right;padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.07)">Значение</th>
</tr>
</thead>
<tbody>
${this._propRow('Молярная масса', `${M} г/моль`)}
${this._propRow('Т. кипения', tboil != null ? `${tboil} °C` : '—')}
${this._propRow('Т. плавления', tmelt != null ? `${tmelt} °C` : '—')}
${this._propRow('Агрегатное состояние (20°C)', state)}
</tbody>
</table>`;
if (notable) {
html += `<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:rgba(155,93,229,0.1);border:1px solid rgba(155,93,229,0.25)">
<div style="font-size:.75rem;font-weight:700;color:#C9A0FF;margin-bottom:4px">${notable.name}</div>
<div style="font-size:.75rem;color:rgba(255,255,255,0.6);line-height:1.4">${notable.info}</div>
</div>`;
}
this._homTableWrap.innerHTML = html;
}
_propRow(label, value) {
return `<tr>
<td style="padding:5px 8px;color:rgba(255,255,255,0.55);border-bottom:1px solid rgba(255,255,255,0.05)">${label}</td>
<td style="padding:5px 8px;text-align:right;font-weight:700;color:#e8e8e8;border-bottom:1px solid rgba(255,255,255,0.05)">${value}</td>
</tr>`;
}
/* ── 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();
}
}));
}