ea2526dc73
4 НОВЫЕ СИМЫ (школьная программа 8-11 классов): Органика (organic.js, 1545 строк): - Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds - Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат - IUPAC-имена для C1-C10 - Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл - 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂ Периодическая таблица (periodic.js, 118 элементов): - Стандартный вид 18×9 + лантаноиды/актиноиды - Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип - Боровская модель электронных оболочек (анимированная) - Подсветка: 11 типов / s/p/d/f-блоки / без подсветки - Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип) - Поиск по символу/имени/Z/массе Качественный анализ (qualanalysis.js, 24 иона): - 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH - 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO - 9 реактивов + пламя - 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений - Анимация капли, осадка с цветом, газовых пузырей, пламени Растворы (solutions.js, 4 режима): - Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта - Разбавление с before/after визуализацией - Смешивание двух растворов с правилом рычага - Кривые растворимости 8 веществ + задача перекристаллизации - 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...) ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file): 12 функций школьной лабораторной графики: - drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой - drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя - animateGasBubbles / animatePrecipitateFall — анимация продуктов - drawProductLabel — fade-in/out стрелка ↑/↓ с подписью - drawEduTooltip — bubble с пояснением реакции - drawDeskBackground / drawVesselShadow — лабораторный фон - drawPHStrip — pH-индикаторная полоса с маркером Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов, educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask. pH-полоса в titration. Каталог теперь: 39 симуляций (было 35 + 4 новых). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1063 lines
58 KiB
JavaScript
1063 lines
58 KiB
JavaScript
'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 = `
|
|
<button id="qa-btn-identify" class="qa-mode-btn" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF;font-size:.77rem;font-weight:700;cursor:pointer">Определить ион</button>
|
|
<button id="qa-btn-unknown" class="qa-mode-btn" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa;font-size:.77rem;font-weight:700;cursor:pointer">Неизвестное вещество</button>
|
|
<div style="flex:1"></div>
|
|
<span style="font-size:.75rem;color:#888">Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span></span>
|
|
<button id="qa-btn-new" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(6,214,224,0.4);background:rgba(6,214,224,0.1);color:#06D6E0;font-size:.75rem;font-weight:700;cursor:pointer">Новый вопрос</button>`;
|
|
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 = '<div style="font-size:.7rem;color:#888;margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Реагенты</div>';
|
|
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 = '<div style="font-size:.7rem;color:#888;margin-bottom:5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Наблюдения</div>';
|
|
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 = `
|
|
<span id="qa-question" style="font-size:.8rem;color:#B0A0FF;flex:1">Добавляй реагенты и определи ион в пробирке</span>
|
|
<select id="qa-answer-sel" style="padding:5px 9px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:#1a1a2e;color:#E0E0FF;font-size:.78rem;cursor:pointer"></select>
|
|
<button id="qa-submit" style="padding:5px 13px;border-radius:8px;border:none;background:linear-gradient(135deg,#9B5DE5,#06D6E0);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer">Ответить</button>
|
|
<div id="qa-verdict" style="display:none;font-size:.8rem;font-weight:700;padding:4px 10px;border-radius:8px"></div>`;
|
|
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 = '<div style="font-size:.7rem;color:#888;margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Список ионов</div>';
|
|
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 = '<option value="">— выберите ион —</option>';
|
|
['Катионы','Анионы'].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 = `<span style="color:${col};font-weight:700">${_esc(entry.reagent)}</span>: ${_esc(entry.obs)}${entry.excess ? `<div style="color:#FFD166;font-size:.67rem;margin-top:2px">${_esc(entry.excess)}</div>` : ''}`;
|
|
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,'<').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();
|
|
}
|
|
}));
|
|
}
|