AgCl / Fe(SCN) / BaSO4`);
+
+ /* Organic Chemistry preview — benzene ring + OH group */
+ const P_ORGANIC = _svg(`
+
+ ${_grid('rgba(255,255,255,0.03)')}
+
+
+
+
+
+ C
+ C
+ C
+ C
+ C
+ C
+
+
+
+ O
+
+
+ H
+
+
+
+ C
+ Конструктор · Ряды · Качественные реакции`);
+
+ /* Solutions preview */
+ const P_SOLUTIONS = _svg(`
+
+
+
+
+
+
+ 20%
+
+
+ ω%
+ C-M
+ ν моль
+ ω = m₀/m · 100%
+ Калькулятор · Разбавление · Смешивание · S(T)`);
+
const SIMS = [
/* ── Математика ── */
{ id: 'graph', cat: 'math',
@@ -790,6 +875,22 @@
title: 'Кристаллическая решётка',
desc: 'NaCl, алмаз, металл — интерактивная 3D-решётка, типы связей, вращение структуры.',
preview: P_CRYSTAL },
+ { id: 'qualanalysis', cat: 'chem',
+ title: 'Качественный анализ',
+ desc: 'Определяй катионы и анионы качественными реакциями: осадки, газы, пламя. Два режима: guided и свободный эксперимент.',
+ preview: P_QUALANALYSIS },
+ { id: 'periodic', cat: 'chem',
+ title: 'Периодическая таблица',
+ desc: '118 элементов: подсветка по типу/блоку, карточка элемента, боровские оболочки, графики свойств.',
+ preview: P_PERIODIC },
+ { id: 'organic', cat: 'chem',
+ title: 'Органическая химия',
+ desc: 'Конструктор молекул с проверкой валентности, гомологические ряды с таблицей свойств, качественные реакции (бромная вода, KMnO₄, зеркало Толленса, Cu(OH)₂, FeCl₃, Na).',
+ preview: P_ORGANIC },
+ { id: 'solutions', cat: 'chem',
+ title: 'Растворы',
+ desc: 'Калькулятор раствора: ω, ν, C_M, плотность. Разбавление и смешивание с визуализацией. Кривые растворимости S(T) для 8 веществ + задача на перекристаллизацию.',
+ preview: P_SOLUTIONS },
/* ── Биология ── */
{ id: 'celldivision', cat: 'bio',
title: 'Деление клетки',
diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js
index f178b62..a9b0a17 100644
--- a/frontend/js/labs/lab-init.js
+++ b/frontend/js/labs/lab-init.js
@@ -28,6 +28,7 @@
var elecSim = null;
var wavesSim = null;
var geomSim = null;
+ var qualSim = null;
var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield',
'sim-molphys',
@@ -37,7 +38,8 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-opticsbench','sim-titration',
'sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
- 'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic'];
+ 'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic',
+ 'sim-qualanalysis','sim-periodic','sim-organic','sim-solutions'];
var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-emfield',
'ctrl-molphys',
'ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
@@ -104,6 +106,10 @@
if (id === 'logic') _openLogic();
if (id === 'heatengine') _openHeatEngine();
if (id === 'stoichiometry') _openStoich();
+ if (id === 'qualanalysis') _openQualAnalysis();
+ if (id === 'periodic') _openPeriodic();
+ if (id === 'organic') _openOrganic();
+ if (id === 'solutions') _openSolutions();
}
function _simShow(elId) {
@@ -188,6 +194,10 @@
if (wavesSim) wavesSim.stop();
if (radioactiveSim) radioactiveSim.stop();
if (heSim) heSim.stop();
+ if (qualSim) qualSim.stop();
+ if (periodicSim) periodicSim.stop();
+ if (organicSim) organicSim.stop();
+ if (_solutionsSim) _solutionsSim.stop();
if (mirrorSim && mirrorSim._playing) mirrorSim._stopAnim();
if (mirrorSim && mirrorSim._photonRaf) mirrorSim._stopPhotons();
// tSim, csSim, quadSim, ndSim, gtSim, lensSim, refrSim have no animation loops — nothing to stop
@@ -633,6 +643,17 @@
{ head: 'Типы распадов', text: 'α-распад: ядро теряет ⁴He (масса -4, заряд -2). β-распад: нейтрон → протон + e⁻ + ν̅. γ-излучение: энергетический переход без изменения нуклидов.' },
]
},
+ qualanalysis: {
+ title: 'Качественный анализ',
+ sections: [
+ { head: 'Качественная реакция', text: 'Реакция, позволяющая обнаружить определённый ион по характерному внешнему признаку: образование осадка, выделение газа, изменение цвета раствора или пламени.' },
+ { head: 'Пламя: катионы', text: 'Na+ — жёлтое. K+ — фиолетовое (через синее стекло). Ca2+ — кирпично-красное. Ba2+ — зелёное.' },
+ { head: 'Fe2+ / Fe3+', text: 'Fe2+ + K3[Fe(CN)6] → Турнбулева синь. Fe3+ + KSCN → ярко-красный раствор.' },
+ { head: 'Галогениды', text: 'Cl- + AgNO3 → белый AgCl (нераств. в HNO3). Br- → желтоватый AgBr. I- → жёлтый AgI.' },
+ { head: 'SO4(2-) и CO3(2-)', text: 'SO4(2-) + BaCl2 → белый BaSO4 (нераств. в HNO3). CO3(2-) + H+ → CO2 (мутит Ca(OH)2).' },
+ { head: 'Амфотерность Al3+ и Zn2+', text: 'NaOH (мало) → белый осадок. NaOH (избыток) → растворяется: [Al(OH)4]- или [Zn(OH)4]2-.' },
+ ]
+ },
heatengine: {
title: 'Тепловые двигатели',
sections: [
@@ -646,8 +667,43 @@
{ head: 'Цикл Брайтона (ГТД)', text: '2 адиабаты + 2 изобары. Основа авиадвигателей и газовых турбин. η зависит от степени повышения давления.' },
]
},
+ periodic: {
+ title: 'Периодическая таблица',
+ sections: [
+ { head: 'Периодический закон', text: 'Свойства химических элементов находятся в периодической зависимости от зарядов их атомных ядер. Сформулирован Д. И. Менделеевым в 1869 году.' },
+ { head: 'Группы и периоды', text: 'Период — горизонтальный ряд; номер периода = число электронных оболочек. Группа — вертикальный столбец; определяет валентность и свойства соединений.' },
+ { head: 's/p/d/f-блоки', text: 's-блок: группы 1–2 (щелочные, щёлочноземельные). p-блок: группы 13–18. d-блок: переходные металлы (группы 3–12). f-блок: лантаноиды и актиноиды.' },
+ { head: 'Электроотрицательность', text: 'Мера способности атома притягивать электроны в химической связи (шкала Полинга). Растёт слева направо по периоду и снизу вверх по группе. Максимум — фтор (3.98).' },
+ { head: 'Атомный радиус', text: 'Уменьшается слева направо (рост заряда ядра) и увеличивается сверху вниз (добавление оболочек).' },
+ { head: 'Металличность', text: 'Металлические свойства убывают слева направо и нарастают сверху вниз. Металлоиды (Si, Ge, As...) — граница металл/неметалл.' },
+ ]
+ },
+ organic: {
+ title: 'Органическая химия',
+ sections: [
+ { head: 'Алканы (CₙH₂ₙ₊₂)', text: 'Насыщенные углеводороды. Все связи одинарные C–C и C–H. sp³-гибридизация. Химически инертны при н.у. Горение, галогенирование (радикальное).' },
+ { head: 'Алкены и алкины', text: 'Алкены (CₙH₂ₙ): одна двойная связь C=C, sp²-гибридизация. Алкины (CₙH₂ₙ₋₂): тройная связь C≡C, sp-гибридизация. Реакции присоединения.' },
+ { head: 'Функциональные группы', text: '-OH спирт; -CHO альдегид; -CO- кетон; -COOH карб.кислота; -NH₂ амин; -Cl галогенид; -COO- сложный эфир; -O- простой эфир.' },
+ { head: 'Качественные реакции', text: 'Br₂(водн): алкены/алкины/фенол — обесцвечивание. KMnO₄: ненасыщенные/альдегиды — обесцвечивание. Ag₂O/NH₃: альдегиды — серебро. Cu(OH)₂: многоатомный спирт — синий; альдегид/нагрев — красный Cu₂O. FeCl₃: фенол — фиолетовый. Na: спирт — H₂.' },
+ { head: 'Гомологический ряд', formula: 'C_nH_{2n+2}\\xrightarrow{+CH_2}C_{n+1}H_{2n+4}', text: 'Гомологи отличаются на группу CH₂. Закономерный рост Tкип с ростом n.' },
+ { head: 'Гибридизация углерода', text: 'sp³: тетраэдр 109.5° (алканы, спирты). sp²: плоский 120° (алкены, альдегиды, кислоты). sp: линейная 180° (алкины).' },
+ ]
+ },
+ solutions: {
+ title: 'Растворы',
+ sections: [
+ { head: 'Массовая доля', formula: '\\omega = \\frac{m_в}{m_{р-ра}} \\times 100\\%', vars: [['m_в','масса растворённого вещества, г'],['m_{р-ра}','масса раствора, г']] },
+ { head: 'Молярная концентрация', formula: 'C_M = \\frac{\\nu}{V} = \\frac{m_в}{M \\cdot V}', vars: [['\\nu','количество вещества, моль'],['V','объём раствора, л'],['M','молярная масса, г/моль']] },
+ { head: 'Связь с плотностью', formula: 'C_M = \\frac{10 \\cdot \\rho \\cdot \\omega}{M}', vars: [['\\rho','плотность раствора, г/мл'],['\\omega','массовая доля, %']] },
+ { head: 'Разбавление', formula: 'm_1 \\cdot \\omega_1 = m_2 \\cdot \\omega_2', text: 'Масса растворённого вещества при разбавлении не меняется. ω₂ = m_в / (m₁ + m_воды).' },
+ { head: 'Смешивание', formula: 'm_3 \\omega_3 = m_1 \\omega_1 + m_2 \\omega_2', text: 'Правило рычага: m₁(ω₃ − ω₁) = m₂(ω₂ − ω₃). Итоговая концентрация — между ω₁ и ω₂.' },
+ { head: 'Растворимость S', text: 'S — масса вещества (г) в 100 г воды при насыщении. Большинство солей: растворимость растёт с T. Газы: убывает. KNO₃: 13.3 г (0°C) → 247 г (100°C). NaCl: почти не меняется.' },
+ { head: 'Перекристаллизация', formula: 'm_{осадка} = m_{KNO_3} - \\frac{S_2}{100} \\cdot m_{H_2O}', text: 'Охлаждение насыщенного раствора KNO₃: при 80°C S=169 г, при 20°C S=31.6 г — часть соли выпадает в осадок.' },
+ ]
+ },
};
/* ══════════════════════════════════════════════
HYDROSTATICS
══════════════════════════════════════════════ */
+
diff --git a/frontend/js/labs/organic.js b/frontend/js/labs/organic.js
new file mode 100644
index 0000000..498d79b
--- /dev/null
+++ b/frontend/js/labs/organic.js
@@ -0,0 +1,1545 @@
+'use strict';
+
+/* ══════════════════════════════════════════════════════════════════
+ OrganicSim — «Органическая химия»
+ 3 sub-modes:
+ 1. «Конструктор молекул» — canvas 2D drag-drop builder, valence check,
+ auto-class detection, IUPAC naming for alkanes
+ 2. «Гомологические ряды» — preset series with property table
+ 3. «Качественные реакции» — drag-drop reagent into test-tube, animations
+ ══════════════════════════════════════════════════════════════════ */
+
+class OrganicSim {
+
+ /* ── Atom valences ─────────────────────────────────────────────── */
+ static VALENCE = { C: 4, H: 1, O: 2, N: 3, Cl: 1, S: 2 };
+
+ /* ── Atom display colors ───────────────────────────────────────── */
+ static ATOM_COLOR = {
+ C: '#9B5DE5',
+ H: '#E0E0E0',
+ O: '#EF476F',
+ N: '#4CC9F0',
+ Cl: '#34d399',
+ S: '#FFD166',
+ };
+
+ /* ── Atom radii on canvas ──────────────────────────────────────── */
+ static ATOM_R = { C: 20, H: 13, O: 17, N: 16, Cl: 17, S: 17 };
+
+ /* ── Homologous series data ────────────────────────────────────── */
+ static HOMOLOG_SERIES = {
+ alkanes: {
+ name: 'Алканы', formula: (n) => `C${n}H${2*n+2}`, minC: 1,
+ tboil: [-161.5,-88.6,-42.1,-0.5,36.1,68.7,98.4,125.6,150.8,174.1],
+ tmelt: [-182.5,-183.3,-187.7,-138.3,-129.7,-95.3,-90.6,-56.8,-53.5,-29.7],
+ state: (n) => n<=4 ? 'Газ' : n<=17 ? 'Жидкость' : 'Твёрдое',
+ M: (n) => 12*n + (2*n+2),
+ notable: {
+ 1: { name: 'Метан', info: 'Природный газ, топливо, 87% в составе природного газа.' },
+ 2: { name: 'Этан', info: 'Компонент природного газа, сырьё для производства этилена.' },
+ 6: { name: 'Гексан', info: 'Растворитель для экстракции масел.' },
+ }
+ },
+ alkenes: {
+ name: 'Алкены', formula: (n) => `C${n}H${2*n}`, minC: 2,
+ tboil: [null,-103.7,-47.6,-6.3,30,63.5,93.6,121.3,146.9,170.5],
+ tmelt: [null,-169.1,-185.2,-185.3,-138.9,-119.7,-101.7,-101.7,-86.9,-75.6],
+ state: (n) => n<=4 ? 'Газ' : 'Жидкость',
+ M: (n) => 12*n + 2*n,
+ notable: {
+ 2: { name: 'Этилен', info: 'Фитогормон, сырьё для полиэтилена. Участвует в созревании плодов.' },
+ 3: { name: 'Пропилен', info: 'Мономер полипропилена. Производство пластмасс и каучука.' },
+ }
+ },
+ alkynes: {
+ name: 'Алкины', formula: (n) => `C${n}H${2*n-2}`, minC: 2,
+ tboil: [null,-84,23.2,8.1,26.1,71.4,99.7,125.2,150.8,174],
+ tmelt: [null,-80.8,-101.5,-123.1,-130,-90,-81,-79.3,-65,-36],
+ state: (n) => n<=4 ? 'Газ' : 'Жидкость',
+ M: (n) => 12*n + (2*n-2),
+ notable: {
+ 2: { name: 'Ацетилен', info: 'Сварка металлов, синтез уксусной кислоты, ПВХ.' },
+ }
+ },
+ alcohols: {
+ name: 'Спирты', formula: (n) => `C${n}H${2*n+1}OH`, minC: 1,
+ tboil: [64.7,78.4,97.2,117.7,137.8,157.9,176,194,213,231],
+ tmelt: [-97.8,-114.1,-126,-89.5,-79,-51.6,-34.5,-17,21,6],
+ state: (n) => n<=11 ? 'Жидкость' : 'Твёрдое',
+ M: (n) => 12*n + (2*n+1) + 17,
+ notable: {
+ 1: { name: 'Метанол', info: 'Метиловый спирт. Топливо, растворитель. Ядовит!' },
+ 2: { name: 'Этанол', info: 'Этиловый спирт. Напитки, медицина, растворитель, биотопливо.' },
+ 3: { name: 'Пропанол', info: 'Растворитель, дезинфектант.' },
+ }
+ },
+ aldehydes: {
+ name: 'Альдегиды', formula: (n) => `C${n}H${2*n}O`, minC: 1,
+ tboil: [-21,20.2,48.8,74.8,103,128,152,173,191,208],
+ tmelt: [-92,-123,-81,-99,-91.5,-66,-45,-26,-12,4],
+ state: (n) => n<=4 ? 'Газ' : 'Жидкость',
+ M: (n) => 12*n + 2*n + 16,
+ notable: {
+ 1: { name: 'Формальдегид', info: 'Консервант, производство смол. Ядовит.' },
+ 2: { name: 'Уксусный альдегид', info: 'Сырьё для уксусной кислоты.' },
+ }
+ },
+ acids: {
+ name: 'Карб. кислоты', formula: (n) => `C${n}H${2*n}O₂`, minC: 1,
+ tboil: [100.7,118.1,141.2,163.7,186.3,205.3,223.1,239.3,253.9,268.5],
+ tmelt: [8.3,16.6,-20.5,-7.9,-33.8,-8,16.5,12,15,31.5],
+ state: (n) => n<=3 ? 'Жидкость (смеш)' : 'Жидкость',
+ M: (n) => 12*n + 2*n + 32,
+ notable: {
+ 1: { name: 'Муравьиная', info: 'Укусы муравьёв, пчёл. Кожное средство.' },
+ 2: { name: 'Уксусная', info: '3–9% в столовом уксусе. Консервация, растворитель, синтез.' },
+ 3: { name: 'Пропионовая', info: 'Консервант (E280). Производство гербицидов.' },
+ }
+ },
+ amines: {
+ name: 'Амины', formula: (n) => `C${n}H${2*n+3}N`, minC: 1,
+ tboil: [-6.3,16.6,48.7,77.8,104.4,130.5,154,177,199,220],
+ tmelt: [-93.5,-81,-83,-50,-55,-23,-12,0,17,30],
+ state: (n) => n<=2 ? 'Газ' : 'Жидкость',
+ M: (n) => 12*n + (2*n+3) + 14,
+ notable: {
+ 1: { name: 'Метиламин', info: 'Рыбный запах, синтез красителей, фармацевтика.' },
+ 2: { name: 'Этиламин', info: 'Растворитель, производство каучука.' },
+ }
+ },
+ };
+
+ /* ── Qualitative reactions ─────────────────────────────────────── */
+ static QUAL_REACTIONS = [
+ {
+ id: 'bromine',
+ reagent: 'Br₂(водн)',
+ reagentColor: '#A0520020',
+ reagentLiquid: '#B85C00',
+ desc: 'Бромная вода (Br₂(aq))',
+ compounds: [
+ { name: 'Алкен (C=C)', result: 'Обесцвечивание', equation: 'R-CH=CH-R + Br₂ → R-CHBr-CHBr-R', color: '#ffffff10', resultColor: '#F5E8CC20', symbol: '+' },
+ { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с Br₂(водн) при н.у.',color: '#B85C0080', resultColor: '#B85C0080', symbol: '−' },
+ { name: 'Фенол', result: 'Белый осадок', equation: 'C₆H₅OH + 3Br₂ → C₆H₂Br₃OH↓ + 3HBr', color: '#ffffff20', resultColor: '#ffffff80', symbol: '+' },
+ { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2Br₂ → CHBr₂-CHBr₂', color: '#B85C0080', resultColor: '#ffffff10', symbol: '+' },
+ ],
+ },
+ {
+ id: 'kmno4',
+ reagent: 'KMnO₄',
+ reagentColor: '#7B2FBE80',
+ reagentLiquid: '#9B3FDE',
+ desc: 'Перманганат калия KMnO₄',
+ compounds: [
+ { name: 'Алкен', result: 'Обесцвечивание', equation: '3R-CH=CH₂ + 2KMnO₄ + 4H₂O → 3R-CH(OH)-CH₂OH + 2MnO₂↓ + 2KOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' },
+ { name: 'Альдегид', result: 'Обесцвечивание', equation: 'R-CHO + [O] → R-COOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' },
+ { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы устойчивы к KMnO₄ при н.у.', color: '#9B3FDE80', resultColor: '#9B3FDE80', symbol: '−' },
+ { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2KMnO₄ → 2CO₂ + 2KOH + 2MnO₂↓', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' },
+ ],
+ },
+ {
+ id: 'silver',
+ reagent: 'Ag₂O/NH₃',
+ reagentColor: '#C0C0C020',
+ reagentLiquid: '#C8C8C8',
+ desc: 'Реакция серебряного зеркала',
+ compounds: [
+ { name: 'Альдегид', result: 'Серебристый налёт', equation: 'R-CHO + Ag₂O → R-COOH + 2Ag↓', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true },
+ { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не реагируют с реактивом Толленса', color: '#C8C8C820', resultColor: '#C8C8C820', symbol: '−' },
+ { name: 'Сахар (альд)', result: 'Серебристый налёт', equation: 'Глюкоза (альдегид) → серебристый налёт', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true },
+ ],
+ },
+ {
+ id: 'cuoh2',
+ reagent: 'Cu(OH)₂',
+ reagentColor: '#4CC9F030',
+ reagentLiquid: '#3aaad0',
+ desc: 'Гидроксид меди(II)',
+ compounds: [
+ { name: 'Многоатом. спирт', result: 'Ярко-синий р-р', equation: 'Глицерин + Cu(OH)₂ → ярко-синий раствор', color: '#3aaad080', resultColor: '#1E90FF80', symbol: '+', heat: false },
+ { name: 'Альдегид (нагрев)', result: 'Красный Cu₂O↓', equation: 'R-CHO + 2Cu(OH)₂ →(нагрев) R-COOH + Cu₂O↓(красный) + 2H₂O', color: '#3aaad080', resultColor: '#CC440080', symbol: '+', heat: true },
+ { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не восстанавливают Cu(OH)₂', color: '#3aaad080', resultColor: '#3aaad080', symbol: '−' },
+ ],
+ },
+ {
+ id: 'fecl3',
+ reagent: 'FeCl₃',
+ reagentColor: '#D4A04040',
+ reagentLiquid: '#C88020',
+ desc: 'Хлорид железа(III)',
+ compounds: [
+ { name: 'Фенол', result: 'Фиолетовый цвет', equation: 'C₆H₅OH + FeCl₃ → [Fe(OC₆H₅)₃]Cl₃ (фиолетовый)', color: '#C8802060', resultColor: '#8000FF80', symbol: '+' },
+ { name: 'Спирт', result: 'Нет реакции', equation: 'Спирты не дают фиолетовой окраски с FeCl₃', color: '#C8802060', resultColor: '#C8802060', symbol: '−' },
+ ],
+ },
+ {
+ id: 'sodium',
+ reagent: 'Na (металл)',
+ reagentColor: '#F5F0C820',
+ reagentLiquid: '#F0EAB0',
+ desc: 'Натрий металлический',
+ compounds: [
+ { name: 'Спирт', result: 'H₂↑ (пузырьки)', equation: '2R-OH + 2Na → 2R-ONa + H₂↑', color: '#F0EAB060', resultColor: '#6EB4D780', symbol: '+', gas: true },
+ { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с натрием', color: '#F0EAB060', resultColor: '#F0EAB060', symbol: '−' },
+ ],
+ },
+ ];
+
+ /* ── Constructor ───────────────────────────────────────────────── */
+ constructor(wrap) {
+ this._wrap = wrap;
+ this._mode = 'constructor'; // 'constructor' | 'homologs' | 'qualitative'
+ this._raf = null;
+ this._dirty = false;
+
+ // constructor state
+ this._atoms = [];
+ this._bonds = [];
+ this._drag = null;
+ this._pendingBond = null; // first atom of bond being drawn
+ this._toolAtom = 'C';
+ this._toolBond = 1; // 1,2,3
+
+ // homologs state
+ this._homSeries = 'alkanes';
+ this._homN = 1;
+
+ // qualitative state
+ this._qualReaction = OrganicSim.QUAL_REACTIONS[0];
+ this._qualCompound = null;
+ this._qualAnim = null;
+
+ this._initDOM();
+ }
+
+ /* ── DOM bootstrap ─────────────────────────────────────────────── */
+ _initDOM() {
+ this._wrap.innerHTML = '';
+ this._wrap.style.display = 'flex';
+ this._wrap.style.flexDirection = 'column';
+ this._wrap.style.height = '100%';
+ this._wrap.style.background = '#0D0D1A';
+ this._wrap.style.fontFamily = "'Manrope', sans-serif";
+ this._wrap.style.color = '#e0e0e0';
+ this._wrap.style.overflow = 'hidden';
+
+ // ── top mode bar
+ const modeBar = document.createElement('div');
+ modeBar.style.cssText = 'display:flex;gap:8px;padding:12px 16px 0;flex-shrink:0';
+ [
+ ['constructor', 'Конструктор молекул'],
+ ['homologs', 'Гомологические ряды'],
+ ['qualitative', 'Качественные реакции'],
+ ].forEach(([id, label]) => {
+ const btn = document.createElement('button');
+ btn.textContent = label;
+ btn.dataset.mode = id;
+ btn.style.cssText = 'padding:6px 14px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);' +
+ 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.78rem;font-family:inherit;' +
+ 'transition:all .15s';
+ btn.addEventListener('click', () => this._setMode(id));
+ modeBar.appendChild(btn);
+ });
+ this._modeBar = modeBar;
+ this._wrap.appendChild(modeBar);
+
+ // ── content area
+ this._content = document.createElement('div');
+ this._content.style.cssText = 'flex:1;display:flex;overflow:hidden;min-height:0';
+ this._wrap.appendChild(this._content);
+
+ this._buildConstructor();
+ this._setMode('constructor');
+ }
+
+ /* ── Mode switch ───────────────────────────────────────────────── */
+ _setMode(mode) {
+ this._mode = mode;
+ // update button styles
+ this._modeBar.querySelectorAll('button').forEach(b => {
+ const active = b.dataset.mode === mode;
+ b.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.04)';
+ b.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.12)';
+ b.style.color = active ? '#C9A0FF' : '#c0c0c0';
+ b.style.fontWeight = active ? '700' : '400';
+ });
+ // show correct panel
+ if (this._consPanel) this._consPanel.style.display = mode === 'constructor' ? 'flex' : 'none';
+ if (this._homoPanel) this._homoPanel.style.display = mode === 'homologs' ? 'flex' : 'none';
+ if (this._qualPanel) this._qualPanel.style.display = mode === 'qualitative' ? 'flex' : 'none';
+
+ if (mode === 'constructor') { this._drawMolecule(); }
+ if (mode === 'homologs') { this._drawHomologs(); }
+ if (mode === 'qualitative') { this._drawQual(); }
+ }
+
+ /* ══════════════════════════════════════════════════════════════
+ MODE 1 — CONSTRUCTOR
+ ══════════════════════════════════════════════════════════════ */
+ _buildConstructor() {
+ const panel = document.createElement('div');
+ panel.style.cssText = 'display:flex;width:100%;height:100%';
+ this._consPanel = panel;
+ this._content.appendChild(panel);
+
+ // left toolbar
+ const left = document.createElement('div');
+ left.style.cssText = 'width:160px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' +
+ 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto';
+ panel.appendChild(left);
+
+ // atom palette
+ const atomTitle = document.createElement('div');
+ atomTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' +
+ 'color:rgba(255,255,255,0.4);margin-bottom:2px';
+ atomTitle.textContent = 'Атомы';
+ left.appendChild(atomTitle);
+
+ this._atomBtns = {};
+ const atomWrap = document.createElement('div');
+ atomWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px';
+ ['C','H','O','N','Cl','S'].forEach(sym => {
+ const btn = document.createElement('button');
+ btn.textContent = sym;
+ const col = OrganicSim.ATOM_COLOR[sym];
+ btn.style.cssText = `width:38px;height:32px;border-radius:6px;border:1.5px solid ${col}44;` +
+ `background:${col}1A;color:${col};cursor:pointer;font-size:.8rem;font-weight:700;font-family:inherit;transition:all .12s`;
+ btn.addEventListener('click', () => this._selectAtom(sym));
+ atomWrap.appendChild(btn);
+ this._atomBtns[sym] = btn;
+ });
+ left.appendChild(atomWrap);
+
+ // bond order
+ const bondTitle = document.createElement('div');
+ bondTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' +
+ 'color:rgba(255,255,255,0.4);margin-bottom:2px';
+ bondTitle.textContent = 'Связь';
+ left.appendChild(bondTitle);
+
+ this._bondBtns = {};
+ const bondWrap = document.createElement('div');
+ bondWrap.style.cssText = 'display:flex;gap:4px;margin-bottom:8px';
+ [[1,'─'],[2,'═'],[3,'≡']].forEach(([n, sym]) => {
+ const btn = document.createElement('button');
+ btn.textContent = sym;
+ btn.style.cssText = 'width:36px;height:28px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.15);' +
+ 'background:rgba(255,255,255,0.05);color:#e0e0e0;cursor:pointer;font-size:.9rem;font-family:inherit;transition:all .12s';
+ btn.addEventListener('click', () => this._selectBond(n));
+ bondWrap.appendChild(btn);
+ this._bondBtns[n] = btn;
+ });
+ left.appendChild(bondWrap);
+
+ // separator
+ const sep = document.createElement('div');
+ sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0';
+ left.appendChild(sep);
+
+ // actions
+ const clearBtn = document.createElement('button');
+ clearBtn.textContent = 'Очистить';
+ clearBtn.style.cssText = 'padding:6px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' +
+ 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit';
+ clearBtn.addEventListener('click', () => { this._atoms = []; this._bonds = []; this._pendingBond = null; this._drawMolecule(); this._updateFormula(); });
+ left.appendChild(clearBtn);
+
+ // hint
+ const hint = document.createElement('div');
+ hint.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4;margin-top:4px';
+ hint.textContent = 'Клик на холст — добавить атом. Клик на 2 атома — нарисовать связь. ПКМ — удалить.';
+ left.appendChild(hint);
+
+ // center canvas
+ const canvasWrap = document.createElement('div');
+ canvasWrap.style.cssText = 'flex:1;position:relative;overflow:hidden;background:#080810';
+ panel.appendChild(canvasWrap);
+
+ const canvas = document.createElement('canvas');
+ canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
+ this._molCanvas = canvas;
+ canvasWrap.appendChild(canvas);
+
+ // right info panel
+ const right = document.createElement('div');
+ right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' +
+ 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto';
+ panel.appendChild(right);
+
+ const infoTitle = document.createElement('div');
+ infoTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' +
+ 'color:rgba(255,255,255,0.4)';
+ infoTitle.textContent = 'Анализ молекулы';
+ right.appendChild(infoTitle);
+
+ this._formulaEl = this._infoBox(right, 'Молекулярная формула', '—');
+ this._structEl = this._infoBox(right, 'Структурная формула', '—');
+ this._classEl = this._infoBox(right, 'Класс соединения', '—');
+ this._iupacEl = this._infoBox(right, 'Название (ИЮПАК)', '—');
+ this._valenceEl = this._infoBox(right, 'Валентность', '—');
+
+ // canvas events
+ canvas.addEventListener('click', e => this._molClick(e));
+ canvas.addEventListener('mousedown', e => this._molMouseDown(e));
+ canvas.addEventListener('mousemove', e => this._molMouseMove(e));
+ canvas.addEventListener('mouseup', e => this._molMouseUp(e));
+ canvas.addEventListener('contextmenu', e => { e.preventDefault(); this._molRightClick(e); });
+
+ this._selectAtom('C');
+ this._selectBond(1);
+ this._updateFormula();
+ }
+
+ _infoBox(parent, label, value) {
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'padding:8px 10px;border-radius:8px;background:rgba(255,255,255,0.04);' +
+ 'border:1px solid rgba(255,255,255,0.07)';
+ const lbl = document.createElement('div');
+ lbl.style.cssText = 'font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;' +
+ 'color:rgba(255,255,255,0.35);margin-bottom:3px';
+ lbl.textContent = label;
+ const val = document.createElement('div');
+ val.style.cssText = 'font-size:.9rem;font-weight:700;color:#e8e8e8;word-break:break-all';
+ val.textContent = value;
+ wrap.appendChild(lbl);
+ wrap.appendChild(val);
+ parent.appendChild(wrap);
+ return val;
+ }
+
+ _selectAtom(sym) {
+ this._toolAtom = sym;
+ Object.entries(this._atomBtns).forEach(([s, btn]) => {
+ const col = OrganicSim.ATOM_COLOR[s];
+ const active = s === sym;
+ btn.style.background = active ? `${col}40` : `${col}1A`;
+ btn.style.borderColor = active ? col : `${col}44`;
+ btn.style.boxShadow = active ? `0 0 8px ${col}60` : 'none';
+ });
+ }
+
+ _selectBond(n) {
+ this._toolBond = n;
+ Object.entries(this._bondBtns).forEach(([k, btn]) => {
+ const active = parseInt(k) === n;
+ btn.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.05)';
+ btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.15)';
+ btn.style.color = active ? '#C9A0FF' : '#e0e0e0';
+ });
+ }
+
+ /* ── Canvas size sync ─────────────────────────────────────────── */
+ _fitMolCanvas() {
+ const c = this._molCanvas;
+ if (!c) return;
+ const rect = c.getBoundingClientRect();
+ if (rect.width === 0) return;
+ c.width = Math.round(rect.width * devicePixelRatio);
+ c.height = Math.round(rect.height * devicePixelRatio);
+ c.getContext('2d').setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+ }
+
+ /* ── Canvas pointer helpers ─────────────────────────────────────── */
+ _molXY(e) {
+ const r = this._molCanvas.getBoundingClientRect();
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
+ }
+
+ _atomAt(x, y) {
+ return this._atoms.find(a => Math.hypot(a.x - x, a.y - y) <= OrganicSim.ATOM_R[a.sym] + 4);
+ }
+
+ /* ── Mouse/click handlers ─────────────────────────────────────── */
+ _molClick(e) {
+ const { x, y } = this._molXY(e);
+ const hit = this._atomAt(x, y);
+ if (hit) {
+ // bond mode: select second atom
+ if (this._pendingBond) {
+ if (this._pendingBond !== hit) {
+ const existing = this._bonds.find(b =>
+ (b.a === this._pendingBond && b.b === hit) ||
+ (b.a === hit && b.b === this._pendingBond));
+ if (existing) {
+ existing.order = this._toolBond;
+ } else {
+ this._bonds.push({ a: this._pendingBond, b: hit, order: this._toolBond });
+ }
+ }
+ this._pendingBond = null;
+ } else {
+ this._pendingBond = hit;
+ }
+ } else {
+ this._pendingBond = null;
+ // place new atom
+ const atom = { id: Date.now() + Math.random(), sym: this._toolAtom, x, y };
+ this._atoms.push(atom);
+ }
+ this._drawMolecule();
+ this._updateFormula();
+ }
+
+ _molMouseDown(e) {
+ if (e.button !== 0) return;
+ const { x, y } = this._molXY(e);
+ const hit = this._atomAt(x, y);
+ if (hit && !this._pendingBond) {
+ this._drag = { atom: hit, ox: hit.x - x, oy: hit.y - y };
+ }
+ }
+
+ _molMouseMove(e) {
+ const { x, y } = this._molXY(e);
+ if (this._drag) {
+ this._drag.atom.x = x + this._drag.ox;
+ this._drag.atom.y = y + this._drag.oy;
+ this._drawMolecule();
+ }
+ this._molCanvas.style.cursor = this._atomAt(x, y) ? 'grab' : 'crosshair';
+ }
+
+ _molMouseUp(e) {
+ if (this._drag) { this._drag = null; this._updateFormula(); }
+ }
+
+ _molRightClick(e) {
+ const { x, y } = this._molXY(e);
+ const hit = this._atomAt(x, y);
+ if (hit) {
+ this._bonds = this._bonds.filter(b => b.a !== hit && b.b !== hit);
+ this._atoms = this._atoms.filter(a => a !== hit);
+ if (this._pendingBond === hit) this._pendingBond = null;
+ this._drawMolecule();
+ this._updateFormula();
+ }
+ }
+
+ /* ── Draw molecule ─────────────────────────────────────────────── */
+ _drawMolecule() {
+ const c = this._molCanvas;
+ if (!c) return;
+ this._fitMolCanvas();
+ const ctx = c.getContext('2d');
+ const W = c.getBoundingClientRect().width;
+ const H = c.getBoundingClientRect().height;
+ ctx.clearRect(0, 0, W, H);
+
+ // subtle grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.03)';
+ ctx.lineWidth = 1;
+ for (let gx = 0; gx < W; gx += 30) {
+ ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke();
+ }
+ for (let gy = 0; gy < H; gy += 30) {
+ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke();
+ }
+
+ // bad valence set
+ const badAtoms = this._getBadValenceAtoms();
+
+ // bonds
+ this._bonds.forEach(b => {
+ const x1 = b.a.x, y1 = b.a.y, x2 = b.b.x, y2 = b.b.y;
+ const dx = x2 - x1, dy = y2 - y1;
+ const len = Math.hypot(dx, dy) || 1;
+ const px = -dy / len, py = dx / len;
+ ctx.strokeStyle = 'rgba(255,255,255,0.55)';
+ ctx.lineWidth = 2;
+ const offsets = b.order === 1 ? [0] : b.order === 2 ? [-3, 3] : [-5, 0, 5];
+ offsets.forEach(o => {
+ ctx.beginPath();
+ ctx.moveTo(x1 + px * o, y1 + py * o);
+ ctx.lineTo(x2 + px * o, y2 + py * o);
+ ctx.stroke();
+ });
+ });
+
+ // pending bond line
+ if (this._pendingBond) {
+ const pa = this._pendingBond;
+ ctx.strokeStyle = 'rgba(155,93,229,0.4)';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 3]);
+ ctx.beginPath();
+ ctx.arc(pa.x, pa.y, OrganicSim.ATOM_R[pa.sym] + 6, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // atoms
+ this._atoms.forEach(a => {
+ const r = OrganicSim.ATOM_R[a.sym];
+ const col = OrganicSim.ATOM_COLOR[a.sym];
+ const bad = badAtoms.has(a);
+ const sel = this._pendingBond === a;
+
+ // glow
+ if (sel) {
+ ctx.shadowColor = '#9B5DE5';
+ ctx.shadowBlur = 16;
+ } else if (bad) {
+ ctx.shadowColor = '#EF476F';
+ ctx.shadowBlur = 12;
+ }
+
+ ctx.beginPath();
+ ctx.arc(a.x, a.y, r, 0, Math.PI * 2);
+ ctx.fillStyle = bad ? '#EF476F30' : (sel ? 'rgba(155,93,229,0.35)' : `${col}28`);
+ ctx.fill();
+
+ ctx.strokeStyle = bad ? '#EF476F' : col;
+ ctx.lineWidth = bad ? 2 : 1.8;
+ ctx.stroke();
+
+ ctx.shadowBlur = 0;
+ ctx.shadowColor = 'transparent';
+
+ ctx.fillStyle = bad ? '#EF476F' : col;
+ ctx.font = `700 ${a.sym.length > 1 ? '11' : '13'}px Manrope, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(a.sym, a.x, a.y);
+ });
+ }
+
+ /* ── Valence check ─────────────────────────────────────────────── */
+ _getBondCount(atom) {
+ let count = 0;
+ this._bonds.forEach(b => {
+ if (b.a === atom || b.b === atom) count += b.order;
+ });
+ return count;
+ }
+
+ _getBadValenceAtoms() {
+ const bad = new Set();
+ this._atoms.forEach(a => {
+ const v = OrganicSim.VALENCE[a.sym] || 1;
+ const used = this._getBondCount(a);
+ if (used > v) bad.add(a);
+ });
+ return bad;
+ }
+
+ /* ── Formula + class detection ─────────────────────────────────── */
+ _updateFormula() {
+ if (!this._formulaEl) return;
+ const counts = {};
+ this._atoms.forEach(a => { counts[a.sym] = (counts[a.sym] || 0) + 1; });
+
+ // hill order: C first, H second, rest alphabetically
+ let formula = '';
+ if (counts['C']) { formula += 'C'; if (counts['C'] > 1) formula += this._sub(counts['C']); }
+ if (counts['H']) { formula += 'H'; if (counts['H'] > 1) formula += this._sub(counts['H']); }
+ ['N','O','S','Cl'].forEach(s => {
+ if (counts[s]) { formula += s; if (counts[s] > 1) formula += this._sub(counts[s]); }
+ });
+
+ const klass = this._detectClass();
+ const struct = this._buildStructural(counts);
+ const iupac = this._iupacName(counts, klass);
+ const bad = this._getBadValenceAtoms();
+ const valMsg = bad.size > 0
+ ? Array.from(bad).map(a => `${a.sym}(${this._getBondCount(a)}/${OrganicSim.VALENCE[a.sym]})`).join(', ') + ' — превышена валентность'
+ : (this._atoms.length > 0 ? 'OK' : '—');
+
+ this._formulaEl.textContent = formula || '—';
+ this._structEl.textContent = struct || '—';
+ this._classEl.textContent = klass || '—';
+ this._iupacEl.textContent = iupac || '—';
+ this._valenceEl.textContent = valMsg;
+ this._valenceEl.style.color = bad.size > 0 ? '#EF476F' : '#34d399';
+ }
+
+ _sub(n) {
+ const map = '₀₁₂₃₄₅₆₇₈₉';
+ return String(n).split('').map(d => map[d]).join('');
+ }
+
+ _buildStructural(counts) {
+ if (!counts['C'] && !counts['H']) return '';
+ const parts = [];
+ if (counts['C']) parts.push(`C${counts['C'] > 1 ? counts['C'] : ''}`);
+ if (counts['H']) parts.push(`H${counts['H'] > 1 ? counts['H'] : ''}`);
+ if (counts['O']) parts.push(`O${counts['O'] > 1 ? counts['O'] : ''}`);
+ if (counts['N']) parts.push(`N${counts['N'] > 1 ? counts['N'] : ''}`);
+ if (counts['Cl']) parts.push(`Cl${counts['Cl'] > 1 ? counts['Cl'] : ''}`);
+ if (counts['S']) parts.push(`S${counts['S'] > 1 ? counts['S'] : ''}`);
+ return parts.join('-');
+ }
+
+ /* ── Class detection ───────────────────────────────────────────── */
+ _detectClass() {
+ if (this._atoms.length === 0) return '—';
+
+ const hasDoubleCO = this._bonds.some(b =>
+ b.order === 2 && ((b.a.sym === 'C' && b.b.sym === 'O') || (b.a.sym === 'O' && b.b.sym === 'C')));
+ const hasDoubleCC = this._bonds.some(b =>
+ b.order === 2 && b.a.sym === 'C' && b.b.sym === 'C');
+ const hasTripleCC = this._bonds.some(b =>
+ b.order === 3 && b.a.sym === 'C' && b.b.sym === 'C');
+
+ // detect -OH group: O connected to C (sp3) and H
+ const hasOH = this._atoms.some(a => {
+ if (a.sym !== 'O') return false;
+ const neighbors = this._getNeighbors(a);
+ return neighbors.some(n => n.sym === 'H') && neighbors.some(n => n.sym === 'C');
+ });
+
+ // detect -CHO: C with double bond to O and single bond to H
+ const hasCHO = this._atoms.some(a => {
+ if (a.sym !== 'C') return false;
+ const nb = this._getNeighbors(a);
+ const hasHNeighbor = nb.some(n => n.sym === 'H');
+ const hasDoubleOBond = this._bonds.some(b =>
+ b.order === 2 &&
+ ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O')));
+ return hasHNeighbor && hasDoubleOBond;
+ });
+
+ // detect -COOH: C with double O and single O (which has H)
+ const hasCOOH = this._atoms.some(a => {
+ if (a.sym !== 'C') return false;
+ const dblO = this._bonds.filter(b =>
+ b.order === 2 &&
+ ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length;
+ const sngO = this._bonds.filter(b =>
+ b.order === 1 &&
+ ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length;
+ return dblO >= 1 && sngO >= 1;
+ });
+
+ // ketone: C with two double-bond O neighbors via C-C bonds with no H on the carbonyl C
+ const hasKetone = hasCOOH ? false : (this._atoms.some(a => {
+ if (a.sym !== 'C') return false;
+ const nb = this._getNeighbors(a);
+ const hasHNeighbor = nb.some(n => n.sym === 'H');
+ const hasDoubleOBond = this._bonds.some(b =>
+ b.order === 2 &&
+ ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O')));
+ const hasCCBond = nb.filter(n => n.sym === 'C').length >= 2;
+ return hasDoubleOBond && hasCCBond && !hasHNeighbor;
+ }));
+
+ // ester: -COO- (C with dbl O and O linked to C)
+ const hasEster = this._atoms.some(a => {
+ if (a.sym !== 'C') return false;
+ const dblOBond = this._bonds.find(b =>
+ b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O')));
+ if (!dblOBond) return false;
+ const sngOs = this._bonds.filter(b =>
+ b.order === 1 &&
+ ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O')));
+ return sngOs.some(b => {
+ const oAtom = b.a === a ? b.b : b.a;
+ const oNb = this._getNeighbors(oAtom);
+ return oNb.some(n => n.sym === 'C' && n !== a);
+ });
+ });
+
+ // ether: O linked to two C (no H on O)
+ const hasEther = this._atoms.some(a => {
+ if (a.sym !== 'O') return false;
+ const nb = this._getNeighbors(a);
+ return nb.filter(n => n.sym === 'C').length >= 2 && !nb.some(n => n.sym === 'H');
+ });
+
+ // amine: N with H or N between carbons
+ const hasAmine = this._atoms.some(a => a.sym === 'N');
+
+ // halide
+ const hasCl = this._atoms.some(a => a.sym === 'Cl');
+ const hasS = this._atoms.some(a => a.sym === 'S');
+
+ // only C & H present
+ const onlyCH = this._atoms.every(a => a.sym === 'C' || a.sym === 'H');
+
+ // benzene ring detection (6 C in ring with alternating bonds)
+ const hasBenzene = this._detectBenzene();
+
+ if (hasCOOH) return 'Карбоновая кислота (-COOH)';
+ if (hasEster) return 'Сложный эфир (-COO-)';
+ if (hasCHO) return 'Альдегид (-CHO)';
+ if (hasKetone) return 'Кетон (C=O между C)';
+ if (hasOH) return 'Спирт (-OH)';
+ if (hasAmine) return 'Амин (-NHₓ)';
+ if (hasBenzene) return 'Ароматическое (бензольное кольцо)';
+ if (hasTripleCC) return 'Алкин (C≡C)';
+ if (hasDoubleCC) return 'Алкен (C=C)';
+ if (hasCl) return 'Галогеналкан (-Cl)';
+ if (hasS) return 'Серосодержащее';
+ if (hasDoubleCO) return 'Карбонильное соединение (C=O)';
+ if (onlyCH) return this._detectCHClass();
+ return 'Органическое соединение';
+ }
+
+ _detectCHClass() {
+ // check if cycle present
+ if (this._detectCycle()) return 'Циклоалкан';
+ return 'Алкан (только C-C + H)';
+ }
+
+ _detectCycle() {
+ if (this._atoms.length < 3) return false;
+ // simple DFS cycle detection
+ const visited = new Set();
+ const adj = new Map();
+ this._atoms.forEach(a => adj.set(a, []));
+ this._bonds.forEach(b => {
+ adj.get(b.a).push(b.b);
+ adj.get(b.b).push(b.a);
+ });
+ let hasCycle = false;
+ const dfs = (node, parent) => {
+ if (hasCycle) return;
+ visited.add(node);
+ for (const nb of adj.get(node)) {
+ if (nb === parent) continue;
+ if (visited.has(nb)) { hasCycle = true; return; }
+ dfs(nb, node);
+ }
+ };
+ if (this._atoms.length > 0) dfs(this._atoms[0], null);
+ return hasCycle;
+ }
+
+ _detectBenzene() {
+ // look for 6 C-atoms forming a ring with alternating single/double bonds
+ const cAtoms = this._atoms.filter(a => a.sym === 'C');
+ if (cAtoms.length < 6) return false;
+ // build adjacency among C atoms
+ const adj = new Map();
+ cAtoms.forEach(a => adj.set(a, []));
+ this._bonds.forEach(b => {
+ if (b.a.sym === 'C' && b.b.sym === 'C') {
+ adj.get(b.a).push(b.b);
+ adj.get(b.b).push(b.a);
+ }
+ });
+ // find if any C is in a 6-membered ring
+ for (const start of cAtoms) {
+ const path = [start];
+ const found = this._findRing(start, start, path, adj, 6);
+ if (found) return true;
+ }
+ return false;
+ }
+
+ _findRing(start, cur, path, adj, targetLen) {
+ if (path.length === targetLen) {
+ return adj.get(cur).includes(start);
+ }
+ for (const nb of adj.get(cur)) {
+ if (path.length > 1 && nb === path[path.length - 2]) continue;
+ if (path.includes(nb)) continue;
+ path.push(nb);
+ if (this._findRing(start, nb, path, adj, targetLen)) return true;
+ path.pop();
+ }
+ return false;
+ }
+
+ _getNeighbors(atom) {
+ const nb = [];
+ this._bonds.forEach(b => {
+ if (b.a === atom) nb.push(b.b);
+ if (b.b === atom) nb.push(b.a);
+ });
+ return nb;
+ }
+
+ /* ── IUPAC name for simple alkanes ─────────────────────────────── */
+ _iupacName(counts, klass) {
+ if (!klass || !klass.includes('Алкан') || klass.includes('Цикло')) {
+ if (!klass || !counts['C']) return '—';
+ }
+ const prefixes = ['','мет','эт','проп','бут','пент','гекс','гепт','окт','нон','дек'];
+ const n = counts['C'] || 0;
+ if (n < 1 || n > 10) return n > 10 ? `алкан C${n}` : '—';
+ if (klass.includes('Циклоалкан')) return `цикло${prefixes[n]}ан`;
+ if (klass.includes('Алкен')) return `${prefixes[n]}ен`;
+ if (klass.includes('Алкин')) return `${prefixes[n]}ин`;
+ if (klass.includes('Алкан')) return `${prefixes[n]}ан`;
+ if (klass.includes('Спирт')) return `${prefixes[n]}анол-1`;
+ if (klass.includes('Альдегид')) return `${prefixes[n]}аналь`;
+ if (klass.includes('Кислота')) return `${prefixes[n]}ановая кислота`;
+ if (klass.includes('Амин')) return `${prefixes[n]}иламин`;
+ return '—';
+ }
+
+ /* ══════════════════════════════════════════════════════════════
+ MODE 2 — HOMOLOGS
+ ══════════════════════════════════════════════════════════════ */
+ _buildHomologs() {
+ const panel = document.createElement('div');
+ panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0';
+ this._homoPanel = panel;
+ this._content.appendChild(panel);
+
+ // left controls
+ const left = document.createElement('div');
+ left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:8px;' +
+ 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto';
+ panel.appendChild(left);
+
+ const serLabel = document.createElement('div');
+ serLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)';
+ serLabel.textContent = 'Гомологический ряд';
+ left.appendChild(serLabel);
+
+ this._serBtns = {};
+ Object.entries(OrganicSim.HOMOLOG_SERIES).forEach(([key, s]) => {
+ const btn = document.createElement('button');
+ btn.textContent = s.name;
+ btn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' +
+ 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.75rem;font-family:inherit;text-align:left';
+ btn.addEventListener('click', () => this._selectSeries(key));
+ left.appendChild(btn);
+ this._serBtns[key] = btn;
+ });
+
+ const sep2 = document.createElement('div');
+ sep2.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0';
+ left.appendChild(sep2);
+
+ const nLabel = document.createElement('div');
+ nLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)';
+ nLabel.textContent = 'Число атомов C';
+ left.appendChild(nLabel);
+
+ const sliderRow = document.createElement('div');
+ sliderRow.style.cssText = 'display:flex;align-items:center;gap:8px';
+ const slider = document.createElement('input');
+ slider.type = 'range';
+ slider.min = 1; slider.max = 10; slider.value = 1;
+ slider.style.cssText = 'flex:1;accent-color:#9B5DE5';
+ const nVal = document.createElement('span');
+ nVal.style.cssText = 'font-size:1rem;font-weight:700;color:#C9A0FF;min-width:18px;text-align:center';
+ nVal.textContent = '1';
+ slider.addEventListener('input', () => {
+ this._homN = parseInt(slider.value);
+ nVal.textContent = this._homN;
+ this._drawHomologs();
+ });
+ sliderRow.appendChild(slider);
+ sliderRow.appendChild(nVal);
+ left.appendChild(sliderRow);
+ this._homSlider = slider;
+ this._homNVal = nVal;
+
+ // center + right
+ const center = document.createElement('div');
+ center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden';
+ panel.appendChild(center);
+
+ // molecule sketch area
+ const sketch = document.createElement('canvas');
+ sketch.style.cssText = 'width:100%;height:260px;flex-shrink:0;display:block';
+ this._homCanvas = sketch;
+ center.appendChild(sketch);
+
+ // properties table
+ const tableWrap = document.createElement('div');
+ tableWrap.style.cssText = 'flex:1;overflow-y:auto;padding:12px 16px';
+ this._homTableWrap = tableWrap;
+ center.appendChild(tableWrap);
+
+ this._selectSeries('alkanes');
+ }
+
+ _selectSeries(key) {
+ this._homSeries = key;
+ const s = OrganicSim.HOMOLOG_SERIES[key];
+ // adjust slider
+ this._homSlider.min = s.minC;
+ if (this._homN < s.minC) { this._homN = s.minC; this._homSlider.value = s.minC; this._homNVal.textContent = s.minC; }
+
+ Object.entries(this._serBtns).forEach(([k, btn]) => {
+ const active = k === key;
+ btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)';
+ btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)';
+ btn.style.color = active ? '#C9A0FF' : '#c0c0c0';
+ btn.style.fontWeight = active ? '700' : '400';
+ });
+ this._drawHomologs();
+ }
+
+ _drawHomologs() {
+ if (!this._homCanvas) return;
+ const s = OrganicSim.HOMOLOG_SERIES[this._homSeries];
+ const n = this._homN;
+ const idx = n - 1;
+
+ // fit canvas
+ const c = this._homCanvas;
+ const rect = c.getBoundingClientRect();
+ if (!rect.width) return;
+ c.width = Math.round(rect.width * devicePixelRatio);
+ c.height = Math.round(rect.height * devicePixelRatio);
+ const ctx = c.getContext('2d');
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+ const W = rect.width, H = rect.height;
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = '#080810';
+ ctx.fillRect(0, 0, W, H);
+
+ // draw 2D skeletal formula
+ this._drawSkeletal(ctx, W, H, s, n);
+
+ // properties table
+ const tboil = s.tboil[idx];
+ const tmelt = s.tmelt[idx];
+ const state = s.state(n);
+ const M = s.M(n);
+ const formula = s.formula(n);
+ const notable = s.notable && s.notable[n];
+
+ let html = `
+ ${s.name} — ${formula}
+
+
+
+ | Параметр |
+ Значение |
+
+
+
+ ${this._propRow('Молярная масса', `${M} г/моль`)}
+ ${this._propRow('Т. кипения', tboil != null ? `${tboil} °C` : '—')}
+ ${this._propRow('Т. плавления', tmelt != null ? `${tmelt} °C` : '—')}
+ ${this._propRow('Агрегатное состояние (20°C)', state)}
+
+
`;
+
+ if (notable) {
+ html += `
+
${notable.name}
+
${notable.info}
+
`;
+ }
+
+ this._homTableWrap.innerHTML = html;
+ }
+
+ _propRow(label, value) {
+ return `
+ | ${label} |
+ ${value} |
+
`;
+ }
+
+ /* ── Skeletal formula 2D auto-drawing ─────────────────────────── */
+ _drawSkeletal(ctx, W, H, s, n) {
+ const key = this._homSeries;
+ const CX = W / 2, CY = H / 2;
+ const bond = Math.min(55, (W - 80) / Math.max(n, 1));
+ const R = 13;
+
+ ctx.fillStyle = '#080810';
+ ctx.fillRect(0, 0, W, H);
+
+ // subtle grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.03)';
+ ctx.lineWidth = 1;
+ for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); }
+ for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); }
+
+ // positions: zigzag for chain
+ const positions = [];
+ const totalW = bond * (n - 1);
+ let startX = CX - totalW / 2;
+ for (let i = 0; i < n; i++) {
+ const y = CY + (i % 2 === 0 ? -12 : 12);
+ positions.push({ x: startX + i * bond, y });
+ }
+
+ // determine bond order for main chain
+ let mainBondOrder = 1;
+ if (key === 'alkenes') mainBondOrder = 2;
+ if (key === 'alkynes') mainBondOrder = 3;
+
+ const drawBond = (x1, y1, x2, y2, order) => {
+ const dx = x2 - x1, dy = y2 - y1;
+ const len = Math.hypot(dx, dy) || 1;
+ const px = -dy/len, py = dx/len;
+ ctx.strokeStyle = 'rgba(255,255,255,0.6)';
+ ctx.lineWidth = 1.8;
+ const offs = order === 1 ? [0] : order === 2 ? [-2.5,2.5] : [-4,0,4];
+ offs.forEach(o => {
+ ctx.beginPath();
+ ctx.moveTo(x1 + px*o, y1 + py*o);
+ ctx.lineTo(x2 + px*o, y2 + py*o);
+ ctx.stroke();
+ });
+ };
+
+ // main chain bonds
+ for (let i = 0; i < n - 1; i++) {
+ const p1 = positions[i], p2 = positions[i+1];
+ const bo = (key === 'alkenes' && i === 0) ? 2
+ : (key === 'alkynes' && i === 0) ? 3
+ : 1;
+ drawBond(p1.x, p1.y, p2.x, p2.y, bo);
+ }
+
+ // side groups: draw H or functional group atoms
+ const colC = OrganicSim.ATOM_COLOR['C'];
+ const colH = OrganicSim.ATOM_COLOR['H'];
+ const colO = OrganicSim.ATOM_COLOR['O'];
+ const colN = OrganicSim.ATOM_COLOR['N'];
+
+ positions.forEach((p, i) => {
+ let hs = 0;
+ if (key === 'alkanes') hs = (i === 0 || i === n-1) ? 3 : 2;
+ if (key === 'alkenes') hs = (i === 0) ? 2 : (i === n-1) ? 3 : 2;
+ if (key === 'alkynes') hs = (i === 0 || i === 1) ? 1 : (i === n-1) ? 3 : 2;
+ if (key === 'alcohols') {
+ hs = (i === 0 || i === n-1) ? 3 : 2;
+ if (i === n-1) {
+ // draw -OH
+ drawBond(p.x, p.y, p.x + 30, p.y - 20, 1);
+ ctx.beginPath(); ctx.arc(p.x+30, p.y-20, R, 0, Math.PI*2);
+ ctx.fillStyle = OrganicSim.ATOM_COLOR['O']+'30'; ctx.fill();
+ ctx.strokeStyle = OrganicSim.ATOM_COLOR['O']; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.fillStyle = OrganicSim.ATOM_COLOR['O'];
+ ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText('O', p.x+30, p.y-20);
+ // H on O
+ drawBond(p.x+30, p.y-20, p.x+44, p.y-30, 1);
+ ctx.beginPath(); ctx.arc(p.x+44, p.y-30, R-4, 0, Math.PI*2);
+ ctx.fillStyle = colH+'30'; ctx.fill();
+ ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke();
+ ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x+44, p.y-30);
+ hs = 2;
+ }
+ }
+ if (key === 'aldehydes') {
+ hs = i === 0 ? 1 : (i === n-1) ? 3 : 2;
+ if (i === 0) {
+ // draw =O
+ drawBond(p.x, p.y, p.x - 28, p.y - 22, 2);
+ ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2);
+ ctx.fillStyle = colO+'30'; ctx.fill();
+ ctx.strokeStyle = colO; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.fillStyle = colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText('O', p.x-28, p.y-22);
+ // H on C-1
+ drawBond(p.x, p.y, p.x - 28, p.y + 22, 1);
+ ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R-4, 0, Math.PI*2);
+ ctx.fillStyle = colH+'30'; ctx.fill();
+ ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke();
+ ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-28, p.y+22);
+ }
+ }
+ if (key === 'acids') {
+ hs = i === 0 ? 0 : (i === n-1) ? 3 : 2;
+ if (i === 0) {
+ // draw -COOH
+ drawBond(p.x, p.y, p.x-28, p.y-22, 2);
+ ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2);
+ ctx.fillStyle = colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.fillStyle=colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText('O', p.x-28, p.y-22);
+ drawBond(p.x, p.y, p.x-28, p.y+22, 1);
+ ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R, 0, Math.PI*2);
+ ctx.fillStyle=colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.fillStyle=colO; ctx.fillText('O', p.x-28, p.y+22);
+ // H on single O
+ drawBond(p.x-28, p.y+22, p.x-42, p.y+32, 1);
+ ctx.beginPath(); ctx.arc(p.x-42, p.y+32, R-4, 0, Math.PI*2);
+ ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke();
+ ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-42, p.y+32);
+ }
+ }
+ if (key === 'amines') {
+ hs = i === n-1 ? 2 : (i === 0 ? 1 : 2);
+ if (i === n-1) {
+ // draw -NH₂
+ drawBond(p.x, p.y, p.x+28, p.y-22, 1);
+ ctx.beginPath(); ctx.arc(p.x+28, p.y-22, R, 0, Math.PI*2);
+ ctx.fillStyle=colN+'30'; ctx.fill(); ctx.strokeStyle=colN; ctx.lineWidth=1.5; ctx.stroke();
+ ctx.fillStyle=colN; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText('N', p.x+28, p.y-22);
+ // 2 H on N
+ [[p.x+42,p.y-32],[p.x+42,p.y-12]].forEach(([hx,hy]) => {
+ drawBond(p.x+28, p.y-22, hx, hy, 1);
+ ctx.beginPath(); ctx.arc(hx,hy,R-4,0,Math.PI*2);
+ ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke();
+ ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H',hx,hy);
+ });
+ }
+ }
+
+ // draw H on main C
+ const hPositions = [[0,-1],[0,1],[-1,0]].slice(0, hs);
+ hPositions.forEach(([hx0, hy0], hi) => {
+ const angle = (hi / Math.max(hs,1)) * Math.PI + (i%2===0 ? Math.PI*1.1 : Math.PI*0.1);
+ const hx = p.x + Math.cos(angle) * (bond*0.55);
+ const hy = p.y + Math.sin(angle) * (bond*0.55);
+ drawBond(p.x, p.y, hx, hy, 1);
+ ctx.beginPath(); ctx.arc(hx, hy, R-3, 0, Math.PI*2);
+ ctx.fillStyle = colH+'25'; ctx.fill();
+ ctx.strokeStyle = colH; ctx.lineWidth = 1.2; ctx.stroke();
+ ctx.fillStyle = colH;
+ ctx.font = 'bold 10px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText('H', hx, hy);
+ });
+
+ // main C atom
+ ctx.beginPath(); ctx.arc(p.x, p.y, R, 0, Math.PI*2);
+ ctx.fillStyle = colC+'30'; ctx.fill();
+ ctx.strokeStyle = colC; ctx.lineWidth = 1.8; ctx.stroke();
+ ctx.fillStyle = colC;
+ ctx.font = 'bold 12px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText('C', p.x, p.y);
+ });
+
+ // formula label
+ const formula = s.formula(n);
+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
+ ctx.font = '12px Manrope,sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText(formula, 12, H - 12);
+ }
+
+ /* ══════════════════════════════════════════════════════════════
+ MODE 3 — QUALITATIVE REACTIONS
+ ══════════════════════════════════════════════════════════════ */
+ _buildQualitative() {
+ const panel = document.createElement('div');
+ panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0';
+ this._qualPanel = panel;
+ this._content.appendChild(panel);
+
+ // left reagent selector
+ const left = document.createElement('div');
+ left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' +
+ 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto';
+ panel.appendChild(left);
+
+ const rl = document.createElement('div');
+ rl.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4);margin-bottom:2px';
+ rl.textContent = 'Реагент в пробирке';
+ left.appendChild(rl);
+
+ this._qualBtns = {};
+ OrganicSim.QUAL_REACTIONS.forEach(rxn => {
+ const btn = document.createElement('button');
+ btn.textContent = rxn.desc;
+ btn.style.cssText = 'padding:6px 8px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' +
+ 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.72rem;font-family:inherit;text-align:left;line-height:1.3';
+ btn.addEventListener('click', () => this._selectQual(rxn));
+ left.appendChild(btn);
+ this._qualBtns[rxn.id] = btn;
+ });
+
+ const sep3 = document.createElement('div');
+ sep3.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0';
+ left.appendChild(sep3);
+
+ const hint3 = document.createElement('div');
+ hint3.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4';
+ hint3.textContent = 'Выберите реагент → затем кликните на вещество для реакции';
+ left.appendChild(hint3);
+
+ // center: test tube canvas
+ const center = document.createElement('div');
+ center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative';
+ panel.appendChild(center);
+
+ const qualCanvas = document.createElement('canvas');
+ qualCanvas.style.cssText = 'width:100%;flex:1;display:block';
+ this._qualCanvas = qualCanvas;
+ center.appendChild(qualCanvas);
+
+ // compounds area
+ const compArea = document.createElement('div');
+ compArea.style.cssText = 'padding:8px 12px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);' +
+ 'display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0';
+ this._compArea = compArea;
+ center.appendChild(compArea);
+
+ // right: result panel
+ const right = document.createElement('div');
+ right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' +
+ 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto';
+ panel.appendChild(right);
+
+ const rt = document.createElement('div');
+ rt.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)';
+ rt.textContent = 'Результат';
+ right.appendChild(rt);
+
+ this._qualResultEl = this._infoBox(right, 'Наблюдение', '—');
+ this._qualEqEl = this._infoBox(right, 'Уравнение реакции', '—');
+ this._qualEqEl.style.fontSize = '.72rem';
+ this._qualEqEl.style.lineHeight = '1.4';
+
+ const resetBtn = document.createElement('button');
+ resetBtn.textContent = 'Сбросить';
+ resetBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' +
+ 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit;margin-top:auto';
+ resetBtn.addEventListener('click', () => {
+ this._qualCompound = null;
+ this._qualAnim = null;
+ this._qualResultEl.textContent = '—';
+ this._qualEqEl.textContent = '—';
+ this._drawQual();
+ });
+ right.appendChild(resetBtn);
+
+ this._selectQual(OrganicSim.QUAL_REACTIONS[0]);
+ }
+
+ _selectQual(rxn) {
+ this._qualReaction = rxn;
+ this._qualCompound = null;
+ this._qualAnim = null;
+ this._qualResultEl.textContent = '—';
+ this._qualEqEl.textContent = '—';
+
+ Object.entries(this._qualBtns).forEach(([id, btn]) => {
+ const active = id === rxn.id;
+ btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)';
+ btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)';
+ btn.style.color = active ? '#C9A0FF' : '#c0c0c0';
+ btn.style.fontWeight = active ? '700' : '400';
+ });
+
+ // build compound buttons
+ this._compArea.innerHTML = '';
+ rxn.compounds.forEach(comp => {
+ const btn = document.createElement('button');
+ btn.textContent = comp.name;
+ btn.style.cssText = 'padding:5px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);' +
+ 'background:rgba(255,255,255,0.06);color:#e0e0e0;cursor:pointer;font-size:.78rem;font-family:inherit;' +
+ 'transition:all .15s';
+ btn.addEventListener('click', () => this._runQualReaction(comp));
+ btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(155,93,229,0.2)'; btn.style.borderColor = '#9B5DE5'; });
+ btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.06)'; btn.style.borderColor = 'rgba(255,255,255,0.12)'; });
+ this._compArea.appendChild(btn);
+ });
+
+ this._drawQual();
+ }
+
+ _runQualReaction(comp) {
+ this._qualCompound = comp;
+ this._qualResultEl.textContent = comp.result;
+ this._qualEqEl.textContent = comp.equation;
+ this._qualResultEl.style.color = comp.symbol === '+' ? '#34d399' : '#EF476F';
+
+ // start animation
+ this._qualAnim = {
+ compound: comp,
+ t: 0,
+ maxT: 120,
+ };
+ this._animQual();
+ }
+
+ _animQual() {
+ if (!this._qualAnim) return;
+ this._qualAnim.t++;
+ this._drawQual();
+ if (this._qualAnim.t < this._qualAnim.maxT) {
+ this._raf = requestAnimationFrame(() => this._animQual());
+ }
+ }
+
+ _drawQual() {
+ const c = this._qualCanvas;
+ if (!c) return;
+ const rect = c.getBoundingClientRect();
+ if (!rect.width) return;
+ c.width = Math.round(rect.width * devicePixelRatio);
+ c.height = Math.round(rect.height * devicePixelRatio);
+ const ctx = c.getContext('2d');
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+ const W = rect.width, H = rect.height;
+
+ ctx.fillStyle = '#080810';
+ ctx.fillRect(0, 0, W, H);
+
+ // subtle grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.03)';
+ ctx.lineWidth = 1;
+ for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); }
+ for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); }
+
+ const rxn = this._qualReaction;
+ const comp = this._qualCompound;
+ const anim = this._qualAnim;
+
+ // draw multiple test tubes
+ const tubes = rxn.compounds;
+ const tubeW = 56, tubeH = 150, gap = 20;
+ const totalW = tubes.length * (tubeW + gap) - gap;
+ let startX = (W - totalW) / 2;
+
+ tubes.forEach((tube, i) => {
+ const tx = startX + i * (tubeW + gap);
+ const ty = (H - tubeH) / 2 - 10;
+ const isActive = comp && comp === tube;
+ const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0;
+
+ this._drawTestTube(ctx, tx, ty, tubeW, tubeH, rxn, tube, progress, isActive);
+
+ // label
+ ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)';
+ ctx.font = `${isActive ? '700' : '400'} 10px Manrope,sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ const label = tube.name.length > 12 ? tube.name.substring(0,11)+'…' : tube.name;
+ ctx.fillText(label, tx + tubeW/2, ty + tubeH + 8);
+ });
+
+ // reagent label
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, H - 4);
+ }
+
+ _drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) {
+ const liqH = h * 0.55;
+ const liqY = y + h - liqH;
+
+ // glass tube outline
+ ctx.save();
+ ctx.strokeStyle = isActive ? '#9B5DE5' : 'rgba(255,255,255,0.25)';
+ ctx.lineWidth = isActive ? 2 : 1.5;
+
+ // tube shape: rect top + rounded bottom
+ ctx.beginPath();
+ ctx.moveTo(x + 4, y);
+ ctx.lineTo(x + 4, y + h - w/2 + 4);
+ ctx.arcTo(x + 4, y + h, x + w/2, y + h, w/2 - 4);
+ ctx.arcTo(x + w - 4, y + h, x + w - 4, y + h - w/2 + 4, w/2 - 4);
+ ctx.lineTo(x + w - 4, y);
+ ctx.stroke();
+
+ // clip to tube for liquid
+ ctx.beginPath();
+ ctx.rect(x + 4, liqY, w - 8, liqH - 8);
+ ctx.arc(x + w/2, y + h - (w/2 - 4), w/2 - 4, 0, Math.PI);
+ ctx.clip();
+
+ // base liquid (reagent color)
+ const baseColor = rxn.reagentLiquid;
+ ctx.fillStyle = baseColor + 'A0';
+ ctx.fillRect(x + 4, liqY, w - 8, liqH);
+
+ // result color overlay animated
+ if (progress > 0) {
+ const resColor = comp.resultColor;
+ ctx.globalAlpha = progress;
+ ctx.fillStyle = resColor;
+ ctx.fillRect(x + 4, liqY, w - 8, liqH);
+ ctx.globalAlpha = 1;
+
+ // precipitate settling
+ if (comp.symbol === '+' && progress > 0.4) {
+ const precH = (progress - 0.4) / 0.6 * 20;
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.fillRect(x + 4, y + h - (w/2-4) - precH, w - 8, precH);
+ }
+
+ // silver mirror effect
+ if (comp.mirror && progress > 0.3) {
+ const mirrorA = (progress - 0.3) / 0.7;
+ const grad = ctx.createLinearGradient(x+4, liqY, x+w-4, liqY);
+ grad.addColorStop(0, `rgba(220,220,220,${mirrorA*0.3})`);
+ grad.addColorStop(0.5, `rgba(240,240,240,${mirrorA*0.8})`);
+ grad.addColorStop(1, `rgba(200,200,200,${mirrorA*0.3})`);
+ ctx.fillStyle = grad;
+ ctx.fillRect(x + 4, liqY, w - 8, 8);
+ }
+
+ // gas bubbles
+ if (comp.gas && progress > 0.2) {
+ const numBubbles = Math.floor((progress - 0.2) / 0.8 * 8);
+ for (let b = 0; b < numBubbles; b++) {
+ const bx = x + 8 + (b * 7) % (w - 16);
+ const by = y + h - 30 - (((this._qualAnim ? this._qualAnim.t : 0) * 3 + b * 15) % 80);
+ ctx.beginPath();
+ ctx.arc(bx, by, 3, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(200,220,255,0.6)';
+ ctx.fill();
+ }
+ }
+
+ // heat shimmer lines
+ if (comp.heat && progress > 0.5) {
+ ctx.strokeStyle = 'rgba(255,120,50,0.4)';
+ ctx.lineWidth = 1;
+ for (let ln = 0; ln < 3; ln++) {
+ ctx.beginPath();
+ const lx = x + 10 + ln * 12;
+ ctx.moveTo(lx, liqY + 10);
+ ctx.quadraticCurveTo(lx + 4, liqY + 20, lx, liqY + 30);
+ ctx.stroke();
+ }
+ }
+ }
+
+ ctx.restore();
+
+ // result symbol badge
+ if (comp.symbol) {
+ ctx.fillStyle = comp.symbol === '+' ? '#34d399' : '#EF476F';
+ ctx.font = 'bold 14px Manrope,sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(comp.symbol, x + w/2, y - 12);
+ } else {
+ // pending indicator
+ ctx.fillStyle = 'rgba(255,255,255,0.2)';
+ ctx.font = '12px Manrope,sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('?', x + w/2, y - 12);
+ }
+ }
+
+ /* ── Lifecycle ─────────────────────────────────────────────────── */
+ start() {
+ this._buildHomologs();
+ this._buildQualitative();
+ this._setMode(this._mode);
+ }
+
+ stop() {
+ if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
+ }
+
+ fit() {
+ if (this._mode === 'constructor') this._drawMolecule();
+ if (this._mode === 'homologs') this._drawHomologs();
+ if (this._mode === 'qualitative') this._drawQual();
+ }
+}
+
+/* ─── lab UI init ─────────────────────────────────── */
+var organicSim = null;
+
+function _openOrganic() {
+ document.getElementById('sim-topbar-title').textContent = 'Органическая химия';
+ _simShow('sim-organic');
+
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const wrap = document.getElementById('sim-organic');
+ if (!organicSim) {
+ organicSim = new OrganicSim(wrap);
+ organicSim.start();
+ } else {
+ organicSim.fit();
+ }
+ }));
+}
diff --git a/frontend/js/labs/periodic.js b/frontend/js/labs/periodic.js
new file mode 100644
index 0000000..fb5f8cf
--- /dev/null
+++ b/frontend/js/labs/periodic.js
@@ -0,0 +1,750 @@
+'use strict';
+/* ══════════════════════════════════════════════════════════════
+ PeriodicTableSim — Периодическая таблица (118 элементов)
+ Режимы: стандартный вид, подсветка по типам/блокам,
+ графики свойств, боровские оболочки, поиск элементов
+ ══════════════════════════════════════════════════════════════ */
+
+/* ── Element data ───────────────────────────────────────────── */
+const ELEMENTS = [
+ { Z:1, symbol:'H', name:'Водород', mass:1.008, group:1, period:1, block:'s', config:'1s¹', oxStates:[-1,+1], En:2.20, density:0.0899, melt:14.01, boil:20.28, type:'nonmetal', discovered:1766, by:'Кавендиш' },
+ { Z:2, symbol:'He', name:'Гелий', mass:4.003, group:18, period:1, block:'s', config:'1s²', oxStates:[0], En:null, density:0.1786, melt:0.95, boil:4.22, type:'noble', discovered:1868, by:'Жансен' },
+ { Z:3, symbol:'Li', name:'Литий', mass:6.941, group:1, period:2, block:'s', config:'[He]2s¹', oxStates:[+1], En:0.98, density:0.534, melt:453.65, boil:1603, type:'alkali', discovered:1817, by:'Арфведсон' },
+ { Z:4, symbol:'Be', name:'Бериллий', mass:9.012, group:2, period:2, block:'s', config:'[He]2s²', oxStates:[+2], En:1.57, density:1.85, melt:1560, boil:2742, type:'alkaline', discovered:1798, by:'Воклен' },
+ { Z:5, symbol:'B', name:'Бор', mass:10.811, group:13, period:2, block:'p', config:'[He]2s²2p¹', oxStates:[+3], En:2.04, density:2.34, melt:2349, boil:4200, type:'metalloid', discovered:1808, by:'Гей-Люссак' },
+ { Z:6, symbol:'C', name:'Углерод', mass:12.011, group:14, period:2, block:'p', config:'[He]2s²2p²', oxStates:[-4,+4], En:2.55, density:2.267, melt:3823, boil:4098, type:'nonmetal', discovered:null, by:'Древний мир' },
+ { Z:7, symbol:'N', name:'Азот', mass:14.007, group:15, period:2, block:'p', config:'[He]2s²2p³', oxStates:[-3,+5], En:3.04, density:1.251, melt:63.15, boil:77.36, type:'nonmetal', discovered:1772, by:'Резерфорд' },
+ { Z:8, symbol:'O', name:'Кислород', mass:15.999, group:16, period:2, block:'p', config:'[He]2s²2p⁴', oxStates:[-2], En:3.44, density:1.429, melt:54.36, boil:90.20, type:'nonmetal', discovered:1774, by:'Пристли' },
+ { Z:9, symbol:'F', name:'Фтор', mass:18.998, group:17, period:2, block:'p', config:'[He]2s²2p⁵', oxStates:[-1], En:3.98, density:1.696, melt:53.48, boil:85.03, type:'halogen', discovered:1886, by:'Муассан' },
+ { Z:10, symbol:'Ne', name:'Неон', mass:20.180, group:18, period:2, block:'p', config:'[He]2s²2p⁶', oxStates:[0], En:null, density:0.9002, melt:24.56, boil:27.07, type:'noble', discovered:1898, by:'Рамзай' },
+ { Z:11, symbol:'Na', name:'Натрий', mass:22.990, group:1, period:3, block:'s', config:'[Ne]3s¹', oxStates:[+1], En:0.93, density:0.971, melt:370.87, boil:1156, type:'alkali', discovered:1807, by:'Дэви' },
+ { Z:12, symbol:'Mg', name:'Магний', mass:24.305, group:2, period:3, block:'s', config:'[Ne]3s²', oxStates:[+2], En:1.31, density:1.738, melt:923, boil:1363, type:'alkaline', discovered:1755, by:'Блэк' },
+ { Z:13, symbol:'Al', name:'Алюминий', mass:26.982, group:13, period:3, block:'p', config:'[Ne]3s²3p¹', oxStates:[+3], En:1.61, density:2.70, melt:933.47, boil:2792, type:'posttransition',discovered:1825, by:'Эрстед' },
+ { Z:14, symbol:'Si', name:'Кремний', mass:28.086, group:14, period:3, block:'p', config:'[Ne]3s²3p²', oxStates:[-4,+4], En:1.90, density:2.329, melt:1687, boil:3538, type:'metalloid', discovered:1824, by:'Берцелиус' },
+ { Z:15, symbol:'P', name:'Фосфор', mass:30.974, group:15, period:3, block:'p', config:'[Ne]3s²3p³', oxStates:[-3,+5], En:2.19, density:1.823, melt:317.30, boil:553.65, type:'nonmetal', discovered:1669, by:'Бранд' },
+ { Z:16, symbol:'S', name:'Сера', mass:32.065, group:16, period:3, block:'p', config:'[Ne]3s²3p⁴', oxStates:[-2,+6], En:2.58, density:2.07, melt:388.36, boil:717.87, type:'nonmetal', discovered:null, by:'Древний мир' },
+ { Z:17, symbol:'Cl', name:'Хлор', mass:35.453, group:17, period:3, block:'p', config:'[Ne]3s²3p⁵', oxStates:[-1,+7], En:3.16, density:3.214, melt:171.65, boil:239.11, type:'halogen', discovered:1774, by:'Шееле' },
+ { Z:18, symbol:'Ar', name:'Аргон', mass:39.948, group:18, period:3, block:'p', config:'[Ne]3s²3p⁶', oxStates:[0], En:null, density:1.784, melt:83.80, boil:87.30, type:'noble', discovered:1894, by:'Рэлей' },
+ { Z:19, symbol:'K', name:'Калий', mass:39.098, group:1, period:4, block:'s', config:'[Ar]4s¹', oxStates:[+1], En:0.82, density:0.862, melt:336.53, boil:1032, type:'alkali', discovered:1807, by:'Дэви' },
+ { Z:20, symbol:'Ca', name:'Кальций', mass:40.078, group:2, period:4, block:'s', config:'[Ar]4s²', oxStates:[+2], En:1.00, density:1.55, melt:1115, boil:1757, type:'alkaline', discovered:1808, by:'Дэви' },
+ { Z:21, symbol:'Sc', name:'Скандий', mass:44.956, group:3, period:4, block:'d', config:'[Ar]3d¹4s²', oxStates:[+3], En:1.36, density:2.985, melt:1814, boil:3109, type:'transition', discovered:1879, by:'Нильсон' },
+ { Z:22, symbol:'Ti', name:'Титан', mass:47.867, group:4, period:4, block:'d', config:'[Ar]3d²4s²', oxStates:[+4], En:1.54, density:4.507, melt:1941, boil:3560, type:'transition', discovered:1791, by:'Грегор' },
+ { Z:23, symbol:'V', name:'Ванадий', mass:50.942, group:5, period:4, block:'d', config:'[Ar]3d³4s²', oxStates:[+5], En:1.63, density:6.11, melt:2183, boil:3680, type:'transition', discovered:1830, by:'Сефстрём' },
+ { Z:24, symbol:'Cr', name:'Хром', mass:51.996, group:6, period:4, block:'d', config:'[Ar]3d⁵4s¹', oxStates:[+3,+6], En:1.66, density:7.19, melt:2180, boil:2944, type:'transition', discovered:1798, by:'Воклен' },
+ { Z:25, symbol:'Mn', name:'Марганец', mass:54.938, group:7, period:4, block:'d', config:'[Ar]3d⁵4s²', oxStates:[+2,+7], En:1.55, density:7.21, melt:1519, boil:2334, type:'transition', discovered:1774, by:'Ган' },
+ { Z:26, symbol:'Fe', name:'Железо', mass:55.845, group:8, period:4, block:'d', config:'[Ar]3d⁶4s²', oxStates:[+2,+3], En:1.83, density:7.874, melt:1811, boil:3134, type:'transition', discovered:null, by:'Древний мир' },
+ { Z:27, symbol:'Co', name:'Кобальт', mass:58.933, group:9, period:4, block:'d', config:'[Ar]3d⁷4s²', oxStates:[+2,+3], En:1.88, density:8.90, melt:1768, boil:3200, type:'transition', discovered:1735, by:'Брандт' },
+ { Z:28, symbol:'Ni', name:'Никель', mass:58.693, group:10, period:4, block:'d', config:'[Ar]3d⁸4s²', oxStates:[+2], En:1.91, density:8.908, melt:1728, boil:3186, type:'transition', discovered:1751, by:'Кронстедт' },
+ { Z:29, symbol:'Cu', name:'Медь', mass:63.546, group:11, period:4, block:'d', config:'[Ar]3d¹⁰4s¹', oxStates:[+1,+2], En:1.90, density:8.96, melt:1357.77,boil:2835, type:'transition', discovered:null, by:'Древний мир' },
+ { Z:30, symbol:'Zn', name:'Цинк', mass:65.38, group:12, period:4, block:'d', config:'[Ar]3d¹⁰4s²', oxStates:[+2], En:1.65, density:7.14, melt:692.68, boil:1180, type:'transition', discovered:1746, by:'Марграф' },
+ { Z:31, symbol:'Ga', name:'Галлий', mass:69.723, group:13, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p¹', oxStates:[+3], En:1.81, density:5.91, melt:302.91, boil:2477, type:'posttransition',discovered:1875, by:'Де Буабодран' },
+ { Z:32, symbol:'Ge', name:'Германий', mass:72.630, group:14, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p²', oxStates:[+4], En:2.01, density:5.323, melt:1211.40,boil:3106, type:'metalloid', discovered:1886, by:'Винклер' },
+ { Z:33, symbol:'As', name:'Мышьяк', mass:74.922, group:15, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p³', oxStates:[-3,+5], En:2.18, density:5.776, melt:1090, boil:887, type:'metalloid', discovered:1250, by:'Альберт Великий' },
+ { Z:34, symbol:'Se', name:'Селен', mass:78.971, group:16, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁴', oxStates:[-2,+6], En:2.55, density:4.809, melt:493.65, boil:958, type:'nonmetal', discovered:1817, by:'Берцелиус' },
+ { Z:35, symbol:'Br', name:'Бром', mass:79.904, group:17, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁵', oxStates:[-1,+5], En:2.96, density:3.122, melt:265.95, boil:332.00, type:'halogen', discovered:1826, by:'Балар' },
+ { Z:36, symbol:'Kr', name:'Криптон', mass:83.798, group:18, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁶', oxStates:[0], En:3.00, density:3.749, melt:115.79, boil:119.93, type:'noble', discovered:1898, by:'Рамзай' },
+ { Z:37, symbol:'Rb', name:'Рубидий', mass:85.468, group:1, period:5, block:'s', config:'[Kr]5s¹', oxStates:[+1], En:0.82, density:1.532, melt:312.46, boil:961, type:'alkali', discovered:1861, by:'Бунзен' },
+ { Z:38, symbol:'Sr', name:'Стронций', mass:87.62, group:2, period:5, block:'s', config:'[Kr]5s²', oxStates:[+2], En:0.95, density:2.64, melt:1050, boil:1655, type:'alkaline', discovered:1790, by:'Кроуфорд' },
+ { Z:39, symbol:'Y', name:'Иттрий', mass:88.906, group:3, period:5, block:'d', config:'[Kr]4d¹5s²', oxStates:[+3], En:1.22, density:4.472, melt:1799, boil:3609, type:'transition', discovered:1794, by:'Гадолин' },
+ { Z:40, symbol:'Zr', name:'Цирконий', mass:91.224, group:4, period:5, block:'d', config:'[Kr]4d²5s²', oxStates:[+4], En:1.33, density:6.52, melt:2128, boil:4682, type:'transition', discovered:1789, by:'Клапрот' },
+ { Z:41, symbol:'Nb', name:'Ниобий', mass:92.906, group:5, period:5, block:'d', config:'[Kr]4d⁴5s¹', oxStates:[+5], En:1.6, density:8.57, melt:2750, boil:5017, type:'transition', discovered:1801, by:'Хатчетт' },
+ { Z:42, symbol:'Mo', name:'Молибден', mass:95.95, group:6, period:5, block:'d', config:'[Kr]4d⁵5s¹', oxStates:[+6], En:2.16, density:10.28, melt:2896, boil:4912, type:'transition', discovered:1781, by:'Шееле' },
+ { Z:43, symbol:'Tc', name:'Технеций', mass:98, group:7, period:5, block:'d', config:'[Kr]4d⁵5s²', oxStates:[+7], En:1.9, density:11.50, melt:2430, boil:4538, type:'transition', discovered:1937, by:'Перье' },
+ { Z:44, symbol:'Ru', name:'Рутений', mass:101.07, group:8, period:5, block:'d', config:'[Kr]4d⁷5s¹', oxStates:[+4], En:2.2, density:12.45, melt:2607, boil:4423, type:'transition', discovered:1844, by:'Клаус' },
+ { Z:45, symbol:'Rh', name:'Родий', mass:102.906, group:9, period:5, block:'d', config:'[Kr]4d⁸5s¹', oxStates:[+3], En:2.28, density:12.41, melt:2237, boil:3968, type:'transition', discovered:1803, by:'Воластон' },
+ { Z:46, symbol:'Pd', name:'Палладий', mass:106.42, group:10, period:5, block:'d', config:'[Kr]4d¹⁰', oxStates:[+2], En:2.20, density:12.023, melt:1828.05,boil:3236, type:'transition', discovered:1803, by:'Воластон' },
+ { Z:47, symbol:'Ag', name:'Серебро', mass:107.868, group:11, period:5, block:'d', config:'[Kr]4d¹⁰5s¹', oxStates:[+1], En:1.93, density:10.49, melt:1234.93,boil:2435, type:'transition', discovered:null, by:'Древний мир' },
+ { Z:48, symbol:'Cd', name:'Кадмий', mass:112.414, group:12, period:5, block:'d', config:'[Kr]4d¹⁰5s²', oxStates:[+2], En:1.69, density:8.65, melt:594.22, boil:1040, type:'transition', discovered:1817, by:'Штромейер' },
+ { Z:49, symbol:'In', name:'Индий', mass:114.818, group:13, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p¹', oxStates:[+3], En:1.78, density:7.31, melt:429.75, boil:2345, type:'posttransition',discovered:1863, by:'Рейх' },
+ { Z:50, symbol:'Sn', name:'Олово', mass:118.710, group:14, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p²', oxStates:[+2,+4], En:1.96, density:7.287, melt:505.08, boil:2875, type:'posttransition',discovered:null, by:'Древний мир' },
+ { Z:51, symbol:'Sb', name:'Сурьма', mass:121.760, group:15, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p³', oxStates:[-3,+5], En:2.05, density:6.697, melt:903.78, boil:1860, type:'metalloid', discovered:null, by:'Древний мир' },
+ { Z:52, symbol:'Te', name:'Теллур', mass:127.60, group:16, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁴', oxStates:[-2,+6], En:2.1, density:6.24, melt:722.66, boil:1261, type:'metalloid', discovered:1782, by:'фон Райхенштайн' },
+ { Z:53, symbol:'I', name:'Йод', mass:126.904, group:17, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁵', oxStates:[-1,+7], En:2.66, density:4.933, melt:386.85, boil:457.55, type:'halogen', discovered:1811, by:'Куртуа' },
+ { Z:54, symbol:'Xe', name:'Ксенон', mass:131.293, group:18, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁶', oxStates:[0], En:2.6, density:5.894, melt:161.40, boil:165.05, type:'noble', discovered:1898, by:'Рамзай' },
+ { Z:55, symbol:'Cs', name:'Цезий', mass:132.905, group:1, period:6, block:'s', config:'[Xe]6s¹', oxStates:[+1], En:0.79, density:1.873, melt:301.59, boil:944, type:'alkali', discovered:1860, by:'Бунзен' },
+ { Z:56, symbol:'Ba', name:'Барий', mass:137.327, group:2, period:6, block:'s', config:'[Xe]6s²', oxStates:[+2], En:0.89, density:3.594, melt:1000, boil:2170, type:'alkaline', discovered:1808, by:'Дэви' },
+ { Z:57, symbol:'La', name:'Лантан', mass:138.905, group:3, period:6, block:'f', config:'[Xe]5d¹6s²', oxStates:[+3], En:1.10, density:6.162, melt:1193, boil:3737, type:'lanthanide', discovered:1839, by:'Мосандер' },
+ { Z:58, symbol:'Ce', name:'Церий', mass:140.116, group:null,period:6, block:'f', config:'[Xe]4f¹5d¹6s²', oxStates:[+3,+4], En:1.12, density:6.770, melt:1068, boil:3716, type:'lanthanide', discovered:1803, by:'Берцелиус' },
+ { Z:59, symbol:'Pr', name:'Празеодим', mass:140.908, group:null,period:6, block:'f', config:'[Xe]4f³6s²', oxStates:[+3], En:1.13, density:6.77, melt:1208, boil:3793, type:'lanthanide', discovered:1885, by:'фон Вельсбах' },
+ { Z:60, symbol:'Nd', name:'Неодим', mass:144.242, group:null,period:6, block:'f', config:'[Xe]4f⁴6s²', oxStates:[+3], En:1.14, density:7.01, melt:1297, boil:3347, type:'lanthanide', discovered:1885, by:'фон Вельсбах' },
+ { Z:61, symbol:'Pm', name:'Прометий', mass:145, group:null,period:6, block:'f', config:'[Xe]4f⁵6s²', oxStates:[+3], En:1.13, density:7.26, melt:1315, boil:3273, type:'lanthanide', discovered:1945, by:'Маринский' },
+ { Z:62, symbol:'Sm', name:'Самарий', mass:150.36, group:null,period:6, block:'f', config:'[Xe]4f⁶6s²', oxStates:[+2,+3], En:1.17, density:7.52, melt:1345, boil:2067, type:'lanthanide', discovered:1879, by:'Буабодран' },
+ { Z:63, symbol:'Eu', name:'Европий', mass:151.964, group:null,period:6, block:'f', config:'[Xe]4f⁷6s²', oxStates:[+2,+3], En:1.20, density:5.244, melt:1099, boil:1802, type:'lanthanide', discovered:1901, by:'Демарсе' },
+ { Z:64, symbol:'Gd', name:'Гадолиний', mass:157.25, group:null,period:6, block:'f', config:'[Xe]4f⁷5d¹6s²', oxStates:[+3], En:1.20, density:7.90, melt:1585, boil:3546, type:'lanthanide', discovered:1880, by:'Мариньяк' },
+ { Z:65, symbol:'Tb', name:'Тербий', mass:158.925, group:null,period:6, block:'f', config:'[Xe]4f⁹6s²', oxStates:[+3], En:1.10, density:8.23, melt:1629, boil:3503, type:'lanthanide', discovered:1843, by:'Мосандер' },
+ { Z:66, symbol:'Dy', name:'Диспрозий', mass:162.500, group:null,period:6, block:'f', config:'[Xe]4f¹⁰6s²', oxStates:[+3], En:1.22, density:8.540, melt:1680, boil:2840, type:'lanthanide', discovered:1886, by:'Буабодран' },
+ { Z:67, symbol:'Ho', name:'Гольмий', mass:164.930, group:null,period:6, block:'f', config:'[Xe]4f¹¹6s²', oxStates:[+3], En:1.23, density:8.795, melt:1734, boil:2993, type:'lanthanide', discovered:1878, by:'Клеве' },
+ { Z:68, symbol:'Er', name:'Эрбий', mass:167.259, group:null,period:6, block:'f', config:'[Xe]4f¹²6s²', oxStates:[+3], En:1.24, density:9.066, melt:1802, boil:3141, type:'lanthanide', discovered:1843, by:'Мосандер' },
+ { Z:69, symbol:'Tm', name:'Тулий', mass:168.934, group:null,period:6, block:'f', config:'[Xe]4f¹³6s²', oxStates:[+3], En:1.25, density:9.32, melt:1818, boil:2223, type:'lanthanide', discovered:1879, by:'Клеве' },
+ { Z:70, symbol:'Yb', name:'Иттербий', mass:173.054, group:null,period:6, block:'f', config:'[Xe]4f¹⁴6s²', oxStates:[+2,+3], En:1.10, density:6.90, melt:1097, boil:1469, type:'lanthanide', discovered:1878, by:'Мариньяк' },
+ { Z:71, symbol:'Lu', name:'Лютеций', mass:174.967, group:3, period:6, block:'d', config:'[Xe]4f¹⁴5d¹6s²', oxStates:[+3], En:1.27, density:9.841, melt:1925, boil:3675, type:'lanthanide', discovered:1907, by:'Урбен' },
+ { Z:72, symbol:'Hf', name:'Гафний', mass:178.49, group:4, period:6, block:'d', config:'[Xe]4f¹⁴5d²6s²', oxStates:[+4], En:1.3, density:13.31, melt:2506, boil:4876, type:'transition', discovered:1923, by:'Костер' },
+ { Z:73, symbol:'Ta', name:'Тантал', mass:180.948, group:5, period:6, block:'d', config:'[Xe]4f¹⁴5d³6s²', oxStates:[+5], En:1.5, density:16.69, melt:3290, boil:5731, type:'transition', discovered:1802, by:'Экеберг' },
+ { Z:74, symbol:'W', name:'Вольфрам', mass:183.84, group:6, period:6, block:'d', config:'[Xe]4f¹⁴5d⁴6s²', oxStates:[+6], En:2.36, density:19.25, melt:3695, boil:5828, type:'transition', discovered:1783, by:'Братья дель Риo' },
+ { Z:75, symbol:'Re', name:'Рений', mass:186.207, group:7, period:6, block:'d', config:'[Xe]4f¹⁴5d⁵6s²', oxStates:[+7], En:1.9, density:21.02, melt:3459, boil:5869, type:'transition', discovered:1925, by:'Ноддак' },
+ { Z:76, symbol:'Os', name:'Осмий', mass:190.23, group:8, period:6, block:'d', config:'[Xe]4f¹⁴5d⁶6s²', oxStates:[+4], En:2.2, density:22.59, melt:3306, boil:5285, type:'transition', discovered:1803, by:'Теннант' },
+ { Z:77, symbol:'Ir', name:'Иридий', mass:192.217, group:9, period:6, block:'d', config:'[Xe]4f¹⁴5d⁷6s²', oxStates:[+4], En:2.20, density:22.56, melt:2719, boil:4701, type:'transition', discovered:1803, by:'Теннант' },
+ { Z:78, symbol:'Pt', name:'Платина', mass:195.084, group:10, period:6, block:'d', config:'[Xe]4f¹⁴5d⁹6s¹', oxStates:[+2,+4], En:2.28, density:21.45, melt:2041.4, boil:4098, type:'transition', discovered:1735, by:'де Улоа' },
+ { Z:79, symbol:'Au', name:'Золото', mass:196.967, group:11, period:6, block:'d', config:'[Xe]4f¹⁴5d¹⁰6s¹',oxStates:[+1,+3], En:2.54, density:19.30, melt:1337.33,boil:3129, type:'transition', discovered:null, by:'Древний мир' },
+ { Z:80, symbol:'Hg', name:'Ртуть', mass:200.592, group:12, period:6, block:'d', config:'[Xe]4f¹⁴5d¹⁰6s²',oxStates:[+1,+2], En:2.00, density:13.534, melt:234.32, boil:629.88, type:'transition', discovered:null, by:'Древний мир' },
+ { Z:81, symbol:'Tl', name:'Таллий', mass:204.38, group:13, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p¹',oxStates:[+1,+3],En:1.62,density:11.85, melt:577, boil:1746, type:'posttransition',discovered:1861, by:'Крукс' },
+ { Z:82, symbol:'Pb', name:'Свинец', mass:207.2, group:14, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p²',oxStates:[+2,+4],En:2.33,density:11.34, melt:600.61, boil:2022, type:'posttransition',discovered:null, by:'Древний мир' },
+ { Z:83, symbol:'Bi', name:'Висмут', mass:208.980, group:15, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p³',oxStates:[+3], En:2.02,density:9.747, melt:544.55, boil:1837, type:'posttransition',discovered:1753, by:'Жоффруа' },
+ { Z:84, symbol:'Po', name:'Полоний', mass:209, group:16, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁴',oxStates:[+4], En:2.0, density:9.32, melt:527, boil:1235, type:'metalloid', discovered:1898, by:'Кюри' },
+ { Z:85, symbol:'At', name:'Астат', mass:210, group:17, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁵',oxStates:[-1,+1],En:2.2, density:null, melt:575, boil:null, type:'halogen', discovered:1940, by:'Корсон' },
+ { Z:86, symbol:'Rn', name:'Радон', mass:222, group:18, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁶',oxStates:[0], En:null,density:9.73, melt:202, boil:211.45, type:'noble', discovered:1900, by:'Дорн' },
+ { Z:87, symbol:'Fr', name:'Франций', mass:223, group:1, period:7, block:'s', config:'[Rn]7s¹', oxStates:[+1], En:0.7, density:null, melt:300, boil:950, type:'alkali', discovered:1939, by:'Перей' },
+ { Z:88, symbol:'Ra', name:'Радий', mass:226, group:2, period:7, block:'s', config:'[Rn]7s²', oxStates:[+2], En:0.9, density:5.0, melt:973, boil:2010, type:'alkaline', discovered:1898, by:'Кюри' },
+ { Z:89, symbol:'Ac', name:'Актиний', mass:227, group:3, period:7, block:'f', config:'[Rn]6d¹7s²', oxStates:[+3], En:1.1, density:10.07, melt:1323, boil:3471, type:'actinide', discovered:1899, by:'Дебьерн' },
+ { Z:90, symbol:'Th', name:'Торий', mass:232.038, group:null,period:7, block:'f', config:'[Rn]6d²7s²', oxStates:[+4], En:1.3, density:11.72, melt:2115, boil:5061, type:'actinide', discovered:1828, by:'Берцелиус' },
+ { Z:91, symbol:'Pa', name:'Протактиний', mass:231.036, group:null,period:7, block:'f', config:'[Rn]5f²6d¹7s²', oxStates:[+5], En:1.5, density:15.37, melt:1841, boil:4300, type:'actinide', discovered:1913, by:'Фаянс' },
+ { Z:92, symbol:'U', name:'Уран', mass:238.029, group:null,period:7, block:'f', config:'[Rn]5f³6d¹7s²', oxStates:[+6], En:1.38, density:19.05, melt:1405.3, boil:4404, type:'actinide', discovered:1789, by:'Клапрот' },
+ { Z:93, symbol:'Np', name:'Нептуний', mass:237, group:null,period:7, block:'f', config:'[Rn]5f⁴6d¹7s²', oxStates:[+5], En:1.36, density:20.25, melt:913, boil:4273, type:'actinide', discovered:1940, by:'МакМиллан' },
+ { Z:94, symbol:'Pu', name:'Плутоний', mass:244, group:null,period:7, block:'f', config:'[Rn]5f⁶7s²', oxStates:[+4], En:1.28, density:19.84, melt:912.5, boil:3501, type:'actinide', discovered:1940, by:'Сиборг' },
+ { Z:95, symbol:'Am', name:'Америций', mass:243, group:null,period:7, block:'f', config:'[Rn]5f⁷7s²', oxStates:[+3], En:1.3, density:13.67, melt:1449, boil:2880, type:'actinide', discovered:1944, by:'Сиборг' },
+ { Z:96, symbol:'Cm', name:'Кюрий', mass:247, group:null,period:7, block:'f', config:'[Rn]5f⁷6d¹7s²', oxStates:[+3], En:1.3, density:13.51, melt:1613, boil:3383, type:'actinide', discovered:1944, by:'Сиборг' },
+ { Z:97, symbol:'Bk', name:'Берклий', mass:247, group:null,period:7, block:'f', config:'[Rn]5f⁹7s²', oxStates:[+3], En:1.3, density:14.79, melt:1259, boil:null, type:'actinide', discovered:1949, by:'Сиборг' },
+ { Z:98, symbol:'Cf', name:'Калифорний', mass:251, group:null,period:7, block:'f', config:'[Rn]5f¹⁰7s²', oxStates:[+3], En:1.3, density:15.1, melt:1173, boil:null, type:'actinide', discovered:1950, by:'Сиборг' },
+ { Z:99, symbol:'Es', name:'Эйнштейний', mass:252, group:null,period:7, block:'f', config:'[Rn]5f¹¹7s²', oxStates:[+3], En:1.3, density:null, melt:1133, boil:null, type:'actinide', discovered:1952, by:'Гиорсо' },
+ { Z:100,symbol:'Fm', name:'Фермий', mass:257, group:null,period:7, block:'f', config:'[Rn]5f¹²7s²', oxStates:[+3], En:1.3, density:null, melt:1800, boil:null, type:'actinide', discovered:1952, by:'Гиорсо' },
+ { Z:101,symbol:'Md', name:'Менделевий', mass:258, group:null,period:7, block:'f', config:'[Rn]5f¹³7s²', oxStates:[+3], En:1.3, density:null, melt:1100, boil:null, type:'actinide', discovered:1955, by:'Гиорсо' },
+ { Z:102,symbol:'No', name:'Нобелий', mass:259, group:null,period:7, block:'f', config:'[Rn]5f¹⁴7s²', oxStates:[+2], En:1.3, density:null, melt:1100, boil:null, type:'actinide', discovered:1958, by:'Флёров' },
+ { Z:103,symbol:'Lr', name:'Лоуренсий', mass:266, group:3, period:7, block:'d', config:'[Rn]5f¹⁴7s²7p¹', oxStates:[+3], En:1.3, density:null, melt:1900, boil:null, type:'actinide', discovered:1961, by:'Гиорсо' },
+ { Z:104,symbol:'Rf', name:'Резерфордий', mass:267, group:4, period:7, block:'d', config:'[Rn]5f¹⁴6d²7s²', oxStates:[+4], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1964, by:'Флёров' },
+ { Z:105,symbol:'Db', name:'Дубний', mass:268, group:5, period:7, block:'d', config:'[Rn]5f¹⁴6d³7s²', oxStates:[+5], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1968, by:'Флёров' },
+ { Z:106,symbol:'Sg', name:'Сиборгий', mass:269, group:6, period:7, block:'d', config:'[Rn]5f¹⁴6d⁴7s²', oxStates:[+6], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1974, by:'Флёров' },
+ { Z:107,symbol:'Bh', name:'Борий', mass:270, group:7, period:7, block:'d', config:'[Rn]5f¹⁴6d⁵7s²', oxStates:[+7], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1981, by:'ГСИ' },
+ { Z:108,symbol:'Hs', name:'Хассий', mass:277, group:8, period:7, block:'d', config:'[Rn]5f¹⁴6d⁶7s²', oxStates:[+8], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1984, by:'ГСИ' },
+ { Z:109,symbol:'Mt', name:'Мейтнерий', mass:278, group:9, period:7, block:'d', config:'[Rn]5f¹⁴6d⁷7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1982, by:'ГСИ' },
+ { Z:110,symbol:'Ds', name:'Дармштадтий', mass:281, group:10, period:7, block:'d', config:'[Rn]5f¹⁴6d⁸7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1994, by:'ГСИ' },
+ { Z:111,symbol:'Rg', name:'Рентгений', mass:282, group:11, period:7, block:'d', config:'[Rn]5f¹⁴6d⁹7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1994, by:'ГСИ' },
+ { Z:112,symbol:'Cn', name:'Коперниций', mass:285, group:12, period:7, block:'d', config:'[Rn]5f¹⁴6d¹⁰7s²',oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1996, by:'ГСИ' },
+ { Z:113,symbol:'Nh', name:'Нихоний', mass:286, group:13, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p¹',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2004, by:'РИКЕН' },
+ { Z:114,symbol:'Fl', name:'Флеровий', mass:289, group:14, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p²',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:1998, by:'Флёров' },
+ { Z:115,symbol:'Mc', name:'Московий', mass:290, group:15, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p³',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2003, by:'Флёров' },
+ { Z:116,symbol:'Lv', name:'Ливерморий', mass:293, group:16, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁴',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2000, by:'Флёров' },
+ { Z:117,symbol:'Ts', name:'Теннессин', mass:294, group:17, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁵',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'halogen', discovered:2010, by:'Флёров' },
+ { Z:118,symbol:'Og', name:'Оганессон', mass:294, group:18, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁶',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'noble', discovered:2002, by:'Флёров' },
+];
+
+/* ── Colour palette per type ──────────────────────────────── */
+const TYPE_COLORS = {
+ alkali: '#EF476F',
+ alkaline: '#FF6B35',
+ transition: '#7B8EF7',
+ posttransition:'#06D6E0',
+ metalloid: '#7BF5A4',
+ nonmetal: '#FFD166',
+ halogen: '#C77DFF',
+ noble: '#A8DADC',
+ lanthanide: '#9B5DE5',
+ actinide: '#F15BB5',
+ metal: '#7B8EF7',
+};
+const TYPE_LABELS = {
+ alkali: 'Щелочные металлы',
+ alkaline: 'Щёлочноземельные',
+ transition: 'Переходные металлы',
+ posttransition:'Постпереходные',
+ metalloid: 'Металлоиды',
+ nonmetal: 'Неметаллы',
+ halogen: 'Галогены',
+ noble: 'Благородные газы',
+ lanthanide: 'Лантаноиды',
+ actinide: 'Актиноиды',
+};
+const BLOCK_COLORS = {
+ s: '#EF476F',
+ p: '#06D6E0',
+ d: '#7B8EF7',
+ f: '#9B5DE5',
+};
+
+/* ── Электронные оболочки (K,L,M,N,O,P,Q) ── */
+const SHELL_CAPACITY = [2, 8, 18, 32, 32, 18, 8];
+
+function getShellFill(Z) {
+ const caps = SHELL_CAPACITY;
+ const shells = [];
+ let rem = Z;
+ for (let i = 0; i < caps.length && rem > 0; i++) {
+ const n = Math.min(rem, caps[i]);
+ shells.push(n);
+ rem -= n;
+ }
+ return shells;
+}
+
+/* ── Layout helpers ──────────────────────────────────────────── */
+/* Standard 18-column layout: returns {col, row} for each element */
+function getCell(el) {
+ if (el.type === 'lanthanide' && el.Z !== 57 && el.Z !== 71) {
+ return { col: el.Z - 57 + 3, row: 9 }; // lanthanide row
+ }
+ if (el.type === 'actinide' && el.Z !== 89 && el.Z !== 103) {
+ return { col: el.Z - 89 + 3, row: 10 }; // actinide row
+ }
+ const g = el.group;
+ const p = el.period;
+ if (!g) return null;
+ return { col: g, row: p };
+}
+
+/* ══════════════════════════════════════════════════════════════
+ CLASS
+ ══════════════════════════════════════════════════════════════ */
+class PeriodicTableSim {
+ constructor(wrap) {
+ this._wrap = wrap;
+ this._mode = 'type'; // type | block | none
+ this._selected = null; // element Z
+ this._searchQ = '';
+ this._highlighted = new Set(); // Zs matching search
+ this._propKey = 'En'; // property for chart
+ this._chartBy = 'period';
+ this._chartN = 2; // period number or group number
+ this._bohrZ = null; // Z for Bohr shell panel
+ this._bohrRaf = null;
+ this._bohrAngle = 0;
+
+ // build
+ this._buildUI();
+ this._buildTable();
+ this._updateCard(null);
+
+ // chart defaults
+ this._drawChart();
+ }
+
+ /* ─────────────────────────────────────────────────────
+ UI BUILD
+ ───────────────────────────────────────────────────── */
+ _buildUI() {
+ this._wrap.innerHTML = '';
+ this._wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0;background:#0D0D1A;overflow:hidden;';
+
+ /* top toolbar */
+ const toolbar = document.createElement('div');
+ toolbar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,0.08);flex-wrap:wrap;flex-shrink:0;';
+ toolbar.innerHTML = `
+ Режим:
+
+
+
+ Поиск:
+
+ `;
+ this._wrap.appendChild(toolbar);
+
+ /* mode buttons */
+ toolbar.querySelectorAll('.ptbl-mode-btn').forEach(btn => {
+ btn.style.cssText = 'padding:4px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.15);background:transparent;color:#aaa;font-size:.75rem;cursor:pointer;transition:all .15s';
+ btn.addEventListener('click', () => {
+ toolbar.querySelectorAll('.ptbl-mode-btn').forEach(b => { b.style.background='transparent'; b.style.color='#aaa'; b.classList.remove('active'); });
+ btn.style.background='rgba(155,93,229,0.25)'; btn.style.color='#fff'; btn.classList.add('active');
+ this._mode = btn.dataset.m;
+ if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.25 });
+ this._colorTable();
+ });
+ });
+ /* set initial active style */
+ toolbar.querySelector('.ptbl-mode-btn.active').style.background = 'rgba(155,93,229,0.25)';
+ toolbar.querySelector('.ptbl-mode-btn.active').style.color = '#fff';
+
+ /* search */
+ const si = toolbar.querySelector('#ptbl-search');
+ si.addEventListener('input', () => { this._searchQ = si.value.trim().toLowerCase(); this._applySearch(); });
+ toolbar.querySelector('#ptbl-search-clear').addEventListener('click', () => { si.value=''; this._searchQ=''; this._applySearch(); });
+
+ /* main area: table + right panel */
+ const main = document.createElement('div');
+ main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden;';
+ this._wrap.appendChild(main);
+
+ /* left: table + legend */
+ const leftCol = document.createElement('div');
+ leftCol.style.cssText = 'flex:1;display:flex;flex-direction:column;min-width:0;overflow:auto;padding:8px 4px 8px 8px;';
+ main.appendChild(leftCol);
+
+ /* table grid container */
+ this._tableEl = document.createElement('div');
+ this._tableEl.id = 'ptbl-grid';
+ this._tableEl.style.cssText = 'display:grid;grid-template-columns:repeat(18,1fr);gap:2px;min-width:540px;';
+ leftCol.appendChild(this._tableEl);
+
+ /* gap filler row */
+ const gapDiv = document.createElement('div');
+ gapDiv.style.cssText = 'height:8px;';
+ leftCol.appendChild(gapDiv);
+
+ /* f-block (lanthanide/actinide) */
+ this._fblockEl = document.createElement('div');
+ this._fblockEl.id = 'ptbl-fblock';
+ this._fblockEl.style.cssText = 'display:grid;grid-template-columns:repeat(15,1fr);gap:2px;min-width:540px;margin-left:calc(2*100%/18 + 4px);';
+ leftCol.appendChild(this._fblockEl);
+
+ /* legend */
+ this._legendEl = document.createElement('div');
+ this._legendEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;min-width:540px;';
+ leftCol.appendChild(this._legendEl);
+
+ /* right panel */
+ const rightCol = document.createElement('div');
+ rightCol.style.cssText = 'width:260px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid rgba(255,255,255,0.07);overflow:hidden;';
+ main.appendChild(rightCol);
+
+ /* element card */
+ this._cardEl = document.createElement('div');
+ this._cardEl.id = 'ptbl-card';
+ this._cardEl.style.cssText = 'flex:1;overflow-y:auto;padding:12px 10px 8px;font-size:.78rem;color:#ccc;';
+ rightCol.appendChild(this._cardEl);
+
+ /* Bohr shells canvas */
+ const bohrWrap = document.createElement('div');
+ bohrWrap.style.cssText = 'height:150px;flex-shrink:0;border-top:1px solid rgba(255,255,255,0.07);background:rgba(0,0,0,0.3);position:relative;';
+ this._bohrCanvas = document.createElement('canvas');
+ this._bohrCanvas.style.cssText = 'width:100%;height:100%;';
+ bohrWrap.appendChild(this._bohrCanvas);
+ rightCol.appendChild(bohrWrap);
+
+ /* chart panel */
+ const chartPan = document.createElement('div');
+ chartPan.style.cssText = 'flex-shrink:0;border-top:1px solid rgba(255,255,255,0.07);padding:8px;background:rgba(0,0,0,0.2);';
+ chartPan.innerHTML = `
+
+ Свойство:
+
+
+
+
+ `;
+ rightCol.appendChild(chartPan);
+
+ chartPan.querySelector('#ptbl-prop-sel').addEventListener('change', e => { this._propKey=e.target.value; this._drawChart(); });
+ chartPan.querySelector('#ptbl-by-sel').addEventListener('change', e => {
+ this._chartBy=e.target.value;
+ const nSel = chartPan.querySelector('#ptbl-n-sel');
+ if (this._chartBy === 'group') {
+ nSel.innerHTML = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18].map(n=>``).join('');
+ this._chartN = 1;
+ } else {
+ nSel.innerHTML = [1,2,3,4,5,6,7].map(n=>``).join('');
+ this._chartN = 2;
+ }
+ this._drawChart();
+ });
+ chartPan.querySelector('#ptbl-n-sel').addEventListener('change', e => { this._chartN=+e.target.value; this._drawChart(); });
+
+ this._chartCanvas = chartPan.querySelector('#ptbl-chart');
+ new ResizeObserver(() => this._drawChart()).observe(this._chartCanvas);
+ new ResizeObserver(() => this._drawBohr()).observe(this._bohrCanvas);
+ }
+
+ /* ─────────────────────────────────────────────────────
+ TABLE BUILD
+ ───────────────────────────────────────────────────── */
+ _buildTable() {
+ /* create placeholder grid for rows 1-7 × cols 1-18 */
+ const cells = {}; // 'row,col' → div
+ for (let r = 1; r <= 7; r++) {
+ for (let c = 1; c <= 18; c++) {
+ const d = document.createElement('div');
+ d.style.cssText = 'aspect-ratio:1;border-radius:4px;';
+ cells[`${r},${c}`] = d;
+ this._tableEl.appendChild(d);
+ }
+ }
+
+ /* f-block rows */
+ const fCells = { 9: {}, 10: {} }; // lanthanides / actinides
+ for (let fc = 1; fc <= 15; fc++) {
+ for (const fr of [9, 10]) {
+ const d = document.createElement('div');
+ d.style.cssText = 'aspect-ratio:1;border-radius:4px;';
+ fCells[fr][fc] = d;
+ this._fblockEl.appendChild(d);
+ }
+ }
+
+ /* place elements */
+ this._cellMap = {}; // Z → div
+ for (const el of ELEMENTS) {
+ const pos = getCell(el);
+ if (!pos) continue;
+ let div;
+ if (pos.row <= 7) {
+ div = cells[`${pos.row},${pos.col}`];
+ } else {
+ const fCol = pos.col - 2; // 3..17 → 1..15
+ div = fCells[pos.row][fCol];
+ }
+ if (!div) continue;
+ this._cellMap[el.Z] = div;
+ div.dataset.z = el.Z;
+ div.title = `${el.name} (${el.symbol})`;
+ div.style.cssText += 'cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:filter .12s,transform .12s;position:relative;overflow:hidden;';
+ div.innerHTML = `
+ ${el.Z}
+ ${el.symbol}
+ ${el.name}`;
+ div.addEventListener('mouseenter', () => { div.style.filter='brightness(1.4)'; div.style.transform='scale(1.12)'; div.style.zIndex='10'; });
+ div.addEventListener('mouseleave', () => { div.style.filter=''; div.style.transform=''; div.style.zIndex=''; });
+ div.addEventListener('click', () => this._selectElement(el.Z));
+ }
+
+ this._colorTable();
+ this._buildLegend();
+ }
+
+ _colorTable() {
+ for (const el of ELEMENTS) {
+ const div = this._cellMap[el.Z];
+ if (!div) continue;
+ let bg, textC = '#fff';
+ if (this._mode === 'type') {
+ bg = TYPE_COLORS[el.type] || '#555';
+ } else if (this._mode === 'block') {
+ bg = BLOCK_COLORS[el.block] || '#555';
+ } else {
+ bg = '#2a2a3e';
+ }
+ div.style.background = bg + '33'; // 20% opacity base
+ div.style.border = `1px solid ${bg}88`;
+ div.style.color = '#fff';
+ if (this._highlighted.size > 0) {
+ if (this._highlighted.has(el.Z)) {
+ div.style.background = bg + 'cc';
+ div.style.border = `2px solid ${bg}`;
+ div.style.boxShadow = `0 0 8px ${bg}99`;
+ } else {
+ div.style.opacity = '0.25';
+ }
+ } else {
+ div.style.opacity = '1';
+ div.style.boxShadow = '';
+ }
+ }
+ this._buildLegend();
+ }
+
+ _buildLegend() {
+ this._legendEl.innerHTML = '';
+ const map = this._mode === 'block' ? BLOCK_COLORS : TYPE_COLORS;
+ const labels = this._mode === 'block' ? { s:'s-блок', p:'p-блок', d:'d-блок', f:'f-блок' } : TYPE_LABELS;
+ for (const [k, col] of Object.entries(map)) {
+ if (!labels[k]) continue;
+ const d = document.createElement('div');
+ d.style.cssText = `display:flex;align-items:center;gap:4px;font-size:.68rem;color:#bbb;cursor:pointer;`;
+ d.innerHTML = `${labels[k]}`;
+ /* highlight on hover */
+ d.addEventListener('mouseenter', () => this._highlightType(k));
+ d.addEventListener('mouseleave', () => this._unhighlightType());
+ this._legendEl.appendChild(d);
+ }
+ }
+
+ _highlightType(key) {
+ for (const el of ELEMENTS) {
+ const div = this._cellMap[el.Z];
+ if (!div) continue;
+ const match = this._mode === 'block' ? el.block === key : el.type === key;
+ if (match) {
+ div.style.filter = 'brightness(1.5)';
+ div.style.transform = 'scale(1.05)';
+ div.style.zIndex = '5';
+ } else {
+ div.style.opacity = '0.2';
+ }
+ }
+ }
+ _unhighlightType() {
+ for (const el of ELEMENTS) {
+ const div = this._cellMap[el.Z];
+ if (!div) continue;
+ div.style.filter = '';
+ div.style.transform = '';
+ div.style.zIndex = '';
+ div.style.opacity = this._highlighted.has(el.Z) ? '1' : (this._highlighted.size > 0 ? '0.25' : '1');
+ }
+ }
+
+ /* ─────────────────────────────────────────────────────
+ SELECT / SEARCH
+ ───────────────────────────────────────────────────── */
+ _selectElement(Z) {
+ this._selected = Z;
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 0.8 + Z * 0.008, volume: 0.25 });
+ /* highlight selected cell */
+ for (const el of ELEMENTS) {
+ const div = this._cellMap[el.Z];
+ if (!div) continue;
+ if (el.Z === Z) {
+ div.style.outline = '2px solid #fff';
+ div.style.outlineOffset = '1px';
+ } else {
+ div.style.outline = '';
+ div.style.outlineOffset = '';
+ }
+ }
+ this._updateCard(ELEMENTS.find(e => e.Z === Z));
+ this._bohrZ = Z;
+ this._startBohr();
+ }
+
+ _applySearch() {
+ this._highlighted.clear();
+ if (this._searchQ) {
+ for (const el of ELEMENTS) {
+ const q = this._searchQ;
+ if (
+ el.symbol.toLowerCase() === q ||
+ el.name.toLowerCase().includes(q) ||
+ String(el.Z) === q ||
+ String(el.mass).startsWith(q)
+ ) {
+ this._highlighted.add(el.Z);
+ }
+ }
+ }
+ this._colorTable();
+ /* auto-select if single result */
+ if (this._highlighted.size === 1) {
+ this._selectElement([...this._highlighted][0]);
+ }
+ }
+
+ /* ─────────────────────────────────────────────────────
+ ELEMENT CARD
+ ───────────────────────────────────────────────────── */
+ _updateCard(el) {
+ if (!el) {
+ this._cardEl.innerHTML = `Кликните на элемент
`;
+ this._bohrZ = null;
+ cancelAnimationFrame(this._bohrRaf);
+ this._bohrRaf = null;
+ this._drawBohr();
+ return;
+ }
+ const col = TYPE_COLORS[el.type] || '#888';
+ const ox = el.oxStates && el.oxStates[0] !== null ? el.oxStates.map(s => (s > 0 ? '+' : '') + s).join(', ') : '—';
+ const fmt = v => v !== null && v !== undefined ? v : '—';
+ this._cardEl.innerHTML = `
+
+
${el.symbol}
+
${el.name}
+
Z = ${el.Z} · ${el.mass} а.е.м.
+
${TYPE_LABELS[el.type] || el.type}
+
+
+ ${this._row('Конфигурация', `${el.config}`)}
+ ${this._row('Блок', el.block + '-блок')}
+ ${this._row('Период / Группа', `${el.period} / ${el.group || '—'}`)}
+ ${this._row('Ст. окисления', ox)}
+ ${this._row('ЭО (Полинг)', fmt(el.En))}
+ ${this._row('Плотность, г/см³', fmt(el.density))}
+ ${this._row('Tпл, K', fmt(el.melt))}
+ ${this._row('Tкип, K', fmt(el.boil))}
+ ${this._row('Открыт', el.discovered ? `${el.discovered}, ${el.by}` : el.by)}
+
`;
+ }
+
+ _row(label, val) {
+ return `
+ | ${label} |
+ ${val} |
+
`;
+ }
+
+ /* ─────────────────────────────────────────────────────
+ BOHR SHELLS ANIMATION
+ ───────────────────────────────────────────────────── */
+ _startBohr() {
+ cancelAnimationFrame(this._bohrRaf);
+ this._bohrAngle = 0;
+ this._animBohr();
+ }
+
+ _animBohr() {
+ this._bohrAngle += 0.018;
+ this._drawBohr();
+ this._bohrRaf = requestAnimationFrame(() => this._animBohr());
+ }
+
+ _drawBohr() {
+ const canvas = this._bohrCanvas;
+ const dpr = window.devicePixelRatio || 1;
+ const W = canvas.offsetWidth || 240;
+ const H = canvas.offsetHeight || 150;
+ canvas.width = W * dpr;
+ canvas.height = H * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, W, H);
+
+ if (!this._bohrZ) return;
+
+ const el = ELEMENTS.find(e => e.Z === this._bohrZ);
+ if (!el) return;
+
+ const shells = getShellFill(el.Z);
+ const cx = W / 2, cy = H / 2;
+ const maxR = Math.min(W, H) * 0.44;
+ const nShells = shells.length;
+ const col = TYPE_COLORS[el.type] || '#7B8EF7';
+
+ /* nucleus */
+ ctx.beginPath();
+ ctx.arc(cx, cy, nShells > 0 ? 5 + nShells * 1.5 : 6, 0, Math.PI * 2);
+ ctx.fillStyle = col;
+ ctx.fill();
+
+ shells.forEach((count, i) => {
+ const r = maxR * (i + 1) / nShells;
+ /* orbit ring */
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.strokeStyle = 'rgba(255,255,255,0.12)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ /* electrons */
+ const speed = 1 - i * 0.12; // inner shells faster
+ for (let e = 0; e < count; e++) {
+ const a = this._bohrAngle * speed + (2 * Math.PI * e) / count;
+ const ex = cx + r * Math.cos(a);
+ const ey = cy + r * Math.sin(a);
+ ctx.beginPath();
+ ctx.arc(ex, ey, 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#06D6E0';
+ ctx.fill();
+ }
+ });
+
+ /* label */
+ ctx.font = `700 10px Manrope,sans-serif`;
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.textAlign = 'center';
+ ctx.fillText(shells.join(','), cx, H - 4);
+ }
+
+ /* ─────────────────────────────────────────────────────
+ PROPERTY CHART
+ ───────────────────────────────────────────────────── */
+ _drawChart() {
+ const canvas = this._chartCanvas;
+ if (!canvas) return;
+ const dpr = window.devicePixelRatio || 1;
+ const W = canvas.offsetWidth || 240;
+ const H = canvas.offsetHeight || 90;
+ canvas.width = W * dpr;
+ canvas.height = H * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, W, H);
+
+ /* filter elements */
+ let els;
+ if (this._chartBy === 'period') {
+ els = ELEMENTS.filter(e => e.period === this._chartN && e.group !== null);
+ els.sort((a, b) => a.group - b.group);
+ } else {
+ els = ELEMENTS.filter(e => e.group === this._chartN);
+ els.sort((a, b) => a.period - b.period);
+ }
+
+ const vals = els.map(e => e[this._propKey]);
+ const validVals = vals.filter(v => v !== null && v !== undefined && isFinite(v));
+ if (validVals.length < 2) {
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.textAlign = 'center';
+ ctx.fillText('Нет данных', W / 2, H / 2);
+ return;
+ }
+
+ const minV = Math.min(...validVals);
+ const maxV = Math.max(...validVals);
+ const pad = { t: 10, r: 8, b: 20, l: 8 };
+ const gW = W - pad.l - pad.r;
+ const gH = H - pad.t - pad.b;
+
+ /* axes */
+ ctx.strokeStyle = 'rgba(255,255,255,0.1)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(pad.l, pad.t);
+ ctx.lineTo(pad.l, pad.t + gH);
+ ctx.lineTo(pad.l + gW, pad.t + gH);
+ ctx.stroke();
+
+ /* line */
+ ctx.beginPath();
+ let first = true;
+ const points = [];
+ els.forEach((el, i) => {
+ const v = el[this._propKey];
+ if (v === null || v === undefined || !isFinite(v)) return;
+ const x = pad.l + (i / Math.max(els.length - 1, 1)) * gW;
+ const y = pad.t + gH - ((v - minV) / (maxV - minV || 1)) * gH;
+ points.push({ x, y, el });
+ if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y);
+ });
+ ctx.strokeStyle = '#9B5DE5';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ /* markers */
+ points.forEach(({ x, y, el }) => {
+ const col = TYPE_COLORS[el.type] || '#7B8EF7';
+ ctx.beginPath();
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
+ ctx.fillStyle = col;
+ ctx.fill();
+ });
+
+ /* x labels (symbols) */
+ ctx.font = '8px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
+ ctx.textAlign = 'center';
+ /* show at most 18 labels */
+ const step = Math.ceil(els.length / 18);
+ els.forEach((el, i) => {
+ if (i % step === 0) {
+ const x = pad.l + (i / Math.max(els.length - 1, 1)) * gW;
+ ctx.fillText(el.symbol, x, H - 4);
+ }
+ });
+ }
+
+ /* ─────────────────────────────────────────────────────
+ LIFECYCLE
+ ───────────────────────────────────────────────────── */
+ stop() {
+ cancelAnimationFrame(this._bohrRaf);
+ this._bohrRaf = null;
+ }
+}
+
+/* ── global opener ─────────────────────────────────────────── */
+var periodicSim = null;
+
+function _openPeriodic() {
+ document.getElementById('sim-periodic').style.display = 'flex';
+ if (!periodicSim) {
+ periodicSim = new PeriodicTableSim(document.getElementById('periodic-wrap'));
+ }
+}
diff --git a/frontend/js/labs/qualanalysis.js b/frontend/js/labs/qualanalysis.js
new file mode 100644
index 0000000..c3ddb12
--- /dev/null
+++ b/frontend/js/labs/qualanalysis.js
@@ -0,0 +1,1062 @@
+'use strict';
+/* ════════════════════════════════════════════════════════════════════
+ QualAnalysisSim — Качественный анализ катионов и анионов
+ Режим 1: «Определить ион» (guided identification)
+ Режим 2: «Неизвестное вещество» (drag-drop free experiment)
+ ════════════════════════════════════════════════════════════════════ */
+
+class QualAnalysisSim {
+ /* ── Ion database ─────────────────────────────────────────────── */
+ static IONS = [
+ /* КАТИОНЫ */
+ {
+ id: 'Na+', label: 'Na⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Пламя жёлтое', color: '#FFD700', type: 'flame', positive: true },
+ NaOH: { obs: 'Нет заметного осадка', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'K+', label: 'K⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Пламя фиолетовое (через синее стекло)', color: '#CC00FF', type: 'flame', positive: true },
+ NaOH: { obs: 'Нет осадка', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'NH4+', label: 'NH₄⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'При нагреве — газ NH₃↑ (запах, влажная лакмусовая бумага синеет)', color: '#CCFFCC', type: 'gas', gasLabel: 'NH₃↑', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Mg2+', label: 'Mg²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Белый осадок Mg(OH)₂, не растворим в избытке NaOH', color: '#FFFFFF', type: 'precip', precipLabel: 'Mg(OH)₂↓', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Ca2+', label: 'Ca²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Пламя кирпично-красное', color: '#CC4400', type: 'flame', positive: true },
+ NaOH: { obs: 'Слабый белый осадок Ca(OH)₂ (малорастворим)', color: '#EEEEEE', type: 'precip', precipLabel: 'Ca(OH)₂↓', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Белый осадок CaSO₄↓ (малорастворим)', color: '#FFFFFF', type: 'precip', precipLabel: 'CaSO₄↓', positive: true },
+ }
+ },
+ {
+ id: 'Ba2+', label: 'Ba²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Пламя зелёное', color: '#00DD00', type: 'flame', positive: true },
+ NaOH: { obs: 'Белый осадок Ba(OH)₂', color: '#EEEEEE', type: 'precip', precipLabel: 'Ba(OH)₂↓', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Белый осадок BaSO₄↓, нерастворим в HNO₃', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true },
+ }
+ },
+ {
+ id: 'Al3+', label: 'Al³⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Белый осадок Al(OH)₃↓, растворяется в избытке NaOH (амфотерность)', color: '#DDDDDD', type: 'precip', precipLabel: 'Al(OH)₃↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Fe2+', label: 'Fe²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(100,160,80,0.25)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Зеленоватый осадок Fe(OH)₂↓', color: '#88BB66', type: 'precip', precipLabel: 'Fe(OH)₂↓', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Слабое розовое окрашивание (следы Fe³⁺), не яркое', color: 'rgba(255,100,80,0.3)', type: 'solution', positive: false },
+ K3FeCN6: { obs: 'Синий осадок — Турнбулева синь (Fe₃[Fe(CN)₆]₂)', color: '#1144CC', type: 'precip', precipLabel: 'Турн. синь↓', positive: true },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Fe3+', label: 'Fe³⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(200,100,30,0.3)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Бурый осадок Fe(OH)₃↓', color: '#884422', type: 'precip', precipLabel: 'Fe(OH)₃↓', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Ярко-красный раствор — роданид железа(III)', color: '#DD1100', type: 'solution', positive: true },
+ K3FeCN6: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции (осаждается NaOH)', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Cu2+', label: 'Cu²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(30,120,200,0.35)',
+ reactions: {
+ flame: { obs: 'Пламя зелёное (галогениды — синий)', color: '#00BB44', type: 'flame', positive: false },
+ NaOH: { obs: 'Голубой осадок Cu(OH)₂↓', color: '#5599FF', type: 'precip', precipLabel: 'Cu(OH)₂↓', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Ярко-синий раствор комплекса [Cu(NH₃)₄]²⁺', color: '#0044EE', type: 'solution', positive: true },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Ag+', label: 'Ag⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.1)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Коричневый осадок Ag₂O↓', color: '#886622', type: 'precip', precipLabel: 'Ag₂O↓', positive: false },
+ HCl: { obs: 'Белый творожистый осадок AgCl↓, темнеет на свету', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Белый осадок AgSCN↓', color: '#FFFFFF', type: 'precip', precipLabel: 'AgSCN↓', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Растворяется — комплекс [Ag(NH₃)₂]⁺', color: 'rgba(255,255,255,0.05)', type: 'solution', positive: true },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Pb2+', label: 'Pb²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(200,200,200,0.12)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Белый осадок Pb(OH)₂↓, растворим в избытке', color: '#EEEEEE', type: 'precip', precipLabel: 'Pb(OH)₂↓', positive: false },
+ HCl: { obs: 'Белый осадок PbCl₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbCl₂↓', positive: true },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Белый осадок PbSO₄↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbSO₄↓', positive: true },
+ }
+ },
+ {
+ id: 'Zn2+', label: 'Zn²⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке NaOH (амфотерность)', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке — [Zn(NH₃)₄]²⁺', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'H+', label: 'H⁺', type: 'cat', group: 'Катионы',
+ solColor: 'rgba(255,255,200,0.1)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нейтрализация. Лакмус синеет при добавлении щёлочи', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: true },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нейтрализация: H⁺ + NH₃ → NH₄⁺', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ },
+ indicators: { litmus: 'красный', methylorange: 'красный', phenolphthalein: 'бесцветный' }
+ },
+ {
+ id: 'OH-', label: 'OH⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нейтрализация, нет видимой реакции', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нейтрализация', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
+ },
+ indicators: { litmus: 'синий', methylorange: 'жёлтый', phenolphthalein: 'малиновый' }
+ },
+ /* АНИОНЫ */
+ {
+ id: 'Cl-', label: 'Cl⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Белый творожистый осадок AgCl↓, нерастворим в HNO₃', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'Br-', label: 'Br⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(180,80,0,0.15)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Желтоватый осадок AgBr↓', color: '#EEEE88', type: 'precip', precipLabel: 'AgBr↓', positive: true },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'I-', label: 'I⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(100,0,120,0.2)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Жёлтый осадок AgI↓, практически нерастворим в NH₃', color: '#FFEE44', type: 'precip', precipLabel: 'AgI↓', positive: true },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'SO42-', label: 'SO₄²⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Белый осадок BaSO₄↓, нерастворим в кислотах', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'SO32-', label: 'SO₃²⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Белый осадок BaSO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₃↓', positive: true },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true },
+ }
+ },
+ {
+ id: 'CO32-', label: 'CO₃²⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Бурное выделение CO₂↑ (пузыри), мутит известковую воду Ca(OH)₂', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Белый осадок BaCO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaCO₃↓', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Бурное выделение CO₂↑ (пузыри)', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true },
+ }
+ },
+ {
+ id: 'NO3-', label: 'NO₃⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'При нагреве с Cu: бурый газ NO₂↑ («бурое кольцо» с FeSO₄)', color: '#DD8800', type: 'gas', gasLabel: 'NO₂↑', positive: true },
+ }
+ },
+ {
+ id: 'PO43-', label: 'PO₄³⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Жёлтый осадок Ag₃PO₄↓', color: '#EECC44', type: 'precip', precipLabel: 'Ag₃PO₄↓', positive: true },
+ BaCl2: { obs: 'Белый осадок Ba₃(PO₄)₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'Ba₃(PO₄)₂↓', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ }
+ },
+ {
+ id: 'S2-', label: 'S²⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(200,200,100,0.1)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true },
+ AgNO3: { obs: 'Чёрный осадок Ag₂S↓', color: '#111111', type: 'precip', precipLabel: 'Ag₂S↓', positive: true },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true },
+ }
+ },
+ {
+ id: 'CH3COO-', label: 'CH₃COO⁻', type: 'an', group: 'Анионы',
+ solColor: 'rgba(255,255,255,0.08)',
+ reactions: {
+ flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
+ NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false },
+ H2SO4: { obs: 'Запах уксуса; с FeCl₃ при нагреве — бурый осадок', color: '#AA6622', type: 'solution', positive: true },
+ }
+ },
+ ];
+
+ static REAGENTS = [
+ { id: 'NaOH', label: 'NaOH', color: '#8866FF' },
+ { id: 'HCl', label: 'HCl', color: '#FF6644' },
+ { id: 'AgNO3', label: 'AgNO₃', color: '#CCCCCC' },
+ { id: 'BaCl2', label: 'BaCl₂', color: '#44BBFF' },
+ { id: 'KSCN', label: 'KSCN', color: '#FF4444' },
+ { id: 'K3FeCN6', label: 'K₃[Fe(CN)₆]', color: '#FFAA00' },
+ { id: 'NH3', label: 'NH₃', color: '#AAFFAA' },
+ { id: 'H2SO4', label: 'H₂SO₄', color: '#FFFF44' },
+ { id: 'flame', label: 'Пламя', color: '#FF8800' },
+ ];
+
+ /* ── constructor ─────────────────────────────────────────────── */
+ constructor(container) {
+ this._container = container;
+ this._mode = 'identify'; // 'identify' | 'unknown'
+ this._targetIon = null;
+ this._log = [];
+ this._answered = false;
+ this._dropAnim = [];
+ this._precipParticles = [];
+ this._gasParticles = [];
+ this._raf = null;
+ this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: 'rgba(100,180,255,0.18)' };
+ this._dragReagent = null;
+ this._dragX = 0; this._dragY = 0;
+ this._score = 0;
+ this._lastT = 0;
+ this._build();
+ this._bindEvents();
+ this._startMode('identify');
+ }
+
+ /* ── DOM build ───────────────────────────────────────────────── */
+ _build() {
+ this._container.innerHTML = '';
+ this._container.style.cssText = 'display:flex;flex-direction:column;height:100%;background:#0D0D1A;color:#E0E0FF;font-family:Manrope,sans-serif;overflow:hidden;user-select:none';
+
+ /* top toolbar */
+ const tb = document.createElement('div');
+ tb.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;flex-wrap:wrap';
+ tb.innerHTML = `
+
+
+
+ Счёт: 0
+ `;
+ this._container.appendChild(tb);
+
+ /* main area */
+ const main = document.createElement('div');
+ main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden';
+ this._container.appendChild(main);
+
+ /* left panel: log + reagents */
+ const left = document.createElement('div');
+ left.id = 'qa-left';
+ left.style.cssText = 'width:230px;display:flex;flex-direction:column;border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0;overflow:hidden';
+ main.appendChild(left);
+
+ /* reagent shelf */
+ const shelf = document.createElement('div');
+ shelf.id = 'qa-shelf';
+ shelf.style.cssText = 'padding:10px 8px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0';
+ shelf.innerHTML = 'Реагенты
';
+ const shelfGrid = document.createElement('div');
+ shelfGrid.id = 'qa-shelf-grid';
+ shelfGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px';
+ QualAnalysisSim.REAGENTS.forEach(r => {
+ const btn = document.createElement('button');
+ btn.className = 'qa-reagent-btn';
+ btn.dataset.reagent = r.id;
+ btn.title = r.label;
+ btn.style.cssText = `padding:4px 7px;border-radius:7px;border:1px solid ${r.color}44;background:${r.color}18;color:${r.color};font-size:.72rem;font-weight:700;cursor:pointer;transition:background .15s`;
+ btn.textContent = r.label;
+ shelfGrid.appendChild(btn);
+ });
+ shelf.appendChild(shelfGrid);
+ left.appendChild(shelf);
+
+ /* log */
+ const logWrap = document.createElement('div');
+ logWrap.style.cssText = 'flex:1;overflow-y:auto;padding:8px';
+ logWrap.innerHTML = 'Наблюдения
';
+ const logList = document.createElement('div');
+ logList.id = 'qa-log';
+ logList.style.cssText = 'display:flex;flex-direction:column;gap:4px';
+ logWrap.appendChild(logList);
+ left.appendChild(logWrap);
+
+ /* center: tube + canvas */
+ const center = document.createElement('div');
+ center.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden';
+ main.appendChild(center);
+
+ const canvas = document.createElement('canvas');
+ canvas.id = 'qa-canvas';
+ canvas.style.cssText = 'display:block;cursor:crosshair';
+ center.appendChild(canvas);
+ this._canvas = canvas;
+ this._ctx = canvas.getContext('2d');
+
+ /* answer bar */
+ const ansBar = document.createElement('div');
+ ansBar.id = 'qa-ansbar';
+ ansBar.style.cssText = 'padding:8px 14px;border-top:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap';
+ ansBar.innerHTML = `
+ Добавляй реагенты и определи ион в пробирке
+
+
+ `;
+ this._container.appendChild(ansBar);
+
+ /* right panel: ion reference list (mode 1) */
+ const right = document.createElement('div');
+ right.id = 'qa-right';
+ right.style.cssText = 'width:180px;border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto;padding:8px;flex-shrink:0';
+ right.innerHTML = 'Список ионов
';
+ const ionList = document.createElement('div');
+ ionList.id = 'qa-ionlist';
+ ionList.style.cssText = 'display:flex;flex-direction:column;gap:3px';
+ ['Катионы','Анионы'].forEach(grp => {
+ const h = document.createElement('div');
+ h.style.cssText = 'font-size:.67rem;color:#666;text-transform:uppercase;letter-spacing:.05em;margin-top:6px;margin-bottom:2px';
+ h.textContent = grp;
+ ionList.appendChild(h);
+ QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => {
+ const d = document.createElement('div');
+ d.className = 'qa-ion-card';
+ d.dataset.id = ion.id;
+ d.style.cssText = 'font-size:.75rem;padding:3px 7px;border-radius:6px;border:1px solid rgba(255,255,255,0.07);background:rgba(255,255,255,0.03);cursor:default;color:#CCC';
+ d.textContent = ion.label;
+ ionList.appendChild(d);
+ });
+ });
+ right.appendChild(ionList);
+ main.appendChild(right);
+
+ this._resizeFit();
+ }
+
+ _resizeFit() {
+ const c = this._canvas;
+ const p = c.parentElement;
+ if (!p) return;
+ const w = p.clientWidth || 400;
+ const h = p.clientHeight || 360;
+ c.width = w;
+ c.height = h;
+ this._W = w; this._H = h;
+ this._drawTube();
+ }
+
+ /* ── Events ──────────────────────────────────────────────────── */
+ _bindEvents() {
+ const el = id => document.getElementById(id);
+
+ el('qa-btn-identify').addEventListener('click', () => this._startMode('identify'));
+ el('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown'));
+ el('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));
+ el('qa-submit').addEventListener('click', () => this._submitAnswer());
+
+ /* reagent buttons → click to apply */
+ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ if (this._answered) return;
+ const rid = btn.dataset.reagent;
+ this._applyReagent(rid);
+ /* visual flash */
+ const col = btn.style.color;
+ btn.style.background = col + '44';
+ setTimeout(() => { btn.style.background = col + '18'; }, 200);
+ });
+ });
+
+ /* drag-and-drop reagent to canvas */
+ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
+ btn.addEventListener('dragstart', e => {
+ this._dragReagent = btn.dataset.reagent;
+ e.dataTransfer.effectAllowed = 'copy';
+ });
+ btn.setAttribute('draggable', 'true');
+ });
+
+ this._canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
+ this._canvas.addEventListener('drop', e => {
+ e.preventDefault();
+ if (this._dragReagent && !this._answered) {
+ const rect = this._canvas.getBoundingClientRect();
+ this._dragX = e.clientX - rect.left;
+ this._dragY = e.clientY - rect.top;
+ this._applyReagent(this._dragReagent);
+ this._dragReagent = null;
+ }
+ });
+
+ /* ResizeObserver */
+ if (window.ResizeObserver) {
+ const ro = new ResizeObserver(() => this._resizeFit());
+ ro.observe(this._canvas.parentElement || this._container);
+ }
+ }
+
+ /* ── Mode start ──────────────────────────────────────────────── */
+ _startMode(mode) {
+ this._mode = mode;
+ this._log = [];
+ this._answered = false;
+ this._dropAnim = [];
+ this._precipParticles = [];
+ this._gasParticles = [];
+
+ /* pick random ion */
+ const ions = QualAnalysisSim.IONS;
+ this._targetIon = ions[Math.floor(Math.random() * ions.length)];
+ this._tubeState = {
+ color: null,
+ precipColor: null,
+ precipH: 0,
+ gasLabel: null,
+ flameColor: null,
+ solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)',
+ };
+
+ /* reset UI */
+ document.getElementById('qa-log').innerHTML = '';
+ document.getElementById('qa-verdict').style.display = 'none';
+ document.getElementById('qa-verdict').textContent = '';
+ this._highlightMode(mode);
+ this._updateAnswerSelect();
+ this._populateAnswerQuestion(mode);
+ this._updateIonHighlight(null);
+
+ this._drawTube();
+ if (!this._raf) this._animLoop(performance.now());
+ }
+
+ _populateAnswerQuestion(mode) {
+ if (mode === 'identify') {
+ document.getElementById('qa-question').textContent = 'Добавляй реагенты и определи ион в пробирке — выбери ответ и нажми «Ответить»';
+ } else {
+ document.getElementById('qa-question').textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь';
+ }
+ }
+
+ _highlightMode(mode) {
+ const bi = document.getElementById('qa-btn-identify');
+ const bu = document.getElementById('qa-btn-unknown');
+ const active = 'border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF';
+ const inactive = 'border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa';
+ bi.style.cssText = bi.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'identify' ? active : inactive);
+ bu.style.cssText = bu.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'unknown' ? active : inactive);
+ }
+
+ _updateAnswerSelect() {
+ const sel = document.getElementById('qa-answer-sel');
+ sel.innerHTML = '';
+ ['Катионы','Анионы'].forEach(grp => {
+ const og = document.createElement('optgroup');
+ og.label = grp;
+ QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => {
+ const opt = document.createElement('option');
+ opt.value = ion.id;
+ opt.textContent = ion.label;
+ og.appendChild(opt);
+ });
+ sel.appendChild(og);
+ });
+ }
+
+ /* ── Apply reagent ───────────────────────────────────────────── */
+ _applyReagent(reagentId) {
+ const ion = this._targetIon;
+ const rxn = ion.reactions[reagentId];
+ if (!rxn) return;
+
+ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
+ const rLabel = rInfo ? rInfo.label : reagentId;
+
+ /* LabFX sounds */
+ if (window.LabFX) {
+ if (rxn.type === 'gas') {
+ LabFX.sound.play('fizz');
+ } else {
+ LabFX.sound.play('pour');
+ }
+ }
+
+ /* update tube state */
+ if (rxn.type === 'flame') {
+ this._tubeState.flameColor = rxn.color;
+ setTimeout(() => { this._tubeState.flameColor = null; this._drawTube(); }, 2000);
+ } else if (rxn.type === 'precip' && rxn.color) {
+ this._tubeState.precipColor = rxn.color;
+ this._tubeState.precipH = 0;
+ /* animate precip settling */
+ this._precipParticles = this._spawnPrecipParticles(rxn.color);
+ } else if (rxn.type === 'solution' && rxn.color) {
+ this._tubeState.solColor = rxn.color;
+ } else if (rxn.type === 'gas' && rxn.color) {
+ this._tubeState.gasLabel = rxn.gasLabel || '↑';
+ this._gasParticles = this._spawnGasParticles(rxn.color);
+ }
+
+ /* drop animation */
+ const cx = this._W * 0.5;
+ const cy = 60;
+ this._dropAnim.push({ x: cx, y: 20, vy: 2, color: rInfo ? rInfo.color : '#FFF', alpha: 1, done: false });
+
+ /* log entry */
+ const isPositive = rxn.positive;
+ const entry = { reagent: rLabel, obs: rxn.obs, positive: isPositive, excess: rxn.excess || null };
+ this._log.push(entry);
+ this._renderLogEntry(entry);
+ this._updateIonHighlight(this._log);
+ }
+
+ _spawnPrecipParticles(color) {
+ const cx = this._W * 0.5;
+ const cy = this._H * 0.6;
+ const ps = [];
+ for (let i = 0; i < 22; i++) {
+ ps.push({ x: cx + (Math.random() - 0.5) * 60, y: cy, vy: 0.5 + Math.random() * 1.5, vx: (Math.random() - 0.5) * 1.5, color, r: 2 + Math.random() * 3, done: false });
+ }
+ return ps;
+ }
+
+ _spawnGasParticles(color) {
+ const cx = this._W * 0.5;
+ const cy = this._H * 0.4;
+ const ps = [];
+ for (let i = 0; i < 18; i++) {
+ ps.push({ x: cx + (Math.random() - 0.5) * 30, y: cy, vy: -(0.8 + Math.random() * 1.2), vx: (Math.random() - 0.5), color, r: 3 + Math.random() * 4, alpha: 0.85, done: false });
+ }
+ return ps;
+ }
+
+ _renderLogEntry(entry) {
+ const log = document.getElementById('qa-log');
+ const d = document.createElement('div');
+ const col = entry.positive ? '#5EF08E' : '#888';
+ d.style.cssText = `font-size:.72rem;padding:4px 6px;border-radius:6px;border-left:3px solid ${col};background:rgba(255,255,255,0.03);color:#CCC;line-height:1.4`;
+ d.innerHTML = `${_esc(entry.reagent)}: ${_esc(entry.obs)}${entry.excess ? `${_esc(entry.excess)}
` : ''}`;
+ log.appendChild(d);
+ log.scrollTop = log.scrollHeight;
+ }
+
+ /* highlight ions that are consistent with observations */
+ _updateIonHighlight(log) {
+ const cards = this._container.querySelectorAll('.qa-ion-card');
+ if (!log || log.length === 0) {
+ cards.forEach(c => { c.style.background = 'rgba(255,255,255,0.03)'; c.style.color = '#CCC'; c.style.borderColor = 'rgba(255,255,255,0.07)'; });
+ return;
+ }
+ cards.forEach(c => {
+ const ionId = c.dataset.id;
+ const ion = QualAnalysisSim.IONS.find(i => i.id === ionId);
+ if (!ion) return;
+ let compatible = true;
+ for (const entry of log) {
+ /* find reagent id from label */
+ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent);
+ if (!rInfo) continue;
+ const expectedRxn = ion.reactions[rInfo.id];
+ if (!expectedRxn) continue;
+ /* if positive result observed but ion doesn't produce positive here */
+ if (entry.positive && !expectedRxn.positive) { compatible = false; break; }
+ }
+ if (compatible) {
+ c.style.background = 'rgba(155,93,229,0.12)';
+ c.style.color = '#D0A0FF';
+ c.style.borderColor = 'rgba(155,93,229,0.3)';
+ } else {
+ c.style.background = 'rgba(255,255,255,0.01)';
+ c.style.color = '#444';
+ c.style.borderColor = 'rgba(255,255,255,0.04)';
+ }
+ });
+ }
+
+ /* ── Submit answer ───────────────────────────────────────────── */
+ _submitAnswer() {
+ if (this._answered) return;
+ const sel = document.getElementById('qa-answer-sel');
+ const chosen = sel.value;
+ if (!chosen) return;
+ this._answered = true;
+ const correct = chosen === this._targetIon.id;
+ const verdict = document.getElementById('qa-verdict');
+ verdict.style.display = 'block';
+ if (correct) {
+ this._score++;
+ document.getElementById('qa-score').textContent = this._score;
+ verdict.textContent = 'Верно! Это ' + this._targetIon.label;
+ verdict.style.background = 'rgba(94,240,142,0.15)';
+ verdict.style.color = '#5EF08E';
+ verdict.style.border = '1px solid rgba(94,240,142,0.3)';
+ if (window.LabFX) LabFX.sound.play('chime');
+ } else {
+ const correctIon = QualAnalysisSim.IONS.find(i => i.id === this._targetIon.id);
+ verdict.textContent = 'Неверно. Правильный ответ: ' + (correctIon ? correctIon.label : this._targetIon.id);
+ verdict.style.background = 'rgba(239,71,111,0.12)';
+ verdict.style.color = '#EF476F';
+ verdict.style.border = '1px solid rgba(239,71,111,0.3)';
+ }
+ /* highlight correct ion card */
+ const cards = this._container.querySelectorAll('.qa-ion-card');
+ cards.forEach(c => {
+ if (c.dataset.id === this._targetIon.id) {
+ c.style.background = 'rgba(94,240,142,0.15)';
+ c.style.color = '#5EF08E';
+ c.style.borderColor = '#5EF08E';
+ }
+ });
+ }
+
+ /* ── Animation loop ──────────────────────────────────────────── */
+ _animLoop(t) {
+ this._raf = requestAnimationFrame(ts => this._animLoop(ts));
+ const dt = Math.min((t - this._lastT) / 1000, 0.05);
+ this._lastT = t;
+
+ /* advance particles */
+ let needDraw = false;
+
+ this._dropAnim = this._dropAnim.filter(d => {
+ if (d.done) return false;
+ d.y += d.vy;
+ d.vy += 0.2;
+ if (d.y > this._H * 0.55) { d.done = true; needDraw = true; return false; }
+ needDraw = true;
+ return true;
+ });
+
+ this._precipParticles.forEach(p => {
+ if (!p.done) {
+ p.x += p.vx; p.y += p.vy;
+ p.vy *= 0.98;
+ const floor = this._H * 0.82;
+ if (p.y >= floor) { p.y = floor; p.vy = 0; p.vx = 0; p.done = true;
+ this._tubeState.precipH = Math.min(this._tubeState.precipH + 2, 30); }
+ needDraw = true;
+ }
+ });
+
+ this._gasParticles = this._gasParticles.filter(p => {
+ if (p.done) return false;
+ p.y += p.vy; p.x += p.vx;
+ p.alpha -= dt * 0.5;
+ if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; }
+ needDraw = true;
+ return true;
+ });
+
+ if (needDraw || this._tubeState.flameColor) this._drawTube();
+ }
+
+ /* ── Draw ────────────────────────────────────────────────────── */
+ _drawTube() {
+ const ctx = this._ctx;
+ const W = this._W || 400;
+ const H = this._H || 360;
+ ctx.clearRect(0, 0, W, H);
+
+ /* background */
+ ctx.fillStyle = '#0D0D1A';
+ ctx.fillRect(0, 0, W, H);
+
+ /* bench surface */
+ ctx.fillStyle = 'rgba(255,255,255,0.03)';
+ ctx.fillRect(0, H * 0.87, W, H * 0.13);
+ ctx.strokeStyle = 'rgba(255,255,255,0.08)';
+ ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(0, H * 0.87); ctx.lineTo(W, H * 0.87); ctx.stroke();
+
+ /* tube dimensions */
+ const tx = W * 0.5 - 28;
+ const tw = 56;
+ const tTop = H * 0.15;
+ const tBot = H * 0.85;
+ const tH = tBot - tTop;
+ const r = 10;
+
+ /* flame halo */
+ if (this._tubeState.flameColor) {
+ const fc = this._tubeState.flameColor;
+ const grad = ctx.createRadialGradient(W * 0.5, tTop - 20, 5, W * 0.5, tTop - 20, 80);
+ grad.addColorStop(0, fc + 'CC');
+ grad.addColorStop(0.4, fc + '44');
+ grad.addColorStop(1, fc + '00');
+ ctx.fillStyle = grad;
+ ctx.beginPath(); ctx.arc(W * 0.5, tTop - 20, 80, 0, Math.PI * 2); ctx.fill();
+
+ /* flame label */
+ ctx.font = 'bold 12px Manrope,sans-serif';
+ ctx.fillStyle = fc;
+ ctx.textAlign = 'center';
+ const flameLabel = (() => {
+ if (fc === '#FFD700') return 'Жёлтое пламя — Na⁺';
+ if (fc === '#CC00FF') return 'Фиолетовое — K⁺';
+ if (fc === '#CC4400') return 'Кирпично-красное — Ca²⁺';
+ if (fc === '#00DD00') return 'Зелёное — Ba²⁺';
+ if (fc === '#00BB44') return 'Зелёное пламя';
+ return 'Окрашивание пламени';
+ })();
+ ctx.fillText(flameLabel, W * 0.5, tTop - 40);
+ }
+
+ /* tube shadow */
+ ctx.shadowColor = 'rgba(155,93,229,0.2)';
+ ctx.shadowBlur = 18;
+
+ /* solution fill */
+ const solTop = tTop + tH * 0.05;
+ const solBot = tBot - 5;
+ const solH = solBot - solTop;
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)';
+ ctx.beginPath();
+ ctx.moveTo(tx + r, solTop);
+ ctx.lineTo(tx + tw - r, solTop);
+ ctx.lineTo(tx + tw - r, solBot - r);
+ ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r);
+ ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r);
+ ctx.lineTo(tx + r, solTop);
+ ctx.closePath();
+ ctx.fill();
+
+ /* precipitate layer */
+ if (this._tubeState.precipColor && this._tubeState.precipH > 0) {
+ const ph = this._tubeState.precipH;
+ const py = solBot - ph;
+ ctx.fillStyle = this._tubeState.precipColor;
+ ctx.globalAlpha = 0.85;
+ ctx.beginPath();
+ ctx.moveTo(tx + r, py);
+ ctx.lineTo(tx + tw - r, py);
+ ctx.lineTo(tx + tw - r, solBot - r);
+ ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r);
+ ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r);
+ ctx.lineTo(tx + r, py);
+ ctx.closePath();
+ ctx.fill();
+ ctx.globalAlpha = 1;
+
+ /* precipitate label */
+ if (this._precipParticles && this._precipParticles.every(p => p.done)) {
+ ctx.font = 'bold 10px Manrope,sans-serif';
+ ctx.fillStyle = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor;
+ ctx.textAlign = 'center';
+ /* find label from last positive precip reaction */
+ const lastPrecip = [...this._log].reverse().find(l => {
+ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent);
+ if (!rInfo) return false;
+ const rxn = this._targetIon.reactions[rInfo.id];
+ return rxn && rxn.type === 'precip' && rxn.precipLabel;
+ });
+ if (lastPrecip) {
+ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent);
+ const rxn = rInfo ? this._targetIon.reactions[rInfo.id] : null;
+ if (rxn && rxn.precipLabel) {
+ ctx.fillText(rxn.precipLabel, W * 0.5, solBot - ph / 2 + 4);
+ }
+ }
+ }
+ }
+
+ /* falling drop particles */
+ this._dropAnim.forEach(d => {
+ ctx.globalAlpha = d.alpha;
+ ctx.fillStyle = d.color;
+ ctx.beginPath(); ctx.arc(d.x, d.y, 5, 0, Math.PI * 2); ctx.fill();
+ /* drop tail */
+ ctx.fillStyle = d.color + '88';
+ ctx.beginPath(); ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2); ctx.fill();
+ ctx.globalAlpha = 1;
+ });
+
+ /* floating precipitate particles */
+ this._precipParticles.filter(p => !p.done).forEach(p => {
+ ctx.globalAlpha = 0.75;
+ ctx.fillStyle = p.color;
+ ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
+ ctx.globalAlpha = 1;
+ });
+
+ /* gas bubbles */
+ this._gasParticles.forEach(p => {
+ ctx.globalAlpha = p.alpha;
+ ctx.strokeStyle = p.color;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke();
+ ctx.globalAlpha = 1;
+ });
+
+ /* gas label above tube */
+ if (this._tubeState.gasLabel) {
+ ctx.font = 'bold 13px Manrope,sans-serif';
+ ctx.fillStyle = '#FFFFAA';
+ ctx.textAlign = 'center';
+ ctx.fillText(this._tubeState.gasLabel, W * 0.5 + 40, tTop + 20);
+ }
+
+ /* tube glass outline */
+ ctx.shadowColor = 'rgba(155,93,229,0.3)';
+ ctx.shadowBlur = 12;
+ ctx.strokeStyle = 'rgba(200,210,255,0.55)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(tx, tTop);
+ ctx.lineTo(tx, tBot - r);
+ ctx.arcTo(tx, tBot, tx + r, tBot, r);
+ ctx.lineTo(tx + tw - r, tBot);
+ ctx.arcTo(tx + tw, tBot, tx + tw, tBot - r, r);
+ ctx.lineTo(tx + tw, tTop);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+
+ /* tube opening rim */
+ ctx.strokeStyle = 'rgba(200,210,255,0.35)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.moveTo(tx - 4, tTop); ctx.lineTo(tx + tw + 4, tTop); ctx.stroke();
+
+ /* tube shine */
+ const shine = ctx.createLinearGradient(tx, 0, tx + tw * 0.35, 0);
+ shine.addColorStop(0, 'rgba(255,255,255,0.10)');
+ shine.addColorStop(1, 'rgba(255,255,255,0)');
+ ctx.fillStyle = shine;
+ ctx.beginPath();
+ ctx.moveTo(tx + 3, tTop);
+ ctx.lineTo(tx + tw * 0.3, tTop);
+ ctx.lineTo(tx + tw * 0.3, tBot - 15);
+ ctx.lineTo(tx + 3, tBot - 15);
+ ctx.closePath();
+ ctx.fill();
+
+ /* mode label */
+ ctx.font = '700 11px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(155,93,229,0.5)';
+ ctx.textAlign = 'center';
+ ctx.fillText(this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', W * 0.5, H - 8);
+ }
+
+ stop() {
+ if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
+ }
+}
+
+/* ── helpers ─────────────────────────────────────────────────────── */
+function _esc(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&').replace(//g,'>');
+}
+
+/* ── lab UI init ─────────────────────────────────────────────────── */
+var qualSim = null;
+
+function _openQualAnalysis() {
+ document.getElementById('sim-topbar-title').textContent = 'Качественный анализ';
+ _simShow('sim-qualanalysis');
+ _registerSimState('qualanalysis', () => null, () => null);
+ if (typeof _embedMode !== 'undefined' && _embedMode) _startStateEmit('qualanalysis');
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const wrap = document.getElementById('qualanalysis-wrap');
+ if (!qualSim) {
+ qualSim = new QualAnalysisSim(wrap);
+ } else {
+ qualSim._resizeFit();
+ }
+ }));
+}
diff --git a/frontend/js/labs/redox.js b/frontend/js/labs/redox.js
index f675136..6adf4c2 100644
--- a/frontend/js/labs/redox.js
+++ b/frontend/js/labs/redox.js
@@ -97,6 +97,12 @@ class RedoxSim {
this._oParts = [];
this._precip = [];
this._gas = [];
+ /* edu-tooltip + product labels */
+ this._eduTooltipAge = -1;
+ this._eduTooltipLines = [];
+ this._prodLabelAge = -1;
+ this._prodLabelText = '';
+ this._prodLabelType = 'precip';
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
@@ -233,6 +239,39 @@ class RedoxSim {
p.phase += dt;
this._clamp(p);
});
+ /* trigger product label + edu-tooltip once */
+ if (window.ChemVisuals && this._prodLabelAge < 0) {
+ const rxn = RedoxSim.RXN[this.rxnId];
+ if (rxn.precip) {
+ this._prodLabelText = (rxn.pname || '') + ' ';
+ this._prodLabelType = 'precip';
+ this._prodLabelAge = 0;
+ } else if (rxn.gas) {
+ this._prodLabelText = (rxn.gname || '') + ' ';
+ this._prodLabelType = 'gas';
+ this._prodLabelAge = 0;
+ }
+ if (this._eduTooltipAge < 0 && rxn.eq_mol) {
+ const stripSVG = s => (s || '').replace(/<[^>]+>/g, '->');
+ const eqClean = stripSVG(rxn.eq_ion || rxn.eq_mol).slice(0, 38);
+ this._eduTooltipLines = [
+ (rxn.name || '').slice(0, 34),
+ 'e⁻: от восстановителя к окислителю',
+ eqClean,
+ ].filter(Boolean).slice(0, 4);
+ this._eduTooltipAge = 0;
+ }
+ }
+ }
+
+ /* advance ages */
+ if (this._eduTooltipAge >= 0) {
+ this._eduTooltipAge += dt / 4.0;
+ if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1;
+ }
+ if (this._prodLabelAge >= 0) {
+ this._prodLabelAge += dt / 3.0;
+ if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
}
/* Electrons — quadratic bezier arc */
@@ -343,6 +382,12 @@ class RedoxSim {
}
}
+ /* desk */
+ if (window.ChemVisuals) {
+ ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.82);
+ ChemVisuals.drawVesselShadow(ctx, W / 2, H * 0.82, W * 0.38);
+ }
+
this._drawBeaker(ctx, W, H);
this._drawParticles(ctx, rxn);
this._drawElectrons(ctx);
@@ -350,6 +395,22 @@ class RedoxSim {
if (rxn.gas) this._drawGas(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
if (window.LabFX) LabFX.particles.draw(this.ctx);
+
+ /* animated product label */
+ if (window.ChemVisuals && this._prodLabelAge >= 0) {
+ const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.76;
+ ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge);
+ if (this._prodLabelType === 'gas') {
+ ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.16, rxn.gcolor || 'rgba(200,235,255,0.8)', this._t);
+ } else {
+ ChemVisuals.animatePrecipitateFall(ctx, W / 2, H * 0.72, rxn.pcolor || '#CCC', this._t);
+ }
+ }
+
+ /* edu tooltip */
+ if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) {
+ ChemVisuals.drawEduTooltip(ctx, W / 2, H * 0.10, 210, this._eduTooltipLines, this._eduTooltipAge);
+ }
}
_drawBeaker(ctx, W, H) {
diff --git a/frontend/js/labs/solutions.js b/frontend/js/labs/solutions.js
new file mode 100644
index 0000000..299ea77
--- /dev/null
+++ b/frontend/js/labs/solutions.js
@@ -0,0 +1,1139 @@
+'use strict';
+
+/* ═══════════════════════════════════════════════════════════════════════
+ SolutionsSim — «Растворы»
+ 4 sub-modes: calculator, dilution, mixing, solubility curves
+ ═══════════════════════════════════════════════════════════════════════ */
+
+/* ── helper: create element ── */
+function _slEl(tag, props) {
+ var el = document.createElement(tag);
+ if (props) {
+ Object.keys(props).forEach(function(k) {
+ if (k === 'style') { el.style.cssText = props[k]; }
+ else if (k === 'textContent') { el.textContent = props[k]; }
+ else if (k === 'innerHTML') { el.innerHTML = props[k]; }
+ else { el[k] = props[k]; }
+ });
+ }
+ return el;
+}
+
+class SolutionsSim {
+
+ /* ── Вещества-пресеты ── */
+ static SUBSTANCES = [
+ { label: 'NaCl', M: 58.5, color: '#bde0f7' },
+ { label: 'NaOH', M: 40.0, color: '#a8f0a8' },
+ { label: 'KOH', M: 56.1, color: '#b8f5b8' },
+ { label: 'HCl (газ)', M: 36.46, color: '#f5e0a0' },
+ { label: 'H₂SO₄', M: 98.08, color: '#ffa07a' },
+ { label: 'HNO₃', M: 63.01, color: '#f5d0a0' },
+ { label: 'H₃PO₄', M: 97.99, color: '#f0c8a0' },
+ { label: 'CuSO₄·5H₂O', M: 249.7, color: '#64b8f0' },
+ { label: 'Глюкоза', M: 180.2, color: '#f0d080' },
+ { label: 'Сахароза', M: 342.3, color: '#e8d0a0' },
+ { label: 'Na₂SO₄', M: 142.0, color: '#c8d8f0' },
+ { label: 'KCl', M: 74.55, color: '#d0f0d0' },
+ { label: 'CaCl₂', M: 111.0, color: '#f0d8b8' },
+ { label: 'AlCl₃', M: 133.3, color: '#e8d0c8' },
+ { label: 'Другое', M: 1.0, color: '#aaaaaa' },
+ ];
+
+ /* ── Кривые растворимости (г/100г H₂O при 0,10,20,30,40,50,60,70,80,90,100 °C) ── */
+ static SOLUBILITY = [
+ { label: 'NaCl', color: '#4CC9F0', data: [35.7,35.8,36.0,36.3,36.6,37.0,37.3,37.8,38.4,39.0,39.8] },
+ { label: 'KNO₃', color: '#FFD166', data: [13.3,20.9,31.6,45.8,63.9,85.5,110,138,169,202,247] },
+ { label: 'KCl', color: '#06D6E0', data: [27.6,31.0,34.0,37.0,40.0,42.6,45.5,48.3,51.1,54.0,56.7] },
+ { label: 'Pb(NO₃)₂', color: '#EF476F', data: [37.6,44.3,54.3,64.5,74.5,84.0,93.3,100,109,116,127] },
+ { label: 'CuSO₄', color: '#9B5DE5', data: [14.3,17.4,20.7,25.0,28.5,33.3,40.2,47.5,55.0,63.4,73.0] },
+ { label: 'NH₄Cl', color: '#F15BB5', data: [29.4,33.3,37.2,41.4,45.8,50.4,55.2,60.2,65.6,71.3,77.3] },
+ { label: 'NH₃', color: '#7BF5A4', data: [88.5,70.0,54.0,39.3,26.8,17.0,10.2,6.5,4.3,3.0,2.1] },
+ { label: 'K₂Cr₂O₇', color: '#FF9F1C', data: [4.7,8.3,12.5,19.0,26.3,35.0,45.6,58.2,73.0,87.0,100] },
+ ];
+
+ constructor(container) {
+ this._container = container;
+ this._mode = 'calc'; // 'calc' | 'dilution' | 'mixing' | 'solubility'
+ this._solEnabled = new Set([0, 1, 2, 3, 4]); // enabled solubility curves
+ this._solT = 20;
+
+ // calc state
+ this._calc = {
+ m_solute: 58.5, // г
+ m_solution: 500, // г
+ rho: 1.08, // г/мл
+ M: 58.5, // г/моль (NaCl)
+ subIdx: 0,
+ T: 20,
+ };
+
+ // dilution state
+ this._dil = { m1: 200, omega1: 20, addWater: 100 };
+
+ // mixing state
+ this._mix = { m1: 200, omega1: 20, m2: 300, omega2: 10 };
+
+ this._init();
+ this._setMode('calc');
+ }
+
+ /* ── Build root DOM ── */
+ _init() {
+ var c = this._container;
+ c.innerHTML = '';
+ c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;font-family:Manrope,sans-serif;';
+
+ // Mode tabs
+ var tabs = _slEl('div', {
+ style: 'flex:0 0 auto;display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.02);',
+ });
+ var MODES = [
+ { id: 'calc', label: 'Калькулятор' },
+ { id: 'dilution', label: 'Разбавление' },
+ { id: 'mixing', label: 'Смешивание' },
+ { id: 'solubility', label: 'Растворимость' },
+ ];
+ this._tabBtns = {};
+ MODES.forEach(function(m) {
+ var btn = _slEl('button', {
+ style: 'flex:1;padding:10px 4px;background:none;border:none;border-bottom:2px solid transparent;color:rgba(255,255,255,0.5);font-size:.78rem;font-weight:700;font-family:Manrope,sans-serif;cursor:pointer;transition:color .2s,border-color .2s;',
+ textContent: m.label,
+ });
+ btn.addEventListener('click', this._setMode.bind(this, m.id));
+ this._tabBtns[m.id] = btn;
+ tabs.appendChild(btn);
+ }.bind(this));
+ c.appendChild(tabs);
+
+ // Content area
+ this._content = _slEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' });
+ c.appendChild(this._content);
+
+ // Canvas for visualisation (shared)
+ this._canvas = document.createElement('canvas');
+ this._ctx2d = this._canvas.getContext('2d');
+ this._raf = null;
+
+ if (window.ResizeObserver) {
+ this._ro = new ResizeObserver(function() { this._fitCanvas(); this._drawViz(); }.bind(this));
+ }
+ }
+
+ /* ── Switch mode ── */
+ _setMode(mode) {
+ this._mode = mode;
+ Object.keys(this._tabBtns).forEach(function(id) {
+ var btn = this._tabBtns[id];
+ btn.style.color = id === mode ? '#fff' : 'rgba(255,255,255,0.45)';
+ btn.style.borderColor = id === mode ? 'var(--violet,#9B5DE5)' : 'transparent';
+ btn.style.background = id === mode ? 'rgba(155,93,229,0.08)' : 'none';
+ }.bind(this));
+
+ if (this._ro && this._canvas.parentElement) {
+ this._ro.unobserve(this._canvas);
+ }
+
+ this._content.innerHTML = '';
+ if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
+
+ switch (mode) {
+ case 'calc': this._buildCalc(); break;
+ case 'dilution': this._buildDilution(); break;
+ case 'mixing': this._buildMixing(); break;
+ case 'solubility':this._buildSolubility(); break;
+ }
+ }
+
+ /* ════════════════════════════════════════════════════════
+ MODE 1 — КАЛЬКУЛЯТОР
+ ════════════════════════════════════════════════════════ */
+ _buildCalc() {
+ var self = this;
+ var c = this._content;
+ c.style.flexDirection = '';
+
+ // Left panel
+ var left = _slEl('div', {
+ style: 'flex:0 0 270px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);',
+ });
+
+ // Section header
+ function sHead(txt) {
+ return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: txt });
+ }
+
+ // Substance selector
+ left.appendChild(sHead('Вещество (M, г/моль)'));
+ var subWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' });
+ var subSel = document.createElement('select');
+ subSel.style.cssText = 'flex:1;background:#1a1a2e;color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:5px 8px;font-size:.78rem;font-family:Manrope,sans-serif;cursor:pointer;';
+ SolutionsSim.SUBSTANCES.forEach(function(s, i) {
+ var opt = document.createElement('option');
+ opt.value = i;
+ opt.textContent = s.label + ' = ' + s.M;
+ if (i === self._calc.subIdx) opt.selected = true;
+ subSel.appendChild(opt);
+ });
+ subSel.addEventListener('change', function() {
+ self._calc.subIdx = +subSel.value;
+ var sub = SolutionsSim.SUBSTANCES[self._calc.subIdx];
+ self._calc.M = sub.M;
+ if (mInput) mInput.value = sub.M;
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 });
+ self._recalcCalc('M');
+ });
+ subWrap.appendChild(subSel);
+ left.appendChild(subWrap);
+
+ // Slider row
+ function sliderRow(label, id, min, max, step, val, unit, color, onInput) {
+ var row = _slEl('div', { style: 'margin-bottom:10px;' });
+ var labelRow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' });
+ labelRow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label }));
+ var valSpan = _slEl('span', { id: id + '-val', style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit });
+ labelRow.appendChild(valSpan);
+ row.appendChild(labelRow);
+
+ var sl = document.createElement('input');
+ sl.type = 'range'; sl.id = id + '-sl';
+ sl.min = min; sl.max = max; sl.step = step; sl.value = val;
+ sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;';
+ sl.addEventListener('input', function() {
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 1.0, volume: 0.25 });
+ onInput(+sl.value);
+ valSpan.textContent = (+sl.value).toFixed(step < 1 ? 2 : 0) + ' ' + unit;
+ });
+ row.appendChild(sl);
+
+ // number input
+ var ni = document.createElement('input');
+ ni.type = 'number'; ni.min = min; ni.max = max; ni.step = step; ni.value = val;
+ ni.style.cssText = 'width:100%;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;margin-top:2px;box-sizing:border-box;';
+ ni.addEventListener('change', function() {
+ var v = Math.min(max, Math.max(min, +ni.value || min));
+ sl.value = v; ni.value = v;
+ valSpan.textContent = v.toFixed(step < 1 ? 2 : 0) + ' ' + unit;
+ onInput(v);
+ });
+ row.appendChild(ni);
+ return { row: row, sl: sl, ni: ni, valSpan: valSpan };
+ }
+
+ left.appendChild(sHead('Входные параметры'));
+
+ var mSoluteCtrl = sliderRow('mв — масса растворённого', 'sl-calc-ms', 0, 500, 1, self._calc.m_solute, 'г', '#4CC9F0', function(v) {
+ self._calc.m_solute = v;
+ self._recalcCalc('m_solute');
+ });
+ left.appendChild(mSoluteCtrl.row);
+
+ var mSolCtrl = sliderRow('mр-ра — масса раствора', 'sl-calc-mr', 1, 1000, 1, self._calc.m_solution, 'г', '#FFD166', function(v) {
+ self._calc.m_solution = Math.max(v, self._calc.m_solute + 0.01);
+ self._recalcCalc('m_solution');
+ });
+ left.appendChild(mSolCtrl.row);
+
+ var rhoCtrl = sliderRow('ρ — плотность раствора', 'sl-calc-rho', 0.8, 2.0, 0.01, self._calc.rho, 'г/мл', '#9B5DE5', function(v) {
+ self._calc.rho = v;
+ self._recalcCalc('rho');
+ });
+ left.appendChild(rhoCtrl.row);
+
+ left.appendChild(sHead('Молярная масса'));
+ var mWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:10px;' });
+ mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'M =' }));
+ var mInput = document.createElement('input');
+ mInput.type = 'number'; mInput.min = 1; mInput.max = 500; mInput.step = 0.1;
+ mInput.value = self._calc.M;
+ mInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;';
+ mInput.addEventListener('change', function() {
+ self._calc.M = Math.max(1, Math.min(500, +mInput.value || 1));
+ mInput.value = self._calc.M;
+ self._recalcCalc('M');
+ });
+ mWrap.appendChild(mInput);
+ mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.5);', textContent: 'г/моль' }));
+ left.appendChild(mWrap);
+
+ left.appendChild(sHead('Температура'));
+ var tCtrl = sliderRow('T — температура', 'sl-calc-t', 0, 100, 1, self._calc.T, '°C', '#F15BB5', function(v) {
+ self._calc.T = v;
+ self._recalcCalc('T');
+ });
+ left.appendChild(tCtrl.row);
+
+ c.appendChild(left);
+
+ // Center — canvas visualization
+ var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;position:relative;' });
+ this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;';
+ centerWrap.appendChild(this._canvas);
+ c.appendChild(centerWrap);
+
+ // Right panel — results
+ var right = _slEl('div', {
+ style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-left:1px solid rgba(255,255,255,0.07);',
+ });
+ right.appendChild(sHead('Вычисленные значения'));
+ this._calcResults = right;
+ c.appendChild(right);
+
+ // attach ResizeObserver
+ if (this._ro) { this._ro.observe(this._canvas); }
+ requestAnimationFrame(function() {
+ self._fitCanvas();
+ self._recalcCalc('init');
+ });
+ }
+
+ _recalcCalc(changed) {
+ var s = this._calc;
+ if (s.m_solute > s.m_solution) s.m_solution = s.m_solute + 0.01;
+
+ var omega = s.m_solution > 0 ? (s.m_solute / s.m_solution) * 100 : 0;
+ var m_water = s.m_solution - s.m_solute;
+ var V_liters = (s.m_solution / s.rho) / 1000; // л
+ var V_ml = s.m_solution / s.rho; // мл
+ var nu = s.M > 0 ? s.m_solute / s.M : 0; // моль
+ var cM = V_liters > 0 ? nu / V_liters : 0; // моль/л
+ // норм. концентрация — упрощённо, как молярная для однозарядных
+ var cN = cM;
+
+ this._calcState = { omega, m_water, V_ml, V_liters, nu, cM, cN };
+
+ var r = this._calcResults;
+ if (!r) return;
+
+ function resLine(label, value, unit, color, formula) {
+ var row = _slEl('div', { style: 'margin-bottom:10px;padding:8px;background:rgba(255,255,255,0.04);border-radius:8px;border-left:3px solid ' + color + ';' });
+ row.appendChild(_slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.5);margin-bottom:2px;', innerHTML: label }));
+ row.appendChild(_slEl('div', { style: 'font-size:1.1rem;font-weight:800;color:' + color + ';', textContent: value + ' ' + unit }));
+ if (formula) {
+ var fd = _slEl('div', { style: 'margin-top:4px;font-size:.7rem;color:rgba(255,255,255,0.35);' });
+ fd.setAttribute('data-formula', formula);
+ row.appendChild(fd);
+ }
+ return row;
+ }
+
+ r.innerHTML = '';
+ r.appendChild(_slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: 'Вычисленные значения' }));
+
+ r.appendChild(resLine('ω — массовая доля', omega.toFixed(2), '%', '#4CC9F0', '\\omega = \\frac{m_в}{m_{р-ра}} \\times 100\\%'));
+ r.appendChild(resLine('mводы — масса воды', m_water.toFixed(1), 'г', '#06D6E0', ''));
+ r.appendChild(resLine('V — объём раствора', V_ml.toFixed(1), 'мл', '#FFD166', 'V = \\frac{m_{р-ра}}{\\rho}'));
+ r.appendChild(resLine('ν — количество вещества', nu.toFixed(4), 'моль', '#9B5DE5', '\\nu = \\frac{m_в}{M}'));
+ r.appendChild(resLine('CМ — молярная', cM.toFixed(4), 'моль/л', '#F15BB5', 'C_M = \\frac{\\nu}{V}'));
+ r.appendChild(resLine('CН — нормальность', cN.toFixed(4), 'моль-экв/л', '#EF476F', ''));
+
+ // Render KaTeX formulas
+ r.querySelectorAll('[data-formula]').forEach(function(el) {
+ if (window.katex && el.getAttribute('data-formula')) {
+ try {
+ katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false });
+ } catch(e) { /* ignore */ }
+ }
+ });
+
+ if (window.LabFX && changed !== 'init') {
+ LabFX.sound.play('chime', { pitch: 1.4, volume: 0.3 });
+ }
+
+ this._drawViz();
+ }
+
+ /* ════════════════════════════════════════════════════════
+ MODE 2 — РАЗБАВЛЕНИЕ
+ ════════════════════════════════════════════════════════ */
+ _buildDilution() {
+ var self = this;
+ var c = this._content;
+ c.style.flexDirection = '';
+
+ // Left controls
+ var left = _slEl('div', {
+ style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);',
+ });
+
+ function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); }
+ function numRow(label, val, min, max, step, unit, color, cb) {
+ var row = _slEl('div', { style: 'margin-bottom:10px;' });
+ var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' });
+ lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label }));
+ var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit });
+ lrow.appendChild(vs);
+ row.appendChild(lrow);
+ var sl = document.createElement('input');
+ sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val;
+ sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;';
+ sl.addEventListener('input', function() {
+ vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit;
+ if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 });
+ cb(+sl.value);
+ });
+ row.appendChild(sl);
+ return { row, sl, vs };
+ }
+
+ left.appendChild(sh('Исходный раствор'));
+ numRow('m₁ — масса раствора', self._dil.m1, 50, 500, 1, 'г', '#FFD166', function(v) { self._dil.m1 = v; self._recalcDil(); }).row;
+ left.appendChild(left.lastChild);
+ var dil_m1 = left.lastChild;
+
+ numRow('ω₁ — концентрация', self._dil.omega1, 1, 80, 0.5, '%', '#4CC9F0', function(v) { self._dil.omega1 = v; self._recalcDil(); }).row;
+ left.appendChild(left.lastChild);
+
+ left.appendChild(sh('Добавляем воду'));
+ numRow('Vводы — объём воды', self._dil.addWater, 0, 1000, 1, 'мл', '#06D6E0', function(v) {
+ self._dil.addWater = v;
+ if (window.LabFX && v > 0) LabFX.sound.play('pour', { volume: 0.4 });
+ self._recalcDil();
+ }).row;
+ left.appendChild(left.lastChild);
+
+ left.appendChild(sh('Результат'));
+ this._dilResultsEl = _slEl('div', {});
+ left.appendChild(this._dilResultsEl);
+
+ c.appendChild(left);
+
+ // Center — canvas
+ var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' });
+ this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;';
+ centerWrap.appendChild(this._canvas);
+ c.appendChild(centerWrap);
+
+ if (this._ro) { this._ro.observe(this._canvas); }
+ requestAnimationFrame(function() { self._fitCanvas(); self._recalcDil(); });
+ }
+
+ _recalcDil() {
+ var d = this._dil;
+ var m_solute = d.m1 * d.omega1 / 100;
+ var m_new = d.m1 + d.addWater; // плотность воды = 1 г/мл
+ var omega2 = m_new > 0 ? (m_solute / m_new) * 100 : 0;
+
+ this._dilState = { m_solute, m_new, omega2 };
+
+ var r = this._dilResultsEl;
+ if (!r) return;
+ r.innerHTML = '';
+
+ function valLine(label, val, unit, color) {
+ var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' });
+ el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label }));
+ el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit }));
+ return el;
+ }
+ r.appendChild(valLine('mв = const', m_solute.toFixed(2), 'г', '#4CC9F0'));
+ r.appendChild(valLine('mр-ра новая', m_new.toFixed(1), 'г', '#FFD166'));
+ r.appendChild(valLine('ω₂ (новая)', omega2.toFixed(3), '%', '#7BF5A4'));
+
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.25 });
+ this._drawViz();
+ }
+
+ /* ════════════════════════════════════════════════════════
+ MODE 3 — СМЕШИВАНИЕ
+ ════════════════════════════════════════════════════════ */
+ _buildMixing() {
+ var self = this;
+ var c = this._content;
+ c.style.flexDirection = '';
+
+ var left = _slEl('div', {
+ style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);',
+ });
+
+ function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); }
+ function addSl(label, val, min, max, step, unit, color, cb) {
+ var row = _slEl('div', { style: 'margin-bottom:10px;' });
+ var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' });
+ lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label }));
+ var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit });
+ lrow.appendChild(vs);
+ row.appendChild(lrow);
+ var sl = document.createElement('input');
+ sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val;
+ sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;';
+ sl.addEventListener('input', function() {
+ vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit;
+ if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 });
+ cb(+sl.value);
+ });
+ row.appendChild(sl);
+ left.appendChild(row);
+ }
+
+ left.appendChild(sh('Раствор 1'));
+ addSl('m₁ — масса', self._mix.m1, 10, 500, 1, 'г', '#4CC9F0', function(v) { self._mix.m1 = v; self._recalcMix(); });
+ addSl('ω₁ — концентрация', self._mix.omega1, 0, 90, 0.5, '%', '#4CC9F0', function(v) { self._mix.omega1 = v; self._recalcMix(); });
+
+ left.appendChild(sh('Раствор 2'));
+ addSl('m₂ — масса', self._mix.m2, 10, 500, 1, 'г', '#FFD166', function(v) { self._mix.m2 = v; self._recalcMix(); });
+ addSl('ω₂ — концентрация', self._mix.omega2, 0, 90, 0.5, '%', '#FFD166', function(v) { self._mix.omega2 = v; self._recalcMix(); });
+
+ left.appendChild(sh('Результат'));
+ this._mixResultsEl = _slEl('div', {});
+ left.appendChild(this._mixResultsEl);
+
+ c.appendChild(left);
+
+ var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' });
+ this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;';
+ centerWrap.appendChild(this._canvas);
+ c.appendChild(centerWrap);
+
+ if (this._ro) { this._ro.observe(this._canvas); }
+ requestAnimationFrame(function() { self._fitCanvas(); self._recalcMix(); });
+ }
+
+ _recalcMix() {
+ var x = this._mix;
+ var m3 = x.m1 + x.m2;
+ var omega3 = m3 > 0 ? (x.m1 * x.omega1 + x.m2 * x.omega2) / m3 : 0;
+ this._mixState = { m3, omega3 };
+
+ var r = this._mixResultsEl;
+ if (!r) return;
+ r.innerHTML = '';
+
+ function valLine(label, val, unit, color) {
+ var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' });
+ el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label }));
+ el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit }));
+ return el;
+ }
+ r.appendChild(valLine('m₃ = m₁ + m₂', m3.toFixed(1), 'г', '#7BF5A4'));
+ r.appendChild(valLine('ω₃ — итоговая', omega3.toFixed(3), '%', '#F15BB5'));
+
+ // правило рычага
+ var fd = _slEl('div', { style: 'margin-top:10px;padding:8px;background:rgba(155,93,229,0.08);border-radius:8px;font-size:.75rem;color:rgba(255,255,255,0.5);' });
+ fd.appendChild(_slEl('div', { style: 'margin-bottom:4px;color:rgba(155,93,229,0.9);font-weight:700;font-size:.72rem;', textContent: 'Правило рычага' }));
+ var formulaEl = _slEl('div', {});
+ formulaEl.setAttribute('data-formula', 'm_1 \\cdot \\omega_1 + m_2 \\cdot \\omega_2 = m_3 \\cdot \\omega_3');
+ fd.appendChild(formulaEl);
+ r.appendChild(fd);
+
+ if (window.katex) {
+ r.querySelectorAll('[data-formula]').forEach(function(el) {
+ try { katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false }); } catch(e) {}
+ });
+ }
+
+ if (window.LabFX) LabFX.sound.play('pour', { volume: 0.5 });
+ this._drawViz();
+ }
+
+ /* ════════════════════════════════════════════════════════
+ MODE 4 — КРИВЫЕ РАСТВОРИМОСТИ
+ ════════════════════════════════════════════════════════ */
+ _buildSolubility() {
+ var self = this;
+ var c = this._content;
+ c.style.flexDirection = '';
+
+ // Left panel
+ var left = _slEl('div', {
+ style: 'flex:0 0 220px;display:flex;flex-direction:column;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);',
+ });
+
+ function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: t }); }
+
+ left.appendChild(sh('Вещества'));
+ SolutionsSim.SOLUBILITY.forEach(function(s, idx) {
+ var row = _slEl('div', { style: 'display:flex;align-items:center;gap:8px;margin-bottom:7px;cursor:pointer;' });
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = self._solEnabled.has(idx);
+ cb.style.cssText = 'accent-color:' + s.color + ';width:14px;height:14px;cursor:pointer;';
+ cb.addEventListener('change', function() {
+ if (cb.checked) { self._solEnabled.add(idx); } else { self._solEnabled.delete(idx); }
+ if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 });
+ self._drawViz();
+ });
+ var colorDot = _slEl('span', { style: 'width:10px;height:10px;border-radius:50%;background:' + s.color + ';flex-shrink:0;display:inline-block;' });
+ row.appendChild(cb);
+ row.appendChild(colorDot);
+ row.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.8);', textContent: s.label }));
+ left.appendChild(row);
+ });
+
+ left.appendChild(sh('Температура'));
+ var tRow = _slEl('div', { style: 'margin-bottom:10px;' });
+ var tRowL = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' });
+ tRowL.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'T =' }));
+ var tValSpan = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:#F15BB5;', textContent: self._solT + ' °C' });
+ tRowL.appendChild(tValSpan);
+ tRow.appendChild(tRowL);
+ var tSl = document.createElement('input');
+ tSl.type = 'range'; tSl.min = 0; tSl.max = 100; tSl.step = 1; tSl.value = self._solT;
+ tSl.style.cssText = 'width:100%;accent-color:#F15BB5;cursor:pointer;';
+ tSl.addEventListener('input', function() {
+ self._solT = +tSl.value;
+ tValSpan.textContent = self._solT + ' °C';
+ if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 });
+ self._drawViz();
+ });
+ tRow.appendChild(tSl);
+ left.appendChild(tRow);
+
+ // Перекристаллизация задача
+ left.appendChild(sh('Задача: перекристаллизация'));
+ var kno3Hint = _slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.55);margin-bottom:8px;line-height:1.5;', textContent: 'Насыщенный раствор KNO₃ при 80°C охладили до 20°C.' });
+ left.appendChild(kno3Hint);
+
+ var crystRow = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' });
+ crystRow.appendChild(_slEl('span', { style: 'font-size:.75rem;color:rgba(255,255,255,0.6);', textContent: 'Масса р-ра (г):' }));
+ var crystInput = document.createElement('input');
+ crystInput.type = 'number'; crystInput.min = 10; crystInput.max = 10000; crystInput.step = 10; crystInput.value = 200;
+ crystInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 6px;font-size:.75rem;';
+ crystRow.appendChild(crystInput);
+ left.appendChild(crystRow);
+
+ var crystBtn = _slEl('button', {
+ style: 'width:100%;padding:8px;border-radius:7px;border:none;background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer;',
+ textContent: 'Рассчитать',
+ });
+ this._crystAnswer = _slEl('div', { style: 'margin-top:8px;font-size:.75rem;line-height:1.5;color:#7BF5A4;', textContent: '' });
+ crystBtn.addEventListener('click', function() {
+ var mSol = +crystInput.value || 200;
+ // S at 80°C = 169 g/100g H2O, at 20°C = 31.6 g/100g H2O
+ var S80 = 169, S20 = 31.6;
+ var kno3Index = 1; // KNO₃ is index 1 in SOLUBILITY
+ // Насыщенный раствор при 80°C: x г KNO3 на 100 г воды
+ // m_r = m_KNO3 + m_H2O => m_KNO3 = mSol * S80 / (100 + S80)
+ var m_kno3 = mSol * S80 / (100 + S80);
+ var m_h2o = mSol - m_kno3;
+ // при 20°C в m_h2o г воды растворится не более S20 * m_h2o / 100
+ var dissolved20 = S20 * m_h2o / 100;
+ var precipitated = m_kno3 - dissolved20;
+ precipitated = Math.max(0, precipitated);
+ self._crystAnswer.textContent =
+ 'KNO₃ в р-ре: ' + m_kno3.toFixed(1) + ' г\n' +
+ 'H₂O: ' + m_h2o.toFixed(1) + ' г\n' +
+ 'Выпало осадка: ' + precipitated.toFixed(1) + ' г';
+ if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.5 });
+ });
+ left.appendChild(crystBtn);
+ left.appendChild(this._crystAnswer);
+
+ c.appendChild(left);
+
+ // Center — canvas
+ var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' });
+ this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;';
+ centerWrap.appendChild(this._canvas);
+ c.appendChild(centerWrap);
+
+ if (this._ro) { this._ro.observe(this._canvas); }
+ requestAnimationFrame(function() { self._fitCanvas(); self._drawViz(); });
+ }
+
+ /* ════════════════════════════════════════════════════════
+ CANVAS — fitCanvas + drawViz dispatcher
+ ════════════════════════════════════════════════════════ */
+ _fitCanvas() {
+ var cv = this._canvas;
+ if (!cv.parentElement) return;
+ var r = cv.parentElement.getBoundingClientRect();
+ if (r.width < 1 || r.height < 1) return;
+ cv.width = r.width * (window.devicePixelRatio || 1);
+ cv.height = r.height * (window.devicePixelRatio || 1);
+ cv.style.width = r.width + 'px';
+ cv.style.height = r.height + 'px';
+ this._ctx2d.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
+ }
+
+ _drawViz() {
+ switch (this._mode) {
+ case 'calc': this._drawCalcViz(); break;
+ case 'dilution': this._drawDilViz(); break;
+ case 'mixing': this._drawMixViz(); break;
+ case 'solubility': this._drawSolubility(); break;
+ }
+ }
+
+ /* ── Draw helper: gradient beaker ── */
+ _drawBeaker(ctx, x, y, w, h, fillH, color, label) {
+ var bw = w * 0.55; // beaker width
+ var bh = h * 0.72; // beaker body height
+ var bx = x + (w - bw) / 2;
+ var by = y + h * 0.1;
+
+ // beaker body (trapezoid-ish, bottom wider)
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ ctx.lineWidth = 2;
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(bx + bw * 0.1, by);
+ ctx.lineTo(bx + bw * 0.9, by);
+ ctx.lineTo(bx + bw, by + bh);
+ ctx.lineTo(bx, by + bh);
+ ctx.closePath();
+ ctx.stroke();
+
+ // liquid fill
+ var fillRatio = Math.min(1, Math.max(0, fillH));
+ var fillY = by + bh * (1 - fillRatio);
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(bx - 2, fillY, bw + 4, bh - (fillY - by));
+ ctx.clip();
+ var grad = ctx.createLinearGradient(bx, fillY, bx + bw, fillY + bh);
+ grad.addColorStop(0, color.replace(')', ',0.85)').replace('rgb', 'rgba'));
+ grad.addColorStop(1, color.replace(')', ',0.5)').replace('rgb', 'rgba'));
+ ctx.fillStyle = color + 'cc';
+ ctx.fill();
+ ctx.restore();
+
+ // label
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
+ ctx.font = 'bold 13px Manrope,sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(label, x + w / 2, y + h + 16);
+ }
+
+ /* ── Calc visualization: beaker + percentage ── */
+ _drawCalcViz() {
+ var cv = this._canvas;
+ var ctx = this._ctx2d;
+ var W = cv.width / (window.devicePixelRatio || 1);
+ var H = cv.height / (window.devicePixelRatio || 1);
+ ctx.clearRect(0, 0, W, H);
+ if (!this._calcState) return;
+
+ var st = this._calcState;
+ var omega = st.omega;
+ var sub = SolutionsSim.SUBSTANCES[this._calc.subIdx];
+
+ // draw background gradient
+ var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W, H) * 0.7);
+ bg.addColorStop(0, '#14142a');
+ bg.addColorStop(1, '#0D0D1A');
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, W, H);
+
+ // Beaker
+ var bW = Math.min(W * 0.45, 220);
+ var bH = Math.min(H * 0.7, 320);
+ var bX = (W - bW) / 2;
+ var bY = H * 0.08;
+
+ var bw = bW * 0.55;
+ var bh = bH * 0.72;
+ var bx = bX + (bW - bw) / 2;
+ var by = bY + bH * 0.1;
+
+ // outer glass
+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
+ ctx.lineWidth = 2.5;
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(bx + bw * 0.08, by);
+ ctx.lineTo(bx + bw * 0.92, by);
+ ctx.lineTo(bx + bw, by + bh);
+ ctx.lineTo(bx, by + bh);
+ ctx.closePath();
+ ctx.stroke();
+
+ // graduation marks
+ ctx.strokeStyle = 'rgba(255,255,255,0.15)';
+ ctx.lineWidth = 1;
+ for (var i = 1; i <= 4; i++) {
+ var gy = by + bh * (i / 5);
+ ctx.beginPath(); ctx.moveTo(bx + 4, gy); ctx.lineTo(bx + 12, gy); ctx.stroke();
+ }
+
+ // liquid fill
+ var fillRatio = Math.min(1, Math.max(0.05, (this._calc.m_solution / 1000) * 0.85));
+ var liquidTop = by + bh * (1 - fillRatio);
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(bx + bw * 0.08, by);
+ ctx.lineTo(bx + bw * 0.92, by);
+ ctx.lineTo(bx + bw, by + bh);
+ ctx.lineTo(bx, by + bh);
+ ctx.closePath();
+ ctx.clip();
+
+ var lgrad = ctx.createLinearGradient(bx, liquidTop, bx + bw, by + bh);
+ var baseColor = sub.color || '#4CC9F0';
+ lgrad.addColorStop(0, baseColor + 'bb');
+ lgrad.addColorStop(1, baseColor + '77');
+ ctx.fillStyle = lgrad;
+ ctx.beginPath();
+ ctx.rect(bx - 5, liquidTop, bw + 10, bh - (liquidTop - by) + 5);
+ ctx.fill();
+
+ // concentration ripple effect on surface
+ ctx.strokeStyle = 'rgba(255,255,255,0.2)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(bx + 4, liquidTop);
+ ctx.bezierCurveTo(bx + bw * 0.25, liquidTop - 4, bx + bw * 0.75, liquidTop + 4, bx + bw - 4, liquidTop);
+ ctx.stroke();
+
+ ctx.restore();
+
+ // % label inside beaker
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
+ ctx.font = 'bold 28px Manrope,sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(omega.toFixed(1) + '%', bx + bw / 2, liquidTop + (by + bh - liquidTop) * 0.45);
+
+ // substance name below
+ ctx.font = '12px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.fillText(sub.label, bx + bw / 2, by + bh + 22);
+
+ // beaker label top
+ ctx.font = 'bold 11px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
+ ctx.fillText('Мерный стакан', bx + bw / 2, by - 10);
+
+ ctx.textBaseline = 'alphabetic';
+
+ // formula annotation
+ var fY = by + bh + 48;
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.textAlign = 'center';
+ ctx.fillText('ω = ' + omega.toFixed(2) + '% · ν = ' + this._calcState.nu.toFixed(3) + ' моль · Cм = ' + this._calcState.cM.toFixed(3) + ' моль/л', W / 2, fY);
+ }
+
+ /* ── Dilution visualization ── */
+ _drawDilViz() {
+ var cv = this._canvas;
+ var ctx = this._ctx2d;
+ var W = cv.width / (window.devicePixelRatio || 1);
+ var H = cv.height / (window.devicePixelRatio || 1);
+ ctx.clearRect(0, 0, W, H);
+ if (!this._dilState) return;
+
+ var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7);
+ bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A');
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
+
+ var d = this._dil;
+ var st = this._dilState;
+
+ var bH = Math.min(H * 0.65, 280);
+ var bW = 100;
+ var gap = 60;
+ var totalW = bW * 2 + gap + 80;
+ var startX = (W - totalW) / 2;
+ var bY = H * 0.12;
+
+ function drawSimpleBeaker(cx, cy, bw, bh, fill, color, label, omegaLabel) {
+ // glass
+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
+ ctx.lineWidth = 2;
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx, cy); ctx.lineTo(cx + bw, cy);
+ ctx.lineTo(cx + bw, cy + bh); ctx.lineTo(cx, cy + bh);
+ ctx.closePath(); ctx.stroke();
+
+ // liquid
+ var lh = Math.max(4, bh * fill);
+ var ly = cy + bh - lh;
+ ctx.save();
+ ctx.beginPath(); ctx.rect(cx + 1, cy + 1, bw - 2, bh - 2); ctx.clip();
+ var g = ctx.createLinearGradient(cx, ly, cx + bw, cy + bh);
+ g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '77');
+ ctx.fillStyle = g;
+ ctx.fillRect(cx + 1, ly, bw - 2, lh); ctx.restore();
+
+ // omega text
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
+ ctx.font = 'bold 14px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(omegaLabel, cx + bw / 2, ly + lh * 0.45);
+
+ // label
+ ctx.font = '12px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillText(label, cx + bw / 2, cy + bh + 20);
+ }
+
+ // Original beaker
+ var fill1 = Math.min(0.9, Math.max(0.1, d.m1 / 500));
+ drawSimpleBeaker(startX, bY, bW, bH, fill1, '#4CC9F0', 'Исходный', d.omega1.toFixed(1) + '%');
+
+ // Arrow + "добавили воды"
+ var arrowX = startX + bW + 10;
+ var arrowY = bY + bH / 2;
+ ctx.strokeStyle = 'rgba(6,214,224,0.7)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(arrowX, arrowY); ctx.lineTo(arrowX + gap - 10, arrowY); ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(arrowX + gap - 10, arrowY - 6);
+ ctx.lineTo(arrowX + gap, arrowY);
+ ctx.lineTo(arrowX + gap - 10, arrowY + 6); ctx.stroke();
+ ctx.fillStyle = 'rgba(255,255,255,0.45)';
+ ctx.font = '10px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
+ ctx.fillText('+' + d.addWater + ' мл', arrowX + gap / 2, arrowY - 10);
+ ctx.fillText('H₂O', arrowX + gap / 2, arrowY + 20);
+
+ // New beaker (lighter color)
+ var colorIntensity = Math.max(0.15, st.omega2 / Math.max(1, d.omega1));
+ var r2 = Math.round(76 + (1 - colorIntensity) * 179);
+ var g2 = Math.round(201 + (1 - colorIntensity) * 54);
+ var b2 = Math.round(240 + 0);
+ var c2 = 'rgb(' + r2 + ',' + Math.min(255, g2) + ',' + Math.min(255, b2) + ')';
+ var fill2 = Math.min(0.9, Math.max(0.1, st.m_new / 600));
+ drawSimpleBeaker(startX + bW + gap, bY, bW, bH, fill2, c2, 'Разбавленный', st.omega2.toFixed(2) + '%');
+
+ // note
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center';
+ ctx.fillText('m_в = const = ' + (d.m1 * d.omega1 / 100).toFixed(1) + ' г', W / 2, bY + bH + 50);
+ }
+
+ /* ── Mixing visualization ── */
+ _drawMixViz() {
+ var cv = this._canvas;
+ var ctx = this._ctx2d;
+ var W = cv.width / (window.devicePixelRatio || 1);
+ var H = cv.height / (window.devicePixelRatio || 1);
+ ctx.clearRect(0, 0, W, H);
+ if (!this._mixState) return;
+
+ var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7);
+ bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A');
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
+
+ var x = this._mix;
+ var st = this._mixState;
+
+ var bH = Math.min(H * 0.55, 240);
+ var bW = 90;
+ var bY = H * 0.1;
+ var totalW3 = bW * 3 + 140;
+ var sx = (W - totalW3) / 2;
+
+ function drawBk(bx, by, bw, bh, fill, color, lbl, pct) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.3)';
+ ctx.lineWidth = 2; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by);
+ ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh);
+ ctx.closePath(); ctx.stroke();
+ var lh = Math.max(4, bh * fill);
+ var ly = by + bh - lh;
+ ctx.save(); ctx.beginPath(); ctx.rect(bx+1, by+1, bw-2, bh-2); ctx.clip();
+ var g = ctx.createLinearGradient(bx, ly, bx + bw, by + bh);
+ g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '66');
+ ctx.fillStyle = g; ctx.fillRect(bx+1, ly, bw-2, lh); ctx.restore();
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
+ ctx.font = 'bold 13px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(pct, bx + bw/2, ly + lh * 0.45);
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillText(lbl, bx + bw/2, by + bh + 18);
+ }
+
+ var fill1 = Math.min(0.88, Math.max(0.08, x.m1 / 500));
+ var fill2 = Math.min(0.88, Math.max(0.08, x.m2 / 500));
+ var fill3 = Math.min(0.88, Math.max(0.08, st.m3 / 700));
+ drawBk(sx, bY, bW, bH, fill1, '#4CC9F0', 'Р-р 1', x.omega1.toFixed(1) + '%');
+
+ // + sign
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.font = 'bold 24px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText('+', sx + bW + 28, bY + bH / 2);
+
+ drawBk(sx + bW + 55, bY, bW, bH, fill2, '#FFD166', 'Р-р 2', x.omega2.toFixed(1) + '%');
+
+ // = sign
+ ctx.font = 'bold 24px Manrope,sans-serif';
+ ctx.fillStyle = 'rgba(255,255,255,0.5)';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText('=', sx + bW * 2 + 55 + 30, bY + bH / 2);
+
+ // mixed color
+ var r0 = 76, g0 = 201, b0 = 240; // #4CC9F0
+ var r1x = 255, g1x = 209, b1x = 102; // #FFD166
+ var t = x.m1 / Math.max(1, x.m1 + x.m2);
+ var mixR = Math.round(r0 * t + r1x * (1 - t));
+ var mixG = Math.round(g0 * t + g1x * (1 - t));
+ var mixB = Math.round(b0 * t + b1x * (1 - t));
+ var mixColor = 'rgb(' + mixR + ',' + mixG + ',' + mixB + ')';
+ drawBk(sx + bW * 2 + 55 + 55, bY, bW, bH, fill3, mixColor, 'Смесь', st.omega3.toFixed(2) + '%');
+
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
+ ctx.fillText('m₃ = ' + st.m3.toFixed(0) + ' г · ω₃ = ' + st.omega3.toFixed(3) + '%', W / 2, bY + bH + 46);
+ }
+
+ /* ── Solubility curve chart ── */
+ _drawSolubility() {
+ var cv = this._canvas;
+ var ctx = this._ctx2d;
+ var W = cv.width / (window.devicePixelRatio || 1);
+ var H = cv.height / (window.devicePixelRatio || 1);
+ ctx.clearRect(0, 0, W, H);
+
+ var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7);
+ bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A');
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
+
+ var pad = { l: 56, r: 24, t: 20, b: 46 };
+ var cW = W - pad.l - pad.r;
+ var cH = H - pad.t - pad.b;
+ var temps = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+ var maxS = 0;
+ SolutionsSim.SOLUBILITY.forEach(function(s, i) {
+ if (this._solEnabled.has(i)) {
+ s.data.forEach(function(v) { if (v > maxS) maxS = v; });
+ }
+ }.bind(this));
+ maxS = maxS > 0 ? Math.ceil(maxS / 50) * 50 : 250;
+
+ function toX(T) { return pad.l + (T / 100) * cW; }
+ function toY(S) { return pad.t + cH - (S / maxS) * cH; }
+
+ // Grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.06)';
+ ctx.lineWidth = 1;
+ for (var gs = 0; gs <= maxS; gs += 50) {
+ ctx.beginPath(); ctx.moveTo(pad.l, toY(gs)); ctx.lineTo(pad.l + cW, toY(gs)); ctx.stroke();
+ }
+ for (var gt = 0; gt <= 100; gt += 20) {
+ ctx.beginPath(); ctx.moveTo(toX(gt), pad.t); ctx.lineTo(toX(gt), pad.t + cH); ctx.stroke();
+ }
+
+ // Axes
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + cH); ctx.lineTo(pad.l + cW, pad.t + cH); ctx.stroke();
+
+ // Axis labels
+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
+ ctx.font = '10px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
+ temps.forEach(function(t) {
+ if (t % 20 === 0) ctx.fillText(t + '°', toX(t), pad.t + cH + 6);
+ });
+ ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
+ for (var ys = 0; ys <= maxS; ys += 50) {
+ ctx.fillText(ys, pad.l - 6, toY(ys));
+ }
+
+ ctx.save();
+ ctx.translate(pad.l - 40, pad.t + cH / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.fillText('S, г / 100г H₂O', 0, 0);
+ ctx.restore();
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
+ ctx.font = '11px Manrope,sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
+ ctx.fillText('T, °C', pad.l + cW / 2, H - 4);
+
+ // Curves
+ SolutionsSim.SOLUBILITY.forEach(function(s, idx) {
+ if (!this._solEnabled.has(idx)) return;
+ ctx.strokeStyle = s.color;
+ ctx.lineWidth = 2.2;
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ s.data.forEach(function(val, i) {
+ var px = toX(temps[i]);
+ var py = toY(val);
+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
+ });
+ ctx.stroke();
+
+ // End label
+ var last = s.data[s.data.length - 1];
+ var lx = toX(100) + 4;
+ var ly = toY(last);
+ ctx.fillStyle = s.color;
+ ctx.font = 'bold 10px Manrope,sans-serif';
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
+ ctx.fillText(s.label, lx, ly);
+ }.bind(this));
+
+ // Current temperature line + dots
+ var Tcur = this._solT;
+ ctx.strokeStyle = '#F15BB5';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([5, 3]);
+ ctx.beginPath(); ctx.moveTo(toX(Tcur), pad.t); ctx.lineTo(toX(Tcur), pad.t + cH); ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Intersection dots + values
+ SolutionsSim.SOLUBILITY.forEach(function(s, idx) {
+ if (!this._solEnabled.has(idx)) return;
+ // interpolate S at Tcur
+ var ti = Tcur / 10;
+ var lo = Math.floor(ti);
+ var hi = Math.min(lo + 1, 10);
+ var frac = ti - lo;
+ var Sval = s.data[lo] * (1 - frac) + s.data[hi] * frac;
+
+ var dotX = toX(Tcur);
+ var dotY = toY(Sval);
+ ctx.beginPath(); ctx.arc(dotX, dotY, 5, 0, Math.PI * 2);
+ ctx.fillStyle = s.color; ctx.fill();
+ ctx.strokeStyle = '#0D0D1A'; ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ // value popup
+ ctx.fillStyle = s.color;
+ ctx.font = 'bold 10px Manrope,sans-serif';
+ ctx.textAlign = dotX > W * 0.6 ? 'right' : 'left';
+ ctx.textBaseline = 'middle';
+ var offset = dotX > W * 0.6 ? -8 : 8;
+ ctx.fillText(Sval.toFixed(1), dotX + offset, dotY - 10);
+ }.bind(this));
+
+ ctx.textAlign = 'left';
+ }
+
+ /* ── stop for closeSim() ── */
+ stop() {
+ if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
+ if (this._ro && this._canvas.parentElement) { this._ro.unobserve(this._canvas); }
+ }
+
+ /* ── fit ── */
+ fit() {
+ this._fitCanvas();
+ this._drawViz();
+ }
+}
+
+if (typeof module !== 'undefined') module.exports = SolutionsSim;
+
+/* ─── lab UI init ─────────────────────────────────── */
+var _solutionsSim = null;
+
+function _openSolutions() {
+ document.getElementById('sim-topbar-title').textContent = 'Растворы';
+ _simShow('sim-solutions');
+
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ var container = document.getElementById('solutions-wrap');
+ if (!_solutionsSim) {
+ _solutionsSim = new SolutionsSim(container);
+ } else {
+ _solutionsSim.fit();
+ }
+ });
+ });
+}
diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js
index 5d7be55..cddd015 100644
--- a/frontend/js/labs/titration.js
+++ b/frontend/js/labs/titration.js
@@ -327,6 +327,11 @@ class TitrationSim {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
+ /* desk surface behind glassware */
+ if (window.ChemVisuals) {
+ ChemVisuals.drawDeskBackground(ctx, simW, H, H * 0.90);
+ }
+
/* divider */
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke();
@@ -337,6 +342,13 @@ class TitrationSim {
this._drawParticles(ctx);
this._drawOverlay(ctx);
this._drawPHCurve(ctx, simW, W, H);
+
+ /* pH strip indicator on the right of flask */
+ if (window.ChemVisuals) {
+ const pH = this._calcPH(this.baseAdded);
+ ChemVisuals.drawPHStrip(ctx, simW * 0.72, H * 0.24, pH);
+ }
+
if (window.LabFX) LabFX.particles.draw(ctx);
}
diff --git a/frontend/lab.html b/frontend/lab.html
index 34827b3..ba7ebba 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -4169,6 +4169,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4281,6 +4299,7 @@
+
@@ -4319,6 +4338,10 @@
+
+
+
+