'use strict'; /* ════════════════════════════════════════════════════════════════════ QualAnalysisSim — Качественный анализ катионов и анионов Режим 1: «Определить ион» (guided identification) Режим 2: «Неизвестное вещество» (drag-drop free experiment) ════════════════════════════════════════════════════════════════════ */ class QualAnalysisSim { /* ── Ion database ─────────────────────────────────────────────── */ static IONS = [ /* КАТИОНЫ */ { id: 'Na+', label: 'Na⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя жёлтое', color: '#FFD700', type: 'flame', positive: true }, NaOH: { obs: 'Нет заметного осадка', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'K+', label: 'K⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя фиолетовое (через синее стекло)', color: '#CC00FF', type: 'flame', positive: true }, NaOH: { obs: 'Нет осадка', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'NH4+', label: 'NH₄⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'При нагреве — газ NH₃↑ (запах, влажная лакмусовая бумага синеет)', color: '#CCFFCC', type: 'gas', gasLabel: 'NH₃↑', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Mg2+', label: 'Mg²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Mg(OH)₂, не растворим в избытке NaOH', color: '#FFFFFF', type: 'precip', precipLabel: 'Mg(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Ca2+', label: 'Ca²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя кирпично-красное', color: '#CC4400', type: 'flame', positive: true }, NaOH: { obs: 'Слабый белый осадок Ca(OH)₂ (малорастворим)', color: '#EEEEEE', type: 'precip', precipLabel: 'Ca(OH)₂↓', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок CaSO₄↓ (малорастворим)', color: '#FFFFFF', type: 'precip', precipLabel: 'CaSO₄↓', positive: true }, } }, { id: 'Ba2+', label: 'Ba²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Пламя зелёное', color: '#00DD00', type: 'flame', positive: true }, NaOH: { obs: 'Белый осадок Ba(OH)₂', color: '#EEEEEE', type: 'precip', precipLabel: 'Ba(OH)₂↓', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок BaSO₄↓, нерастворим в HNO₃', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true }, } }, { id: 'Al3+', label: 'Al³⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Al(OH)₃↓, растворяется в избытке NaOH (амфотерность)', color: '#DDDDDD', type: 'precip', precipLabel: 'Al(OH)₃↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Fe2+', label: 'Fe²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(100,160,80,0.25)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Зеленоватый осадок Fe(OH)₂↓', color: '#88BB66', type: 'precip', precipLabel: 'Fe(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Слабое розовое окрашивание (следы Fe³⁺), не яркое', color: 'rgba(255,100,80,0.3)', type: 'solution', positive: false }, K3FeCN6: { obs: 'Синий осадок — Турнбулева синь (Fe₃[Fe(CN)₆]₂)', color: '#1144CC', type: 'precip', precipLabel: 'Турн. синь↓', positive: true }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Fe3+', label: 'Fe³⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(200,100,30,0.3)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Бурый осадок Fe(OH)₃↓', color: '#884422', type: 'precip', precipLabel: 'Fe(OH)₃↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Ярко-красный раствор — роданид железа(III)', color: '#DD1100', type: 'solution', positive: true }, K3FeCN6: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции (осаждается NaOH)', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Cu2+', label: 'Cu²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(30,120,200,0.35)', reactions: { flame: { obs: 'Пламя зелёное (галогениды — синий)', color: '#00BB44', type: 'flame', positive: false }, NaOH: { obs: 'Голубой осадок Cu(OH)₂↓', color: '#5599FF', type: 'precip', precipLabel: 'Cu(OH)₂↓', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Ярко-синий раствор комплекса [Cu(NH₃)₄]²⁺', color: '#0044EE', type: 'solution', positive: true }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Ag+', label: 'Ag⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Коричневый осадок Ag₂O↓', color: '#886622', type: 'precip', precipLabel: 'Ag₂O↓', positive: false }, HCl: { obs: 'Белый творожистый осадок AgCl↓, темнеет на свету', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Белый осадок AgSCN↓', color: '#FFFFFF', type: 'precip', precipLabel: 'AgSCN↓', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Растворяется — комплекс [Ag(NH₃)₂]⁺', color: 'rgba(255,255,255,0.05)', type: 'solution', positive: true }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Pb2+', label: 'Pb²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(200,200,200,0.12)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Pb(OH)₂↓, растворим в избытке', color: '#EEEEEE', type: 'precip', precipLabel: 'Pb(OH)₂↓', positive: false }, HCl: { obs: 'Белый осадок PbCl₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbCl₂↓', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Белый осадок PbSO₄↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbSO₄↓', positive: true }, } }, { id: 'Zn2+', label: 'Zn²⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке NaOH (амфотерность)', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке — [Zn(NH₃)₄]²⁺', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'H+', label: 'H⁺', type: 'cat', group: 'Катионы', solColor: 'rgba(255,255,200,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нейтрализация. Лакмус синеет при добавлении щёлочи', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: true }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нейтрализация: H⁺ + NH₃ → NH₄⁺', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, }, indicators: { litmus: 'красный', methylorange: 'красный', phenolphthalein: 'бесцветный' } }, { id: 'OH-', label: 'OH⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нейтрализация, нет видимой реакции', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нейтрализация', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, }, indicators: { litmus: 'синий', methylorange: 'жёлтый', phenolphthalein: 'малиновый' } }, /* АНИОНЫ */ { id: 'Cl-', label: 'Cl⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Белый творожистый осадок AgCl↓, нерастворим в HNO₃', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'Br-', label: 'Br⁻', type: 'an', group: 'Анионы', solColor: 'rgba(180,80,0,0.15)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Желтоватый осадок AgBr↓', color: '#EEEE88', type: 'precip', precipLabel: 'AgBr↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'I-', label: 'I⁻', type: 'an', group: 'Анионы', solColor: 'rgba(100,0,120,0.2)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Жёлтый осадок AgI↓, практически нерастворим в NH₃', color: '#FFEE44', type: 'precip', precipLabel: 'AgI↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'SO42-', label: 'SO₄²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaSO₄↓, нерастворим в кислотах', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'SO32-', label: 'SO₃²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaSO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₃↓', positive: true }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true }, } }, { id: 'CO32-', label: 'CO₃²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Бурное выделение CO₂↑ (пузыри), мутит известковую воду Ca(OH)₂', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Белый осадок BaCO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaCO₃↓', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Бурное выделение CO₂↑ (пузыри)', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true }, } }, { id: 'NO3-', label: 'NO₃⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'При нагреве с Cu: бурый газ NO₂↑ («бурое кольцо» с FeSO₄)', color: '#DD8800', type: 'gas', gasLabel: 'NO₂↑', positive: true }, } }, { id: 'PO43-', label: 'PO₄³⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Жёлтый осадок Ag₃PO₄↓', color: '#EECC44', type: 'precip', precipLabel: 'Ag₃PO₄↓', positive: true }, BaCl2: { obs: 'Белый осадок Ba₃(PO₄)₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'Ba₃(PO₄)₂↓', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, } }, { id: 'S2-', label: 'S²⁻', type: 'an', group: 'Анионы', solColor: 'rgba(200,200,100,0.1)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true }, AgNO3: { obs: 'Чёрный осадок Ag₂S↓', color: '#111111', type: 'precip', precipLabel: 'Ag₂S↓', positive: true }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true }, } }, { id: 'CH3COO-', label: 'CH₃COO⁻', type: 'an', group: 'Анионы', solColor: 'rgba(255,255,255,0.08)', reactions: { flame: { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false }, NaOH: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, HCl: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, AgNO3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, BaCl2: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, KSCN: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Запах уксуса; с FeCl₃ при нагреве — бурый осадок', color: '#AA6622', type: 'solution', positive: true }, } }, ]; static REAGENTS = [ { id: 'NaOH', label: 'NaOH', color: '#8866FF' }, { id: 'HCl', label: 'HCl', color: '#FF6644' }, { id: 'AgNO3', label: 'AgNO₃', color: '#CCCCCC' }, { id: 'BaCl2', label: 'BaCl₂', color: '#44BBFF' }, { id: 'KSCN', label: 'KSCN', color: '#FF4444' }, { id: 'K3FeCN6', label: 'K₃[Fe(CN)₆]', color: '#FFAA00' }, { id: 'NH3', label: 'NH₃', color: '#AAFFAA' }, { id: 'H2SO4', label: 'H₂SO₄', color: '#FFFF44' }, { id: 'flame', label: 'Пламя', color: '#FF8800' }, ]; /* ── constructor ─────────────────────────────────────────────── */ constructor(container) { this._container = container; this._mode = 'identify'; // 'identify' | 'unknown' this._targetIon = null; this._log = []; this._answered = false; this._dropAnim = []; this._precipParticles = []; this._gasParticles = []; this._raf = null; this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: 'rgba(100,180,255,0.18)' }; this._dragReagent = null; this._dragX = 0; this._dragY = 0; this._score = 0; this._lastT = 0; this._build(); this._bindEvents(); this._startMode('identify'); } /* ── DOM build ───────────────────────────────────────────────── */ _build() { this._container.innerHTML = ''; this._container.style.cssText = 'display:flex;flex-direction:column;height:100%;background:#0D0D1A;color:#E0E0FF;font-family:Manrope,sans-serif;overflow:hidden;user-select:none'; /* top toolbar */ const tb = document.createElement('div'); tb.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;flex-wrap:wrap'; tb.innerHTML = `
Счёт: 0 `; this._container.appendChild(tb); /* main area */ const main = document.createElement('div'); main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden'; this._container.appendChild(main); /* left panel: log + reagents */ const left = document.createElement('div'); left.id = 'qa-left'; left.style.cssText = 'width:230px;display:flex;flex-direction:column;border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0;overflow:hidden'; main.appendChild(left); /* reagent shelf */ const shelf = document.createElement('div'); shelf.id = 'qa-shelf'; shelf.style.cssText = 'padding:10px 8px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0'; shelf.innerHTML = '
Реагенты
'; const shelfGrid = document.createElement('div'); shelfGrid.id = 'qa-shelf-grid'; shelfGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px'; QualAnalysisSim.REAGENTS.forEach(r => { const btn = document.createElement('button'); btn.className = 'qa-reagent-btn'; btn.dataset.reagent = r.id; btn.title = r.label; btn.style.cssText = `padding:4px 7px;border-radius:7px;border:1px solid ${r.color}44;background:${r.color}18;color:${r.color};font-size:.72rem;font-weight:700;cursor:pointer;transition:background .15s`; btn.textContent = r.label; shelfGrid.appendChild(btn); }); shelf.appendChild(shelfGrid); left.appendChild(shelf); /* log */ const logWrap = document.createElement('div'); logWrap.style.cssText = 'flex:1;overflow-y:auto;padding:8px'; logWrap.innerHTML = '
Наблюдения
'; const logList = document.createElement('div'); logList.id = 'qa-log'; logList.style.cssText = 'display:flex;flex-direction:column;gap:4px'; logWrap.appendChild(logList); left.appendChild(logWrap); /* center: tube + canvas */ const center = document.createElement('div'); center.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden'; main.appendChild(center); const canvas = document.createElement('canvas'); canvas.id = 'qa-canvas'; canvas.style.cssText = 'display:block;cursor:crosshair'; center.appendChild(canvas); this._canvas = canvas; this._ctx = canvas.getContext('2d'); /* answer bar */ const ansBar = document.createElement('div'); ansBar.id = 'qa-ansbar'; ansBar.style.cssText = 'padding:8px 14px;border-top:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap'; ansBar.innerHTML = ` Добавляй реагенты и определи ион в пробирке `; this._container.appendChild(ansBar); /* right panel: ion reference list (mode 1) */ const right = document.createElement('div'); right.id = 'qa-right'; right.style.cssText = 'width:180px;border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto;padding:8px;flex-shrink:0'; right.innerHTML = '
Список ионов
'; const ionList = document.createElement('div'); ionList.id = 'qa-ionlist'; ionList.style.cssText = 'display:flex;flex-direction:column;gap:3px'; ['Катионы','Анионы'].forEach(grp => { const h = document.createElement('div'); h.style.cssText = 'font-size:.67rem;color:#666;text-transform:uppercase;letter-spacing:.05em;margin-top:6px;margin-bottom:2px'; h.textContent = grp; ionList.appendChild(h); QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => { const d = document.createElement('div'); d.className = 'qa-ion-card'; d.dataset.id = ion.id; d.style.cssText = 'font-size:.75rem;padding:3px 7px;border-radius:6px;border:1px solid rgba(255,255,255,0.07);background:rgba(255,255,255,0.03);cursor:default;color:#CCC'; d.textContent = ion.label; ionList.appendChild(d); }); }); right.appendChild(ionList); main.appendChild(right); this._resizeFit(); } _resizeFit() { const c = this._canvas; const p = c.parentElement; if (!p) return; const w = p.clientWidth || 400; const h = p.clientHeight || 360; c.width = w; c.height = h; this._W = w; this._H = h; this._drawTube(); } /* ── Events ──────────────────────────────────────────────────── */ _bindEvents() { const el = id => document.getElementById(id); el('qa-btn-identify').addEventListener('click', () => this._startMode('identify')); el('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown')); el('qa-btn-new').addEventListener('click', () => this._startMode(this._mode)); el('qa-submit').addEventListener('click', () => this._submitAnswer()); /* reagent buttons → click to apply */ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { btn.addEventListener('click', () => { if (this._answered) return; const rid = btn.dataset.reagent; this._applyReagent(rid); /* visual flash */ const col = btn.style.color; btn.style.background = col + '44'; setTimeout(() => { btn.style.background = col + '18'; }, 200); }); }); /* drag-and-drop reagent to canvas */ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { btn.addEventListener('dragstart', e => { this._dragReagent = btn.dataset.reagent; e.dataTransfer.effectAllowed = 'copy'; }); btn.setAttribute('draggable', 'true'); }); this._canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); this._canvas.addEventListener('drop', e => { e.preventDefault(); if (this._dragReagent && !this._answered) { const rect = this._canvas.getBoundingClientRect(); this._dragX = e.clientX - rect.left; this._dragY = e.clientY - rect.top; this._applyReagent(this._dragReagent); this._dragReagent = null; } }); /* ResizeObserver */ if (window.ResizeObserver) { const ro = new ResizeObserver(() => this._resizeFit()); ro.observe(this._canvas.parentElement || this._container); } } /* ── Mode start ──────────────────────────────────────────────── */ _startMode(mode) { this._mode = mode; this._log = []; this._answered = false; this._dropAnim = []; this._precipParticles = []; this._gasParticles = []; /* pick random ion */ const ions = QualAnalysisSim.IONS; this._targetIon = ions[Math.floor(Math.random() * ions.length)]; this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)', }; /* reset UI */ document.getElementById('qa-log').innerHTML = ''; document.getElementById('qa-verdict').style.display = 'none'; document.getElementById('qa-verdict').textContent = ''; this._highlightMode(mode); this._updateAnswerSelect(); this._populateAnswerQuestion(mode); this._updateIonHighlight(null); this._drawTube(); if (!this._raf) this._animLoop(performance.now()); } _populateAnswerQuestion(mode) { if (mode === 'identify') { document.getElementById('qa-question').textContent = 'Добавляй реагенты и определи ион в пробирке — выбери ответ и нажми «Ответить»'; } else { document.getElementById('qa-question').textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь'; } } _highlightMode(mode) { const bi = document.getElementById('qa-btn-identify'); const bu = document.getElementById('qa-btn-unknown'); const active = 'border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF'; const inactive = 'border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa'; bi.style.cssText = bi.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'identify' ? active : inactive); bu.style.cssText = bu.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'unknown' ? active : inactive); } _updateAnswerSelect() { const sel = document.getElementById('qa-answer-sel'); sel.innerHTML = ''; ['Катионы','Анионы'].forEach(grp => { const og = document.createElement('optgroup'); og.label = grp; QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => { const opt = document.createElement('option'); opt.value = ion.id; opt.textContent = ion.label; og.appendChild(opt); }); sel.appendChild(og); }); } /* ── Apply reagent ───────────────────────────────────────────── */ _applyReagent(reagentId) { const ion = this._targetIon; const rxn = ion.reactions[reagentId]; if (!rxn) return; const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId); const rLabel = rInfo ? rInfo.label : reagentId; /* LabFX sounds */ if (window.LabFX) { if (rxn.type === 'gas') { LabFX.sound.play('fizz'); } else { LabFX.sound.play('pour'); } } /* update tube state */ if (rxn.type === 'flame') { this._tubeState.flameColor = rxn.color; setTimeout(() => { this._tubeState.flameColor = null; this._drawTube(); }, 2000); } else if (rxn.type === 'precip' && rxn.color) { this._tubeState.precipColor = rxn.color; this._tubeState.precipH = 0; /* animate precip settling */ this._precipParticles = this._spawnPrecipParticles(rxn.color); } else if (rxn.type === 'solution' && rxn.color) { this._tubeState.solColor = rxn.color; } else if (rxn.type === 'gas' && rxn.color) { this._tubeState.gasLabel = rxn.gasLabel || '↑'; this._gasParticles = this._spawnGasParticles(rxn.color); } /* drop animation */ const cx = this._W * 0.5; const cy = 60; this._dropAnim.push({ x: cx, y: 20, vy: 2, color: rInfo ? rInfo.color : '#FFF', alpha: 1, done: false }); /* log entry */ const isPositive = rxn.positive; const entry = { reagent: rLabel, obs: rxn.obs, positive: isPositive, excess: rxn.excess || null }; this._log.push(entry); this._renderLogEntry(entry); this._updateIonHighlight(this._log); } _spawnPrecipParticles(color) { const cx = this._W * 0.5; const cy = this._H * 0.6; const ps = []; for (let i = 0; i < 22; i++) { ps.push({ x: cx + (Math.random() - 0.5) * 60, y: cy, vy: 0.5 + Math.random() * 1.5, vx: (Math.random() - 0.5) * 1.5, color, r: 2 + Math.random() * 3, done: false }); } return ps; } _spawnGasParticles(color) { const cx = this._W * 0.5; const cy = this._H * 0.4; const ps = []; for (let i = 0; i < 18; i++) { ps.push({ x: cx + (Math.random() - 0.5) * 30, y: cy, vy: -(0.8 + Math.random() * 1.2), vx: (Math.random() - 0.5), color, r: 3 + Math.random() * 4, alpha: 0.85, done: false }); } return ps; } _renderLogEntry(entry) { const log = document.getElementById('qa-log'); const d = document.createElement('div'); const col = entry.positive ? '#5EF08E' : '#888'; d.style.cssText = `font-size:.72rem;padding:4px 6px;border-radius:6px;border-left:3px solid ${col};background:rgba(255,255,255,0.03);color:#CCC;line-height:1.4`; d.innerHTML = `${_esc(entry.reagent)}: ${_esc(entry.obs)}${entry.excess ? `
${_esc(entry.excess)}
` : ''}`; log.appendChild(d); log.scrollTop = log.scrollHeight; } /* highlight ions that are consistent with observations */ _updateIonHighlight(log) { const cards = this._container.querySelectorAll('.qa-ion-card'); if (!log || log.length === 0) { cards.forEach(c => { c.style.background = 'rgba(255,255,255,0.03)'; c.style.color = '#CCC'; c.style.borderColor = 'rgba(255,255,255,0.07)'; }); return; } cards.forEach(c => { const ionId = c.dataset.id; const ion = QualAnalysisSim.IONS.find(i => i.id === ionId); if (!ion) return; let compatible = true; for (const entry of log) { /* find reagent id from label */ const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent); if (!rInfo) continue; const expectedRxn = ion.reactions[rInfo.id]; if (!expectedRxn) continue; /* if positive result observed but ion doesn't produce positive here */ if (entry.positive && !expectedRxn.positive) { compatible = false; break; } } if (compatible) { c.style.background = 'rgba(155,93,229,0.12)'; c.style.color = '#D0A0FF'; c.style.borderColor = 'rgba(155,93,229,0.3)'; } else { c.style.background = 'rgba(255,255,255,0.01)'; c.style.color = '#444'; c.style.borderColor = 'rgba(255,255,255,0.04)'; } }); } /* ── Submit answer ───────────────────────────────────────────── */ _submitAnswer() { if (this._answered) return; const sel = document.getElementById('qa-answer-sel'); const chosen = sel.value; if (!chosen) return; this._answered = true; const correct = chosen === this._targetIon.id; const verdict = document.getElementById('qa-verdict'); verdict.style.display = 'block'; if (correct) { this._score++; document.getElementById('qa-score').textContent = this._score; verdict.textContent = 'Верно! Это ' + this._targetIon.label; verdict.style.background = 'rgba(94,240,142,0.15)'; verdict.style.color = '#5EF08E'; verdict.style.border = '1px solid rgba(94,240,142,0.3)'; if (window.LabFX) LabFX.sound.play('chime'); } else { const correctIon = QualAnalysisSim.IONS.find(i => i.id === this._targetIon.id); verdict.textContent = 'Неверно. Правильный ответ: ' + (correctIon ? correctIon.label : this._targetIon.id); verdict.style.background = 'rgba(239,71,111,0.12)'; verdict.style.color = '#EF476F'; verdict.style.border = '1px solid rgba(239,71,111,0.3)'; } /* highlight correct ion card */ const cards = this._container.querySelectorAll('.qa-ion-card'); cards.forEach(c => { if (c.dataset.id === this._targetIon.id) { c.style.background = 'rgba(94,240,142,0.15)'; c.style.color = '#5EF08E'; c.style.borderColor = '#5EF08E'; } }); } /* ── Animation loop ──────────────────────────────────────────── */ _animLoop(t) { this._raf = requestAnimationFrame(ts => this._animLoop(ts)); const dt = Math.min((t - this._lastT) / 1000, 0.05); this._lastT = t; /* advance particles */ let needDraw = false; this._dropAnim = this._dropAnim.filter(d => { if (d.done) return false; d.y += d.vy; d.vy += 0.2; if (d.y > this._H * 0.55) { d.done = true; needDraw = true; return false; } needDraw = true; return true; }); this._precipParticles.forEach(p => { if (!p.done) { p.x += p.vx; p.y += p.vy; p.vy *= 0.98; const floor = this._H * 0.82; if (p.y >= floor) { p.y = floor; p.vy = 0; p.vx = 0; p.done = true; this._tubeState.precipH = Math.min(this._tubeState.precipH + 2, 30); } needDraw = true; } }); this._gasParticles = this._gasParticles.filter(p => { if (p.done) return false; p.y += p.vy; p.x += p.vx; p.alpha -= dt * 0.5; if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; } needDraw = true; return true; }); if (needDraw || this._tubeState.flameColor) this._drawTube(); } /* ── Draw ────────────────────────────────────────────────────── */ _drawTube() { const ctx = this._ctx; const W = this._W || 400; const H = this._H || 360; ctx.clearRect(0, 0, W, H); /* background */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); /* bench surface */ ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fillRect(0, H * 0.87, W, H * 0.13); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, H * 0.87); ctx.lineTo(W, H * 0.87); ctx.stroke(); /* tube dimensions */ const tx = W * 0.5 - 28; const tw = 56; const tTop = H * 0.15; const tBot = H * 0.85; const tH = tBot - tTop; const r = 10; /* flame halo */ if (this._tubeState.flameColor) { const fc = this._tubeState.flameColor; const grad = ctx.createRadialGradient(W * 0.5, tTop - 20, 5, W * 0.5, tTop - 20, 80); grad.addColorStop(0, fc + 'CC'); grad.addColorStop(0.4, fc + '44'); grad.addColorStop(1, fc + '00'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(W * 0.5, tTop - 20, 80, 0, Math.PI * 2); ctx.fill(); /* flame label */ ctx.font = 'bold 12px Manrope,sans-serif'; ctx.fillStyle = fc; ctx.textAlign = 'center'; const flameLabel = (() => { if (fc === '#FFD700') return 'Жёлтое пламя — Na⁺'; if (fc === '#CC00FF') return 'Фиолетовое — K⁺'; if (fc === '#CC4400') return 'Кирпично-красное — Ca²⁺'; if (fc === '#00DD00') return 'Зелёное — Ba²⁺'; if (fc === '#00BB44') return 'Зелёное пламя'; return 'Окрашивание пламени'; })(); ctx.fillText(flameLabel, W * 0.5, tTop - 40); } /* tube shadow */ ctx.shadowColor = 'rgba(155,93,229,0.2)'; ctx.shadowBlur = 18; /* solution fill */ const solTop = tTop + tH * 0.05; const solBot = tBot - 5; const solH = solBot - solTop; ctx.shadowBlur = 0; ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)'; ctx.beginPath(); ctx.moveTo(tx + r, solTop); ctx.lineTo(tx + tw - r, solTop); ctx.lineTo(tx + tw - r, solBot - r); ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r); ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r); ctx.lineTo(tx + r, solTop); ctx.closePath(); ctx.fill(); /* precipitate layer */ if (this._tubeState.precipColor && this._tubeState.precipH > 0) { const ph = this._tubeState.precipH; const py = solBot - ph; ctx.fillStyle = this._tubeState.precipColor; ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(tx + r, py); ctx.lineTo(tx + tw - r, py); ctx.lineTo(tx + tw - r, solBot - r); ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r); ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r); ctx.lineTo(tx + r, py); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; /* precipitate label */ if (this._precipParticles && this._precipParticles.every(p => p.done)) { ctx.font = 'bold 10px Manrope,sans-serif'; ctx.fillStyle = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor; ctx.textAlign = 'center'; /* find label from last positive precip reaction */ const lastPrecip = [...this._log].reverse().find(l => { const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent); if (!rInfo) return false; const rxn = this._targetIon.reactions[rInfo.id]; return rxn && rxn.type === 'precip' && rxn.precipLabel; }); if (lastPrecip) { const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent); const rxn = rInfo ? this._targetIon.reactions[rInfo.id] : null; if (rxn && rxn.precipLabel) { ctx.fillText(rxn.precipLabel, W * 0.5, solBot - ph / 2 + 4); } } } } /* falling drop particles */ this._dropAnim.forEach(d => { ctx.globalAlpha = d.alpha; ctx.fillStyle = d.color; ctx.beginPath(); ctx.arc(d.x, d.y, 5, 0, Math.PI * 2); ctx.fill(); /* drop tail */ ctx.fillStyle = d.color + '88'; ctx.beginPath(); ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }); /* floating precipitate particles */ this._precipParticles.filter(p => !p.done).forEach(p => { ctx.globalAlpha = 0.75; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }); /* gas bubbles */ this._gasParticles.forEach(p => { ctx.globalAlpha = p.alpha; ctx.strokeStyle = p.color; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1; }); /* gas label above tube */ if (this._tubeState.gasLabel) { ctx.font = 'bold 13px Manrope,sans-serif'; ctx.fillStyle = '#FFFFAA'; ctx.textAlign = 'center'; ctx.fillText(this._tubeState.gasLabel, W * 0.5 + 40, tTop + 20); } /* tube glass outline */ ctx.shadowColor = 'rgba(155,93,229,0.3)'; ctx.shadowBlur = 12; ctx.strokeStyle = 'rgba(200,210,255,0.55)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(tx, tTop); ctx.lineTo(tx, tBot - r); ctx.arcTo(tx, tBot, tx + r, tBot, r); ctx.lineTo(tx + tw - r, tBot); ctx.arcTo(tx + tw, tBot, tx + tw, tBot - r, r); ctx.lineTo(tx + tw, tTop); ctx.stroke(); ctx.shadowBlur = 0; /* tube opening rim */ ctx.strokeStyle = 'rgba(200,210,255,0.35)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(tx - 4, tTop); ctx.lineTo(tx + tw + 4, tTop); ctx.stroke(); /* tube shine */ const shine = ctx.createLinearGradient(tx, 0, tx + tw * 0.35, 0); shine.addColorStop(0, 'rgba(255,255,255,0.10)'); shine.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = shine; ctx.beginPath(); ctx.moveTo(tx + 3, tTop); ctx.lineTo(tx + tw * 0.3, tTop); ctx.lineTo(tx + tw * 0.3, tBot - 15); ctx.lineTo(tx + 3, tBot - 15); ctx.closePath(); ctx.fill(); /* mode label */ ctx.font = '700 11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(155,93,229,0.5)'; ctx.textAlign = 'center'; ctx.fillText(this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', W * 0.5, H - 8); } stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } } /* ── helpers ─────────────────────────────────────────────────────── */ function _esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&').replace(//g,'>'); } /* ── 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(); } })); }