'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 = 'Счёт: 0'; 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 = [ 'Как пользоваться', '', ].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 = ''; ['Катионы', 'Анионы'].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, '>'); } /* ── 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(); } })); }