9aa8c76932
- Стехиометрия → 4-шаговый wizard (Реакция → Количества → Лимит → Продукты), KaTeX в displayMode, крупные карточки - Качественные реакции → центрированная сцена с большой пробиркой, журнал справа 290px, нижняя полка реагентов, убран список ионов - Контраст: основной текст rgba(.92), вторичный (.7), шрифты от .85rem
1311 lines
61 KiB
JavaScript
1311 lines
61 KiB
JavaScript
'use strict';
|
|
/* ════════════════════════════════════════════════════════════════════
|
|
QualAnalysisSim — Качественный анализ катионов и анионов
|
|
Редизайн: centered tube, large log panel, reagent shelf bottom
|
|
════════════════════════════════════════════════════════════════════ */
|
|
|
|
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';
|
|
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._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',
|
|
].join(';');
|
|
|
|
/* ── TOP BAR ── */
|
|
const tb = document.createElement('div');
|
|
tb.style.cssText = [
|
|
'display:flex',
|
|
'align-items:center',
|
|
'gap:10px',
|
|
'padding:10px 16px',
|
|
'border-bottom:1px solid rgba(255,255,255,0.09)',
|
|
'flex-shrink:0',
|
|
'flex-wrap:wrap',
|
|
'background:rgba(255,255,255,0.02)',
|
|
].join(';');
|
|
|
|
/* mode tabs */
|
|
const tabsWrap = document.createElement('div');
|
|
tabsWrap.style.cssText = 'display:flex;gap:6px';
|
|
|
|
const makeTab = (id, text) => {
|
|
const b = document.createElement('button');
|
|
b.id = id;
|
|
b.textContent = text;
|
|
b.style.cssText = [
|
|
'padding:8px 20px',
|
|
'border-radius:10px',
|
|
'font-size:1rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
'transition:all .15s',
|
|
'border:1px solid rgba(255,255,255,0.12)',
|
|
'background:rgba(255,255,255,0.04)',
|
|
'color:rgba(255,255,255,0.55)',
|
|
].join(';');
|
|
return b;
|
|
};
|
|
|
|
const btnIdentify = makeTab('qa-btn-identify', 'Определить ион');
|
|
const btnUnknown = makeTab('qa-btn-unknown', 'Неизвестное вещество');
|
|
tabsWrap.appendChild(btnIdentify);
|
|
tabsWrap.appendChild(btnUnknown);
|
|
tb.appendChild(tabsWrap);
|
|
|
|
const spacer = document.createElement('div');
|
|
spacer.style.cssText = 'flex:1';
|
|
tb.appendChild(spacer);
|
|
|
|
/* score */
|
|
const scoreWrap = document.createElement('span');
|
|
scoreWrap.style.cssText = 'font-size:1rem;color:rgba(255,255,255,0.7);font-weight:600';
|
|
scoreWrap.innerHTML = 'Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span>';
|
|
tb.appendChild(scoreWrap);
|
|
|
|
/* new question button */
|
|
const btnNew = document.createElement('button');
|
|
btnNew.id = 'qa-btn-new';
|
|
btnNew.textContent = 'Новый вопрос';
|
|
btnNew.style.cssText = [
|
|
'padding:8px 18px',
|
|
'border-radius:10px',
|
|
'border:1px solid rgba(6,214,224,0.45)',
|
|
'background:rgba(6,214,224,0.1)',
|
|
'color:#06D6E0',
|
|
'font-size:.95rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
'transition:background .15s',
|
|
].join(';');
|
|
tb.appendChild(btnNew);
|
|
this._container.appendChild(tb);
|
|
|
|
/* ── MAIN ROW: scene + log ── */
|
|
const mainRow = document.createElement('div');
|
|
mainRow.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden';
|
|
this._container.appendChild(mainRow);
|
|
|
|
/* CENTER SCENE */
|
|
const scene = document.createElement('div');
|
|
scene.style.cssText = [
|
|
'flex:1',
|
|
'display:flex',
|
|
'flex-direction:column',
|
|
'min-width:0',
|
|
'overflow:hidden',
|
|
'position:relative',
|
|
].join(';');
|
|
mainRow.appendChild(scene);
|
|
|
|
/* canvas wrapper — takes all available height */
|
|
const canvasWrap = document.createElement('div');
|
|
canvasWrap.style.cssText = 'flex:1;position:relative;min-height:0;overflow:hidden';
|
|
scene.appendChild(canvasWrap);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.id = 'qa-canvas';
|
|
canvas.style.cssText = 'display:block;width:100%;height:100%;cursor:crosshair';
|
|
canvasWrap.appendChild(canvas);
|
|
this._canvas = canvas;
|
|
this._ctx = canvas.getContext('2d');
|
|
|
|
/* answer card inside scene, below canvas area */
|
|
const ansCard = document.createElement('div');
|
|
ansCard.id = 'qa-anscard';
|
|
ansCard.style.cssText = [
|
|
'flex-shrink:0',
|
|
'padding:10px 16px',
|
|
'border-top:1px solid rgba(255,255,255,0.08)',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'gap:10px',
|
|
'flex-wrap:wrap',
|
|
'background:rgba(255,255,255,0.025)',
|
|
].join(';');
|
|
ansCard.innerHTML = [
|
|
'<span id="qa-question" style="font-size:.95rem;color:#B0A0FF;flex:1;min-width:160px">',
|
|
'Добавляй реагенты и определи ион в пробирке',
|
|
'</span>',
|
|
'<select id="qa-answer-sel" style="',
|
|
'padding:7px 12px;',
|
|
'border-radius:9px;',
|
|
'border:1px solid rgba(255,255,255,0.18);',
|
|
'background:#1a1a2e;',
|
|
'color:#E0E0FF;',
|
|
'font-size:.92rem;',
|
|
'cursor:pointer;',
|
|
'outline:none',
|
|
'"></select>',
|
|
'<button id="qa-submit" style="',
|
|
'padding:7px 18px;',
|
|
'border-radius:9px;',
|
|
'border:none;',
|
|
'background:linear-gradient(135deg,#9B5DE5,#06D6E0);',
|
|
'color:#fff;',
|
|
'font-size:.92rem;',
|
|
'font-weight:700;',
|
|
'cursor:pointer',
|
|
'">Ответить</button>',
|
|
'<div id="qa-verdict" style="',
|
|
'display:none;',
|
|
'font-size:.92rem;',
|
|
'font-weight:700;',
|
|
'padding:6px 14px;',
|
|
'border-radius:9px',
|
|
'"></div>',
|
|
].join('');
|
|
scene.appendChild(ansCard);
|
|
|
|
/* RIGHT PANEL — log */
|
|
const logPanel = document.createElement('div');
|
|
logPanel.style.cssText = [
|
|
'width:290px',
|
|
'flex-shrink:0',
|
|
'border-left:1px solid rgba(255,255,255,0.08)',
|
|
'display:flex',
|
|
'flex-direction:column',
|
|
'overflow:hidden',
|
|
'background:rgba(255,255,255,0.015)',
|
|
].join(';');
|
|
mainRow.appendChild(logPanel);
|
|
|
|
const logHeader = document.createElement('div');
|
|
logHeader.style.cssText = [
|
|
'padding:12px 14px 8px',
|
|
'font-size:.82rem',
|
|
'font-weight:700',
|
|
'text-transform:uppercase',
|
|
'letter-spacing:.07em',
|
|
'color:rgba(255,255,255,0.55)',
|
|
'border-bottom:1px solid rgba(255,255,255,0.07)',
|
|
'flex-shrink:0',
|
|
].join(';');
|
|
logHeader.textContent = 'Журнал наблюдений';
|
|
logPanel.appendChild(logHeader);
|
|
|
|
const logScroll = document.createElement('div');
|
|
logScroll.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px';
|
|
logPanel.appendChild(logScroll);
|
|
|
|
const logList = document.createElement('div');
|
|
logList.id = 'qa-log';
|
|
logList.style.cssText = 'display:flex;flex-direction:column;gap:6px';
|
|
logScroll.appendChild(logList);
|
|
|
|
/* empty state hint */
|
|
const logHint = document.createElement('div');
|
|
logHint.id = 'qa-log-hint';
|
|
logHint.style.cssText = [
|
|
'font-size:.88rem',
|
|
'color:rgba(255,255,255,0.28)',
|
|
'text-align:center',
|
|
'margin-top:20px',
|
|
'padding:0 10px',
|
|
'line-height:1.5',
|
|
].join(';');
|
|
logHint.textContent = 'Нажимай реагенты снизу — здесь появятся результаты реакций';
|
|
logScroll.appendChild(logHint);
|
|
|
|
/* ── REAGENT SHELF (bottom) ── */
|
|
const shelf = document.createElement('div');
|
|
shelf.id = 'qa-shelf';
|
|
shelf.style.cssText = [
|
|
'flex-shrink:0',
|
|
'border-top:1px solid rgba(255,255,255,0.09)',
|
|
'padding:10px 14px',
|
|
'background:rgba(255,255,255,0.025)',
|
|
'display:flex',
|
|
'flex-wrap:wrap',
|
|
'gap:7px',
|
|
'align-items:center',
|
|
].join(';');
|
|
|
|
const shelfLabel = document.createElement('span');
|
|
shelfLabel.style.cssText = [
|
|
'font-size:.75rem',
|
|
'font-weight:700',
|
|
'text-transform:uppercase',
|
|
'letter-spacing:.06em',
|
|
'color:rgba(255,255,255,0.4)',
|
|
'margin-right:4px',
|
|
'flex-shrink:0',
|
|
].join(';');
|
|
shelfLabel.textContent = 'Реагенты:';
|
|
shelf.appendChild(shelfLabel);
|
|
|
|
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:7px 14px',
|
|
'border-radius:9px',
|
|
`border:1px solid ${r.color}55`,
|
|
`background:${r.color}18`,
|
|
`color:${r.color}`,
|
|
'font-size:.95rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
'transition:background .15s, transform .1s',
|
|
'min-width:60px',
|
|
'text-align:center',
|
|
].join(';');
|
|
btn.textContent = r.label;
|
|
btn.addEventListener('mouseenter', () => {
|
|
btn.style.background = r.color + '33';
|
|
btn.style.transform = 'translateY(-1px)';
|
|
});
|
|
btn.addEventListener('mouseleave', () => {
|
|
btn.style.background = r.color + '18';
|
|
btn.style.transform = '';
|
|
});
|
|
shelf.appendChild(btn);
|
|
});
|
|
this._container.appendChild(shelf);
|
|
|
|
this._resizeFit();
|
|
}
|
|
|
|
_resizeFit() {
|
|
const c = this._canvas;
|
|
const p = c.parentElement;
|
|
if (!p) return;
|
|
const rect = p.getBoundingClientRect();
|
|
const w = rect.width || p.clientWidth || 400;
|
|
const h = rect.height || p.clientHeight || 360;
|
|
c.width = Math.round(w);
|
|
c.height = Math.round(h);
|
|
this._W = c.width;
|
|
this._H = c.height;
|
|
this._drawTube();
|
|
}
|
|
|
|
/* ── Tab highlight ───────────────────────────────────────────── */
|
|
_highlightMode(mode) {
|
|
const bi = document.getElementById('qa-btn-identify');
|
|
const bu = document.getElementById('qa-btn-unknown');
|
|
const activeStyle = [
|
|
'border:1px solid rgba(155,93,229,0.65)',
|
|
'background:rgba(155,93,229,0.2)',
|
|
'color:#D0A0FF',
|
|
].join(';');
|
|
const inactiveStyle = [
|
|
'border:1px solid rgba(255,255,255,0.12)',
|
|
'background:rgba(255,255,255,0.04)',
|
|
'color:rgba(255,255,255,0.55)',
|
|
].join(';');
|
|
/* reapply only the color/border parts by toggling a known block */
|
|
[bi, bu].forEach(btn => {
|
|
const isActive = (btn === bi && mode === 'identify') || (btn === bu && mode === 'unknown');
|
|
btn.style.border = isActive ? '1px solid rgba(155,93,229,0.65)' : '1px solid rgba(255,255,255,0.12)';
|
|
btn.style.background = isActive ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)';
|
|
btn.style.color = isActive ? '#D0A0FF' : 'rgba(255,255,255,0.55)';
|
|
});
|
|
}
|
|
|
|
/* ── Events ──────────────────────────────────────────────────── */
|
|
_bindEvents() {
|
|
const $ = id => document.getElementById(id);
|
|
|
|
$('qa-btn-identify').addEventListener('click', () => this._startMode('identify'));
|
|
$('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown'));
|
|
$('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));
|
|
$('qa-submit').addEventListener('click', () => this._submitAnswer());
|
|
|
|
this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (this._answered) return;
|
|
this._applyReagent(btn.dataset.reagent);
|
|
});
|
|
btn.setAttribute('draggable', 'true');
|
|
btn.addEventListener('dragstart', e => {
|
|
this._dragReagent = btn.dataset.reagent;
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
});
|
|
|
|
this._canvas.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
this._canvas.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
if (this._dragReagent && !this._answered) {
|
|
this._applyReagent(this._dragReagent);
|
|
this._dragReagent = null;
|
|
}
|
|
});
|
|
|
|
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 = [];
|
|
this._dragReagent = null;
|
|
|
|
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 DOM */
|
|
document.getElementById('qa-log').innerHTML = '';
|
|
const hint = document.getElementById('qa-log-hint');
|
|
if (hint) hint.style.display = '';
|
|
document.getElementById('qa-verdict').style.display = 'none';
|
|
document.getElementById('qa-verdict').textContent = '';
|
|
this._highlightMode(mode);
|
|
this._updateAnswerSelect();
|
|
this._populateQuestion(mode);
|
|
|
|
this._drawTube();
|
|
if (!this._raf) this._animLoop(performance.now());
|
|
}
|
|
|
|
_populateQuestion(mode) {
|
|
const q = document.getElementById('qa-question');
|
|
if (mode === 'identify') {
|
|
q.textContent = 'Добавляй реагенты — определи ион в пробирке, выбери ответ и нажми «Ответить»';
|
|
} else {
|
|
q.textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь';
|
|
}
|
|
}
|
|
|
|
_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;
|
|
|
|
/* sounds */
|
|
if (window.LabFX) {
|
|
LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : '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;
|
|
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 — from top center */
|
|
this._dropAnim.push({
|
|
x: this._W * 0.5,
|
|
y: 20,
|
|
vy: 2.5,
|
|
color: rInfo ? rInfo.color : '#FFF',
|
|
alpha: 1,
|
|
done: false,
|
|
});
|
|
|
|
/* log */
|
|
const entry = { reagent: rLabel, obs: rxn.obs, positive: rxn.positive, excess: rxn.excess || null };
|
|
this._log.push(entry);
|
|
this._renderLogEntry(entry);
|
|
}
|
|
|
|
_spawnPrecipParticles(color) {
|
|
const cx = this._W * 0.5;
|
|
const cy = this._H * 0.55;
|
|
const ps = [];
|
|
for (let i = 0; i < 32; i++) {
|
|
ps.push({
|
|
x: cx + (Math.random() - 0.5) * 80,
|
|
y: cy,
|
|
vy: 0.6 + Math.random() * 2,
|
|
vx: (Math.random() - 0.5) * 2,
|
|
color,
|
|
r: 3 + Math.random() * 4,
|
|
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 < 26; i++) {
|
|
ps.push({
|
|
x: cx + (Math.random() - 0.5) * 50,
|
|
y: cy,
|
|
vy: -(1 + Math.random() * 1.8),
|
|
vx: (Math.random() - 0.5) * 1.2,
|
|
color,
|
|
r: 4 + Math.random() * 5,
|
|
alpha: 0.9,
|
|
done: false,
|
|
});
|
|
}
|
|
return ps;
|
|
}
|
|
|
|
/* ── Log rendering ───────────────────────────────────────────── */
|
|
_renderLogEntry(entry) {
|
|
const hint = document.getElementById('qa-log-hint');
|
|
if (hint) hint.style.display = 'none';
|
|
|
|
const log = document.getElementById('qa-log');
|
|
const n = log.children.length + 1;
|
|
const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.4)';
|
|
|
|
const card = document.createElement('div');
|
|
card.style.cssText = [
|
|
'padding:9px 11px',
|
|
'border-radius:10px',
|
|
`border-left:3px solid ${col}`,
|
|
'background:rgba(255,255,255,0.04)',
|
|
'border-top:1px solid rgba(255,255,255,0.07)',
|
|
'border-right:1px solid rgba(255,255,255,0.07)',
|
|
'border-bottom:1px solid rgba(255,255,255,0.07)',
|
|
'display:flex',
|
|
'flex-direction:column',
|
|
'gap:3px',
|
|
].join(';');
|
|
|
|
/* reagent row */
|
|
const topRow = document.createElement('div');
|
|
topRow.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:.9rem';
|
|
|
|
const numSpan = document.createElement('span');
|
|
numSpan.style.cssText = 'font-size:.78rem;color:rgba(255,255,255,0.35);font-weight:600;min-width:16px';
|
|
numSpan.textContent = n + '.';
|
|
|
|
const reagentSpan = document.createElement('span');
|
|
/* find color for this reagent */
|
|
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent);
|
|
const rColor = rInfo ? rInfo.color : '#AAA';
|
|
reagentSpan.style.cssText = `color:${rColor};font-weight:700;font-size:.92rem`;
|
|
reagentSpan.textContent = _esc(entry.reagent);
|
|
|
|
const arrowSpan = document.createElement('span');
|
|
arrowSpan.style.cssText = 'color:rgba(255,255,255,0.35);font-size:.85rem';
|
|
arrowSpan.textContent = '→';
|
|
|
|
const dotSpan = document.createElement('span');
|
|
dotSpan.style.cssText = `width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0;margin-left:auto`;
|
|
|
|
topRow.appendChild(numSpan);
|
|
topRow.appendChild(reagentSpan);
|
|
topRow.appendChild(arrowSpan);
|
|
topRow.appendChild(dotSpan);
|
|
card.appendChild(topRow);
|
|
|
|
/* observation text */
|
|
const obsDiv = document.createElement('div');
|
|
obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.8);line-height:1.45;padding-left:23px';
|
|
obsDiv.textContent = entry.obs;
|
|
card.appendChild(obsDiv);
|
|
|
|
/* excess note */
|
|
if (entry.excess) {
|
|
const exDiv = document.createElement('div');
|
|
exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;padding-left:23px;margin-top:1px';
|
|
exDiv.textContent = entry.excess;
|
|
card.appendChild(exDiv);
|
|
}
|
|
|
|
log.appendChild(card);
|
|
log.parentElement.scrollTop = log.parentElement.scrollHeight;
|
|
}
|
|
|
|
/* ── 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.35)';
|
|
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.35)';
|
|
}
|
|
}
|
|
|
|
/* ── Animation loop ──────────────────────────────────────────── */
|
|
_animLoop(t) {
|
|
this._raf = requestAnimationFrame(ts => this._animLoop(ts));
|
|
const dt = Math.min((t - this._lastT) / 1000, 0.05);
|
|
this._lastT = t;
|
|
|
|
let needDraw = false;
|
|
|
|
this._dropAnim = this._dropAnim.filter(d => {
|
|
if (d.done) return false;
|
|
d.y += d.vy;
|
|
d.vy += 0.25;
|
|
const floor = this._H * 0.52;
|
|
if (d.y > floor) { 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.83;
|
|
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.5, 38);
|
|
}
|
|
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.6;
|
|
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 || 400;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
/* background */
|
|
ctx.fillStyle = '#0D0D1A';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
/* subtle bench surface */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.025)';
|
|
ctx.fillRect(0, H * 0.88, W, H * 0.12);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, H * 0.88);
|
|
ctx.lineTo(W, H * 0.88);
|
|
ctx.stroke();
|
|
|
|
/* tube dimensions — large, centered */
|
|
const tubeW = Math.min(120, W * 0.22);
|
|
const tx = W * 0.5 - tubeW * 0.5;
|
|
const tTop = H * 0.1;
|
|
const tBot = H * 0.86;
|
|
const tH = tBot - tTop;
|
|
const rBot = tubeW * 0.5; /* bottom arc radius */
|
|
|
|
/* ── flame halo ── */
|
|
if (this._tubeState.flameColor) {
|
|
const fc = this._tubeState.flameColor;
|
|
const gx = W * 0.5;
|
|
const gy = tTop - 30;
|
|
const gr = ctx.createRadialGradient(gx, gy, 6, gx, gy, 110);
|
|
gr.addColorStop(0, fc + 'DD');
|
|
gr.addColorStop(0.35, fc + '55');
|
|
gr.addColorStop(1, fc + '00');
|
|
ctx.fillStyle = gr;
|
|
ctx.beginPath();
|
|
ctx.arc(gx, gy, 110, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
/* flame label */
|
|
ctx.save();
|
|
ctx.font = 'bold 14px Manrope,sans-serif';
|
|
ctx.fillStyle = fc;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const flameLabel = (
|
|
fc === '#FFD700' ? 'Жёлтое пламя — Na⁺' :
|
|
fc === '#CC00FF' ? 'Фиолетовое — K⁺' :
|
|
fc === '#CC4400' ? 'Кирпично-красное — Ca²⁺' :
|
|
fc === '#00DD00' ? 'Зелёное — Ba²⁺' :
|
|
fc === '#00BB44' ? 'Зелёное пламя — Cu²⁺' :
|
|
'Окрашивание пламени'
|
|
);
|
|
ctx.fillText(flameLabel, W * 0.5, tTop - 55);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* solution fill path helper */
|
|
const tubePath = (fromY, toY) => {
|
|
const arcR = Math.min(rBot, (toY - fromY) * 0.5);
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx, fromY);
|
|
ctx.lineTo(tx, toY - arcR);
|
|
ctx.arcTo(tx, toY, tx + rBot, toY, arcR);
|
|
ctx.arcTo(tx + tubeW, toY, tx + tubeW, toY - arcR, arcR);
|
|
ctx.lineTo(tx + tubeW, fromY);
|
|
ctx.closePath();
|
|
};
|
|
|
|
/* solution */
|
|
const solTop = tTop + tH * 0.06;
|
|
const solBot = tBot;
|
|
ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)';
|
|
tubePath(solTop, solBot);
|
|
ctx.fill();
|
|
|
|
/* precipitate layer */
|
|
if (this._tubeState.precipColor && this._tubeState.precipH > 0) {
|
|
const ph = this._tubeState.precipH;
|
|
const py = solBot - ph;
|
|
ctx.globalAlpha = 0.88;
|
|
ctx.fillStyle = this._tubeState.precipColor;
|
|
tubePath(py, solBot);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
|
|
/* label when settled */
|
|
if (this._precipParticles.every(p => p.done)) {
|
|
const lastPrecip = [...this._log].reverse().find(l => {
|
|
const ri = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent);
|
|
if (!ri) return false;
|
|
const rx2 = this._targetIon.reactions[ri.id];
|
|
return rx2 && rx2.type === 'precip' && rx2.precipLabel;
|
|
});
|
|
if (lastPrecip) {
|
|
const ri = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent);
|
|
const rx2 = ri ? this._targetIon.reactions[ri.id] : null;
|
|
if (rx2 && rx2.precipLabel) {
|
|
ctx.save();
|
|
ctx.font = 'bold 13px Manrope,sans-serif';
|
|
const labelCol = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor;
|
|
ctx.fillStyle = labelCol;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(rx2.precipLabel, W * 0.5, solBot - ph * 0.5);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* drop particles */
|
|
this._dropAnim.forEach(d => {
|
|
ctx.globalAlpha = d.alpha;
|
|
ctx.fillStyle = d.color;
|
|
ctx.beginPath();
|
|
ctx.arc(d.x, d.y, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = d.color + '77';
|
|
ctx.beginPath();
|
|
ctx.arc(d.x, d.y - 10, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
});
|
|
|
|
/* precipitate flying particles */
|
|
this._precipParticles.filter(p => !p.done).forEach(p => {
|
|
ctx.globalAlpha = 0.8;
|
|
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 = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
});
|
|
|
|
/* gas label */
|
|
if (this._tubeState.gasLabel) {
|
|
ctx.save();
|
|
ctx.font = 'bold 15px Manrope,sans-serif';
|
|
ctx.fillStyle = '#FFFFAA';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this._tubeState.gasLabel, tx + tubeW + 12, tTop + tH * 0.2);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ── tube glass outline ── */
|
|
ctx.save();
|
|
ctx.shadowColor = 'rgba(155,93,229,0.35)';
|
|
ctx.shadowBlur = 14;
|
|
ctx.strokeStyle = 'rgba(200,215,255,0.6)';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx, tTop);
|
|
ctx.lineTo(tx, tBot - rBot);
|
|
ctx.arcTo(tx, tBot, tx + rBot, tBot, rBot);
|
|
ctx.arcTo(tx + tubeW, tBot, tx + tubeW, tBot - rBot, rBot);
|
|
ctx.lineTo(tx + tubeW, tTop);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
/* rim */
|
|
ctx.strokeStyle = 'rgba(200,215,255,0.35)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx - 5, tTop);
|
|
ctx.lineTo(tx + tubeW + 5, tTop);
|
|
ctx.stroke();
|
|
|
|
/* glass shine */
|
|
const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.4, 0);
|
|
shine.addColorStop(0, 'rgba(255,255,255,0.12)');
|
|
shine.addColorStop(1, 'rgba(255,255,255,0)');
|
|
ctx.fillStyle = shine;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx + 4, tTop + 4);
|
|
ctx.lineTo(tx + tubeW * 0.32, tTop + 4);
|
|
ctx.lineTo(tx + tubeW * 0.32, tBot - tubeW * 0.25);
|
|
ctx.lineTo(tx + 4, tBot - tubeW * 0.25);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
/* mode watermark */
|
|
ctx.save();
|
|
ctx.font = '700 11px Manrope,sans-serif';
|
|
ctx.fillStyle = 'rgba(155,93,229,0.35)';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(
|
|
this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР',
|
|
W * 0.5,
|
|
H - 4
|
|
);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ── Public API ──────────────────────────────────────────────── */
|
|
stop() {
|
|
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
|
}
|
|
|
|
destroy() {
|
|
this.stop();
|
|
}
|
|
}
|
|
|
|
/* ── 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();
|
|
}
|
|
}));
|
|
}
|