'use strict'; /* ════════════════════════════════════════════════════════════════════ QualAnalysisSim — Качественный анализ катионов и анионов Редизайн: centered tube, large log panel, reagent shelf bottom ════════════════════════════════════════════════════════════════════ */ class QualAnalysisSim { /* ── Ion database ─────────────────────────────────────────────── */ static IONS = [ /* КАТИОНЫ */ { id: 'Na+', label: 'Na⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя жёлтое', color: '#FFD700', type: 'flame', positive: true }, NaOH: { obs: 'Нет заметного осадка', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'K+', label: 'K⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя фиолетовое (через синее стекло)', color: '#CC00FF', type: 'flame', positive: true }, NaOH: { obs: 'Нет осадка', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'NH4+', label: 'NH₄⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'При нагреве — газ NH₃↑ (запах, влажная лакмусовая бумага синеет)', color: '#CCFFCC', type: 'gas', gasLabel: 'NH₃↑', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Mg2+', label: 'Mg²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Mg(OH)₂, не растворим в избытке NaOH', color: '#FFFFFF', type: 'precip', precipLabel: 'Mg(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Ca2+', label: 'Ca²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя кирпично-красное', color: '#CC4400', type: 'flame', positive: true }, NaOH: { obs: 'Слабый белый осадок Ca(OH)₂ (малорастворим)', color: '#EEEEEE', type: 'precip', precipLabel: 'Ca(OH)₂↓', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок CaSO₄↓ (малорастворим)', color: '#FFFFFF', type: 'precip', precipLabel: 'CaSO₄↓', positive: true }, } }, { id: 'Ba2+', label: 'Ba²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя зелёное', color: '#00DD00', type: 'flame', positive: true }, NaOH: { obs: 'Белый осадок Ba(OH)₂', color: '#EEEEEE', type: 'precip', precipLabel: 'Ba(OH)₂↓', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок BaSO₄↓, нерастворим в HNO₃', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true }, } }, { id: 'Al3+', label: 'Al³⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Al(OH)₃↓, растворяется в избытке NaOH (амфотерность)', color: '#DDDDDD', type: 'precip', precipLabel: 'Al(OH)₃↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Fe2+', label: 'Fe²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(100,160,80,0.25)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Зеленоватый осадок Fe(OH)₂↓', color: '#88BB66', type: 'precip', precipLabel: 'Fe(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Слабое розовое окрашивание (следы Fe³⁺), не яркое', color: 'rgba(255,100,80,0.3)', type: 'solution', positive: false }, K3FeCN6: { obs: 'Синий осадок — Турнбулева синь (Fe₃[Fe(CN)₆]₂)', color: '#1144CC', type: 'precip', precipLabel: 'Турн. синь↓', positive: true }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Fe3+', label: 'Fe³⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(200,100,30,0.3)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Бурый осадок Fe(OH)₃↓', color: '#884422', type: 'precip', precipLabel: 'Fe(OH)₃↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Ярко-красный раствор — роданид железа(III)', color: '#DD1100', type: 'solution', positive: true }, K3FeCN6: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции (осаждается NaOH)', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Cu2+', label: 'Cu²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(30,120,200,0.35)', reactions: { flame: { obs: 'Пламя зелёное (галогениды — синий)', color: '#00BB44', type: 'flame', positive: false }, NaOH: { obs: 'Голубой осадок Cu(OH)₂↓', color: '#5599FF', type: 'precip', precipLabel: 'Cu(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Ярко-синий раствор комплекса [Cu(NH₃)₄]²⁺', color: '#0044EE', type: 'solution', positive: true }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Ag+', label: 'Ag⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Коричневый осадок Ag₂O↓', color: '#886622', type: 'precip', precipLabel: 'Ag₂O↓', positive: false }, HCl: { obs: 'Белый творожистый осадок AgCl↓, темнеет на свету', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Белый осадок AgSCN↓', color: '#FFFFFF', type: 'precip', precipLabel: 'AgSCN↓', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Растворяется — комплекс [Ag(NH₃)₂]⁺', color: 'rgba(255,255,255,0.05)', type: 'solution', positive: true }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Pb2+', label: 'Pb²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(200,200,200,0.12)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Pb(OH)₂↓, растворим в избытке', color: '#EEEEEE', type: 'precip', precipLabel: 'Pb(OH)₂↓', positive: false }, HCl: { obs: 'Белый осадок PbCl₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbCl₂↓', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок PbSO₄↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbSO₄↓', positive: true }, } }, { id: 'Zn2+', label: 'Zn²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке NaOH (амфотерность)', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке — [Zn(NH₃)₄]²⁺', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'H+', label: 'H⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,200,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нейтрализация. Лакмус синеет при добавлении щёлочи', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нейтрализация: H⁺ + NH₃ → NH₄⁺', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, }, indicators: { litmus: 'красный', methylorange: 'красный', phenolphthalein: 'бесцветный' } }, { id: 'OH-', label: 'OH⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нейтрализация, нет видимой реакции', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нейтрализация', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, }, indicators: { litmus: 'синий', methylorange: 'жёлтый', phenolphthalein: 'малиновый' } }, /* АНИОНЫ */ { id: 'Cl-', label: 'Cl⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Белый творожистый осадок AgCl↓, нерастворим в HNO₃', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Br-', label: 'Br⁻', type: 'an', group: 'Анионы', solColor: 'rgba(180,80,0,0.15)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Желтоватый осадок AgBr↓', color: '#EEEE88', type: 'precip', precipLabel: 'AgBr↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'I-', label: 'I⁻', type: 'an', group: 'Анионы', solColor: 'rgba(100,0,120,0.2)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Жёлтый осадок AgI↓, практически нерастворим в NH₃', color: '#FFEE44', type: 'precip', precipLabel: 'AgI↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'SO42-', label: 'SO₄²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaSO₄↓, нерастворим в кислотах', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'SO32-', label: 'SO₃²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaSO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₃↓', positive: true }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true }, } }, { id: 'CO32-', label: 'CO₃²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Бурное выделение CO₂↑ (пузыри), мутит известковую воду Ca(OH)₂', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaCO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaCO₃↓', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Бурное выделение CO₂↑ (пузыри)', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true }, } }, { id: 'NO3-', label: 'NO₃⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'При нагреве с Cu: бурый газ NO₂↑ («бурое кольцо» с FeSO₄)', color: '#DD8800', type: 'gas', gasLabel: 'NO₂↑', positive: true }, } }, { id: 'PO43-', label: 'PO₄³⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Жёлтый осадок Ag₃PO₄↓', color: '#EECC44', type: 'precip', precipLabel: 'Ag₃PO₄↓', positive: true }, BaCl2: { obs: 'Белый осадок Ba₃(PO₄)₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'Ba₃(PO₄)₂↓', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'S2-', label: 'S²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(200,200,100,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true }, AgNO3: { obs: 'Чёрный осадок Ag₂S↓', color: '#111111', type: 'precip', precipLabel: 'Ag₂S↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true }, } }, { id: 'CH3COO-', label: 'CH₃COO⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Запах уксуса; с FeCl₃ при нагреве — бурый осадок', color: '#AA6622', type: 'solution', positive: true }, } }, ]; static REAGENTS = [ { id: 'NaOH', label: 'NaOH', color: '#8866FF' }, { id: 'HCl', label: 'HCl', color: '#FF6644' }, { id: 'AgNO3', label: 'AgNO₃', color: '#CCCCCC' }, { id: 'BaCl2', label: 'BaCl₂', color: '#44BBFF' }, { id: 'KSCN', label: 'KSCN', color: '#FF4444' }, { id: 'K3FeCN6', label: 'K₃[Fe(CN)₆]', color: '#FFAA00' }, { id: 'NH3', label: 'NH₃', color: '#AAFFAA' }, { id: 'H2SO4', label: 'H₂SO₄', color: '#FFFF44' }, { id: 'flame', label: 'Пламя', color: '#FF8800' }, ]; /* ── constructor ─────────────────────────────────────────────── */ constructor(container) { this._container = container; this._mode = 'identify'; this._targetIon = null; this._log = []; this._answered = false; this._dropAnim = []; this._precipParticles = []; this._gasParticles = []; this._raf = null; this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: 'rgba(100,180,255,0.18)' }; this._score = 0; this._lastT = 0; this._build(); this._bindEvents(); this._startMode('identify'); } /* ── DOM build ───────────────────────────────────────────────── */ _build() { this._container.innerHTML = ''; this._container.style.cssText = [ 'display:flex', 'flex-direction:column', 'height:100%', 'background:#0D0D1A', 'color:#E0E0FF', 'font-family:Manrope,sans-serif', 'overflow:hidden', 'user-select:none', ].join(';'); /* ── TOP BAR ── */ const tb = document.createElement('div'); tb.style.cssText = [ 'display:flex', 'align-items:center', 'gap:10px', 'padding:10px 16px', 'border-bottom:1px solid rgba(255,255,255,0.09)', 'flex-shrink:0', 'flex-wrap:wrap', 'background:rgba(255,255,255,0.02)', ].join(';'); /* mode tabs */ const tabsWrap = document.createElement('div'); tabsWrap.style.cssText = 'display:flex;gap:6px'; const makeTab = (id, text) => { const b = document.createElement('button'); b.id = id; b.textContent = text; b.style.cssText = [ 'padding:8px 20px', 'border-radius:10px', 'font-size:1rem', 'font-weight:700', 'cursor:pointer', 'transition:all .15s', 'border:1px solid rgba(255,255,255,0.12)', 'background:rgba(255,255,255,0.04)', 'color:rgba(255,255,255,0.55)', ].join(';'); return b; }; const btnIdentify = makeTab('qa-btn-identify', 'Определить ион'); const btnUnknown = makeTab('qa-btn-unknown', 'Неизвестное вещество'); tabsWrap.appendChild(btnIdentify); tabsWrap.appendChild(btnUnknown); tb.appendChild(tabsWrap); const spacer = document.createElement('div'); spacer.style.cssText = 'flex:1'; tb.appendChild(spacer); /* score */ const scoreWrap = document.createElement('span'); scoreWrap.style.cssText = 'font-size:1rem;color:rgba(255,255,255,0.7);font-weight:600'; scoreWrap.innerHTML = 'Счёт: 0'; tb.appendChild(scoreWrap); /* new question button */ const btnNew = document.createElement('button'); btnNew.id = 'qa-btn-new'; btnNew.textContent = 'Новый вопрос'; btnNew.style.cssText = [ 'padding:8px 18px', 'border-radius:10px', 'border:1px solid rgba(6,214,224,0.45)', 'background:rgba(6,214,224,0.1)', 'color:#06D6E0', 'font-size:.95rem', 'font-weight:700', 'cursor:pointer', 'transition:background .15s', ].join(';'); tb.appendChild(btnNew); this._container.appendChild(tb); /* ── MAIN ROW: scene + log ── */ const mainRow = document.createElement('div'); mainRow.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden'; this._container.appendChild(mainRow); /* CENTER SCENE */ const scene = document.createElement('div'); scene.style.cssText = [ 'flex:1', 'display:flex', 'flex-direction:column', 'min-width:0', 'overflow:hidden', 'position:relative', ].join(';'); mainRow.appendChild(scene); /* canvas wrapper — takes all available height */ const canvasWrap = document.createElement('div'); canvasWrap.style.cssText = 'flex:1;position:relative;min-height:0;overflow:hidden'; scene.appendChild(canvasWrap); const canvas = document.createElement('canvas'); canvas.id = 'qa-canvas'; canvas.style.cssText = 'display:block;width:100%;height:100%;cursor:crosshair'; canvasWrap.appendChild(canvas); this._canvas = canvas; this._ctx = canvas.getContext('2d'); /* answer card inside scene, below canvas area */ const ansCard = document.createElement('div'); ansCard.id = 'qa-anscard'; ansCard.style.cssText = [ 'flex-shrink:0', 'padding:10px 16px', 'border-top:1px solid rgba(255,255,255,0.08)', 'display:flex', 'align-items:center', 'gap:10px', 'flex-wrap:wrap', 'background:rgba(255,255,255,0.025)', ].join(';'); ansCard.innerHTML = [ '', 'Добавляй реагенты и определи ион в пробирке', '', '', '', '
', ].join(''); scene.appendChild(ansCard); /* RIGHT PANEL — log */ const logPanel = document.createElement('div'); logPanel.style.cssText = [ 'width:290px', 'flex-shrink:0', 'border-left:1px solid rgba(255,255,255,0.08)', 'display:flex', 'flex-direction:column', 'overflow:hidden', 'background:rgba(255,255,255,0.015)', ].join(';'); mainRow.appendChild(logPanel); const logHeader = document.createElement('div'); logHeader.style.cssText = [ 'padding:12px 14px 8px', 'font-size:.82rem', 'font-weight:700', 'text-transform:uppercase', 'letter-spacing:.07em', 'color:rgba(255,255,255,0.55)', 'border-bottom:1px solid rgba(255,255,255,0.07)', 'flex-shrink:0', ].join(';'); logHeader.textContent = 'Журнал наблюдений'; logPanel.appendChild(logHeader); const logScroll = document.createElement('div'); logScroll.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px'; logPanel.appendChild(logScroll); const logList = document.createElement('div'); logList.id = 'qa-log'; logList.style.cssText = 'display:flex;flex-direction:column;gap:6px'; logScroll.appendChild(logList); /* empty state hint */ const logHint = document.createElement('div'); logHint.id = 'qa-log-hint'; logHint.style.cssText = [ 'font-size:.88rem', 'color:rgba(255,255,255,0.28)', 'text-align:center', 'margin-top:20px', 'padding:0 10px', 'line-height:1.5', ].join(';'); logHint.textContent = 'Нажимай реагенты снизу — здесь появятся результаты реакций'; logScroll.appendChild(logHint); /* ── REAGENT SHELF (bottom) ── */ const shelf = document.createElement('div'); shelf.id = 'qa-shelf'; shelf.style.cssText = [ 'flex-shrink:0', 'border-top:1px solid rgba(255,255,255,0.09)', 'padding:10px 14px', 'background:rgba(255,255,255,0.025)', 'display:flex', 'flex-wrap:wrap', 'gap:7px', 'align-items:center', ].join(';'); const shelfLabel = document.createElement('span'); shelfLabel.style.cssText = [ 'font-size:.75rem', 'font-weight:700', 'text-transform:uppercase', 'letter-spacing:.06em', 'color:rgba(255,255,255,0.4)', 'margin-right:4px', 'flex-shrink:0', ].join(';'); shelfLabel.textContent = 'Реагенты:'; shelf.appendChild(shelfLabel); QualAnalysisSim.REAGENTS.forEach(r => { const btn = document.createElement('button'); btn.className = 'qa-reagent-btn'; btn.dataset.reagent = r.id; btn.title = r.label; btn.style.cssText = [ 'padding:7px 14px', 'border-radius:9px', `border:1px solid ${r.color}55`, `background:${r.color}18`, `color:${r.color}`, 'font-size:.95rem', 'font-weight:700', 'cursor:pointer', 'transition:background .15s, transform .1s', 'min-width:60px', 'text-align:center', ].join(';'); btn.textContent = r.label; btn.addEventListener('mouseenter', () => { btn.style.background = r.color + '33'; btn.style.transform = 'translateY(-1px)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = r.color + '18'; btn.style.transform = ''; }); shelf.appendChild(btn); }); this._container.appendChild(shelf); this._resizeFit(); } _resizeFit() { const c = this._canvas; const p = c.parentElement; if (!p) return; const rect = p.getBoundingClientRect(); const w = rect.width || p.clientWidth || 400; const h = rect.height || p.clientHeight || 360; c.width = Math.round(w); c.height = Math.round(h); this._W = c.width; this._H = c.height; this._drawTube(); } /* ── Tab highlight ───────────────────────────────────────────── */ _highlightMode(mode) { const bi = document.getElementById('qa-btn-identify'); const bu = document.getElementById('qa-btn-unknown'); const activeStyle = [ 'border:1px solid rgba(155,93,229,0.65)', 'background:rgba(155,93,229,0.2)', 'color:#D0A0FF', ].join(';'); const inactiveStyle = [ 'border:1px solid rgba(255,255,255,0.12)', 'background:rgba(255,255,255,0.04)', 'color:rgba(255,255,255,0.55)', ].join(';'); /* reapply only the color/border parts by toggling a known block */ [bi, bu].forEach(btn => { const isActive = (btn === bi && mode === 'identify') || (btn === bu && mode === 'unknown'); btn.style.border = isActive ? '1px solid rgba(155,93,229,0.65)' : '1px solid rgba(255,255,255,0.12)'; btn.style.background = isActive ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; btn.style.color = isActive ? '#D0A0FF' : 'rgba(255,255,255,0.55)'; }); } /* ── Events ──────────────────────────────────────────────────── */ _bindEvents() { const $ = id => document.getElementById(id); $('qa-btn-identify').addEventListener('click', () => this._startMode('identify')); $('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown')); $('qa-btn-new').addEventListener('click', () => this._startMode(this._mode)); $('qa-submit').addEventListener('click', () => this._submitAnswer()); this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { btn.addEventListener('click', () => { if (this._answered) return; this._applyReagent(btn.dataset.reagent); }); btn.setAttribute('draggable', 'true'); btn.addEventListener('dragstart', e => { this._dragReagent = btn.dataset.reagent; e.dataTransfer.effectAllowed = 'copy'; }); }); this._canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); this._canvas.addEventListener('drop', e => { e.preventDefault(); if (this._dragReagent && !this._answered) { this._applyReagent(this._dragReagent); this._dragReagent = null; } }); if (window.ResizeObserver) { const ro = new ResizeObserver(() => this._resizeFit()); ro.observe(this._canvas.parentElement || this._container); } } /* ── Mode start ──────────────────────────────────────────────── */ _startMode(mode) { this._mode = mode; this._log = []; this._answered = false; this._dropAnim = []; this._precipParticles = []; this._gasParticles = []; this._dragReagent = null; const ions = QualAnalysisSim.IONS; this._targetIon = ions[Math.floor(Math.random() * ions.length)]; this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)', }; /* reset DOM */ document.getElementById('qa-log').innerHTML = ''; const hint = document.getElementById('qa-log-hint'); if (hint) hint.style.display = ''; document.getElementById('qa-verdict').style.display = 'none'; document.getElementById('qa-verdict').textContent = ''; this._highlightMode(mode); this._updateAnswerSelect(); this._populateQuestion(mode); this._drawTube(); if (!this._raf) this._animLoop(performance.now()); } _populateQuestion(mode) { const q = document.getElementById('qa-question'); if (mode === 'identify') { q.textContent = 'Добавляй реагенты — определи ион в пробирке, выбери ответ и нажми «Ответить»'; } else { q.textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь'; } } _updateAnswerSelect() { const sel = document.getElementById('qa-answer-sel'); sel.innerHTML = ''; ['Катионы', 'Анионы'].forEach(grp => { const og = document.createElement('optgroup'); og.label = grp; QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => { const opt = document.createElement('option'); opt.value = ion.id; opt.textContent = ion.label; og.appendChild(opt); }); sel.appendChild(og); }); } /* ── Apply reagent ───────────────────────────────────────────── */ _applyReagent(reagentId) { const ion = this._targetIon; const rxn = ion.reactions[reagentId]; if (!rxn) return; const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId); const rLabel = rInfo ? rInfo.label : reagentId; /* sounds */ if (window.LabFX) { LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour'); } /* update tube state */ if (rxn.type === 'flame') { this._tubeState.flameColor = rxn.color; setTimeout(() => { this._tubeState.flameColor = null; this._drawTube(); }, 2000); } else if (rxn.type === 'precip' && rxn.color) { this._tubeState.precipColor = rxn.color; this._tubeState.precipH = 0; this._precipParticles = this._spawnPrecipParticles(rxn.color); } else if (rxn.type === 'solution' && rxn.color) { this._tubeState.solColor = rxn.color; } else if (rxn.type === 'gas' && rxn.color) { this._tubeState.gasLabel = rxn.gasLabel || '↑'; this._gasParticles = this._spawnGasParticles(rxn.color); } /* drop animation — from top center */ this._dropAnim.push({ x: this._W * 0.5, y: 20, vy: 2.5, color: rInfo ? rInfo.color : '#FFF', alpha: 1, done: false, }); /* log */ const entry = { reagent: rLabel, obs: rxn.obs, positive: rxn.positive, excess: rxn.excess || null }; this._log.push(entry); this._renderLogEntry(entry); } _spawnPrecipParticles(color) { const cx = this._W * 0.5; const cy = this._H * 0.55; const ps = []; for (let i = 0; i < 32; i++) { ps.push({ x: cx + (Math.random() - 0.5) * 80, y: cy, vy: 0.6 + Math.random() * 2, vx: (Math.random() - 0.5) * 2, color, r: 3 + Math.random() * 4, done: false, }); } return ps; } _spawnGasParticles(color) { const cx = this._W * 0.5; const cy = this._H * 0.4; const ps = []; for (let i = 0; i < 26; i++) { ps.push({ x: cx + (Math.random() - 0.5) * 50, y: cy, vy: -(1 + Math.random() * 1.8), vx: (Math.random() - 0.5) * 1.2, color, r: 4 + Math.random() * 5, alpha: 0.9, done: false, }); } return ps; } /* ── Log rendering ───────────────────────────────────────────── */ _renderLogEntry(entry) { const hint = document.getElementById('qa-log-hint'); if (hint) hint.style.display = 'none'; const log = document.getElementById('qa-log'); const n = log.children.length + 1; const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.4)'; const card = document.createElement('div'); card.style.cssText = [ 'padding:9px 11px', 'border-radius:10px', `border-left:3px solid ${col}`, 'background:rgba(255,255,255,0.04)', 'border-top:1px solid rgba(255,255,255,0.07)', 'border-right:1px solid rgba(255,255,255,0.07)', 'border-bottom:1px solid rgba(255,255,255,0.07)', 'display:flex', 'flex-direction:column', 'gap:3px', ].join(';'); /* reagent row */ const topRow = document.createElement('div'); topRow.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:.9rem'; const numSpan = document.createElement('span'); numSpan.style.cssText = 'font-size:.78rem;color:rgba(255,255,255,0.35);font-weight:600;min-width:16px'; numSpan.textContent = n + '.'; const reagentSpan = document.createElement('span'); /* find color for this reagent */ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent); const rColor = rInfo ? rInfo.color : '#AAA'; reagentSpan.style.cssText = `color:${rColor};font-weight:700;font-size:.92rem`; reagentSpan.textContent = _esc(entry.reagent); const arrowSpan = document.createElement('span'); arrowSpan.style.cssText = 'color:rgba(255,255,255,0.35);font-size:.85rem'; arrowSpan.textContent = '→'; const dotSpan = document.createElement('span'); dotSpan.style.cssText = `width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0;margin-left:auto`; topRow.appendChild(numSpan); topRow.appendChild(reagentSpan); topRow.appendChild(arrowSpan); topRow.appendChild(dotSpan); card.appendChild(topRow); /* observation text */ const obsDiv = document.createElement('div'); obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.8);line-height:1.45;padding-left:23px'; obsDiv.textContent = entry.obs; card.appendChild(obsDiv); /* excess note */ if (entry.excess) { const exDiv = document.createElement('div'); exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;padding-left:23px;margin-top:1px'; exDiv.textContent = entry.excess; card.appendChild(exDiv); } log.appendChild(card); log.parentElement.scrollTop = log.parentElement.scrollHeight; } /* ── Submit answer ───────────────────────────────────────────── */ _submitAnswer() { if (this._answered) return; const sel = document.getElementById('qa-answer-sel'); const chosen = sel.value; if (!chosen) return; this._answered = true; const correct = chosen === this._targetIon.id; const verdict = document.getElementById('qa-verdict'); verdict.style.display = 'block'; if (correct) { this._score++; document.getElementById('qa-score').textContent = this._score; verdict.textContent = 'Верно! Это ' + this._targetIon.label; verdict.style.background = 'rgba(94,240,142,0.15)'; verdict.style.color = '#5EF08E'; verdict.style.border = '1px solid rgba(94,240,142,0.35)'; if (window.LabFX) LabFX.sound.play('chime'); } else { const correctIon = QualAnalysisSim.IONS.find(i => i.id === this._targetIon.id); verdict.textContent = 'Неверно. Правильный ответ: ' + (correctIon ? correctIon.label : this._targetIon.id); verdict.style.background = 'rgba(239,71,111,0.12)'; verdict.style.color = '#EF476F'; verdict.style.border = '1px solid rgba(239,71,111,0.35)'; } } /* ── Animation loop ──────────────────────────────────────────── */ _animLoop(t) { this._raf = requestAnimationFrame(ts => this._animLoop(ts)); const dt = Math.min((t - this._lastT) / 1000, 0.05); this._lastT = t; let needDraw = false; this._dropAnim = this._dropAnim.filter(d => { if (d.done) return false; d.y += d.vy; d.vy += 0.25; const floor = this._H * 0.52; if (d.y > floor) { d.done = true; needDraw = true; return false; } needDraw = true; return true; }); this._precipParticles.forEach(p => { if (!p.done) { p.x += p.vx; p.y += p.vy; p.vy *= 0.98; const floor = this._H * 0.83; if (p.y >= floor) { p.y = floor; p.vy = 0; p.vx = 0; p.done = true; this._tubeState.precipH = Math.min(this._tubeState.precipH + 2.5, 38); } needDraw = true; } }); this._gasParticles = this._gasParticles.filter(p => { if (p.done) return false; p.y += p.vy; p.x += p.vx; p.alpha -= dt * 0.6; if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; } needDraw = true; return true; }); if (needDraw || this._tubeState.flameColor) this._drawTube(); } /* ── Draw ────────────────────────────────────────────────────── */ _drawTube() { const ctx = this._ctx; const W = this._W || 400; const H = this._H || 400; ctx.clearRect(0, 0, W, H); /* background */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); /* subtle bench surface */ ctx.fillStyle = 'rgba(255,255,255,0.025)'; ctx.fillRect(0, H * 0.88, W, H * 0.12); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, H * 0.88); ctx.lineTo(W, H * 0.88); ctx.stroke(); /* tube dimensions — large, centered */ const tubeW = Math.min(120, W * 0.22); const tx = W * 0.5 - tubeW * 0.5; const tTop = H * 0.1; const tBot = H * 0.86; const tH = tBot - tTop; const rBot = tubeW * 0.5; /* bottom arc radius */ /* ── flame halo ── */ if (this._tubeState.flameColor) { const fc = this._tubeState.flameColor; const gx = W * 0.5; const gy = tTop - 30; const gr = ctx.createRadialGradient(gx, gy, 6, gx, gy, 110); gr.addColorStop(0, fc + 'DD'); gr.addColorStop(0.35, fc + '55'); gr.addColorStop(1, fc + '00'); ctx.fillStyle = gr; ctx.beginPath(); ctx.arc(gx, gy, 110, 0, Math.PI * 2); ctx.fill(); /* flame label */ ctx.save(); ctx.font = 'bold 14px Manrope,sans-serif'; ctx.fillStyle = fc; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const flameLabel = ( fc === '#FFD700' ? 'Жёлтое пламя — Na⁺' : fc === '#CC00FF' ? 'Фиолетовое — K⁺' : fc === '#CC4400' ? 'Кирпично-красное — Ca²⁺' : fc === '#00DD00' ? 'Зелёное — Ba²⁺' : fc === '#00BB44' ? 'Зелёное пламя — Cu²⁺' : 'Окрашивание пламени' ); ctx.fillText(flameLabel, W * 0.5, tTop - 55); ctx.restore(); } /* solution fill path helper */ const tubePath = (fromY, toY) => { const arcR = Math.min(rBot, (toY - fromY) * 0.5); ctx.beginPath(); ctx.moveTo(tx, fromY); ctx.lineTo(tx, toY - arcR); ctx.arcTo(tx, toY, tx + rBot, toY, arcR); ctx.arcTo(tx + tubeW, toY, tx + tubeW, toY - arcR, arcR); ctx.lineTo(tx + tubeW, fromY); ctx.closePath(); }; /* solution */ const solTop = tTop + tH * 0.06; const solBot = tBot; ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)'; tubePath(solTop, solBot); ctx.fill(); /* precipitate layer */ if (this._tubeState.precipColor && this._tubeState.precipH > 0) { const ph = this._tubeState.precipH; const py = solBot - ph; ctx.globalAlpha = 0.88; ctx.fillStyle = this._tubeState.precipColor; tubePath(py, solBot); ctx.fill(); ctx.globalAlpha = 1; /* label when settled */ if (this._precipParticles.every(p => p.done)) { const lastPrecip = [...this._log].reverse().find(l => { const ri = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent); if (!ri) return false; const rx2 = this._targetIon.reactions[ri.id]; return rx2 && rx2.type === 'precip' && rx2.precipLabel; }); if (lastPrecip) { const ri = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent); const rx2 = ri ? this._targetIon.reactions[ri.id] : null; if (rx2 && rx2.precipLabel) { ctx.save(); ctx.font = 'bold 13px Manrope,sans-serif'; const labelCol = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor; ctx.fillStyle = labelCol; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(rx2.precipLabel, W * 0.5, solBot - ph * 0.5); ctx.restore(); } } } } /* drop particles */ this._dropAnim.forEach(d => { ctx.globalAlpha = d.alpha; ctx.fillStyle = d.color; ctx.beginPath(); ctx.arc(d.x, d.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = d.color + '77'; ctx.beginPath(); ctx.arc(d.x, d.y - 10, 4, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }); /* precipitate flying particles */ this._precipParticles.filter(p => !p.done).forEach(p => { ctx.globalAlpha = 0.8; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }); /* gas bubbles */ this._gasParticles.forEach(p => { ctx.globalAlpha = p.alpha; ctx.strokeStyle = p.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1; }); /* gas label */ if (this._tubeState.gasLabel) { ctx.save(); ctx.font = 'bold 15px Manrope,sans-serif'; ctx.fillStyle = '#FFFFAA'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(this._tubeState.gasLabel, tx + tubeW + 12, tTop + tH * 0.2); ctx.restore(); } /* ── tube glass outline ── */ ctx.save(); ctx.shadowColor = 'rgba(155,93,229,0.35)'; ctx.shadowBlur = 14; ctx.strokeStyle = 'rgba(200,215,255,0.6)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(tx, tTop); ctx.lineTo(tx, tBot - rBot); ctx.arcTo(tx, tBot, tx + rBot, tBot, rBot); ctx.arcTo(tx + tubeW, tBot, tx + tubeW, tBot - rBot, rBot); ctx.lineTo(tx + tubeW, tTop); ctx.stroke(); ctx.restore(); /* rim */ ctx.strokeStyle = 'rgba(200,215,255,0.35)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(tx - 5, tTop); ctx.lineTo(tx + tubeW + 5, tTop); ctx.stroke(); /* glass shine */ const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.4, 0); shine.addColorStop(0, 'rgba(255,255,255,0.12)'); shine.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = shine; ctx.beginPath(); ctx.moveTo(tx + 4, tTop + 4); ctx.lineTo(tx + tubeW * 0.32, tTop + 4); ctx.lineTo(tx + tubeW * 0.32, tBot - tubeW * 0.25); ctx.lineTo(tx + 4, tBot - tubeW * 0.25); ctx.closePath(); ctx.fill(); /* mode watermark */ ctx.save(); ctx.font = '700 11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.35)'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText( this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', W * 0.5, H - 4 ); ctx.restore(); } /* ── Public API ──────────────────────────────────────────────── */ stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } destroy() { this.stop(); } } /* ── helpers ─────────────────────────────────────────────────────── */ function _esc(s) { if (!s) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>'); } /* ── 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(); } })); }