be1e558be9
- Менделеев: clamp() для font-size символа элемента (2.4rem..4.4rem) + padding-top 28px → символ не обрезается на узких панелях - Качественные реакции: в Свободно/Тренировке Проб1-4 содержат известные ионы (видна подпись), в Тренировке Образец — отдельный неизвестный; в Экзамене можно переключаться между пробирками и ответить отдельно для каждой (verdict сохраняется) - Стехиометрия: непрерывный анимационный цикл — волна на поверхности жидкости, пузырьки в газах/растворах, пульсирующая красная рамка + ЛИМИТ-лейбл у лимитирующего реагента, искры вдоль стрелки реакции, glow на стрелке во время реакции
1783 lines
77 KiB
JavaScript
1783 lines
77 KiB
JavaScript
'use strict';
|
|
/* ════════════════════════════════════════════════════════════════════
|
|
QualAnalysisSim — Качественный анализ (Стол-лаборатория v3)
|
|
Layout: question-bar → 4 tubes + sample → reagent shelf → log → answer-bar
|
|
════════════════════════════════════════════════════════════════════ */
|
|
|
|
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 },
|
|
}
|
|
},
|
|
{
|
|
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 },
|
|
}
|
|
},
|
|
/* АНИОНЫ */
|
|
{
|
|
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' },
|
|
];
|
|
|
|
/* ── Tube count based on viewport ──────────────────────────────── */
|
|
static _tubeCount() {
|
|
return (typeof window !== 'undefined' && window.innerWidth < 600) ? 2 : 4;
|
|
}
|
|
|
|
/* ── constructor ─────────────────────────────────────────────── */
|
|
constructor(container) {
|
|
this._container = container;
|
|
this._mode = 'free'; // 'free' | 'train' | 'exam'
|
|
this._targetIon = null; // ион для «Тренировки» (один)
|
|
this._examIons = []; // ионы для «Экзамена» (по одному на пробирку)
|
|
this._log = []; // все записи журнала
|
|
this._answered = false;
|
|
this._score = 0;
|
|
this._scoreTotal = 0;
|
|
this._raf = null;
|
|
this._lastT = 0;
|
|
this._dragReagent = null;
|
|
this._activeTube = 0; // индекс активной пробирки (0-3)
|
|
this._helpVisible = false;
|
|
this._tubeCount = QualAnalysisSim._tubeCount();
|
|
|
|
/* per-tube state array (index 0..tubeCount-1) */
|
|
this._tubes = [];
|
|
this._tubeParticles = []; // { drops, precip, gas } per tube
|
|
this._tubeCanvas = null;
|
|
this._tubeCtx = null;
|
|
|
|
this._build();
|
|
this._bindEvents();
|
|
this._startMode('free');
|
|
}
|
|
|
|
/* ── Helpers ─────────────────────────────────────────────────── */
|
|
_makeTubeState(solColor) {
|
|
return {
|
|
solColor: solColor || 'rgba(100,180,255,0.15)',
|
|
precipColor: null,
|
|
precipH: 0,
|
|
gasLabel: null,
|
|
flameColor: null,
|
|
flameTimer: 0,
|
|
};
|
|
}
|
|
|
|
_makeTubeParticles() {
|
|
return { drops: [], precip: [], gas: [] };
|
|
}
|
|
|
|
_resetTubes() {
|
|
this._tubes = [];
|
|
this._tubeParticles = [];
|
|
for (let i = 0; i < this._tubeCount; i++) {
|
|
this._tubes.push(this._makeTubeState());
|
|
this._tubeParticles.push(this._makeTubeParticles());
|
|
}
|
|
/* sample tube (index = tubeCount) — always present in train/exam */
|
|
this._tubes.push(this._makeTubeState());
|
|
this._tubeParticles.push(this._makeTubeParticles());
|
|
}
|
|
|
|
/* ── 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',
|
|
'position:relative',
|
|
].join(';');
|
|
|
|
/* ── QUESTION BAR ── */
|
|
const qbar = document.createElement('div');
|
|
qbar.id = 'qa-qbar';
|
|
qbar.style.cssText = [
|
|
'flex-shrink:0',
|
|
'padding:10px 16px 8px',
|
|
'background:rgba(155,93,229,0.10)',
|
|
'border-bottom:1px solid rgba(155,93,229,0.22)',
|
|
'position:relative',
|
|
].join(';');
|
|
|
|
/* task text */
|
|
const taskText = document.createElement('div');
|
|
taskText.id = 'qa-task';
|
|
taskText.style.cssText = [
|
|
'font-size:1.05rem',
|
|
'font-weight:700',
|
|
'color:rgba(255,255,255,0.95)',
|
|
'margin-bottom:8px',
|
|
'line-height:1.4',
|
|
'padding-right:44px',
|
|
].join(';');
|
|
taskText.textContent = 'Выбери режим и начни эксперимент';
|
|
qbar.appendChild(taskText);
|
|
|
|
/* controls row */
|
|
const ctrlRow = document.createElement('div');
|
|
ctrlRow.style.cssText = 'display:flex;align-items:center;gap:12px;flex-wrap:wrap';
|
|
|
|
/* mode select */
|
|
const modeSel = document.createElement('select');
|
|
modeSel.id = 'qa-mode-sel';
|
|
modeSel.style.cssText = [
|
|
'padding:5px 10px',
|
|
'border-radius:8px',
|
|
'border:1px solid rgba(155,93,229,0.45)',
|
|
'background:#1a1a2e',
|
|
'color:#D0A0FF',
|
|
'font-size:.92rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
'outline:none',
|
|
].join(';');
|
|
[['free','Свободно'],['train','Тренировка'],['exam','Экзамен']].forEach(([v,t]) => {
|
|
const o = document.createElement('option');
|
|
o.value = v; o.textContent = t;
|
|
modeSel.appendChild(o);
|
|
});
|
|
ctrlRow.appendChild(modeSel);
|
|
|
|
/* score */
|
|
const scoreEl = document.createElement('span');
|
|
scoreEl.id = 'qa-score-wrap';
|
|
scoreEl.style.cssText = 'font-size:.95rem;color:rgba(255,255,255,0.65)';
|
|
scoreEl.innerHTML = 'Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span><span id="qa-score-total" style="color:rgba(255,255,255,0.45)"></span>';
|
|
ctrlRow.appendChild(scoreEl);
|
|
|
|
/* new task button */
|
|
const btnNew = document.createElement('button');
|
|
btnNew.id = 'qa-btn-new';
|
|
btnNew.textContent = 'Новая задача';
|
|
btnNew.style.cssText = [
|
|
'padding:5px 14px',
|
|
'border-radius:8px',
|
|
'border:1px solid rgba(6,214,224,0.5)',
|
|
'background:rgba(6,214,224,0.1)',
|
|
'color:#06D6E0',
|
|
'font-size:.92rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
].join(';');
|
|
ctrlRow.appendChild(btnNew);
|
|
|
|
qbar.appendChild(ctrlRow);
|
|
|
|
/* help button */
|
|
const btnHelp = document.createElement('button');
|
|
btnHelp.id = 'qa-btn-help';
|
|
btnHelp.title = 'Справка';
|
|
btnHelp.style.cssText = [
|
|
'position:absolute',
|
|
'top:10px',
|
|
'right:14px',
|
|
'width:30px',
|
|
'height:30px',
|
|
'border-radius:50%',
|
|
'border:1px solid rgba(255,255,255,0.25)',
|
|
'background:rgba(255,255,255,0.07)',
|
|
'color:rgba(255,255,255,0.75)',
|
|
'font-size:.95rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:center',
|
|
].join(';');
|
|
btnHelp.textContent = '?';
|
|
qbar.appendChild(btnHelp);
|
|
|
|
this._container.appendChild(qbar);
|
|
|
|
/* ── HELP POPOVER ── */
|
|
const helpPop = document.createElement('div');
|
|
helpPop.id = 'qa-help-pop';
|
|
helpPop.style.cssText = [
|
|
'display:none',
|
|
'position:absolute',
|
|
'top:90px',
|
|
'right:14px',
|
|
'z-index:100',
|
|
'width:280px',
|
|
'background:#1a1a2e',
|
|
'border:1px solid rgba(155,93,229,0.45)',
|
|
'border-radius:12px',
|
|
'padding:14px 16px',
|
|
'font-size:.88rem',
|
|
'color:rgba(255,255,255,0.85)',
|
|
'line-height:1.55',
|
|
'box-shadow:0 8px 32px rgba(0,0,0,0.6)',
|
|
].join(';');
|
|
helpPop.innerHTML = [
|
|
'<b style="font-size:.95rem;color:#D0A0FF">Как пользоваться</b>',
|
|
'<ul style="margin:8px 0 0;padding-left:18px;display:flex;flex-direction:column;gap:5px">',
|
|
'<li>Перетащи реагент в пробирку или нажми реагент (активная пробирка выделена синим)</li>',
|
|
'<li>Наблюдай реакцию на холсте и читай журнал</li>',
|
|
'<li><b>Свободно</b> — просто эксперименты, нет задания</li>',
|
|
'<li><b>Тренировка</b> — один неизвестный ион в Образце, определи его</li>',
|
|
'<li><b>Экзамен</b> — 4 неизвестных иона (по одному в каждой пробирке), без подсказок</li>',
|
|
'<li>Используй пробирки Проб1-4 как вспомогательные для сравнения</li>',
|
|
'</ul>',
|
|
].join('');
|
|
this._container.appendChild(helpPop);
|
|
|
|
/* ── SCENE (canvas) ── */
|
|
const scene = document.createElement('div');
|
|
scene.id = 'qa-scene';
|
|
scene.style.cssText = [
|
|
'flex:1',
|
|
'min-height:220px',
|
|
'position:relative',
|
|
'overflow:hidden',
|
|
].join(';');
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.id = 'qa-canvas';
|
|
canvas.style.cssText = 'display:block;width:100%;height:100%';
|
|
scene.appendChild(canvas);
|
|
this._container.appendChild(scene);
|
|
this._tubeCanvas = canvas;
|
|
this._tubeCtx = canvas.getContext('2d');
|
|
|
|
/* ── REAGENT SHELF ── */
|
|
const shelf = document.createElement('div');
|
|
shelf.id = 'qa-shelf';
|
|
shelf.style.cssText = [
|
|
'flex-shrink:0',
|
|
'padding:8px 14px',
|
|
'border-top:1px solid rgba(255,255,255,0.07)',
|
|
'border-bottom:1px solid rgba(255,255,255,0.07)',
|
|
'background:rgba(255,255,255,0.025)',
|
|
'display:flex',
|
|
'flex-wrap:wrap',
|
|
'gap:6px',
|
|
'align-items:center',
|
|
'min-height:52px',
|
|
].join(';');
|
|
|
|
const shelfLabel = document.createElement('span');
|
|
shelfLabel.style.cssText = [
|
|
'font-size:.78rem',
|
|
'font-weight:700',
|
|
'text-transform:uppercase',
|
|
'letter-spacing:.06em',
|
|
'color:rgba(255,255,255,0.5)',
|
|
'flex-shrink:0',
|
|
].join(';');
|
|
shelfLabel.textContent = 'Реагенты (drag в пробирку):';
|
|
shelf.appendChild(shelfLabel);
|
|
|
|
QualAnalysisSim.REAGENTS.forEach(r => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'qa-reagent-btn';
|
|
btn.dataset.reagent = r.id;
|
|
btn.draggable = true;
|
|
btn.style.cssText = [
|
|
'padding:6px 13px',
|
|
'border-radius:8px',
|
|
`border:1px solid ${r.color}55`,
|
|
`background:${r.color}15`,
|
|
`color:${r.color}`,
|
|
'font-size:.9rem',
|
|
'font-weight:700',
|
|
'cursor:grab',
|
|
'transition:background .15s,transform .1s',
|
|
].join(';');
|
|
btn.textContent = r.label;
|
|
btn.addEventListener('mouseenter', () => {
|
|
btn.style.background = r.color + '30';
|
|
btn.style.transform = 'translateY(-2px)';
|
|
});
|
|
btn.addEventListener('mouseleave', () => {
|
|
btn.style.background = r.color + '15';
|
|
btn.style.transform = '';
|
|
});
|
|
shelf.appendChild(btn);
|
|
});
|
|
this._container.appendChild(shelf);
|
|
|
|
/* ── LOG PANEL ── */
|
|
const logWrap = document.createElement('div');
|
|
logWrap.style.cssText = [
|
|
'flex-shrink:0',
|
|
'max-height:180px',
|
|
'overflow-y:auto',
|
|
'border-bottom:1px solid rgba(255,255,255,0.07)',
|
|
'background:rgba(0,0,0,0.15)',
|
|
].join(';');
|
|
|
|
const logHeader = document.createElement('div');
|
|
logHeader.style.cssText = [
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:space-between',
|
|
'padding:8px 14px 5px',
|
|
'font-size:.78rem',
|
|
'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.06)',
|
|
'position:sticky',
|
|
'top:0',
|
|
'background:#0D0D1A',
|
|
'z-index:2',
|
|
].join(';');
|
|
|
|
const logTitle = document.createElement('span');
|
|
logTitle.textContent = 'Журнал наблюдений';
|
|
logHeader.appendChild(logTitle);
|
|
|
|
const btnClearLog = document.createElement('button');
|
|
btnClearLog.id = 'qa-btn-clear-log';
|
|
btnClearLog.textContent = 'Очистить';
|
|
btnClearLog.style.cssText = [
|
|
'padding:3px 10px',
|
|
'border-radius:6px',
|
|
'border:1px solid rgba(255,255,255,0.18)',
|
|
'background:rgba(255,255,255,0.04)',
|
|
'color:rgba(255,255,255,0.65)',
|
|
'font-size:.8rem',
|
|
'cursor:pointer',
|
|
].join(';');
|
|
logHeader.appendChild(btnClearLog);
|
|
logWrap.appendChild(logHeader);
|
|
|
|
const logList = document.createElement('div');
|
|
logList.id = 'qa-log';
|
|
logList.style.cssText = 'padding:8px 12px;display:flex;flex-direction:column;gap:5px';
|
|
logWrap.appendChild(logList);
|
|
|
|
const logHint = document.createElement('div');
|
|
logHint.id = 'qa-log-hint';
|
|
logHint.style.cssText = [
|
|
'padding:10px 14px',
|
|
'font-size:.88rem',
|
|
'color:rgba(255,255,255,0.38)',
|
|
'text-align:center',
|
|
].join(';');
|
|
logHint.textContent = 'Перетащи реагент в пробирку, чтобы начать';
|
|
logWrap.appendChild(logHint);
|
|
this._container.appendChild(logWrap);
|
|
|
|
/* ── ANSWER BAR ── */
|
|
const ansBar = document.createElement('div');
|
|
ansBar.id = 'qa-ansbar';
|
|
ansBar.style.cssText = [
|
|
'flex-shrink:0',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'gap:10px',
|
|
'flex-wrap:wrap',
|
|
'padding:8px 14px',
|
|
'border-top:1px solid rgba(255,255,255,0.07)',
|
|
'background:rgba(255,255,255,0.02)',
|
|
'min-height:50px',
|
|
].join(';');
|
|
|
|
const ansLabel = document.createElement('span');
|
|
ansLabel.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.65);flex-shrink:0';
|
|
ansLabel.textContent = 'ОТВЕТ:';
|
|
ansBar.appendChild(ansLabel);
|
|
|
|
const ansSel = document.createElement('select');
|
|
ansSel.id = 'qa-answer-sel';
|
|
ansSel.style.cssText = [
|
|
'padding:6px 11px',
|
|
'border-radius:8px',
|
|
'border:1px solid rgba(255,255,255,0.2)',
|
|
'background:#1a1a2e',
|
|
'color:#E0E0FF',
|
|
'font-size:.9rem',
|
|
'cursor:pointer',
|
|
'outline:none',
|
|
'flex:1',
|
|
'min-width:140px',
|
|
'max-width:240px',
|
|
].join(';');
|
|
ansBar.appendChild(ansSel);
|
|
|
|
const btnSubmit = document.createElement('button');
|
|
btnSubmit.id = 'qa-submit';
|
|
btnSubmit.textContent = 'Ответить';
|
|
btnSubmit.style.cssText = [
|
|
'padding:6px 18px',
|
|
'border-radius:8px',
|
|
'border:none',
|
|
'background:linear-gradient(135deg,#9B5DE5,#06D6E0)',
|
|
'color:#fff',
|
|
'font-size:.92rem',
|
|
'font-weight:700',
|
|
'cursor:pointer',
|
|
].join(';');
|
|
ansBar.appendChild(btnSubmit);
|
|
|
|
const verdict = document.createElement('div');
|
|
verdict.id = 'qa-verdict';
|
|
verdict.style.cssText = [
|
|
'display:none',
|
|
'font-size:.92rem',
|
|
'font-weight:700',
|
|
'padding:5px 13px',
|
|
'border-radius:8px',
|
|
].join(';');
|
|
ansBar.appendChild(verdict);
|
|
|
|
this._container.appendChild(ansBar);
|
|
|
|
this._resizeFit();
|
|
this._updateAnswerSelect();
|
|
}
|
|
|
|
/* ── Resize ──────────────────────────────────────────────────── */
|
|
_resizeFit() {
|
|
const c = this._tubeCanvas;
|
|
if (!c) return;
|
|
const p = c.parentElement;
|
|
if (!p) return;
|
|
const rect = p.getBoundingClientRect();
|
|
const w = rect.width || p.clientWidth || 500;
|
|
const h = rect.height || p.clientHeight || 300;
|
|
c.width = Math.round(w);
|
|
c.height = Math.round(h);
|
|
this._W = c.width;
|
|
this._H = c.height;
|
|
/* recheck tube count on resize */
|
|
const newCount = QualAnalysisSim._tubeCount();
|
|
if (newCount !== this._tubeCount) {
|
|
this._tubeCount = newCount;
|
|
this._resetTubes();
|
|
}
|
|
this._drawScene();
|
|
}
|
|
|
|
/* ── Tube geometry ───────────────────────────────────────────── */
|
|
_tubeGeometry() {
|
|
const W = this._W || 500;
|
|
const H = this._H || 300;
|
|
const n = this._tubeCount;
|
|
/* show sample only in train/exam */
|
|
const showSample = this._mode !== 'free';
|
|
const totalSlots = showSample ? n + 1 : n;
|
|
const tubeW = Math.max(50, Math.min(80, (W - 32) / totalSlots - 18));
|
|
const tH = Math.min(H * 0.72, 150);
|
|
const tTop = Math.max(20, (H - tH) * 0.38);
|
|
const tBot = tTop + tH;
|
|
const totalW = totalSlots * (tubeW + 18) - 18;
|
|
const startX = (W - totalW) / 2;
|
|
|
|
const tubes = [];
|
|
for (let i = 0; i < totalSlots; i++) {
|
|
const isSample = showSample && i === n;
|
|
const tx = startX + i * (tubeW + 18);
|
|
tubes.push({ i, tx, tubeW, tTop, tBot, isSample });
|
|
}
|
|
return { tubes, tubeW, tTop, tBot, H, W };
|
|
}
|
|
|
|
/* ── Hit test — which tube slot contains point (x,y) ── */
|
|
_hitTestTube(x, y) {
|
|
const { tubes } = this._tubeGeometry();
|
|
for (const t of tubes) {
|
|
if (x >= t.tx - 8 && x <= t.tx + t.tubeW + 8 &&
|
|
y >= t.tTop - 10 && y <= t.tBot + 28) {
|
|
return t.i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/* ── Events ──────────────────────────────────────────────────── */
|
|
_bindEvents() {
|
|
const $ = id => document.getElementById(id);
|
|
|
|
/* mode selector */
|
|
$('qa-mode-sel').addEventListener('change', e => {
|
|
this._startMode(e.target.value);
|
|
});
|
|
|
|
/* new task */
|
|
$('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));
|
|
|
|
/* submit */
|
|
$('qa-submit').addEventListener('click', () => this._submitAnswer());
|
|
|
|
/* clear log */
|
|
$('qa-btn-clear-log').addEventListener('click', () => this._clearLog());
|
|
|
|
/* help toggle */
|
|
$('qa-btn-help').addEventListener('click', () => {
|
|
this._helpVisible = !this._helpVisible;
|
|
$('qa-help-pop').style.display = this._helpVisible ? 'block' : 'none';
|
|
});
|
|
document.addEventListener('click', e => {
|
|
if (this._helpVisible && !e.target.closest('#qa-help-pop') && e.target.id !== 'qa-btn-help') {
|
|
this._helpVisible = false;
|
|
const pop = $('qa-help-pop');
|
|
if (pop) pop.style.display = 'none';
|
|
}
|
|
}, true);
|
|
|
|
/* reagent buttons — click on active tube */
|
|
this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const rId = btn.dataset.reagent;
|
|
this._applyReagent(this._activeTube, rId);
|
|
});
|
|
|
|
/* drag start */
|
|
btn.addEventListener('dragstart', e => {
|
|
this._dragReagent = btn.dataset.reagent;
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
e.dataTransfer.setData('text/plain', btn.dataset.reagent);
|
|
});
|
|
});
|
|
|
|
/* canvas — drop target + click to select tube */
|
|
const canvas = this._tubeCanvas;
|
|
|
|
canvas.addEventListener('dragover', e => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
|
|
canvas.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
const rId = this._dragReagent || e.dataTransfer.getData('text/plain');
|
|
if (!rId) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
const idx = this._hitTestTube(x, y);
|
|
if (idx >= 0) {
|
|
this._activeTube = idx;
|
|
this._applyReagent(idx, rId);
|
|
}
|
|
this._dragReagent = null;
|
|
});
|
|
|
|
canvas.addEventListener('click', e => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
const idx = this._hitTestTube(x, y);
|
|
if (idx >= 0) {
|
|
this._activeTube = idx;
|
|
/* in exam: switching tube allows re-submit; verdict resets */
|
|
if (this._mode === 'exam') {
|
|
const alreadyAnswered = this._examAnswered && this._examAnswered[idx];
|
|
this._answered = !!alreadyAnswered;
|
|
const verdict = document.getElementById('qa-verdict');
|
|
if (verdict) {
|
|
if (alreadyAnswered) {
|
|
verdict.style.display = 'block';
|
|
verdict.textContent = alreadyAnswered.text;
|
|
verdict.style.background = alreadyAnswered.bg;
|
|
verdict.style.color = alreadyAnswered.fg;
|
|
verdict.style.border = alreadyAnswered.border;
|
|
} else {
|
|
verdict.style.display = 'none';
|
|
verdict.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
this._updateTaskText();
|
|
this._drawScene();
|
|
}
|
|
});
|
|
|
|
/* touch fallback: tap reagent then tap tube */
|
|
canvas.addEventListener('touchstart', e => {
|
|
if (!this._pendingReagent) return;
|
|
const t = e.touches[0];
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (t.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (t.clientY - rect.top) * (canvas.height / rect.height);
|
|
const idx = this._hitTestTube(x, y);
|
|
if (idx >= 0) {
|
|
this._applyReagent(idx, this._pendingReagent);
|
|
this._pendingReagent = null;
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
/* resize */
|
|
if (window.ResizeObserver) {
|
|
const ro = new ResizeObserver(() => this._resizeFit());
|
|
ro.observe(this._tubeCanvas.parentElement || this._container);
|
|
}
|
|
}
|
|
|
|
/* ── Mode start ──────────────────────────────────────────────── */
|
|
_startMode(mode) {
|
|
this._mode = mode;
|
|
this._answered = false;
|
|
this._examAnswered = {}; // per-tube answered tracking for exam
|
|
this._log = [];
|
|
this._dragReagent = null;
|
|
this._pendingReagent = null;
|
|
this._tubeCount = QualAnalysisSim._tubeCount();
|
|
this._activeTube = 0;
|
|
|
|
/* update mode selector UI */
|
|
const modeSel = document.getElementById('qa-mode-sel');
|
|
if (modeSel) modeSel.value = mode;
|
|
|
|
const ions = QualAnalysisSim.IONS;
|
|
|
|
/* pick known ions for helper tubes (visible labels) */
|
|
this._helperIons = [];
|
|
const shuffled = ions.slice().sort(() => Math.random() - 0.5);
|
|
for (let i = 0; i < this._tubeCount; i++) {
|
|
this._helperIons.push(shuffled[i % shuffled.length]);
|
|
}
|
|
|
|
/* pick random ions per mode */
|
|
if (mode === 'train') {
|
|
/* pick target distinct from helpers if possible */
|
|
const helperIds = new Set(this._helperIons.map(h => h.id));
|
|
const pool = ions.filter(i => !helperIds.has(i.id));
|
|
const src = pool.length > 0 ? pool : ions;
|
|
this._targetIon = src[Math.floor(Math.random() * src.length)];
|
|
this._examIons = [];
|
|
} else if (mode === 'exam') {
|
|
/* exam: helper tubes hold unknown ions, no sample */
|
|
this._examIons = this._helperIons.slice();
|
|
this._helperIons = []; // hide helper labels in exam (they're unknown)
|
|
this._targetIon = null;
|
|
} else {
|
|
/* free mode: helpers have known ions, no sample */
|
|
this._targetIon = null;
|
|
this._examIons = [];
|
|
}
|
|
|
|
/* reset tubes */
|
|
this._resetTubes();
|
|
/* paint helper tubes with their ion colors (free + train modes) */
|
|
if (mode !== 'exam') {
|
|
for (let i = 0; i < this._tubeCount; i++) {
|
|
if (this._helperIons[i]) {
|
|
this._tubes[i].solColor = this._helperIons[i].solColor || 'rgba(100,180,255,0.15)';
|
|
}
|
|
}
|
|
}
|
|
/* set solution color for sample tube in train mode */
|
|
if (mode === 'train' && this._targetIon) {
|
|
this._tubes[this._tubeCount].solColor = this._targetIon.solColor || 'rgba(100,180,255,0.15)';
|
|
}
|
|
/* set colors for exam tubes */
|
|
if (mode === 'exam') {
|
|
for (let i = 0; i < this._tubeCount; i++) {
|
|
if (this._examIons[i]) {
|
|
this._tubes[i].solColor = this._examIons[i].solColor || 'rgba(100,180,255,0.15)';
|
|
}
|
|
}
|
|
}
|
|
|
|
/* reset log */
|
|
const logEl = document.getElementById('qa-log');
|
|
if (logEl) logEl.innerHTML = '';
|
|
const hint = document.getElementById('qa-log-hint');
|
|
if (hint) hint.style.display = '';
|
|
|
|
/* verdict */
|
|
const verdict = document.getElementById('qa-verdict');
|
|
if (verdict) { verdict.style.display = 'none'; verdict.textContent = ''; }
|
|
|
|
/* update answer select */
|
|
this._updateAnswerSelect();
|
|
|
|
/* task text */
|
|
this._updateTaskText();
|
|
|
|
/* answer bar visibility */
|
|
const ansBar = document.getElementById('qa-ansbar');
|
|
if (ansBar) ansBar.style.display = (mode === 'free') ? 'none' : 'flex';
|
|
|
|
this._drawScene();
|
|
if (!this._raf) this._animLoop(performance.now());
|
|
}
|
|
|
|
_updateTaskText() {
|
|
const el = document.getElementById('qa-task');
|
|
if (!el) return;
|
|
if (this._mode === 'free') {
|
|
el.textContent = 'Свободно: в каждой пробирке известный ион (см. подпись) — пробуй реагенты, изучай реакции';
|
|
} else if (this._mode === 'train') {
|
|
el.textContent = 'Тренировка: определи ион в Образце (золотая рамка). В Проб1–4 — известные ионы для сравнения';
|
|
} else {
|
|
const ansFor = 'Проб' + (this._activeTube + 1);
|
|
el.textContent = 'Экзамен: в каждой пробирке свой неизвестный ион. Кликни на пробирку → определи реакциями → ответь. Сейчас отвечаешь для: ' + ansFor;
|
|
}
|
|
}
|
|
|
|
_updateAnswerSelect() {
|
|
const sel = document.getElementById('qa-answer-sel');
|
|
if (!sel) return;
|
|
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 to tube ───────────────────────────────────── */
|
|
_applyReagent(tubeIdx, reagentId) {
|
|
/* which ion is in this tube? */
|
|
const isSample = tubeIdx === this._tubeCount;
|
|
let ion = this._getIonForTube(tubeIdx);
|
|
/* if no assigned ion → free tube with blank reactions */
|
|
/* still animate the drop but log "нет ионов" */
|
|
if (!ion) {
|
|
/* animate drop only */
|
|
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
|
|
this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF');
|
|
if (window.LabFX) LabFX.sound.play('pour');
|
|
return;
|
|
}
|
|
|
|
const rxn = ion.reactions[reagentId];
|
|
if (!rxn) return;
|
|
|
|
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
|
|
const rLabel = rInfo ? rInfo.label : reagentId;
|
|
const ts = this._tubes[tubeIdx];
|
|
const tp = this._tubeParticles[tubeIdx];
|
|
|
|
if (window.LabFX) LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour');
|
|
|
|
/* update tube visual state */
|
|
if (rxn.type === 'flame') {
|
|
ts.flameColor = rxn.color;
|
|
ts.flameTimer = 2.0;
|
|
} else if (rxn.type === 'precip' && rxn.color) {
|
|
ts.precipColor = rxn.color;
|
|
ts.precipH = 0;
|
|
tp.precip = this._spawnPrecipParticles(tubeIdx, rxn.color);
|
|
} else if (rxn.type === 'solution' && rxn.color) {
|
|
ts.solColor = rxn.color;
|
|
} else if (rxn.type === 'gas') {
|
|
ts.gasLabel = rxn.gasLabel || '↑';
|
|
tp.gas = this._spawnGasParticles(tubeIdx, rxn.color || '#FFFFFF');
|
|
}
|
|
|
|
this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF');
|
|
|
|
/* determine tube name for log */
|
|
const isSampleTube = tubeIdx === this._tubeCount;
|
|
const tubeName = isSampleTube ? 'Образец' : ('Проб' + (tubeIdx + 1));
|
|
|
|
const entry = {
|
|
tubeIdx,
|
|
tubeName,
|
|
reagentId,
|
|
reagentLabel: rLabel,
|
|
reagentColor: rInfo ? rInfo.color : '#AAA',
|
|
obs: rxn.obs,
|
|
positive: rxn.positive,
|
|
excess: rxn.excess || null,
|
|
};
|
|
this._log.push(entry);
|
|
this._renderLogEntry(entry);
|
|
}
|
|
|
|
/* ── Particle spawners ───────────────────────────────────────── */
|
|
_tubeCenter(tubeIdx) {
|
|
const { tubes } = this._tubeGeometry();
|
|
const t = tubes[tubeIdx];
|
|
if (!t) return { x: this._W / 2, tTop: 20, tBot: 200, tubeW: 70 };
|
|
return { x: t.tx + t.tubeW / 2, tTop: t.tTop, tBot: t.tBot, tubeW: t.tubeW };
|
|
}
|
|
|
|
_spawnDrop(tubeIdx, color) {
|
|
const { x, tTop } = this._tubeCenter(tubeIdx);
|
|
const tp = this._tubeParticles[tubeIdx];
|
|
if (!tp) return;
|
|
tp.drops.push({ x, y: tTop - 30, vy: 3, color, alpha: 1, done: false });
|
|
}
|
|
|
|
_spawnPrecipParticles(tubeIdx, color) {
|
|
const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx);
|
|
const cy = tTop + (tBot - tTop) * 0.5;
|
|
const ps = [];
|
|
for (let i = 0; i < 28; i++) {
|
|
ps.push({
|
|
x: x + (Math.random() - 0.5) * tubeW * 0.7,
|
|
y: cy,
|
|
vy: 0.8 + Math.random() * 1.8,
|
|
vx: (Math.random() - 0.5) * 1.5,
|
|
color,
|
|
r: 2.5 + Math.random() * 3,
|
|
done: false,
|
|
});
|
|
}
|
|
return ps;
|
|
}
|
|
|
|
_spawnGasParticles(tubeIdx, color) {
|
|
const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx);
|
|
const cy = tTop + (tBot - tTop) * 0.4;
|
|
const ps = [];
|
|
for (let i = 0; i < 22; i++) {
|
|
ps.push({
|
|
x: x + (Math.random() - 0.5) * tubeW * 0.5,
|
|
y: cy,
|
|
vy: -(0.8 + Math.random() * 1.6),
|
|
vx: (Math.random() - 0.5) * 1,
|
|
color,
|
|
r: 3.5 + Math.random() * 4,
|
|
alpha: 0.85,
|
|
done: false,
|
|
});
|
|
}
|
|
return ps;
|
|
}
|
|
|
|
/* ── Log rendering ───────────────────────────────────────────── */
|
|
_clearLog() {
|
|
this._log = [];
|
|
const logEl = document.getElementById('qa-log');
|
|
if (logEl) logEl.innerHTML = '';
|
|
const hint = document.getElementById('qa-log-hint');
|
|
if (hint) hint.style.display = '';
|
|
}
|
|
|
|
_renderLogEntry(entry) {
|
|
const hint = document.getElementById('qa-log-hint');
|
|
if (hint) hint.style.display = 'none';
|
|
|
|
const logEl = document.getElementById('qa-log');
|
|
if (!logEl) return;
|
|
|
|
const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.35)';
|
|
|
|
const card = document.createElement('div');
|
|
card.style.cssText = [
|
|
'padding:7px 11px',
|
|
'border-radius:8px',
|
|
`border-left:3px solid ${entry.reagentColor}`,
|
|
'background:rgba(255,255,255,0.04)',
|
|
'border-top:1px solid rgba(255,255,255,0.06)',
|
|
'border-right:1px solid rgba(255,255,255,0.06)',
|
|
'border-bottom:1px solid rgba(255,255,255,0.06)',
|
|
'display:flex',
|
|
'gap:8px',
|
|
'align-items:flex-start',
|
|
].join(';');
|
|
|
|
/* left part */
|
|
const left = document.createElement('div');
|
|
left.style.cssText = 'flex:1;min-width:0';
|
|
|
|
const topLine = document.createElement('div');
|
|
topLine.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-bottom:3px';
|
|
|
|
const tubeSpan = document.createElement('span');
|
|
tubeSpan.style.cssText = 'font-size:.82rem;font-weight:700;color:rgba(255,255,255,0.6)';
|
|
tubeSpan.textContent = entry.tubeName;
|
|
|
|
const plusSpan = document.createElement('span');
|
|
plusSpan.style.cssText = 'font-size:.82rem;color:rgba(255,255,255,0.35)';
|
|
plusSpan.textContent = '+';
|
|
|
|
const rSpan = document.createElement('span');
|
|
rSpan.style.cssText = `font-size:.9rem;font-weight:700;color:${entry.reagentColor}`;
|
|
rSpan.textContent = entry.reagentLabel;
|
|
|
|
const arrSpan = document.createElement('span');
|
|
arrSpan.style.cssText = 'font-size:.85rem;color:rgba(255,255,255,0.35)';
|
|
arrSpan.textContent = '→';
|
|
|
|
topLine.appendChild(tubeSpan);
|
|
topLine.appendChild(plusSpan);
|
|
topLine.appendChild(rSpan);
|
|
topLine.appendChild(arrSpan);
|
|
left.appendChild(topLine);
|
|
|
|
const obsDiv = document.createElement('div');
|
|
obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.85);line-height:1.4';
|
|
obsDiv.textContent = entry.obs;
|
|
left.appendChild(obsDiv);
|
|
|
|
if (entry.excess) {
|
|
const exDiv = document.createElement('div');
|
|
exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;margin-top:2px';
|
|
exDiv.textContent = entry.excess;
|
|
left.appendChild(exDiv);
|
|
}
|
|
|
|
card.appendChild(left);
|
|
|
|
/* badge */
|
|
if (entry.positive) {
|
|
const badge = document.createElement('span');
|
|
badge.style.cssText = [
|
|
'flex-shrink:0',
|
|
'font-size:.75rem',
|
|
'font-weight:700',
|
|
'padding:2px 7px',
|
|
'border-radius:10px',
|
|
'background:rgba(94,240,142,0.18)',
|
|
'color:#5EF08E',
|
|
'border:1px solid rgba(94,240,142,0.35)',
|
|
'margin-top:1px',
|
|
].join(';');
|
|
badge.textContent = 'pos';
|
|
card.appendChild(badge);
|
|
}
|
|
|
|
logEl.appendChild(card);
|
|
/* scroll to bottom */
|
|
logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight;
|
|
}
|
|
|
|
/* ── Submit answer ───────────────────────────────────────────── */
|
|
_submitAnswer() {
|
|
if (this._answered) return;
|
|
if (this._mode === 'free') return;
|
|
|
|
const sel = document.getElementById('qa-answer-sel');
|
|
const chosen = sel ? sel.value : '';
|
|
if (!chosen) return;
|
|
|
|
this._answered = true;
|
|
this._scoreTotal++;
|
|
|
|
let correct = false;
|
|
let correctIon = null;
|
|
|
|
if (this._mode === 'train') {
|
|
correctIon = this._targetIon;
|
|
correct = chosen === correctIon.id;
|
|
} else if (this._mode === 'exam') {
|
|
/* answer applies to active tube */
|
|
correctIon = this._examIons[this._activeTube] || null;
|
|
correct = correctIon && chosen === correctIon.id;
|
|
}
|
|
|
|
if (correct) this._score++;
|
|
|
|
const verdict = document.getElementById('qa-verdict');
|
|
if (!verdict) return;
|
|
verdict.style.display = 'block';
|
|
|
|
const scoreEl = document.getElementById('qa-score');
|
|
if (scoreEl) scoreEl.textContent = this._score;
|
|
const totalEl = document.getElementById('qa-score-total');
|
|
if (totalEl) totalEl.textContent = '/' + this._scoreTotal;
|
|
|
|
let vText, vBg, vFg, vBorder;
|
|
if (correct) {
|
|
vText = 'Верно! Это ' + (correctIon ? correctIon.label : chosen);
|
|
vBg = 'rgba(94,240,142,0.15)';
|
|
vFg = '#5EF08E';
|
|
vBorder = '1px solid rgba(94,240,142,0.35)';
|
|
if (window.LabFX) LabFX.sound.play('chime');
|
|
} else {
|
|
const label = correctIon ? correctIon.label : '?';
|
|
vText = 'Неверно — это ' + label;
|
|
vBg = 'rgba(239,71,111,0.12)';
|
|
vFg = '#EF476F';
|
|
vBorder = '1px solid rgba(239,71,111,0.35)';
|
|
}
|
|
verdict.textContent = vText;
|
|
verdict.style.background = vBg;
|
|
verdict.style.color = vFg;
|
|
verdict.style.border = vBorder;
|
|
verdict.style.display = 'block';
|
|
|
|
/* exam: remember verdict per tube; allow switching to next */
|
|
if (this._mode === 'exam') {
|
|
this._examAnswered = this._examAnswered || {};
|
|
this._examAnswered[this._activeTube] = { text: vText, bg: vBg, fg: vFg, border: vBorder, correct };
|
|
}
|
|
}
|
|
|
|
/* ── 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;
|
|
|
|
/* update particles per tube */
|
|
for (let i = 0; i < this._tubes.length; i++) {
|
|
const ts2 = this._tubes[i];
|
|
const tp = this._tubeParticles[i];
|
|
if (!tp) continue;
|
|
|
|
/* flame timer */
|
|
if (ts2.flameTimer > 0) {
|
|
ts2.flameTimer -= dt;
|
|
if (ts2.flameTimer <= 0) { ts2.flameColor = null; ts2.flameTimer = 0; }
|
|
needDraw = true;
|
|
}
|
|
|
|
/* drops */
|
|
const { tBot } = this._tubeCenter(i);
|
|
const floor = tBot * 0.92;
|
|
tp.drops = tp.drops.filter(d => {
|
|
if (d.done) return false;
|
|
d.y += d.vy;
|
|
d.vy += 0.3;
|
|
if (d.y > floor) { d.done = true; needDraw = true; return false; }
|
|
needDraw = true;
|
|
return true;
|
|
});
|
|
|
|
/* precip */
|
|
const precipFloor = tBot - 4;
|
|
tp.precip.forEach(p => {
|
|
if (!p.done) {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.vy *= 0.97;
|
|
if (p.y >= precipFloor) {
|
|
p.y = precipFloor;
|
|
p.vy = 0; p.vx = 0;
|
|
p.done = true;
|
|
ts2.precipH = Math.min(ts2.precipH + 2, 32);
|
|
}
|
|
needDraw = true;
|
|
}
|
|
});
|
|
|
|
/* gas */
|
|
tp.gas = tp.gas.filter(p => {
|
|
if (p.done) return false;
|
|
p.y += p.vy;
|
|
p.x += p.vx;
|
|
p.alpha -= dt * 0.55;
|
|
if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; }
|
|
needDraw = true;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (needDraw) this._drawScene();
|
|
}
|
|
|
|
/* ── Draw ────────────────────────────────────────────────────── */
|
|
_drawScene() {
|
|
const ctx = this._tubeCtx;
|
|
const W = this._W || 500;
|
|
const H = this._H || 300;
|
|
if (!ctx) return;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
/* dark background */
|
|
ctx.fillStyle = '#0D0D1A';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
/* bench surface */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.018)';
|
|
ctx.fillRect(0, H * 0.84, W, H * 0.16);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, H * 0.84);
|
|
ctx.lineTo(W, H * 0.84);
|
|
ctx.stroke();
|
|
|
|
const geom = this._tubeGeometry();
|
|
const showSample = this._mode !== 'free';
|
|
|
|
for (const t of geom.tubes) {
|
|
this._drawOneTube(ctx, t, showSample);
|
|
}
|
|
|
|
/* clear-all button hint */
|
|
if (showSample) {
|
|
const sampleT = geom.tubes[this._tubeCount];
|
|
if (sampleT) {
|
|
const bx = sampleT.tx + sampleT.tubeW / 2;
|
|
const by = sampleT.tBot + 46;
|
|
ctx.save();
|
|
ctx.font = '700 11px Manrope,sans-serif';
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('[Очистить все]', bx, by);
|
|
ctx.restore();
|
|
this._clearBtnRect = { x: bx - 46, y: by - 9, w: 92, h: 18 };
|
|
}
|
|
}
|
|
}
|
|
|
|
_drawOneTube(ctx, t, showSample) {
|
|
const { i, tx, tubeW, tTop, tBot, isSample } = t;
|
|
const H = this._H || 300;
|
|
const tH = tBot - tTop;
|
|
const rBot = tubeW * 0.5;
|
|
const ts2 = this._tubes[i];
|
|
const tp = this._tubeParticles[i];
|
|
if (!ts2 || !tp) return;
|
|
|
|
const isActive = i === this._activeTube;
|
|
|
|
/* active highlight glow */
|
|
if (isActive) {
|
|
ctx.save();
|
|
ctx.shadowColor = '#4CC9F0';
|
|
ctx.shadowBlur = 18;
|
|
ctx.strokeStyle = '#4CC9F088';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(tx - 5, tTop - 5, tubeW + 10, tH + 10 + rBot);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* sample highlight (gold) */
|
|
if (isSample) {
|
|
ctx.save();
|
|
ctx.shadowColor = '#FFD166';
|
|
ctx.shadowBlur = 20;
|
|
ctx.strokeStyle = '#FFD16688';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(tx - 7, tTop - 7, tubeW + 14, tH + 14 + rBot);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* flame halo */
|
|
if (ts2.flameColor) {
|
|
const fc = ts2.flameColor;
|
|
const gx = tx + tubeW / 2;
|
|
const gy = tTop - 28;
|
|
const gr = ctx.createRadialGradient(gx, gy, 5, gx, gy, 80);
|
|
gr.addColorStop(0, fc + 'CC');
|
|
gr.addColorStop(0.4, fc + '44');
|
|
gr.addColorStop(1, fc + '00');
|
|
ctx.fillStyle = gr;
|
|
ctx.beginPath();
|
|
ctx.arc(gx, gy, 80, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.save();
|
|
ctx.font = 'bold 11px Manrope,sans-serif';
|
|
ctx.fillStyle = fc;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const flameLabel = this._flameLabel(fc);
|
|
ctx.fillText(flameLabel, gx, gy - 44);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* tube fill path helper */
|
|
const tubePath = (fromY, toY) => {
|
|
const arcR = Math.min(rBot, Math.max(2, (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;
|
|
ctx.fillStyle = ts2.solColor || 'rgba(100,180,255,0.15)';
|
|
tubePath(solTop, tBot);
|
|
ctx.fill();
|
|
|
|
/* precipitate layer */
|
|
if (ts2.precipColor && ts2.precipH > 0) {
|
|
const ph = ts2.precipH;
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.fillStyle = ts2.precipColor;
|
|
tubePath(tBot - ph, tBot);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
|
|
/* precip label when settled */
|
|
if (tp.precip.every(p => p.done)) {
|
|
const lastPrecipEntry = [...this._log].reverse().find(l =>
|
|
l.tubeIdx === i && (() => {
|
|
const rx2 = this._getIonForTube(i);
|
|
if (!rx2) return false;
|
|
const rxn = rx2.reactions[l.reagentId];
|
|
return rxn && rxn.type === 'precip' && rxn.precipLabel;
|
|
})()
|
|
);
|
|
if (lastPrecipEntry) {
|
|
const ion2 = this._getIonForTube(i);
|
|
const rxn2 = ion2 ? ion2.reactions[lastPrecipEntry.reagentId] : null;
|
|
if (rxn2 && rxn2.precipLabel) {
|
|
ctx.save();
|
|
ctx.font = 'bold 11px Manrope,sans-serif';
|
|
const lc = ts2.precipColor === '#111111' ? '#666' : ts2.precipColor;
|
|
ctx.fillStyle = lc;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(rxn2.precipLabel, tx + tubeW / 2, tBot - ph / 2);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* drop particles */
|
|
tp.drops.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();
|
|
ctx.fillStyle = d.color + '66';
|
|
ctx.beginPath();
|
|
ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
});
|
|
|
|
/* precip flying */
|
|
tp.precip.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 */
|
|
tp.gas.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 */
|
|
if (ts2.gasLabel && tp.gas.length > 0) {
|
|
ctx.save();
|
|
ctx.font = 'bold 11px Manrope,sans-serif';
|
|
ctx.fillStyle = '#FFFFAA';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(ts2.gasLabel, tx + tubeW + 5, tTop + tH * 0.2);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* glass outline */
|
|
ctx.save();
|
|
ctx.shadowColor = isSample ? 'rgba(255,209,102,0.25)' : 'rgba(155,93,229,0.25)';
|
|
ctx.shadowBlur = 10;
|
|
ctx.strokeStyle = isSample ? 'rgba(255,209,102,0.75)' : 'rgba(200,215,255,0.6)';
|
|
ctx.lineWidth = isSample ? 2.5 : 2;
|
|
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 = isSample ? 'rgba(255,209,102,0.4)' : 'rgba(200,215,255,0.3)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx - 4, tTop);
|
|
ctx.lineTo(tx + tubeW + 4, tTop);
|
|
ctx.stroke();
|
|
|
|
/* glass shine */
|
|
const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.38, 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 + 3);
|
|
ctx.lineTo(tx + tubeW * 0.30, tTop + 3);
|
|
ctx.lineTo(tx + tubeW * 0.30, tBot - tubeW * 0.22);
|
|
ctx.lineTo(tx + 3, tBot - tubeW * 0.22);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
/* label below tube */
|
|
const labelY = tBot + 20;
|
|
ctx.save();
|
|
ctx.font = '700 12px Manrope,sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
if (isSample) {
|
|
ctx.fillStyle = '#FFD166';
|
|
ctx.fillText('Образец', tx + tubeW / 2, labelY);
|
|
if (this._mode === 'train') {
|
|
ctx.font = '600 10px Manrope,sans-serif';
|
|
ctx.fillStyle = 'rgba(255,209,102,0.6)';
|
|
ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
|
|
}
|
|
} else {
|
|
ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.78)';
|
|
ctx.fillText('Проб' + (i + 1), tx + tubeW / 2, labelY);
|
|
if (this._mode === 'exam') {
|
|
ctx.font = '600 10px Manrope,sans-serif';
|
|
ctx.fillStyle = isActive ? 'rgba(76,201,240,0.85)' : 'rgba(255,255,255,0.45)';
|
|
ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
|
|
} else {
|
|
/* show known ion label in free / train modes */
|
|
const knownIon = (this._helperIons || [])[i];
|
|
if (knownIon) {
|
|
ctx.font = '700 11px Manrope,sans-serif';
|
|
ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.6)';
|
|
ctx.fillText(knownIon.label, tx + tubeW / 2, labelY + 14);
|
|
}
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
_getIonForTube(tubeIdx) {
|
|
const isSample = tubeIdx === this._tubeCount;
|
|
if (this._mode === 'train' && isSample) return this._targetIon;
|
|
if (this._mode === 'exam' && !isSample) return this._examIons[tubeIdx] || null;
|
|
if (this._mode !== 'exam' && !isSample) return (this._helperIons || [])[tubeIdx] || null;
|
|
return null;
|
|
}
|
|
|
|
_flameLabel(fc) {
|
|
if (fc === '#FFD700') return 'Жёлтое — Na⁺';
|
|
if (fc === '#CC00FF') return 'Фиолет. — K⁺';
|
|
if (fc === '#CC4400') return 'Красное — Ca²⁺';
|
|
if (fc === '#00DD00') return 'Зелёное — Ba²⁺';
|
|
if (fc === '#00BB44') return 'Зелёное — Cu²⁺';
|
|
return 'Окрашивание пламени';
|
|
}
|
|
|
|
/* ── Public API ──────────────────────────────────────────────── */
|
|
stop() {
|
|
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
|
}
|
|
|
|
destroy() {
|
|
this.stop();
|
|
this._container.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
/* ── 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();
|
|
}
|
|
}));
|
|
}
|