ea2526dc73
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>
1546 lines
66 KiB
JavaScript
1546 lines
66 KiB
JavaScript
'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();
|
||
}
|
||
}));
|
||
}
|