From ea2526dc7350a9da2316e491cf3c3f0f6623f38d Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 26 May 2026 13:08:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(labs):=204=20=D1=88=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=85=D0=B8=D0=BC.=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=20+=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=BA=D0=B0=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 НОВЫЕ СИМЫ (школьная программа 8-11 классов): Органика (organic.js, 1545 строк): - Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds - Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат - IUPAC-имена для C1-C10 - Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл - 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂ Периодическая таблица (periodic.js, 118 элементов): - Стандартный вид 18×9 + лантаноиды/актиноиды - Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип - Боровская модель электронных оболочек (анимированная) - Подсветка: 11 типов / s/p/d/f-блоки / без подсветки - Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип) - Поиск по символу/имени/Z/массе Качественный анализ (qualanalysis.js, 24 иона): - 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH - 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO - 9 реактивов + пламя - 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений - Анимация капли, осадка с цветом, газовых пузырей, пламени Растворы (solutions.js, 4 режима): - Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта - Разбавление с before/after визуализацией - Смешивание двух растворов с правилом рычага - Кривые растворимости 8 веществ + задача перекристаллизации - 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...) ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file): 12 функций школьной лабораторной графики: - drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой - drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя - animateGasBubbles / animatePrecipitateFall — анимация продуктов - drawProductLabel — fade-in/out стрелка ↑/↓ с подписью - drawEduTooltip — bubble с пояснением реакции - drawDeskBackground / drawVesselShadow — лабораторный фон - drawPHStrip — pH-индикаторная полоса с маркером Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов, educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask. pH-полоса в titration. Каталог теперь: 39 симуляций (было 35 + 4 новых). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/js/admin/sections/sims.js | 4 + frontend/js/labs/_chem_visuals.js | 778 ++++++++++++++ frontend/js/labs/chemsandbox.js | 110 +- frontend/js/labs/electrolysis.js | 7 + frontend/js/labs/flask.js | 48 +- frontend/js/labs/ionexchange.js | 61 ++ frontend/js/labs/lab-glue.js | 101 ++ frontend/js/labs/lab-init.js | 58 +- frontend/js/labs/organic.js | 1545 ++++++++++++++++++++++++++++ frontend/js/labs/periodic.js | 750 ++++++++++++++ frontend/js/labs/qualanalysis.js | 1062 +++++++++++++++++++ frontend/js/labs/redox.js | 61 ++ frontend/js/labs/solutions.js | 1139 ++++++++++++++++++++ frontend/js/labs/titration.js | 12 + frontend/lab.html | 23 + 15 files changed, 5738 insertions(+), 21 deletions(-) create mode 100644 frontend/js/labs/_chem_visuals.js create mode 100644 frontend/js/labs/organic.js create mode 100644 frontend/js/labs/periodic.js create mode 100644 frontend/js/labs/qualanalysis.js create mode 100644 frontend/js/labs/solutions.js diff --git a/frontend/js/admin/sections/sims.js b/frontend/js/admin/sections/sims.js index ffa0623..2ce4298 100644 --- a/frontend/js/admin/sections/sims.js +++ b/frontend/js/admin/sections/sims.js @@ -38,6 +38,10 @@ { id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' }, { id: 'stoichiometry', cat: 'Химия', title: 'Стехиометрия' }, { id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' }, + { id: 'qualanalysis', cat: 'Химия', title: 'Качественный анализ' }, + { id: 'periodic', cat: 'Химия', title: 'Периодическая таблица' }, + { id: 'organic', cat: 'Химия', title: 'Органическая химия' }, + { id: 'solutions', cat: 'Химия', title: 'Растворы' }, { id: 'celldivision', cat: 'Биология', title: 'Деление клетки' }, { id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' }, { id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' }, diff --git a/frontend/js/labs/_chem_visuals.js b/frontend/js/labs/_chem_visuals.js new file mode 100644 index 0000000..6947bc2 --- /dev/null +++ b/frontend/js/labs/_chem_visuals.js @@ -0,0 +1,778 @@ +'use strict'; +/** + * _chem_visuals.js — shared lab-glassware drawing helpers + * Used by: chemsandbox, flask, titration, electrolysis, ionexchange, redox + * + * Public API: + * ChemVisuals.drawErlenmeyer(ctx, x, y, w, h, fillColor) + * ChemVisuals.drawBeaker(ctx, x, y, w, h, fillColor, opts) + * ChemVisuals.drawBurette(ctx, x, y, h, fillColor, valveOpen) + * ChemVisuals.drawTube(ctx, x, y, h, fillColor) + * ChemVisuals.drawSpiritLamp(ctx, x, y, flameOn, t) + * ChemVisuals.animateGasBubbles(ctx, x, y, color, t) + * ChemVisuals.animatePrecipitateFall(ctx, x, y, color, t) + * ChemVisuals.drawProductLabel(ctx, x, y, text, type, age) + * ChemVisuals.drawEduTooltip(ctx, x, y, w, lines, age) + * ChemVisuals.drawDeskBackground(ctx, W, H, tableY) + * ChemVisuals.drawPHStrip(ctx, x, y, pH) + */ + +window.ChemVisuals = (() => { + + /* ── internal helpers ─────────────────────────────────────────── */ + + function _rrect(ctx, x, y, w, h, r) { + r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + } + + function _glassHighlight(ctx, x, y, w, h) { + /* left-edge specular stripe */ + const hl = ctx.createLinearGradient(x, y, x + w * 0.28, y); + hl.addColorStop(0, 'rgba(255,255,255,0.22)'); + hl.addColorStop(0.45, 'rgba(255,255,255,0.07)'); + hl.addColorStop(1, 'rgba(255,255,255,0.00)'); + ctx.save(); + ctx.fillStyle = hl; + ctx.fillRect(x + 2, y + 4, w * 0.26, h - 8); + ctx.restore(); + } + + /* ── Erlenmeyer flask (Коническая колба) ─────────────────────── */ + /* x,y = top-center of neck; w = body width; h = total height */ + function drawErlenmeyer(ctx, x, y, w, h, fillColor) { + const nw = w * 0.14; /* half-neck-width */ + const nb = y + h * 0.28; /* neck-bottom y */ + const bHalf = w * 0.48; /* half-body-width */ + const bot = y + h; + + /* erlenmeyer path: straight neck → shoulder curve → flat bottom */ + const erlPath = () => { + ctx.beginPath(); + ctx.moveTo(x - nw, y); + ctx.lineTo(x - nw, nb); + /* left shoulder */ + ctx.bezierCurveTo(x - nw, nb + h * 0.18, x - bHalf, bot - h * 0.10, x - bHalf, bot); + ctx.lineTo(x + bHalf, bot); + /* right shoulder */ + ctx.bezierCurveTo(x + bHalf, bot - h * 0.10, x + nw, nb + h * 0.18, x + nw, nb); + ctx.lineTo(x + nw, y); + ctx.closePath(); + }; + + /* liquid fill */ + if (fillColor) { + ctx.save(); + erlPath(); + ctx.clip(); + const fillTop = nb + h * 0.22; + const fg = ctx.createLinearGradient(0, fillTop, 0, bot); + fg.addColorStop(0, _alpha(fillColor, 0.35)); + fg.addColorStop(1, _alpha(fillColor, 0.65)); + ctx.fillStyle = fg; + ctx.fillRect(x - bHalf - 2, fillTop, bHalf * 2 + 4, bot - fillTop + 2); + ctx.restore(); + } + + /* glass tint fill */ + ctx.save(); + erlPath(); + const gf = ctx.createLinearGradient(x - bHalf, y, x + bHalf, bot); + gf.addColorStop(0, 'rgba(220,238,255,0.07)'); + gf.addColorStop(1, 'rgba(180,210,255,0.03)'); + ctx.fillStyle = gf; + ctx.fill(); + + /* outline */ + ctx.strokeStyle = 'rgba(140,190,255,0.70)'; + ctx.lineWidth = 1.8; + ctx.stroke(); + ctx.restore(); + + /* neck rim ellipse */ + ctx.save(); + ctx.beginPath(); + ctx.ellipse(x, y, nw + 2, 3, 0, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(180,215,255,0.55)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.restore(); + + /* specular left stripe */ + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x - nw + 2, y + 6); + ctx.lineTo(x - nw + 2, nb + 4); + ctx.bezierCurveTo(x - nw + 2, nb + h * 0.22, x - bHalf * 0.68, bot - h * 0.18, x - bHalf * 0.72, bot - h * 0.10); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; + ctx.lineWidth = 2.5; + ctx.stroke(); + ctx.restore(); + + /* graduation marks on body */ + ctx.save(); + ctx.strokeStyle = 'rgba(180,210,255,0.25)'; + ctx.lineWidth = 0.8; + for (let i = 1; i <= 3; i++) { + const gy = nb + (bot - nb) * (i / 4); + const hw = nw + (bHalf - nw) * (i / 4) - 4; + ctx.beginPath(); + ctx.moveTo(x - hw, gy); + ctx.lineTo(x - hw + 8, gy); + ctx.stroke(); + } + ctx.restore(); + } + + /* ── Beaker (Химический стакан) ─────────────────────────────── */ + /* x,y = top-left corner; w,h = dimensions */ + function drawBeaker(ctx, x, y, w, h, fillColor, opts) { + opts = opts || {}; + const hasPour = opts.spout !== false; /* small spout notch top-right */ + const r = 5; + + /* beaker outline path — open top, straight walls, rounded bottom */ + const bPath = () => { + ctx.beginPath(); + ctx.moveTo(x, y); + if (hasPour) { + ctx.lineTo(x + w - 14, y); + ctx.lineTo(x + w - 10, y - 7); + ctx.lineTo(x + w + 2, y - 7); + ctx.lineTo(x + w, y); + } else { + ctx.lineTo(x + w, y); + } + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y); + }; + + /* liquid fill clipped */ + if (fillColor) { + ctx.save(); + bPath(); + ctx.clip(); + const liqTop = y + h * 0.25; + const lg = ctx.createLinearGradient(0, liqTop, 0, y + h); + lg.addColorStop(0, _alpha(fillColor, 0.30)); + lg.addColorStop(1, _alpha(fillColor, 0.60)); + ctx.fillStyle = lg; + ctx.fillRect(x + 1, liqTop, w - 2, h); + ctx.restore(); + } + + /* glass tint */ + ctx.save(); + bPath(); + const gf = ctx.createLinearGradient(x, y, x + w, y + h); + gf.addColorStop(0, 'rgba(220,238,255,0.06)'); + gf.addColorStop(1, 'rgba(180,210,255,0.02)'); + ctx.fillStyle = gf; + ctx.fill(); + ctx.strokeStyle = 'rgba(130,185,255,0.60)'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + + /* graduation scale on right wall */ + ctx.save(); + ctx.strokeStyle = 'rgba(170,205,255,0.28)'; + ctx.lineWidth = 0.8; + ctx.font = '7px monospace'; + ctx.fillStyle = 'rgba(170,205,255,0.40)'; + ctx.textAlign = 'right'; + for (let i = 1; i <= 4; i++) { + const gy = y + h * 0.85 - (h * 0.55 * i / 4); + ctx.beginPath(); + ctx.moveTo(x + w, gy); + ctx.lineTo(x + w - (i % 2 === 0 ? 8 : 4), gy); + ctx.stroke(); + if (i % 2 === 0) ctx.fillText((i * 25).toString(), x + w - 10, gy + 3); + } + ctx.restore(); + + /* specular highlight */ + _glassHighlight(ctx, x, y, w, h); + } + + /* ── Burette (Бюретка) ──────────────────────────────────────── */ + /* x,y = top-center; h = length; frac = fill fraction 0..1 */ + function drawBurette(ctx, x, y, h, fillColor, valveOpen) { + const bw = 10; /* half-width */ + const valveY = y + h - 12; + + /* glass tube */ + ctx.save(); + const gg = ctx.createLinearGradient(x - bw, 0, x + bw, 0); + gg.addColorStop(0, 'rgba(140,185,255,0.22)'); + gg.addColorStop(0.38, 'rgba(170,210,255,0.08)'); + gg.addColorStop(0.62, 'rgba(170,210,255,0.08)'); + gg.addColorStop(1, 'rgba(120,165,245,0.18)'); + ctx.fillStyle = gg; + _rrect(ctx, x - bw, y, bw * 2, h - 10, 4); + ctx.fill(); + ctx.strokeStyle = 'rgba(130,185,255,0.55)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.restore(); + + /* liquid inside */ + if (fillColor) { + const lh = h * 0.85; + ctx.save(); + _rrect(ctx, x - bw + 2, y + 4, bw * 2 - 4, lh - 4, 3); + ctx.clip(); + const lg = ctx.createLinearGradient(0, y, 0, y + lh); + lg.addColorStop(0, _alpha(fillColor, 0.25)); + lg.addColorStop(1, _alpha(fillColor, 0.45)); + ctx.fillStyle = lg; + ctx.fillRect(x - bw + 2, y + 4, bw * 2 - 4, lh - 4); + ctx.restore(); + } + + /* graduation lines */ + ctx.save(); + ctx.strokeStyle = 'rgba(170,210,255,0.32)'; + ctx.lineWidth = 0.7; + ctx.font = '7px monospace'; + ctx.fillStyle = 'rgba(170,210,255,0.45)'; + ctx.textAlign = 'left'; + for (let i = 0; i <= 10; i++) { + const gy = y + 6 + (h - 18) * (i / 10); + const maj = i % 2 === 0; + ctx.beginPath(); + ctx.moveTo(x + bw, gy); + ctx.lineTo(x + bw + (maj ? 7 : 3), gy); + ctx.stroke(); + if (maj) ctx.fillText((i * 5).toString(), x + bw + 9, gy + 3); + } + ctx.restore(); + + /* stopcock */ + ctx.save(); + ctx.fillStyle = valveOpen ? 'rgba(100,180,120,0.70)' : 'rgba(190,170,140,0.65)'; + _rrect(ctx, x - bw - 3, valveY, bw * 2 + 6, 8, 3); + ctx.fill(); + ctx.strokeStyle = 'rgba(200,200,200,0.35)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + + /* nozzle */ + ctx.save(); + ctx.fillStyle = 'rgba(150,175,220,0.55)'; + ctx.beginPath(); + ctx.moveTo(x - 3, valveY + 8); + ctx.lineTo(x + 3, valveY + 8); + ctx.lineTo(x + 1.5, valveY + 16); + ctx.lineTo(x - 1.5, valveY + 16); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + /* specular */ + ctx.save(); + ctx.strokeStyle = 'rgba(220,240,255,0.25)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x - bw + 3, y + 8); + ctx.lineTo(x - bw + 3, valveY - 6); + ctx.stroke(); + ctx.restore(); + } + + /* ── Test tube (Пробирка) ────────────────────────────────────── */ + /* x,y = top-center; h = length; w = half-width */ + function drawTube(ctx, x, y, h, fillColor) { + const w = 9; + const tubeBot = y + h; + + /* tube path: open top, rounded bottom */ + const tubePath = () => { + ctx.beginPath(); + ctx.moveTo(x - w, y); + ctx.lineTo(x - w, tubeBot - w); + ctx.arc(x, tubeBot - w, w, Math.PI, 0, true); + ctx.lineTo(x + w, y); + }; + + /* fill */ + if (fillColor) { + ctx.save(); + tubePath(); + ctx.clip(); + const liqTop = y + h * 0.35; + const lg = ctx.createLinearGradient(0, liqTop, 0, tubeBot); + lg.addColorStop(0, _alpha(fillColor, 0.35)); + lg.addColorStop(1, _alpha(fillColor, 0.65)); + ctx.fillStyle = lg; + ctx.fillRect(x - w, liqTop, w * 2, tubeBot - liqTop + 2); + ctx.restore(); + } + + /* glass */ + ctx.save(); + tubePath(); + const gf = ctx.createLinearGradient(x - w, y, x + w, tubeBot); + gf.addColorStop(0, 'rgba(210,235,255,0.08)'); + gf.addColorStop(1, 'rgba(180,215,255,0.02)'); + ctx.fillStyle = gf; + ctx.fill(); + ctx.strokeStyle = 'rgba(140,190,255,0.65)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.restore(); + + /* highlight */ + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x - w + 2.5, y + 6); + ctx.lineTo(x - w + 2.5, tubeBot - w * 1.4); + ctx.stroke(); + ctx.restore(); + } + + /* ── Spirit lamp (Спиртовка) ─────────────────────────────────── */ + /* x,y = bottom-center of lamp base; flameOn = bool; t = anim time */ + function drawSpiritLamp(ctx, x, y, flameOn, t) { + t = t || 0; + + /* base / reservoir */ + const rw = 22, rh = 18; + ctx.save(); + const bg = ctx.createLinearGradient(x - rw, y - rh, x + rw, y); + bg.addColorStop(0, 'rgba(180,210,255,0.25)'); + bg.addColorStop(1, 'rgba(140,170,220,0.15)'); + ctx.fillStyle = bg; + _rrect(ctx, x - rw, y - rh, rw * 2, rh, 5); + ctx.fill(); + ctx.strokeStyle = 'rgba(140,185,255,0.55)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + /* alcohol inside (tinted) */ + ctx.save(); + _rrect(ctx, x - rw + 2, y - rh + 3, rw * 2 - 4, rh - 5, 4); + ctx.clip(); + ctx.fillStyle = 'rgba(160,200,255,0.20)'; + ctx.fillRect(x - rw + 2, y - rh * 0.5, rw * 2 - 4, rh); + ctx.restore(); + + /* specular on reservoir */ + ctx.strokeStyle = 'rgba(220,240,255,0.25)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x - rw + 4, y - rh + 5); + ctx.lineTo(x - rw + 4, y - 5); + ctx.stroke(); + ctx.restore(); + + /* wick holder + wick */ + ctx.save(); + ctx.fillStyle = 'rgba(180,175,165,0.70)'; + _rrect(ctx, x - 4, y - rh - 8, 8, 10, 2); + ctx.fill(); + /* wick */ + ctx.fillStyle = 'rgba(240,235,215,0.85)'; + ctx.fillRect(x - 1.5, y - rh - 6, 3, 7); + ctx.restore(); + + /* flame */ + if (flameOn) { + const flickX = Math.sin(t * 7.3) * 2.0 + Math.sin(t * 13.1) * 0.8; + const flickY = Math.abs(Math.sin(t * 5.7)) * 2.0; + const fTop = y - rh - 22 - flickY; + const fMid = y - rh - 14; + const fBase = y - rh - 8; + + /* outer flame (orange) */ + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x + flickX - 6, fBase); + ctx.bezierCurveTo(x + flickX - 8, fMid, x + flickX - 3, fTop + 6, x + flickX, fTop); + ctx.bezierCurveTo(x + flickX + 3, fTop + 6, x + flickX + 8, fMid, x + flickX + 6, fBase); + ctx.closePath(); + const fg = ctx.createLinearGradient(x, fBase, x, fTop); + fg.addColorStop(0, 'rgba(255,120,10,0.90)'); + fg.addColorStop(0.5, 'rgba(255,185,40,0.75)'); + fg.addColorStop(1, 'rgba(255,240,140,0.35)'); + ctx.fillStyle = fg; + ctx.shadowColor = 'rgba(255,140,20,0.55)'; + ctx.shadowBlur = 14; + ctx.fill(); + ctx.restore(); + + /* inner blue core */ + ctx.save(); + ctx.beginPath(); + const cTop = fMid + (fBase - fMid) * 0.15; + ctx.moveTo(x - 2.5, fBase); + ctx.bezierCurveTo(x - 3, fMid + 4, x - 1.5, cTop + 3, x, cTop); + ctx.bezierCurveTo(x + 1.5, cTop + 3, x + 3, fMid + 4, x + 2.5, fBase); + ctx.closePath(); + const cf = ctx.createLinearGradient(x, fBase, x, cTop); + cf.addColorStop(0, 'rgba(80,160,255,0.80)'); + cf.addColorStop(1, 'rgba(120,200,255,0.20)'); + ctx.fillStyle = cf; + ctx.fill(); + ctx.restore(); + + /* warm glow on table beneath lamp */ + ctx.save(); + const gl = ctx.createRadialGradient(x, y, 0, x, y, rw * 1.8); + gl.addColorStop(0, 'rgba(255,130,20,0.12)'); + gl.addColorStop(1, 'rgba(255,130,20,0.00)'); + ctx.fillStyle = gl; + ctx.scale(1, 0.25); + ctx.beginPath(); + ctx.arc(x, y / 0.25, rw * 1.8, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + /* ── Gas bubbles animation ───────────────────────────────────── */ + /* Draws 3 ascending bubbles at staggered offsets */ + function animateGasBubbles(ctx, x, y, color, t) { + t = t || 0; + for (let i = 0; i < 3; i++) { + const phase = (t * 1.4 + i * 0.8) % 2.5; + if (phase > 2.0) continue; /* fade in/out cycle */ + const bx = x + (i - 1) * 6 + Math.sin(phase * 3.1 + i) * 3; + const by = y - phase * 18; + const br = 2.8 + Math.sin(phase * 2) * 0.6; + const alp = Math.min(1, phase < 1.6 ? phase / 0.4 : (2.0 - phase) / 0.4); + ctx.save(); + ctx.globalAlpha = alp * 0.60; + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.strokeStyle = color || 'rgba(200,230,255,0.9)'; + ctx.lineWidth = 0.9; + ctx.stroke(); + /* glint */ + ctx.beginPath(); + ctx.arc(bx - br * 0.3, by - br * 0.3, br * 0.22, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fill(); + ctx.restore(); + } + } + + /* ── Animated up-arrow for gas product ─────────────────────── */ + function _drawArrowUp(ctx, x, y, color, alpha) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1.8; + /* shaft */ + ctx.beginPath(); + ctx.moveTo(x, y + 10); + ctx.lineTo(x, y + 2); + ctx.stroke(); + /* head */ + ctx.beginPath(); + ctx.moveTo(x - 5, y + 6); + ctx.lineTo(x, y); + ctx.lineTo(x + 5, y + 6); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + /* ── Animated down-arrow for precipitate ────────────────────── */ + function _drawArrowDown(ctx, x, y, color, alpha) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1.8; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y + 8); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - 5, y + 4); + ctx.lineTo(x, y + 10); + ctx.lineTo(x + 5, y + 4); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + /* ── Precipitate falling particles ──────────────────────────── */ + function animatePrecipitateFall(ctx, x, y, color, t) { + t = t || 0; + for (let i = 0; i < 4; i++) { + const phase = (t * 0.9 + i * 0.7) % 2.8; + if (phase > 2.2) continue; + const px = x + (i - 1.5) * 7 + Math.sin(phase * 2.5 + i * 1.1) * 3; + const py = y + phase * 14; + const pr = 2.2 + Math.sin(i) * 0.6; + const alp = Math.min(1, phase < 1.8 ? 1 : (2.2 - phase) / 0.4); + ctx.save(); + ctx.globalAlpha = alp * 0.65; + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fillStyle = color || '#CCCCCC'; + ctx.fill(); + ctx.restore(); + } + } + + /* ── Product label (gas or precipitate) ────────────────────── */ + /* age: 0..1, 0=appear, 1=disappear */ + function drawProductLabel(ctx, x, y, text, type, age) { + /* type: 'gas' | 'precip' | 'liquid' */ + age = Math.max(0, Math.min(1, age === undefined ? 0 : age)); + const alpha = age < 0.15 ? age / 0.15 : age > 0.75 ? (1 - age) / 0.25 : 1.0; + if (alpha <= 0) return; + + const isGas = type === 'gas'; + const isPrec = type === 'precip'; + const color = isGas ? '#A8E6FF' : + isPrec ? '#FFD580' : '#9FF0B0'; + const arrowY = isGas ? y - 18 : y + 8; + const textY = isGas ? y - 34 : y + 28; + const offsetX = isGas ? 0 : 0; + + /* bouncing offset for gas (rises) */ + const drift = isGas ? -age * 8 : age * 6; + + if (isGas) { + _drawArrowUp(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9); + } else if (isPrec) { + _drawArrowDown(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9); + } + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.font = 'bold 10px "Courier New", monospace'; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.fillText(text, x + offsetX, textY + drift); + ctx.shadowBlur = 0; + ctx.restore(); + } + + /* ── Educational tooltip bubble ─────────────────────────────── */ + /* x,y = pointer tip; lines = string[]; age = 0..1 */ + function drawEduTooltip(ctx, x, y, w, lines, age) { + age = Math.max(0, Math.min(1, age === undefined ? 0 : age)); + const alpha = age < 0.12 ? age / 0.12 : age > 0.75 ? (1 - age) / 0.25 : 1.0; + if (alpha <= 0.02) return; + + const lineH = 14; + const pad = 10; + const bh = lines.length * lineH + pad * 2; + const bx = x - w / 2; + const by = y - bh - 14; + + ctx.save(); + ctx.globalAlpha = alpha; + + /* shadow */ + ctx.shadowColor = 'rgba(0,80,180,0.35)'; + ctx.shadowBlur = 12; + + /* background */ + _rrect(ctx, bx, by, w, bh, 7); + const bg = ctx.createLinearGradient(bx, by, bx, by + bh); + bg.addColorStop(0, 'rgba(10,25,60,0.94)'); + bg.addColorStop(1, 'rgba(6,14,40,0.96)'); + ctx.fillStyle = bg; + ctx.fill(); + ctx.shadowBlur = 0; + + /* border */ + ctx.strokeStyle = 'rgba(80,145,255,0.50)'; + ctx.lineWidth = 1.2; + ctx.stroke(); + + /* pointer triangle */ + const ptx = x; + ctx.beginPath(); + ctx.moveTo(ptx - 7, by + bh); + ctx.lineTo(ptx, by + bh + 11); + ctx.lineTo(ptx + 7, by + bh); + ctx.closePath(); + ctx.fillStyle = 'rgba(10,25,60,0.94)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(80,145,255,0.50)'; + ctx.lineWidth = 1.2; + ctx.stroke(); + + /* left accent bar */ + ctx.fillStyle = 'rgba(80,145,255,0.60)'; + ctx.fillRect(bx + 5, by + pad, 2.5, bh - pad * 2); + + /* text lines */ + ctx.font = '10.5px "Manrope", sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + lines.forEach((line, i) => { + ctx.fillStyle = i === 0 ? 'rgba(140,195,255,0.95)' : 'rgba(200,220,255,0.80)'; + ctx.fillText(line, bx + 15, by + pad + lineH * i + lineH / 2); + }); + + ctx.restore(); + } + + /* ── Laboratory desk background ─────────────────────────────── */ + /* Draws warm desk surface + subtle shadow under vessel */ + function drawDeskBackground(ctx, W, H, tableY) { + tableY = tableY || H * 0.78; + + /* wooden desk stripe */ + ctx.save(); + const dg = ctx.createLinearGradient(0, tableY, 0, H); + dg.addColorStop(0, '#1e1610'); + dg.addColorStop(0.15, '#261c12'); + dg.addColorStop(1, '#0e0b08'); + ctx.fillStyle = dg; + ctx.fillRect(0, tableY, W, H - tableY); + + /* wood grain lines */ + ctx.strokeStyle = 'rgba(255,200,120,0.04)'; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + const gy = tableY + 8 + i * 12; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.lineTo(W, gy + (Math.random() * 4 - 2)); + ctx.stroke(); + } + + /* edge highlight */ + ctx.strokeStyle = 'rgba(200,160,100,0.18)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(0, tableY); + ctx.lineTo(W, tableY); + ctx.stroke(); + ctx.restore(); + } + + /* ── Vessel shadow on desk ───────────────────────────────────── */ + function drawVesselShadow(ctx, cx, tableY, radius) { + ctx.save(); + ctx.scale(1, 0.22); + const sg = ctx.createRadialGradient(cx, tableY / 0.22, 0, cx, tableY / 0.22, radius * 1.3); + sg.addColorStop(0, 'rgba(0,0,0,0.35)'); + sg.addColorStop(0.5, 'rgba(0,0,0,0.14)'); + sg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = sg; + ctx.beginPath(); + ctx.arc(cx, tableY / 0.22, radius * 1.3, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + /* ── pH strip indicator ─────────────────────────────────────── */ + /* x,y = left edge top; pH = current value */ + function drawPHStrip(ctx, x, y, pH) { + const sw = 14, sh = 90; + const strip = [ + { ph: 0, rgb: [180, 0, 0] }, + { ph: 3, rgb: [220, 40, 40] }, + { ph: 4, rgb: [255, 130, 50] }, + { ph: 5, rgb: [255, 215, 60] }, + { ph: 6, rgb: [170, 220, 80] }, + { ph: 7, rgb: [60, 185, 100] }, + { ph: 8, rgb: [40, 155, 180] }, + { ph: 9, rgb: [50, 100, 210] }, + { ph: 11, rgb: [100, 60, 180] }, + { ph: 14, rgb: [80, 20, 140] }, + ]; + + /* rainbow gradient */ + const gr = ctx.createLinearGradient(0, y, 0, y + sh); + strip.forEach(s => { + gr.addColorStop(s.ph / 14, `rgb(${s.rgb[0]},${s.rgb[1]},${s.rgb[2]})`); + }); + + ctx.save(); + _rrect(ctx, x, y, sw, sh, 3); + ctx.fillStyle = gr; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.20)'; + ctx.lineWidth = 1; + ctx.stroke(); + + /* pH value labels */ + ctx.font = '6.5px monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.40)'; + ctx.textAlign = 'left'; + [0, 4, 7, 10, 14].forEach(v => { + const gy = y + (v / 14) * sh; + ctx.fillText(v.toString(), x + sw + 3, gy + 2); + }); + + /* marker for current pH */ + const markerY = y + (Math.min(14, Math.max(0, pH)) / 14) * sh; + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x - 4, markerY); + ctx.lineTo(x + sw + 2, markerY); + ctx.stroke(); + /* marker arrowhead */ + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.beginPath(); + ctx.moveTo(x - 4, markerY - 4); + ctx.lineTo(x - 4, markerY + 4); + ctx.lineTo(x - 10, markerY); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + + ctx.restore(); + } + + /* ── Color utility ──────────────────────────────────────────── */ + function _alpha(hex, a) { + if (hex.startsWith('#')) { + const n = parseInt(hex.slice(1), 16); + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; + } + /* already rgb/rgba — just return with rough alpha inject */ + if (hex.startsWith('rgba')) return hex.replace(/[\d.]+\)$/, a + ')'); + if (hex.startsWith('rgb(')) return hex.replace('rgb(', 'rgba(').replace(')', `,${a})`); + return hex; + } + + /* ── Public API ─────────────────────────────────────────────── */ + return { + drawErlenmeyer, + drawBeaker, + drawBurette, + drawTube, + drawSpiritLamp, + animateGasBubbles, + animatePrecipitateFall, + drawProductLabel, + drawEduTooltip, + drawDeskBackground, + drawVesselShadow, + drawPHStrip, + }; +})(); diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js index f5535b1..fac1b56 100644 --- a/frontend/js/labs/chemsandbox.js +++ b/frontend/js/labs/chemsandbox.js @@ -305,6 +305,16 @@ class ChemSandboxSim { this._quizResult = null; // 'correct' | 'wrong' | null this._quizResultT = 0; + // edu-tooltip state + this._eduTooltipAge = -1; // -1 = inactive; 0..1 = active + this._eduTooltipLines = []; + this._showHints = true; // can be toggled + + // product label animation state + this._prodLabelAge = -1; + this._prodLabelText = ''; + this._prodLabelType = 'gas'; + this.onUpdate = null; this.onQuizUpdate = null; // callback(quizInfo) this.fit(); @@ -560,6 +570,36 @@ class ChemSandboxSim { if (fx.violent) this._spawnSparks(35); } + /* product label animation */ + if (fx.gas && window.ChemVisuals) { + this._prodLabelText = fx.gas + ' '; + this._prodLabelType = 'gas'; + this._prodLabelAge = 0; + } else if (fx.precip && window.ChemVisuals) { + this._prodLabelText = (fx.precip.n || '') + ' '; + this._prodLabelType = 'precip'; + this._prodLabelAge = 0; + } + + /* educational tooltip */ + if (rx.why && this._showHints && window.ChemVisuals) { + const typeLabel = rx.type ? 'Тип: ' + rx.type : ''; + const why = rx.why.replace(/<[^>]+>/g, ''); /* strip SVG tags */ + this._eduTooltipLines = [ + typeLabel, + ...why.split(' ').reduce((acc, w) => { + const last = acc[acc.length - 1]; + if (last && (last + ' ' + w).length < 32) { + acc[acc.length - 1] = last + ' ' + w; + } else { + acc.push(w); + } + return acc; + }, []), + ].filter(Boolean).slice(0, 4); + this._eduTooltipAge = 0; + } + if (window.LabFX) { const { cx, cy } = this._g; if (fx.violent) { @@ -711,6 +751,18 @@ class ChemSandboxSim { } } + /* advance edu-tooltip age (total lifespan = 4s) */ + if (this._eduTooltipAge >= 0) { + this._eduTooltipAge += dt / 4.0; + if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1; + } + + /* advance product label age (total lifespan = 3s) */ + if (this._prodLabelAge >= 0) { + this._prodLabelAge += dt / 3.0; + if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1; + } + this._updatePour(dt); this._updateBubbles(dt); this._updatePrecip(dt); @@ -821,10 +873,16 @@ class ChemSandboxSim { if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint(); if (this._quizMode) this._drawQuizOverlay(); if (window.LabFX) LabFX.particles.draw(ctx); + /* edu-tooltip overlay */ + if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) { + const { cx, nt } = this._g; + ChemVisuals.drawEduTooltip(ctx, cx, nt - 20, 200, this._eduTooltipLines, this._eduTooltipAge); + } } _drawBackground() { const { ctx, W, H } = this; + const { cy, r } = this._g; const bg = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.75); bg.addColorStop(0, '#0c0c1a'); bg.addColorStop(1, '#050508'); @@ -835,17 +893,26 @@ class ChemSandboxSim { ctx.lineWidth = 0.5; for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } + /* desk surface behind flask */ + if (window.ChemVisuals) { + const tableY = cy + r + 6; + ChemVisuals.drawDeskBackground(ctx, W, H, tableY); + } } _drawFlaskShadow() { const { ctx } = this; const { cx, cy, r } = this._g; - // shadow beneath flask - const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1); - sg.addColorStop(0, 'rgba(0,0,0,0.25)'); - sg.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = sg; - ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25); + if (window.ChemVisuals) { + ChemVisuals.drawVesselShadow(ctx, cx, cy + r + 4, r); + } else { + // fallback + const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1); + sg.addColorStop(0, 'rgba(0,0,0,0.25)'); + sg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = sg; + ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25); + } } _drawLiquid() { @@ -933,6 +1000,11 @@ class ChemSandboxSim { } ctx.restore(); + + /* precipitate product label with animated arrow */ + if (window.ChemVisuals && this._prodLabelType === 'precip' && this._prodLabelAge >= 0) { + ChemVisuals.drawProductLabel(ctx, cx, cy + r + 2, this._prodLabelText, 'precip', this._prodLabelAge); + } } _drawBubbles() { @@ -952,12 +1024,19 @@ class ChemSandboxSim { // gas label above neck if (this._gasLabel && this._bubbles.length > 0) { const { cx, nt } = this._g; - ctx.save(); - ctx.font = '11px "JetBrains Mono", monospace'; - ctx.fillStyle = 'rgba(255,255,255,0.45)'; - ctx.textAlign = 'center'; - ctx.fillText(this._gasLabel + ' ↑', cx, nt - 14); - ctx.restore(); + if (window.ChemVisuals && this._prodLabelAge >= 0) { + /* animated product label with arrow */ + ChemVisuals.drawProductLabel(ctx, cx, nt - 10, this._prodLabelText, 'gas', this._prodLabelAge); + /* continuous bubble particles near neck */ + ChemVisuals.animateGasBubbles(ctx, cx, nt - 6, 'rgba(200,235,255,0.8)', this._time); + } else { + ctx.save(); + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.textAlign = 'center'; + ctx.fillText(this._gasLabel + ' ', cx, nt - 14); + ctx.restore(); + } } } @@ -1435,9 +1514,10 @@ class ChemSandboxSim { ctx.font = '14px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillText('Выберите реагенты на полке или панели', W / 2, cy); - ctx.font = '36px serif'; - ctx.fillStyle = 'rgba(255,255,255,0.06)'; - ctx.fillText('\u{1F9EA}', W / 2, cy - 30); + /* small test-tube icon (canvas-drawn, no emoji) */ + if (window.ChemVisuals) { + ChemVisuals.drawTube(ctx, W / 2, cy - 52, 36, null); + } ctx.restore(); } diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js index 6f469a2..68224df 100644 --- a/frontend/js/labs/electrolysis.js +++ b/frontend/js/labs/electrolysis.js @@ -334,6 +334,13 @@ class ElectrolysisSim { ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill(); } + /* desk surface */ + if (window.ChemVisuals) { + const { cx, cy, cw, ch } = this._cell(); + ChemVisuals.drawDeskBackground(ctx, W, H, cy + ch + 6); + ChemVisuals.drawVesselShadow(ctx, cx + cw / 2, cy + ch + 4, cw * 0.55); + } + this._drawWiresAndBattery(); this._drawCellBody(); this._drawSolution(); diff --git a/frontend/js/labs/flask.js b/frontend/js/labs/flask.js index 05bb67f..0f8b0a9 100644 --- a/frontend/js/labs/flask.js +++ b/frontend/js/labs/flask.js @@ -94,6 +94,12 @@ class FlaskSim { this._last = 0; this._paused = false; + /* product label animation */ + this._prodLabelAge = -1; + this._prodLabelText = ''; + this._prodLabelType = 'gas'; + this._time = 0; + this.onUpdate = null; this.W = 0; this.H = 0; this._g = {}; @@ -239,6 +245,7 @@ class FlaskSim { _tick(now) { const dt = Math.min((now - this._last) / 1000, 0.05); this._last = now; + this._time += dt; if (window.LabFX) LabFX.particles.update(dt); if (!this._paused) { this._wave += dt * 1.7; @@ -253,6 +260,11 @@ class FlaskSim { this._stepSplashes(dt); this._stepCaustics(dt); } + /* product label age */ + if (this._prodLabelAge >= 0) { + this._prodLabelAge += dt / 3.0; + if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1; + } this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } @@ -328,6 +340,13 @@ class FlaskSim { this._bubTmr--; } + /* trigger H2 product label when reaction first picks up */ + if (md.h2 > 0 && this._rxRate > 0.05 && this._prodLabelAge < 0 && window.ChemVisuals) { + this._prodLabelText = 'H₂ '; + this._prodLabelType = 'gas'; + this._prodLabelAge = 0; + } + if (md.rust && Math.random() < rate * dt * 14) { this._spawnDust(m.x + (Math.random() - 0.5) * m.r, m.y + m.r * 0.3, '#8B3A0A', 0.65); } @@ -544,11 +563,23 @@ class FlaskSim { /* Стол */ const tableY = g.cy + g.r + 8; - const tg = ctx.createLinearGradient(0, tableY, 0, tableY + 48); - tg.addColorStop(0, '#19223a'); tg.addColorStop(1, '#0c101e'); - ctx.fillStyle = tg; ctx.fillRect(0, tableY, W, H - tableY); - ctx.strokeStyle = 'rgba(90,120,200,0.20)'; ctx.lineWidth = 1; - ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke(); + if (window.ChemVisuals) { + ChemVisuals.drawDeskBackground(ctx, W, H, tableY); + ChemVisuals.drawVesselShadow(ctx, g.cx, tableY + 2, g.r); + } else { + const tg = ctx.createLinearGradient(0, tableY, 0, tableY + 48); + tg.addColorStop(0, '#19223a'); tg.addColorStop(1, '#0c101e'); + ctx.fillStyle = tg; ctx.fillRect(0, tableY, W, H - tableY); + ctx.strokeStyle = 'rgba(90,120,200,0.20)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke(); + } + + /* Spirit lamp under flask when flame is on */ + if (window.ChemVisuals) { + const lampX = g.cx; + const lampY = tableY + 18; + ChemVisuals.drawSpiritLamp(ctx, lampX, lampY, this._flameOn, this._time); + } this._drawFlaskShadow(ctx, tableY); this._drawLiquid(ctx); @@ -566,6 +597,13 @@ class FlaskSim { this._drawInfoPanel(ctx); if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx); if (window.LabFX) LabFX.particles.draw(ctx); + /* animated product labels */ + if (window.ChemVisuals && this._prodLabelAge >= 0) { + ChemVisuals.drawProductLabel(ctx, g.cx, g.nt - 10, this._prodLabelText, this._prodLabelType, this._prodLabelAge); + if (this._prodLabelType === 'gas') { + ChemVisuals.animateGasBubbles(ctx, g.cx, g.nt - 8, 'rgba(200,235,255,0.8)', this._time); + } + } } /* ── Тень/отражение колбы на столе ── */ diff --git a/frontend/js/labs/ionexchange.js b/frontend/js/labs/ionexchange.js index 436b6fb..35d29be 100644 --- a/frontend/js/labs/ionexchange.js +++ b/frontend/js/labs/ionexchange.js @@ -90,6 +90,13 @@ class IonExSim { this._pairs = []; this._precip = []; this._gas = []; + /* edu-tooltip */ + this._eduTooltipAge = -1; + this._eduTooltipLines = []; + /* product label */ + this._prodLabelAge = -1; + this._prodLabelText = ''; + this._prodLabelType = 'precip'; this.W = 0; this.H = 0; this.onUpdate = null; this.fit(); @@ -267,6 +274,39 @@ class IonExSim { ion.phase += dt; this._clampIon(ion); }); + /* trigger product label + edu-tooltip once on transition to done */ + if (window.ChemVisuals && this._prodLabelAge < 0) { + const rxn = IonExSim.RXN[this.rxnId]; + if (rxn.type === 'precip') { + this._prodLabelText = (rxn.pname || '') + ' '; + this._prodLabelType = 'precip'; + this._prodLabelAge = 0; + } else if (rxn.type === 'gas') { + this._prodLabelText = (rxn.gname || '') + ' '; + this._prodLabelType = 'gas'; + this._prodLabelAge = 0; + } + /* edu tooltip from reaction net_ion */ + if (this._eduTooltipAge < 0) { + const netIon = (rxn.net_ion || '').replace(/→/g, '->'); + this._eduTooltipLines = [ + 'Краткое ионное уравнение:', + netIon.length > 32 ? netIon.slice(0, 32) + '...' : netIon, + (rxn.pname || rxn.gname || '').slice(0, 34), + ].filter(Boolean).slice(0, 4); + this._eduTooltipAge = 0; + } + } + } + + /* advance ages */ + if (this._eduTooltipAge >= 0) { + this._eduTooltipAge += dt / 4.0; + if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1; + } + if (this._prodLabelAge >= 0) { + this._prodLabelAge += dt / 3.0; + if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1; } this.draw(); @@ -316,6 +356,11 @@ class IonExSim { } } + /* desk */ + if (window.ChemVisuals) { + ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.80); + } + if (this._phase === 'idle') { this._drawTwoBeakers(ctx, W, H, rxn); } else { @@ -327,6 +372,22 @@ class IonExSim { this._drawPrecipitate(ctx, rxn); this._drawPanel(ctx, W, H, rxn); if (window.LabFX) LabFX.particles.draw(ctx); + + /* animated product label */ + if (window.ChemVisuals && this._prodLabelAge >= 0) { + const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.74; + ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge); + if (this._prodLabelType === 'gas') { + ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.15, 'rgba(200,235,255,0.8)', this._t); + } else { + ChemVisuals.animatePrecipitateFall(ctx, W / 2, H * 0.72, rxn.pcolor || '#CCC', this._t); + } + } + + /* edu tooltip */ + if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) { + ChemVisuals.drawEduTooltip(ctx, W / 2, H * 0.10, 210, this._eduTooltipLines, this._eduTooltipAge); + } } _drawTwoBeakers(ctx, W, H, rxn) { diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 52e53df..474ddba 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -490,6 +490,24 @@ H₂ ● лимит`); + /* Periodic Table — 6×4 coloured cell grid */ + const P_PERIODIC = _svg(` + + ${(function(){ + const cols=18,rows=4,pad=6,w=(270-pad*2)/cols,h=(140-pad*2)/rows; + const colors=['#EF476F','#FF6B35','#FFD166','#7BF5A4','#C77DFF','#A8DADC', + '#7B8EF7','#06D6E0','#9B5DE5','#F15BB5','#EF476F','#FF6B35', + '#06D6E0','#7B8EF7','#FFD166','#C77DFF','#A8DADC','#7BF5A4']; + let s=''; + for(let r=0;r=2&&c<=15)||(r===1&&c>=2&&c<=11); + if(!skip) s+=``; + } + return s; + })()} + 118 элементов`); + const P_CRYSTAL = _svg(` ${[ @@ -656,6 +674,73 @@ C S = A⊕B · C = A∧B · Таблица истинности`); + + /* Qualitative Analysis preview */ + const P_QUALANALYSIS = _svg(` + + + + + + + + + + + Cl + Fe(III) + SO4 + AgNO3 + + + + AgCl / Fe(SCN) / BaSO4`); + + /* Organic Chemistry preview — benzene ring + OH group */ + const P_ORGANIC = _svg(` + + ${_grid('rgba(255,255,255,0.03)')} + + + + + + C + C + C + C + C + C + + + + O + + + H + + + + C + Конструктор · Ряды · Качественные реакции`); + + /* Solutions preview */ + const P_SOLUTIONS = _svg(` + + + + + + + 20% + + + ω% + C-M + ν моль + ω = m₀/m​ · 100% + Калькулятор · Разбавление · Смешивание · S(T)`); + const SIMS = [ /* ── Математика ── */ { id: 'graph', cat: 'math', @@ -790,6 +875,22 @@ title: 'Кристаллическая решётка', desc: 'NaCl, алмаз, металл — интерактивная 3D-решётка, типы связей, вращение структуры.', preview: P_CRYSTAL }, + { id: 'qualanalysis', cat: 'chem', + title: 'Качественный анализ', + desc: 'Определяй катионы и анионы качественными реакциями: осадки, газы, пламя. Два режима: guided и свободный эксперимент.', + preview: P_QUALANALYSIS }, + { id: 'periodic', cat: 'chem', + title: 'Периодическая таблица', + desc: '118 элементов: подсветка по типу/блоку, карточка элемента, боровские оболочки, графики свойств.', + preview: P_PERIODIC }, + { id: 'organic', cat: 'chem', + title: 'Органическая химия', + desc: 'Конструктор молекул с проверкой валентности, гомологические ряды с таблицей свойств, качественные реакции (бромная вода, KMnO₄, зеркало Толленса, Cu(OH)₂, FeCl₃, Na).', + preview: P_ORGANIC }, + { id: 'solutions', cat: 'chem', + title: 'Растворы', + desc: 'Калькулятор раствора: ω, ν, C_M, плотность. Разбавление и смешивание с визуализацией. Кривые растворимости S(T) для 8 веществ + задача на перекристаллизацию.', + preview: P_SOLUTIONS }, /* ── Биология ── */ { id: 'celldivision', cat: 'bio', title: 'Деление клетки', diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index f178b62..a9b0a17 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -28,6 +28,7 @@ var elecSim = null; var wavesSim = null; var geomSim = null; + var qualSim = null; var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield', 'sim-molphys', @@ -37,7 +38,8 @@ 'sim-quadratic','sim-normaldist','sim-graphtransform', 'sim-pendulum','sim-equilibrium','sim-opticsbench','sim-titration', 'sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis', - 'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic']; + 'sim-waves','sim-hydro','sim-radioactive','sim-geometry','sim-heatengine','sim-logic', + 'sim-qualanalysis','sim-periodic','sim-organic','sim-solutions']; var ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-emfield', 'ctrl-molphys', 'ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox', @@ -104,6 +106,10 @@ if (id === 'logic') _openLogic(); if (id === 'heatengine') _openHeatEngine(); if (id === 'stoichiometry') _openStoich(); + if (id === 'qualanalysis') _openQualAnalysis(); + if (id === 'periodic') _openPeriodic(); + if (id === 'organic') _openOrganic(); + if (id === 'solutions') _openSolutions(); } function _simShow(elId) { @@ -188,6 +194,10 @@ if (wavesSim) wavesSim.stop(); if (radioactiveSim) radioactiveSim.stop(); if (heSim) heSim.stop(); + if (qualSim) qualSim.stop(); + if (periodicSim) periodicSim.stop(); + if (organicSim) organicSim.stop(); + if (_solutionsSim) _solutionsSim.stop(); if (mirrorSim && mirrorSim._playing) mirrorSim._stopAnim(); if (mirrorSim && mirrorSim._photonRaf) mirrorSim._stopPhotons(); // tSim, csSim, quadSim, ndSim, gtSim, lensSim, refrSim have no animation loops — nothing to stop @@ -633,6 +643,17 @@ { head: 'Типы распадов', text: 'α-распад: ядро теряет ⁴He (масса -4, заряд -2). β-распад: нейтрон → протон + e⁻ + ν̅. γ-излучение: энергетический переход без изменения нуклидов.' }, ] }, + qualanalysis: { + title: 'Качественный анализ', + sections: [ + { head: 'Качественная реакция', text: 'Реакция, позволяющая обнаружить определённый ион по характерному внешнему признаку: образование осадка, выделение газа, изменение цвета раствора или пламени.' }, + { head: 'Пламя: катионы', text: 'Na+ — жёлтое. K+ — фиолетовое (через синее стекло). Ca2+ — кирпично-красное. Ba2+ — зелёное.' }, + { head: 'Fe2+ / Fe3+', text: 'Fe2+ + K3[Fe(CN)6] → Турнбулева синь. Fe3+ + KSCN → ярко-красный раствор.' }, + { head: 'Галогениды', text: 'Cl- + AgNO3 → белый AgCl (нераств. в HNO3). Br- → желтоватый AgBr. I- → жёлтый AgI.' }, + { head: 'SO4(2-) и CO3(2-)', text: 'SO4(2-) + BaCl2 → белый BaSO4 (нераств. в HNO3). CO3(2-) + H+ → CO2 (мутит Ca(OH)2).' }, + { head: 'Амфотерность Al3+ и Zn2+', text: 'NaOH (мало) → белый осадок. NaOH (избыток) → растворяется: [Al(OH)4]- или [Zn(OH)4]2-.' }, + ] + }, heatengine: { title: 'Тепловые двигатели', sections: [ @@ -646,8 +667,43 @@ { head: 'Цикл Брайтона (ГТД)', text: '2 адиабаты + 2 изобары. Основа авиадвигателей и газовых турбин. η зависит от степени повышения давления.' }, ] }, + periodic: { + title: 'Периодическая таблица', + sections: [ + { head: 'Периодический закон', text: 'Свойства химических элементов находятся в периодической зависимости от зарядов их атомных ядер. Сформулирован Д. И. Менделеевым в 1869 году.' }, + { head: 'Группы и периоды', text: 'Период — горизонтальный ряд; номер периода = число электронных оболочек. Группа — вертикальный столбец; определяет валентность и свойства соединений.' }, + { head: 's/p/d/f-блоки', text: 's-блок: группы 1–2 (щелочные, щёлочноземельные). p-блок: группы 13–18. d-блок: переходные металлы (группы 3–12). f-блок: лантаноиды и актиноиды.' }, + { head: 'Электроотрицательность', text: 'Мера способности атома притягивать электроны в химической связи (шкала Полинга). Растёт слева направо по периоду и снизу вверх по группе. Максимум — фтор (3.98).' }, + { head: 'Атомный радиус', text: 'Уменьшается слева направо (рост заряда ядра) и увеличивается сверху вниз (добавление оболочек).' }, + { head: 'Металличность', text: 'Металлические свойства убывают слева направо и нарастают сверху вниз. Металлоиды (Si, Ge, As...) — граница металл/неметалл.' }, + ] + }, + organic: { + title: 'Органическая химия', + sections: [ + { head: 'Алканы (CₙH₂ₙ₊₂)', text: 'Насыщенные углеводороды. Все связи одинарные C–C и C–H. sp³-гибридизация. Химически инертны при н.у. Горение, галогенирование (радикальное).' }, + { head: 'Алкены и алкины', text: 'Алкены (CₙH₂ₙ): одна двойная связь C=C, sp²-гибридизация. Алкины (CₙH₂ₙ₋₂): тройная связь C≡C, sp-гибридизация. Реакции присоединения.' }, + { head: 'Функциональные группы', text: '-OH спирт; -CHO альдегид; -CO- кетон; -COOH карб.кислота; -NH₂ амин; -Cl галогенид; -COO- сложный эфир; -O- простой эфир.' }, + { head: 'Качественные реакции', text: 'Br₂(водн): алкены/алкины/фенол — обесцвечивание. KMnO₄: ненасыщенные/альдегиды — обесцвечивание. Ag₂O/NH₃: альдегиды — серебро. Cu(OH)₂: многоатомный спирт — синий; альдегид/нагрев — красный Cu₂O. FeCl₃: фенол — фиолетовый. Na: спирт — H₂.' }, + { head: 'Гомологический ряд', formula: 'C_nH_{2n+2}\\xrightarrow{+CH_2}C_{n+1}H_{2n+4}', text: 'Гомологи отличаются на группу CH₂. Закономерный рост Tкип с ростом n.' }, + { head: 'Гибридизация углерода', text: 'sp³: тетраэдр 109.5° (алканы, спирты). sp²: плоский 120° (алкены, альдегиды, кислоты). sp: линейная 180° (алкины).' }, + ] + }, + solutions: { + title: 'Растворы', + sections: [ + { head: 'Массовая доля', formula: '\\omega = \\frac{m_в}{m_{р-ра}} \\times 100\\%', vars: [['m_в','масса растворённого вещества, г'],['m_{р-ра}','масса раствора, г']] }, + { head: 'Молярная концентрация', formula: 'C_M = \\frac{\\nu}{V} = \\frac{m_в}{M \\cdot V}', vars: [['\\nu','количество вещества, моль'],['V','объём раствора, л'],['M','молярная масса, г/моль']] }, + { head: 'Связь с плотностью', formula: 'C_M = \\frac{10 \\cdot \\rho \\cdot \\omega}{M}', vars: [['\\rho','плотность раствора, г/мл'],['\\omega','массовая доля, %']] }, + { head: 'Разбавление', formula: 'm_1 \\cdot \\omega_1 = m_2 \\cdot \\omega_2', text: 'Масса растворённого вещества при разбавлении не меняется. ω₂ = m_в / (m₁ + m_воды).' }, + { head: 'Смешивание', formula: 'm_3 \\omega_3 = m_1 \\omega_1 + m_2 \\omega_2', text: 'Правило рычага: m₁(ω₃ − ω₁) = m₂(ω₂ − ω₃). Итоговая концентрация — между ω₁ и ω₂.' }, + { head: 'Растворимость S', text: 'S — масса вещества (г) в 100 г воды при насыщении. Большинство солей: растворимость растёт с T. Газы: убывает. KNO₃: 13.3 г (0°C) → 247 г (100°C). NaCl: почти не меняется.' }, + { head: 'Перекристаллизация', formula: 'm_{осадка} = m_{KNO_3} - \\frac{S_2}{100} \\cdot m_{H_2O}', text: 'Охлаждение насыщенного раствора KNO₃: при 80°C S=169 г, при 20°C S=31.6 г — часть соли выпадает в осадок.' }, + ] + }, }; /* ══════════════════════════════════════════════ HYDROSTATICS ══════════════════════════════════════════════ */ + diff --git a/frontend/js/labs/organic.js b/frontend/js/labs/organic.js new file mode 100644 index 0000000..498d79b --- /dev/null +++ b/frontend/js/labs/organic.js @@ -0,0 +1,1545 @@ +'use strict'; + +/* ══════════════════════════════════════════════════════════════════ + OrganicSim — «Органическая химия» + 3 sub-modes: + 1. «Конструктор молекул» — canvas 2D drag-drop builder, valence check, + auto-class detection, IUPAC naming for alkanes + 2. «Гомологические ряды» — preset series with property table + 3. «Качественные реакции» — drag-drop reagent into test-tube, animations + ══════════════════════════════════════════════════════════════════ */ + +class OrganicSim { + + /* ── Atom valences ─────────────────────────────────────────────── */ + static VALENCE = { C: 4, H: 1, O: 2, N: 3, Cl: 1, S: 2 }; + + /* ── Atom display colors ───────────────────────────────────────── */ + static ATOM_COLOR = { + C: '#9B5DE5', + H: '#E0E0E0', + O: '#EF476F', + N: '#4CC9F0', + Cl: '#34d399', + S: '#FFD166', + }; + + /* ── Atom radii on canvas ──────────────────────────────────────── */ + static ATOM_R = { C: 20, H: 13, O: 17, N: 16, Cl: 17, S: 17 }; + + /* ── Homologous series data ────────────────────────────────────── */ + static HOMOLOG_SERIES = { + alkanes: { + name: 'Алканы', formula: (n) => `C${n}H${2*n+2}`, minC: 1, + tboil: [-161.5,-88.6,-42.1,-0.5,36.1,68.7,98.4,125.6,150.8,174.1], + tmelt: [-182.5,-183.3,-187.7,-138.3,-129.7,-95.3,-90.6,-56.8,-53.5,-29.7], + state: (n) => n<=4 ? 'Газ' : n<=17 ? 'Жидкость' : 'Твёрдое', + M: (n) => 12*n + (2*n+2), + notable: { + 1: { name: 'Метан', info: 'Природный газ, топливо, 87% в составе природного газа.' }, + 2: { name: 'Этан', info: 'Компонент природного газа, сырьё для производства этилена.' }, + 6: { name: 'Гексан', info: 'Растворитель для экстракции масел.' }, + } + }, + alkenes: { + name: 'Алкены', formula: (n) => `C${n}H${2*n}`, minC: 2, + tboil: [null,-103.7,-47.6,-6.3,30,63.5,93.6,121.3,146.9,170.5], + tmelt: [null,-169.1,-185.2,-185.3,-138.9,-119.7,-101.7,-101.7,-86.9,-75.6], + state: (n) => n<=4 ? 'Газ' : 'Жидкость', + M: (n) => 12*n + 2*n, + notable: { + 2: { name: 'Этилен', info: 'Фитогормон, сырьё для полиэтилена. Участвует в созревании плодов.' }, + 3: { name: 'Пропилен', info: 'Мономер полипропилена. Производство пластмасс и каучука.' }, + } + }, + alkynes: { + name: 'Алкины', formula: (n) => `C${n}H${2*n-2}`, minC: 2, + tboil: [null,-84,23.2,8.1,26.1,71.4,99.7,125.2,150.8,174], + tmelt: [null,-80.8,-101.5,-123.1,-130,-90,-81,-79.3,-65,-36], + state: (n) => n<=4 ? 'Газ' : 'Жидкость', + M: (n) => 12*n + (2*n-2), + notable: { + 2: { name: 'Ацетилен', info: 'Сварка металлов, синтез уксусной кислоты, ПВХ.' }, + } + }, + alcohols: { + name: 'Спирты', formula: (n) => `C${n}H${2*n+1}OH`, minC: 1, + tboil: [64.7,78.4,97.2,117.7,137.8,157.9,176,194,213,231], + tmelt: [-97.8,-114.1,-126,-89.5,-79,-51.6,-34.5,-17,21,6], + state: (n) => n<=11 ? 'Жидкость' : 'Твёрдое', + M: (n) => 12*n + (2*n+1) + 17, + notable: { + 1: { name: 'Метанол', info: 'Метиловый спирт. Топливо, растворитель. Ядовит!' }, + 2: { name: 'Этанол', info: 'Этиловый спирт. Напитки, медицина, растворитель, биотопливо.' }, + 3: { name: 'Пропанол', info: 'Растворитель, дезинфектант.' }, + } + }, + aldehydes: { + name: 'Альдегиды', formula: (n) => `C${n}H${2*n}O`, minC: 1, + tboil: [-21,20.2,48.8,74.8,103,128,152,173,191,208], + tmelt: [-92,-123,-81,-99,-91.5,-66,-45,-26,-12,4], + state: (n) => n<=4 ? 'Газ' : 'Жидкость', + M: (n) => 12*n + 2*n + 16, + notable: { + 1: { name: 'Формальдегид', info: 'Консервант, производство смол. Ядовит.' }, + 2: { name: 'Уксусный альдегид', info: 'Сырьё для уксусной кислоты.' }, + } + }, + acids: { + name: 'Карб. кислоты', formula: (n) => `C${n}H${2*n}O₂`, minC: 1, + tboil: [100.7,118.1,141.2,163.7,186.3,205.3,223.1,239.3,253.9,268.5], + tmelt: [8.3,16.6,-20.5,-7.9,-33.8,-8,16.5,12,15,31.5], + state: (n) => n<=3 ? 'Жидкость (смеш)' : 'Жидкость', + M: (n) => 12*n + 2*n + 32, + notable: { + 1: { name: 'Муравьиная', info: 'Укусы муравьёв, пчёл. Кожное средство.' }, + 2: { name: 'Уксусная', info: '3–9% в столовом уксусе. Консервация, растворитель, синтез.' }, + 3: { name: 'Пропионовая', info: 'Консервант (E280). Производство гербицидов.' }, + } + }, + amines: { + name: 'Амины', formula: (n) => `C${n}H${2*n+3}N`, minC: 1, + tboil: [-6.3,16.6,48.7,77.8,104.4,130.5,154,177,199,220], + tmelt: [-93.5,-81,-83,-50,-55,-23,-12,0,17,30], + state: (n) => n<=2 ? 'Газ' : 'Жидкость', + M: (n) => 12*n + (2*n+3) + 14, + notable: { + 1: { name: 'Метиламин', info: 'Рыбный запах, синтез красителей, фармацевтика.' }, + 2: { name: 'Этиламин', info: 'Растворитель, производство каучука.' }, + } + }, + }; + + /* ── Qualitative reactions ─────────────────────────────────────── */ + static QUAL_REACTIONS = [ + { + id: 'bromine', + reagent: 'Br₂(водн)', + reagentColor: '#A0520020', + reagentLiquid: '#B85C00', + desc: 'Бромная вода (Br₂(aq))', + compounds: [ + { name: 'Алкен (C=C)', result: 'Обесцвечивание', equation: 'R-CH=CH-R + Br₂ → R-CHBr-CHBr-R', color: '#ffffff10', resultColor: '#F5E8CC20', symbol: '+' }, + { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с Br₂(водн) при н.у.',color: '#B85C0080', resultColor: '#B85C0080', symbol: '−' }, + { name: 'Фенол', result: 'Белый осадок', equation: 'C₆H₅OH + 3Br₂ → C₆H₂Br₃OH↓ + 3HBr', color: '#ffffff20', resultColor: '#ffffff80', symbol: '+' }, + { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2Br₂ → CHBr₂-CHBr₂', color: '#B85C0080', resultColor: '#ffffff10', symbol: '+' }, + ], + }, + { + id: 'kmno4', + reagent: 'KMnO₄', + reagentColor: '#7B2FBE80', + reagentLiquid: '#9B3FDE', + desc: 'Перманганат калия KMnO₄', + compounds: [ + { name: 'Алкен', result: 'Обесцвечивание', equation: '3R-CH=CH₂ + 2KMnO₄ + 4H₂O → 3R-CH(OH)-CH₂OH + 2MnO₂↓ + 2KOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, + { name: 'Альдегид', result: 'Обесцвечивание', equation: 'R-CHO + [O] → R-COOH', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, + { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы устойчивы к KMnO₄ при н.у.', color: '#9B3FDE80', resultColor: '#9B3FDE80', symbol: '−' }, + { name: 'Алкин', result: 'Обесцвечивание', equation: 'HC≡CH + 2KMnO₄ → 2CO₂ + 2KOH + 2MnO₂↓', color: '#9B3FDE80', resultColor: '#A0700020', symbol: '+' }, + ], + }, + { + id: 'silver', + reagent: 'Ag₂O/NH₃', + reagentColor: '#C0C0C020', + reagentLiquid: '#C8C8C8', + desc: 'Реакция серебряного зеркала', + compounds: [ + { name: 'Альдегид', result: 'Серебристый налёт', equation: 'R-CHO + Ag₂O → R-COOH + 2Ag↓', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true }, + { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не реагируют с реактивом Толленса', color: '#C8C8C820', resultColor: '#C8C8C820', symbol: '−' }, + { name: 'Сахар (альд)', result: 'Серебристый налёт', equation: 'Глюкоза (альдегид) → серебристый налёт', color: '#C8C8C820', resultColor: '#E8E8E880', symbol: '+', mirror: true }, + ], + }, + { + id: 'cuoh2', + reagent: 'Cu(OH)₂', + reagentColor: '#4CC9F030', + reagentLiquid: '#3aaad0', + desc: 'Гидроксид меди(II)', + compounds: [ + { name: 'Многоатом. спирт', result: 'Ярко-синий р-р', equation: 'Глицерин + Cu(OH)₂ → ярко-синий раствор', color: '#3aaad080', resultColor: '#1E90FF80', symbol: '+', heat: false }, + { name: 'Альдегид (нагрев)', result: 'Красный Cu₂O↓', equation: 'R-CHO + 2Cu(OH)₂ →(нагрев) R-COOH + Cu₂O↓(красный) + 2H₂O', color: '#3aaad080', resultColor: '#CC440080', symbol: '+', heat: true }, + { name: 'Кетон', result: 'Нет реакции', equation: 'Кетоны не восстанавливают Cu(OH)₂', color: '#3aaad080', resultColor: '#3aaad080', symbol: '−' }, + ], + }, + { + id: 'fecl3', + reagent: 'FeCl₃', + reagentColor: '#D4A04040', + reagentLiquid: '#C88020', + desc: 'Хлорид железа(III)', + compounds: [ + { name: 'Фенол', result: 'Фиолетовый цвет', equation: 'C₆H₅OH + FeCl₃ → [Fe(OC₆H₅)₃]Cl₃ (фиолетовый)', color: '#C8802060', resultColor: '#8000FF80', symbol: '+' }, + { name: 'Спирт', result: 'Нет реакции', equation: 'Спирты не дают фиолетовой окраски с FeCl₃', color: '#C8802060', resultColor: '#C8802060', symbol: '−' }, + ], + }, + { + id: 'sodium', + reagent: 'Na (металл)', + reagentColor: '#F5F0C820', + reagentLiquid: '#F0EAB0', + desc: 'Натрий металлический', + compounds: [ + { name: 'Спирт', result: 'H₂↑ (пузырьки)', equation: '2R-OH + 2Na → 2R-ONa + H₂↑', color: '#F0EAB060', resultColor: '#6EB4D780', symbol: '+', gas: true }, + { name: 'Алкан', result: 'Нет реакции', equation: 'Алканы не реагируют с натрием', color: '#F0EAB060', resultColor: '#F0EAB060', symbol: '−' }, + ], + }, + ]; + + /* ── Constructor ───────────────────────────────────────────────── */ + constructor(wrap) { + this._wrap = wrap; + this._mode = 'constructor'; // 'constructor' | 'homologs' | 'qualitative' + this._raf = null; + this._dirty = false; + + // constructor state + this._atoms = []; + this._bonds = []; + this._drag = null; + this._pendingBond = null; // first atom of bond being drawn + this._toolAtom = 'C'; + this._toolBond = 1; // 1,2,3 + + // homologs state + this._homSeries = 'alkanes'; + this._homN = 1; + + // qualitative state + this._qualReaction = OrganicSim.QUAL_REACTIONS[0]; + this._qualCompound = null; + this._qualAnim = null; + + this._initDOM(); + } + + /* ── DOM bootstrap ─────────────────────────────────────────────── */ + _initDOM() { + this._wrap.innerHTML = ''; + this._wrap.style.display = 'flex'; + this._wrap.style.flexDirection = 'column'; + this._wrap.style.height = '100%'; + this._wrap.style.background = '#0D0D1A'; + this._wrap.style.fontFamily = "'Manrope', sans-serif"; + this._wrap.style.color = '#e0e0e0'; + this._wrap.style.overflow = 'hidden'; + + // ── top mode bar + const modeBar = document.createElement('div'); + modeBar.style.cssText = 'display:flex;gap:8px;padding:12px 16px 0;flex-shrink:0'; + [ + ['constructor', 'Конструктор молекул'], + ['homologs', 'Гомологические ряды'], + ['qualitative', 'Качественные реакции'], + ].forEach(([id, label]) => { + const btn = document.createElement('button'); + btn.textContent = label; + btn.dataset.mode = id; + btn.style.cssText = 'padding:6px 14px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);' + + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.78rem;font-family:inherit;' + + 'transition:all .15s'; + btn.addEventListener('click', () => this._setMode(id)); + modeBar.appendChild(btn); + }); + this._modeBar = modeBar; + this._wrap.appendChild(modeBar); + + // ── content area + this._content = document.createElement('div'); + this._content.style.cssText = 'flex:1;display:flex;overflow:hidden;min-height:0'; + this._wrap.appendChild(this._content); + + this._buildConstructor(); + this._setMode('constructor'); + } + + /* ── Mode switch ───────────────────────────────────────────────── */ + _setMode(mode) { + this._mode = mode; + // update button styles + this._modeBar.querySelectorAll('button').forEach(b => { + const active = b.dataset.mode === mode; + b.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.04)'; + b.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.12)'; + b.style.color = active ? '#C9A0FF' : '#c0c0c0'; + b.style.fontWeight = active ? '700' : '400'; + }); + // show correct panel + if (this._consPanel) this._consPanel.style.display = mode === 'constructor' ? 'flex' : 'none'; + if (this._homoPanel) this._homoPanel.style.display = mode === 'homologs' ? 'flex' : 'none'; + if (this._qualPanel) this._qualPanel.style.display = mode === 'qualitative' ? 'flex' : 'none'; + + if (mode === 'constructor') { this._drawMolecule(); } + if (mode === 'homologs') { this._drawHomologs(); } + if (mode === 'qualitative') { this._drawQual(); } + } + + /* ══════════════════════════════════════════════════════════════ + MODE 1 — CONSTRUCTOR + ══════════════════════════════════════════════════════════════ */ + _buildConstructor() { + const panel = document.createElement('div'); + panel.style.cssText = 'display:flex;width:100%;height:100%'; + this._consPanel = panel; + this._content.appendChild(panel); + + // left toolbar + const left = document.createElement('div'); + left.style.cssText = 'width:160px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' + + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; + panel.appendChild(left); + + // atom palette + const atomTitle = document.createElement('div'); + atomTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + + 'color:rgba(255,255,255,0.4);margin-bottom:2px'; + atomTitle.textContent = 'Атомы'; + left.appendChild(atomTitle); + + this._atomBtns = {}; + const atomWrap = document.createElement('div'); + atomWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px'; + ['C','H','O','N','Cl','S'].forEach(sym => { + const btn = document.createElement('button'); + btn.textContent = sym; + const col = OrganicSim.ATOM_COLOR[sym]; + btn.style.cssText = `width:38px;height:32px;border-radius:6px;border:1.5px solid ${col}44;` + + `background:${col}1A;color:${col};cursor:pointer;font-size:.8rem;font-weight:700;font-family:inherit;transition:all .12s`; + btn.addEventListener('click', () => this._selectAtom(sym)); + atomWrap.appendChild(btn); + this._atomBtns[sym] = btn; + }); + left.appendChild(atomWrap); + + // bond order + const bondTitle = document.createElement('div'); + bondTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + + 'color:rgba(255,255,255,0.4);margin-bottom:2px'; + bondTitle.textContent = 'Связь'; + left.appendChild(bondTitle); + + this._bondBtns = {}; + const bondWrap = document.createElement('div'); + bondWrap.style.cssText = 'display:flex;gap:4px;margin-bottom:8px'; + [[1,'─'],[2,'═'],[3,'≡']].forEach(([n, sym]) => { + const btn = document.createElement('button'); + btn.textContent = sym; + btn.style.cssText = 'width:36px;height:28px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.15);' + + 'background:rgba(255,255,255,0.05);color:#e0e0e0;cursor:pointer;font-size:.9rem;font-family:inherit;transition:all .12s'; + btn.addEventListener('click', () => this._selectBond(n)); + bondWrap.appendChild(btn); + this._bondBtns[n] = btn; + }); + left.appendChild(bondWrap); + + // separator + const sep = document.createElement('div'); + sep.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; + left.appendChild(sep); + + // actions + const clearBtn = document.createElement('button'); + clearBtn.textContent = 'Очистить'; + clearBtn.style.cssText = 'padding:6px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' + + 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit'; + clearBtn.addEventListener('click', () => { this._atoms = []; this._bonds = []; this._pendingBond = null; this._drawMolecule(); this._updateFormula(); }); + left.appendChild(clearBtn); + + // hint + const hint = document.createElement('div'); + hint.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4;margin-top:4px'; + hint.textContent = 'Клик на холст — добавить атом. Клик на 2 атома — нарисовать связь. ПКМ — удалить.'; + left.appendChild(hint); + + // center canvas + const canvasWrap = document.createElement('div'); + canvasWrap.style.cssText = 'flex:1;position:relative;overflow:hidden;background:#080810'; + panel.appendChild(canvasWrap); + + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%'; + this._molCanvas = canvas; + canvasWrap.appendChild(canvas); + + // right info panel + const right = document.createElement('div'); + right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' + + 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto'; + panel.appendChild(right); + + const infoTitle = document.createElement('div'); + infoTitle.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;' + + 'color:rgba(255,255,255,0.4)'; + infoTitle.textContent = 'Анализ молекулы'; + right.appendChild(infoTitle); + + this._formulaEl = this._infoBox(right, 'Молекулярная формула', '—'); + this._structEl = this._infoBox(right, 'Структурная формула', '—'); + this._classEl = this._infoBox(right, 'Класс соединения', '—'); + this._iupacEl = this._infoBox(right, 'Название (ИЮПАК)', '—'); + this._valenceEl = this._infoBox(right, 'Валентность', '—'); + + // canvas events + canvas.addEventListener('click', e => this._molClick(e)); + canvas.addEventListener('mousedown', e => this._molMouseDown(e)); + canvas.addEventListener('mousemove', e => this._molMouseMove(e)); + canvas.addEventListener('mouseup', e => this._molMouseUp(e)); + canvas.addEventListener('contextmenu', e => { e.preventDefault(); this._molRightClick(e); }); + + this._selectAtom('C'); + this._selectBond(1); + this._updateFormula(); + } + + _infoBox(parent, label, value) { + const wrap = document.createElement('div'); + wrap.style.cssText = 'padding:8px 10px;border-radius:8px;background:rgba(255,255,255,0.04);' + + 'border:1px solid rgba(255,255,255,0.07)'; + const lbl = document.createElement('div'); + lbl.style.cssText = 'font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;' + + 'color:rgba(255,255,255,0.35);margin-bottom:3px'; + lbl.textContent = label; + const val = document.createElement('div'); + val.style.cssText = 'font-size:.9rem;font-weight:700;color:#e8e8e8;word-break:break-all'; + val.textContent = value; + wrap.appendChild(lbl); + wrap.appendChild(val); + parent.appendChild(wrap); + return val; + } + + _selectAtom(sym) { + this._toolAtom = sym; + Object.entries(this._atomBtns).forEach(([s, btn]) => { + const col = OrganicSim.ATOM_COLOR[s]; + const active = s === sym; + btn.style.background = active ? `${col}40` : `${col}1A`; + btn.style.borderColor = active ? col : `${col}44`; + btn.style.boxShadow = active ? `0 0 8px ${col}60` : 'none'; + }); + } + + _selectBond(n) { + this._toolBond = n; + Object.entries(this._bondBtns).forEach(([k, btn]) => { + const active = parseInt(k) === n; + btn.style.background = active ? 'rgba(155,93,229,0.25)' : 'rgba(255,255,255,0.05)'; + btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.15)'; + btn.style.color = active ? '#C9A0FF' : '#e0e0e0'; + }); + } + + /* ── Canvas size sync ─────────────────────────────────────────── */ + _fitMolCanvas() { + const c = this._molCanvas; + if (!c) return; + const rect = c.getBoundingClientRect(); + if (rect.width === 0) return; + c.width = Math.round(rect.width * devicePixelRatio); + c.height = Math.round(rect.height * devicePixelRatio); + c.getContext('2d').setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + } + + /* ── Canvas pointer helpers ─────────────────────────────────────── */ + _molXY(e) { + const r = this._molCanvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + } + + _atomAt(x, y) { + return this._atoms.find(a => Math.hypot(a.x - x, a.y - y) <= OrganicSim.ATOM_R[a.sym] + 4); + } + + /* ── Mouse/click handlers ─────────────────────────────────────── */ + _molClick(e) { + const { x, y } = this._molXY(e); + const hit = this._atomAt(x, y); + if (hit) { + // bond mode: select second atom + if (this._pendingBond) { + if (this._pendingBond !== hit) { + const existing = this._bonds.find(b => + (b.a === this._pendingBond && b.b === hit) || + (b.a === hit && b.b === this._pendingBond)); + if (existing) { + existing.order = this._toolBond; + } else { + this._bonds.push({ a: this._pendingBond, b: hit, order: this._toolBond }); + } + } + this._pendingBond = null; + } else { + this._pendingBond = hit; + } + } else { + this._pendingBond = null; + // place new atom + const atom = { id: Date.now() + Math.random(), sym: this._toolAtom, x, y }; + this._atoms.push(atom); + } + this._drawMolecule(); + this._updateFormula(); + } + + _molMouseDown(e) { + if (e.button !== 0) return; + const { x, y } = this._molXY(e); + const hit = this._atomAt(x, y); + if (hit && !this._pendingBond) { + this._drag = { atom: hit, ox: hit.x - x, oy: hit.y - y }; + } + } + + _molMouseMove(e) { + const { x, y } = this._molXY(e); + if (this._drag) { + this._drag.atom.x = x + this._drag.ox; + this._drag.atom.y = y + this._drag.oy; + this._drawMolecule(); + } + this._molCanvas.style.cursor = this._atomAt(x, y) ? 'grab' : 'crosshair'; + } + + _molMouseUp(e) { + if (this._drag) { this._drag = null; this._updateFormula(); } + } + + _molRightClick(e) { + const { x, y } = this._molXY(e); + const hit = this._atomAt(x, y); + if (hit) { + this._bonds = this._bonds.filter(b => b.a !== hit && b.b !== hit); + this._atoms = this._atoms.filter(a => a !== hit); + if (this._pendingBond === hit) this._pendingBond = null; + this._drawMolecule(); + this._updateFormula(); + } + } + + /* ── Draw molecule ─────────────────────────────────────────────── */ + _drawMolecule() { + const c = this._molCanvas; + if (!c) return; + this._fitMolCanvas(); + const ctx = c.getContext('2d'); + const W = c.getBoundingClientRect().width; + const H = c.getBoundingClientRect().height; + ctx.clearRect(0, 0, W, H); + + // subtle grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + for (let gx = 0; gx < W; gx += 30) { + ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); + } + for (let gy = 0; gy < H; gy += 30) { + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke(); + } + + // bad valence set + const badAtoms = this._getBadValenceAtoms(); + + // bonds + this._bonds.forEach(b => { + const x1 = b.a.x, y1 = b.a.y, x2 = b.b.x, y2 = b.b.y; + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const px = -dy / len, py = dx / len; + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; + ctx.lineWidth = 2; + const offsets = b.order === 1 ? [0] : b.order === 2 ? [-3, 3] : [-5, 0, 5]; + offsets.forEach(o => { + ctx.beginPath(); + ctx.moveTo(x1 + px * o, y1 + py * o); + ctx.lineTo(x2 + px * o, y2 + py * o); + ctx.stroke(); + }); + }); + + // pending bond line + if (this._pendingBond) { + const pa = this._pendingBond; + ctx.strokeStyle = 'rgba(155,93,229,0.4)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.arc(pa.x, pa.y, OrganicSim.ATOM_R[pa.sym] + 6, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + + // atoms + this._atoms.forEach(a => { + const r = OrganicSim.ATOM_R[a.sym]; + const col = OrganicSim.ATOM_COLOR[a.sym]; + const bad = badAtoms.has(a); + const sel = this._pendingBond === a; + + // glow + if (sel) { + ctx.shadowColor = '#9B5DE5'; + ctx.shadowBlur = 16; + } else if (bad) { + ctx.shadowColor = '#EF476F'; + ctx.shadowBlur = 12; + } + + ctx.beginPath(); + ctx.arc(a.x, a.y, r, 0, Math.PI * 2); + ctx.fillStyle = bad ? '#EF476F30' : (sel ? 'rgba(155,93,229,0.35)' : `${col}28`); + ctx.fill(); + + ctx.strokeStyle = bad ? '#EF476F' : col; + ctx.lineWidth = bad ? 2 : 1.8; + ctx.stroke(); + + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + + ctx.fillStyle = bad ? '#EF476F' : col; + ctx.font = `700 ${a.sym.length > 1 ? '11' : '13'}px Manrope, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(a.sym, a.x, a.y); + }); + } + + /* ── Valence check ─────────────────────────────────────────────── */ + _getBondCount(atom) { + let count = 0; + this._bonds.forEach(b => { + if (b.a === atom || b.b === atom) count += b.order; + }); + return count; + } + + _getBadValenceAtoms() { + const bad = new Set(); + this._atoms.forEach(a => { + const v = OrganicSim.VALENCE[a.sym] || 1; + const used = this._getBondCount(a); + if (used > v) bad.add(a); + }); + return bad; + } + + /* ── Formula + class detection ─────────────────────────────────── */ + _updateFormula() { + if (!this._formulaEl) return; + const counts = {}; + this._atoms.forEach(a => { counts[a.sym] = (counts[a.sym] || 0) + 1; }); + + // hill order: C first, H second, rest alphabetically + let formula = ''; + if (counts['C']) { formula += 'C'; if (counts['C'] > 1) formula += this._sub(counts['C']); } + if (counts['H']) { formula += 'H'; if (counts['H'] > 1) formula += this._sub(counts['H']); } + ['N','O','S','Cl'].forEach(s => { + if (counts[s]) { formula += s; if (counts[s] > 1) formula += this._sub(counts[s]); } + }); + + const klass = this._detectClass(); + const struct = this._buildStructural(counts); + const iupac = this._iupacName(counts, klass); + const bad = this._getBadValenceAtoms(); + const valMsg = bad.size > 0 + ? Array.from(bad).map(a => `${a.sym}(${this._getBondCount(a)}/${OrganicSim.VALENCE[a.sym]})`).join(', ') + ' — превышена валентность' + : (this._atoms.length > 0 ? 'OK' : '—'); + + this._formulaEl.textContent = formula || '—'; + this._structEl.textContent = struct || '—'; + this._classEl.textContent = klass || '—'; + this._iupacEl.textContent = iupac || '—'; + this._valenceEl.textContent = valMsg; + this._valenceEl.style.color = bad.size > 0 ? '#EF476F' : '#34d399'; + } + + _sub(n) { + const map = '₀₁₂₃₄₅₆₇₈₉'; + return String(n).split('').map(d => map[d]).join(''); + } + + _buildStructural(counts) { + if (!counts['C'] && !counts['H']) return ''; + const parts = []; + if (counts['C']) parts.push(`C${counts['C'] > 1 ? counts['C'] : ''}`); + if (counts['H']) parts.push(`H${counts['H'] > 1 ? counts['H'] : ''}`); + if (counts['O']) parts.push(`O${counts['O'] > 1 ? counts['O'] : ''}`); + if (counts['N']) parts.push(`N${counts['N'] > 1 ? counts['N'] : ''}`); + if (counts['Cl']) parts.push(`Cl${counts['Cl'] > 1 ? counts['Cl'] : ''}`); + if (counts['S']) parts.push(`S${counts['S'] > 1 ? counts['S'] : ''}`); + return parts.join('-'); + } + + /* ── Class detection ───────────────────────────────────────────── */ + _detectClass() { + if (this._atoms.length === 0) return '—'; + + const hasDoubleCO = this._bonds.some(b => + b.order === 2 && ((b.a.sym === 'C' && b.b.sym === 'O') || (b.a.sym === 'O' && b.b.sym === 'C'))); + const hasDoubleCC = this._bonds.some(b => + b.order === 2 && b.a.sym === 'C' && b.b.sym === 'C'); + const hasTripleCC = this._bonds.some(b => + b.order === 3 && b.a.sym === 'C' && b.b.sym === 'C'); + + // detect -OH group: O connected to C (sp3) and H + const hasOH = this._atoms.some(a => { + if (a.sym !== 'O') return false; + const neighbors = this._getNeighbors(a); + return neighbors.some(n => n.sym === 'H') && neighbors.some(n => n.sym === 'C'); + }); + + // detect -CHO: C with double bond to O and single bond to H + const hasCHO = this._atoms.some(a => { + if (a.sym !== 'C') return false; + const nb = this._getNeighbors(a); + const hasHNeighbor = nb.some(n => n.sym === 'H'); + const hasDoubleOBond = this._bonds.some(b => + b.order === 2 && + ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); + return hasHNeighbor && hasDoubleOBond; + }); + + // detect -COOH: C with double O and single O (which has H) + const hasCOOH = this._atoms.some(a => { + if (a.sym !== 'C') return false; + const dblO = this._bonds.filter(b => + b.order === 2 && + ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length; + const sngO = this._bonds.filter(b => + b.order === 1 && + ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))).length; + return dblO >= 1 && sngO >= 1; + }); + + // ketone: C with two double-bond O neighbors via C-C bonds with no H on the carbonyl C + const hasKetone = hasCOOH ? false : (this._atoms.some(a => { + if (a.sym !== 'C') return false; + const nb = this._getNeighbors(a); + const hasHNeighbor = nb.some(n => n.sym === 'H'); + const hasDoubleOBond = this._bonds.some(b => + b.order === 2 && + ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); + const hasCCBond = nb.filter(n => n.sym === 'C').length >= 2; + return hasDoubleOBond && hasCCBond && !hasHNeighbor; + })); + + // ester: -COO- (C with dbl O and O linked to C) + const hasEster = this._atoms.some(a => { + if (a.sym !== 'C') return false; + const dblOBond = this._bonds.find(b => + b.order === 2 && ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); + if (!dblOBond) return false; + const sngOs = this._bonds.filter(b => + b.order === 1 && + ((b.a === a && b.b.sym === 'O') || (b.b === a && b.a.sym === 'O'))); + return sngOs.some(b => { + const oAtom = b.a === a ? b.b : b.a; + const oNb = this._getNeighbors(oAtom); + return oNb.some(n => n.sym === 'C' && n !== a); + }); + }); + + // ether: O linked to two C (no H on O) + const hasEther = this._atoms.some(a => { + if (a.sym !== 'O') return false; + const nb = this._getNeighbors(a); + return nb.filter(n => n.sym === 'C').length >= 2 && !nb.some(n => n.sym === 'H'); + }); + + // amine: N with H or N between carbons + const hasAmine = this._atoms.some(a => a.sym === 'N'); + + // halide + const hasCl = this._atoms.some(a => a.sym === 'Cl'); + const hasS = this._atoms.some(a => a.sym === 'S'); + + // only C & H present + const onlyCH = this._atoms.every(a => a.sym === 'C' || a.sym === 'H'); + + // benzene ring detection (6 C in ring with alternating bonds) + const hasBenzene = this._detectBenzene(); + + if (hasCOOH) return 'Карбоновая кислота (-COOH)'; + if (hasEster) return 'Сложный эфир (-COO-)'; + if (hasCHO) return 'Альдегид (-CHO)'; + if (hasKetone) return 'Кетон (C=O между C)'; + if (hasOH) return 'Спирт (-OH)'; + if (hasAmine) return 'Амин (-NHₓ)'; + if (hasBenzene) return 'Ароматическое (бензольное кольцо)'; + if (hasTripleCC) return 'Алкин (C≡C)'; + if (hasDoubleCC) return 'Алкен (C=C)'; + if (hasCl) return 'Галогеналкан (-Cl)'; + if (hasS) return 'Серосодержащее'; + if (hasDoubleCO) return 'Карбонильное соединение (C=O)'; + if (onlyCH) return this._detectCHClass(); + return 'Органическое соединение'; + } + + _detectCHClass() { + // check if cycle present + if (this._detectCycle()) return 'Циклоалкан'; + return 'Алкан (только C-C + H)'; + } + + _detectCycle() { + if (this._atoms.length < 3) return false; + // simple DFS cycle detection + const visited = new Set(); + const adj = new Map(); + this._atoms.forEach(a => adj.set(a, [])); + this._bonds.forEach(b => { + adj.get(b.a).push(b.b); + adj.get(b.b).push(b.a); + }); + let hasCycle = false; + const dfs = (node, parent) => { + if (hasCycle) return; + visited.add(node); + for (const nb of adj.get(node)) { + if (nb === parent) continue; + if (visited.has(nb)) { hasCycle = true; return; } + dfs(nb, node); + } + }; + if (this._atoms.length > 0) dfs(this._atoms[0], null); + return hasCycle; + } + + _detectBenzene() { + // look for 6 C-atoms forming a ring with alternating single/double bonds + const cAtoms = this._atoms.filter(a => a.sym === 'C'); + if (cAtoms.length < 6) return false; + // build adjacency among C atoms + const adj = new Map(); + cAtoms.forEach(a => adj.set(a, [])); + this._bonds.forEach(b => { + if (b.a.sym === 'C' && b.b.sym === 'C') { + adj.get(b.a).push(b.b); + adj.get(b.b).push(b.a); + } + }); + // find if any C is in a 6-membered ring + for (const start of cAtoms) { + const path = [start]; + const found = this._findRing(start, start, path, adj, 6); + if (found) return true; + } + return false; + } + + _findRing(start, cur, path, adj, targetLen) { + if (path.length === targetLen) { + return adj.get(cur).includes(start); + } + for (const nb of adj.get(cur)) { + if (path.length > 1 && nb === path[path.length - 2]) continue; + if (path.includes(nb)) continue; + path.push(nb); + if (this._findRing(start, nb, path, adj, targetLen)) return true; + path.pop(); + } + return false; + } + + _getNeighbors(atom) { + const nb = []; + this._bonds.forEach(b => { + if (b.a === atom) nb.push(b.b); + if (b.b === atom) nb.push(b.a); + }); + return nb; + } + + /* ── IUPAC name for simple alkanes ─────────────────────────────── */ + _iupacName(counts, klass) { + if (!klass || !klass.includes('Алкан') || klass.includes('Цикло')) { + if (!klass || !counts['C']) return '—'; + } + const prefixes = ['','мет','эт','проп','бут','пент','гекс','гепт','окт','нон','дек']; + const n = counts['C'] || 0; + if (n < 1 || n > 10) return n > 10 ? `алкан C${n}` : '—'; + if (klass.includes('Циклоалкан')) return `цикло${prefixes[n]}ан`; + if (klass.includes('Алкен')) return `${prefixes[n]}ен`; + if (klass.includes('Алкин')) return `${prefixes[n]}ин`; + if (klass.includes('Алкан')) return `${prefixes[n]}ан`; + if (klass.includes('Спирт')) return `${prefixes[n]}анол-1`; + if (klass.includes('Альдегид')) return `${prefixes[n]}аналь`; + if (klass.includes('Кислота')) return `${prefixes[n]}ановая кислота`; + if (klass.includes('Амин')) return `${prefixes[n]}иламин`; + return '—'; + } + + /* ══════════════════════════════════════════════════════════════ + MODE 2 — HOMOLOGS + ══════════════════════════════════════════════════════════════ */ + _buildHomologs() { + const panel = document.createElement('div'); + panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0'; + this._homoPanel = panel; + this._content.appendChild(panel); + + // left controls + const left = document.createElement('div'); + left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:8px;' + + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; + panel.appendChild(left); + + const serLabel = document.createElement('div'); + serLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; + serLabel.textContent = 'Гомологический ряд'; + left.appendChild(serLabel); + + this._serBtns = {}; + Object.entries(OrganicSim.HOMOLOG_SERIES).forEach(([key, s]) => { + const btn = document.createElement('button'); + btn.textContent = s.name; + btn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' + + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.75rem;font-family:inherit;text-align:left'; + btn.addEventListener('click', () => this._selectSeries(key)); + left.appendChild(btn); + this._serBtns[key] = btn; + }); + + const sep2 = document.createElement('div'); + sep2.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; + left.appendChild(sep2); + + const nLabel = document.createElement('div'); + nLabel.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; + nLabel.textContent = 'Число атомов C'; + left.appendChild(nLabel); + + const sliderRow = document.createElement('div'); + sliderRow.style.cssText = 'display:flex;align-items:center;gap:8px'; + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = 1; slider.max = 10; slider.value = 1; + slider.style.cssText = 'flex:1;accent-color:#9B5DE5'; + const nVal = document.createElement('span'); + nVal.style.cssText = 'font-size:1rem;font-weight:700;color:#C9A0FF;min-width:18px;text-align:center'; + nVal.textContent = '1'; + slider.addEventListener('input', () => { + this._homN = parseInt(slider.value); + nVal.textContent = this._homN; + this._drawHomologs(); + }); + sliderRow.appendChild(slider); + sliderRow.appendChild(nVal); + left.appendChild(sliderRow); + this._homSlider = slider; + this._homNVal = nVal; + + // center + right + const center = document.createElement('div'); + center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden'; + panel.appendChild(center); + + // molecule sketch area + const sketch = document.createElement('canvas'); + sketch.style.cssText = 'width:100%;height:260px;flex-shrink:0;display:block'; + this._homCanvas = sketch; + center.appendChild(sketch); + + // properties table + const tableWrap = document.createElement('div'); + tableWrap.style.cssText = 'flex:1;overflow-y:auto;padding:12px 16px'; + this._homTableWrap = tableWrap; + center.appendChild(tableWrap); + + this._selectSeries('alkanes'); + } + + _selectSeries(key) { + this._homSeries = key; + const s = OrganicSim.HOMOLOG_SERIES[key]; + // adjust slider + this._homSlider.min = s.minC; + if (this._homN < s.minC) { this._homN = s.minC; this._homSlider.value = s.minC; this._homNVal.textContent = s.minC; } + + Object.entries(this._serBtns).forEach(([k, btn]) => { + const active = k === key; + btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; + btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)'; + btn.style.color = active ? '#C9A0FF' : '#c0c0c0'; + btn.style.fontWeight = active ? '700' : '400'; + }); + this._drawHomologs(); + } + + _drawHomologs() { + if (!this._homCanvas) return; + const s = OrganicSim.HOMOLOG_SERIES[this._homSeries]; + const n = this._homN; + const idx = n - 1; + + // fit canvas + const c = this._homCanvas; + const rect = c.getBoundingClientRect(); + if (!rect.width) return; + c.width = Math.round(rect.width * devicePixelRatio); + c.height = Math.round(rect.height * devicePixelRatio); + const ctx = c.getContext('2d'); + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + const W = rect.width, H = rect.height; + + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#080810'; + ctx.fillRect(0, 0, W, H); + + // draw 2D skeletal formula + this._drawSkeletal(ctx, W, H, s, n); + + // properties table + const tboil = s.tboil[idx]; + const tmelt = s.tmelt[idx]; + const state = s.state(n); + const M = s.M(n); + const formula = s.formula(n); + const notable = s.notable && s.notable[n]; + + let html = ` +
${s.name} — ${formula}
+ + + + + + + + + ${this._propRow('Молярная масса', `${M} г/моль`)} + ${this._propRow('Т. кипения', tboil != null ? `${tboil} °C` : '—')} + ${this._propRow('Т. плавления', tmelt != null ? `${tmelt} °C` : '—')} + ${this._propRow('Агрегатное состояние (20°C)', state)} + +
ПараметрЗначение
`; + + if (notable) { + html += `
+
${notable.name}
+
${notable.info}
+
`; + } + + this._homTableWrap.innerHTML = html; + } + + _propRow(label, value) { + return ` + ${label} + ${value} + `; + } + + /* ── Skeletal formula 2D auto-drawing ─────────────────────────── */ + _drawSkeletal(ctx, W, H, s, n) { + const key = this._homSeries; + const CX = W / 2, CY = H / 2; + const bond = Math.min(55, (W - 80) / Math.max(n, 1)); + const R = 13; + + ctx.fillStyle = '#080810'; + ctx.fillRect(0, 0, W, H); + + // subtle grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); } + for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); } + + // positions: zigzag for chain + const positions = []; + const totalW = bond * (n - 1); + let startX = CX - totalW / 2; + for (let i = 0; i < n; i++) { + const y = CY + (i % 2 === 0 ? -12 : 12); + positions.push({ x: startX + i * bond, y }); + } + + // determine bond order for main chain + let mainBondOrder = 1; + if (key === 'alkenes') mainBondOrder = 2; + if (key === 'alkynes') mainBondOrder = 3; + + const drawBond = (x1, y1, x2, y2, order) => { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const px = -dy/len, py = dx/len; + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 1.8; + const offs = order === 1 ? [0] : order === 2 ? [-2.5,2.5] : [-4,0,4]; + offs.forEach(o => { + ctx.beginPath(); + ctx.moveTo(x1 + px*o, y1 + py*o); + ctx.lineTo(x2 + px*o, y2 + py*o); + ctx.stroke(); + }); + }; + + // main chain bonds + for (let i = 0; i < n - 1; i++) { + const p1 = positions[i], p2 = positions[i+1]; + const bo = (key === 'alkenes' && i === 0) ? 2 + : (key === 'alkynes' && i === 0) ? 3 + : 1; + drawBond(p1.x, p1.y, p2.x, p2.y, bo); + } + + // side groups: draw H or functional group atoms + const colC = OrganicSim.ATOM_COLOR['C']; + const colH = OrganicSim.ATOM_COLOR['H']; + const colO = OrganicSim.ATOM_COLOR['O']; + const colN = OrganicSim.ATOM_COLOR['N']; + + positions.forEach((p, i) => { + let hs = 0; + if (key === 'alkanes') hs = (i === 0 || i === n-1) ? 3 : 2; + if (key === 'alkenes') hs = (i === 0) ? 2 : (i === n-1) ? 3 : 2; + if (key === 'alkynes') hs = (i === 0 || i === 1) ? 1 : (i === n-1) ? 3 : 2; + if (key === 'alcohols') { + hs = (i === 0 || i === n-1) ? 3 : 2; + if (i === n-1) { + // draw -OH + drawBond(p.x, p.y, p.x + 30, p.y - 20, 1); + ctx.beginPath(); ctx.arc(p.x+30, p.y-20, R, 0, Math.PI*2); + ctx.fillStyle = OrganicSim.ATOM_COLOR['O']+'30'; ctx.fill(); + ctx.strokeStyle = OrganicSim.ATOM_COLOR['O']; ctx.lineWidth=1.5; ctx.stroke(); + ctx.fillStyle = OrganicSim.ATOM_COLOR['O']; + ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('O', p.x+30, p.y-20); + // H on O + drawBond(p.x+30, p.y-20, p.x+44, p.y-30, 1); + ctx.beginPath(); ctx.arc(p.x+44, p.y-30, R-4, 0, Math.PI*2); + ctx.fillStyle = colH+'30'; ctx.fill(); + ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke(); + ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x+44, p.y-30); + hs = 2; + } + } + if (key === 'aldehydes') { + hs = i === 0 ? 1 : (i === n-1) ? 3 : 2; + if (i === 0) { + // draw =O + drawBond(p.x, p.y, p.x - 28, p.y - 22, 2); + ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2); + ctx.fillStyle = colO+'30'; ctx.fill(); + ctx.strokeStyle = colO; ctx.lineWidth=1.5; ctx.stroke(); + ctx.fillStyle = colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('O', p.x-28, p.y-22); + // H on C-1 + drawBond(p.x, p.y, p.x - 28, p.y + 22, 1); + ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R-4, 0, Math.PI*2); + ctx.fillStyle = colH+'30'; ctx.fill(); + ctx.strokeStyle = colH; ctx.lineWidth=1.2; ctx.stroke(); + ctx.fillStyle = colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-28, p.y+22); + } + } + if (key === 'acids') { + hs = i === 0 ? 0 : (i === n-1) ? 3 : 2; + if (i === 0) { + // draw -COOH + drawBond(p.x, p.y, p.x-28, p.y-22, 2); + ctx.beginPath(); ctx.arc(p.x-28, p.y-22, R, 0, Math.PI*2); + ctx.fillStyle = colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke(); + ctx.fillStyle=colO; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('O', p.x-28, p.y-22); + drawBond(p.x, p.y, p.x-28, p.y+22, 1); + ctx.beginPath(); ctx.arc(p.x-28, p.y+22, R, 0, Math.PI*2); + ctx.fillStyle=colO+'30'; ctx.fill(); ctx.strokeStyle=colO; ctx.lineWidth=1.5; ctx.stroke(); + ctx.fillStyle=colO; ctx.fillText('O', p.x-28, p.y+22); + // H on single O + drawBond(p.x-28, p.y+22, p.x-42, p.y+32, 1); + ctx.beginPath(); ctx.arc(p.x-42, p.y+32, R-4, 0, Math.PI*2); + ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke(); + ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H', p.x-42, p.y+32); + } + } + if (key === 'amines') { + hs = i === n-1 ? 2 : (i === 0 ? 1 : 2); + if (i === n-1) { + // draw -NH₂ + drawBond(p.x, p.y, p.x+28, p.y-22, 1); + ctx.beginPath(); ctx.arc(p.x+28, p.y-22, R, 0, Math.PI*2); + ctx.fillStyle=colN+'30'; ctx.fill(); ctx.strokeStyle=colN; ctx.lineWidth=1.5; ctx.stroke(); + ctx.fillStyle=colN; ctx.font='bold 11px Manrope,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('N', p.x+28, p.y-22); + // 2 H on N + [[p.x+42,p.y-32],[p.x+42,p.y-12]].forEach(([hx,hy]) => { + drawBond(p.x+28, p.y-22, hx, hy, 1); + ctx.beginPath(); ctx.arc(hx,hy,R-4,0,Math.PI*2); + ctx.fillStyle=colH+'30'; ctx.fill(); ctx.strokeStyle=colH; ctx.lineWidth=1.2; ctx.stroke(); + ctx.fillStyle=colH; ctx.font='bold 10px Manrope,sans-serif'; ctx.fillText('H',hx,hy); + }); + } + } + + // draw H on main C + const hPositions = [[0,-1],[0,1],[-1,0]].slice(0, hs); + hPositions.forEach(([hx0, hy0], hi) => { + const angle = (hi / Math.max(hs,1)) * Math.PI + (i%2===0 ? Math.PI*1.1 : Math.PI*0.1); + const hx = p.x + Math.cos(angle) * (bond*0.55); + const hy = p.y + Math.sin(angle) * (bond*0.55); + drawBond(p.x, p.y, hx, hy, 1); + ctx.beginPath(); ctx.arc(hx, hy, R-3, 0, Math.PI*2); + ctx.fillStyle = colH+'25'; ctx.fill(); + ctx.strokeStyle = colH; ctx.lineWidth = 1.2; ctx.stroke(); + ctx.fillStyle = colH; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('H', hx, hy); + }); + + // main C atom + ctx.beginPath(); ctx.arc(p.x, p.y, R, 0, Math.PI*2); + ctx.fillStyle = colC+'30'; ctx.fill(); + ctx.strokeStyle = colC; ctx.lineWidth = 1.8; ctx.stroke(); + ctx.fillStyle = colC; + ctx.font = 'bold 12px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('C', p.x, p.y); + }); + + // formula label + const formula = s.formula(n); + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '12px Manrope,sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(formula, 12, H - 12); + } + + /* ══════════════════════════════════════════════════════════════ + MODE 3 — QUALITATIVE REACTIONS + ══════════════════════════════════════════════════════════════ */ + _buildQualitative() { + const panel = document.createElement('div'); + panel.style.cssText = 'display:flex;width:100%;height:100%;gap:0'; + this._qualPanel = panel; + this._content.appendChild(panel); + + // left reagent selector + const left = document.createElement('div'); + left.style.cssText = 'width:200px;flex-shrink:0;padding:12px 10px;display:flex;flex-direction:column;gap:6px;' + + 'border-right:1px solid rgba(255,255,255,0.07);overflow-y:auto'; + panel.appendChild(left); + + const rl = document.createElement('div'); + rl.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4);margin-bottom:2px'; + rl.textContent = 'Реагент в пробирке'; + left.appendChild(rl); + + this._qualBtns = {}; + OrganicSim.QUAL_REACTIONS.forEach(rxn => { + const btn = document.createElement('button'); + btn.textContent = rxn.desc; + btn.style.cssText = 'padding:6px 8px;border-radius:6px;border:1px solid rgba(255,255,255,0.1);' + + 'background:rgba(255,255,255,0.04);color:#c0c0c0;cursor:pointer;font-size:.72rem;font-family:inherit;text-align:left;line-height:1.3'; + btn.addEventListener('click', () => this._selectQual(rxn)); + left.appendChild(btn); + this._qualBtns[rxn.id] = btn; + }); + + const sep3 = document.createElement('div'); + sep3.style.cssText = 'height:1px;background:rgba(255,255,255,0.07);margin:2px 0'; + left.appendChild(sep3); + + const hint3 = document.createElement('div'); + hint3.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.3);line-height:1.4'; + hint3.textContent = 'Выберите реагент → затем кликните на вещество для реакции'; + left.appendChild(hint3); + + // center: test tube canvas + const center = document.createElement('div'); + center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative'; + panel.appendChild(center); + + const qualCanvas = document.createElement('canvas'); + qualCanvas.style.cssText = 'width:100%;flex:1;display:block'; + this._qualCanvas = qualCanvas; + center.appendChild(qualCanvas); + + // compounds area + const compArea = document.createElement('div'); + compArea.style.cssText = 'padding:8px 12px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);' + + 'display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0'; + this._compArea = compArea; + center.appendChild(compArea); + + // right: result panel + const right = document.createElement('div'); + right.style.cssText = 'width:220px;flex-shrink:0;padding:12px;display:flex;flex-direction:column;gap:8px;' + + 'border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto'; + panel.appendChild(right); + + const rt = document.createElement('div'); + rt.style.cssText = 'font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,0.4)'; + rt.textContent = 'Результат'; + right.appendChild(rt); + + this._qualResultEl = this._infoBox(right, 'Наблюдение', '—'); + this._qualEqEl = this._infoBox(right, 'Уравнение реакции', '—'); + this._qualEqEl.style.fontSize = '.72rem'; + this._qualEqEl.style.lineHeight = '1.4'; + + const resetBtn = document.createElement('button'); + resetBtn.textContent = 'Сбросить'; + resetBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid rgba(239,71,111,0.3);' + + 'background:rgba(239,71,111,0.08);color:#EF476F;cursor:pointer;font-size:.75rem;font-family:inherit;margin-top:auto'; + resetBtn.addEventListener('click', () => { + this._qualCompound = null; + this._qualAnim = null; + this._qualResultEl.textContent = '—'; + this._qualEqEl.textContent = '—'; + this._drawQual(); + }); + right.appendChild(resetBtn); + + this._selectQual(OrganicSim.QUAL_REACTIONS[0]); + } + + _selectQual(rxn) { + this._qualReaction = rxn; + this._qualCompound = null; + this._qualAnim = null; + this._qualResultEl.textContent = '—'; + this._qualEqEl.textContent = '—'; + + Object.entries(this._qualBtns).forEach(([id, btn]) => { + const active = id === rxn.id; + btn.style.background = active ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; + btn.style.borderColor = active ? '#9B5DE5' : 'rgba(255,255,255,0.1)'; + btn.style.color = active ? '#C9A0FF' : '#c0c0c0'; + btn.style.fontWeight = active ? '700' : '400'; + }); + + // build compound buttons + this._compArea.innerHTML = ''; + rxn.compounds.forEach(comp => { + const btn = document.createElement('button'); + btn.textContent = comp.name; + btn.style.cssText = 'padding:5px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);' + + 'background:rgba(255,255,255,0.06);color:#e0e0e0;cursor:pointer;font-size:.78rem;font-family:inherit;' + + 'transition:all .15s'; + btn.addEventListener('click', () => this._runQualReaction(comp)); + btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(155,93,229,0.2)'; btn.style.borderColor = '#9B5DE5'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.06)'; btn.style.borderColor = 'rgba(255,255,255,0.12)'; }); + this._compArea.appendChild(btn); + }); + + this._drawQual(); + } + + _runQualReaction(comp) { + this._qualCompound = comp; + this._qualResultEl.textContent = comp.result; + this._qualEqEl.textContent = comp.equation; + this._qualResultEl.style.color = comp.symbol === '+' ? '#34d399' : '#EF476F'; + + // start animation + this._qualAnim = { + compound: comp, + t: 0, + maxT: 120, + }; + this._animQual(); + } + + _animQual() { + if (!this._qualAnim) return; + this._qualAnim.t++; + this._drawQual(); + if (this._qualAnim.t < this._qualAnim.maxT) { + this._raf = requestAnimationFrame(() => this._animQual()); + } + } + + _drawQual() { + const c = this._qualCanvas; + if (!c) return; + const rect = c.getBoundingClientRect(); + if (!rect.width) return; + c.width = Math.round(rect.width * devicePixelRatio); + c.height = Math.round(rect.height * devicePixelRatio); + const ctx = c.getContext('2d'); + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + const W = rect.width, H = rect.height; + + ctx.fillStyle = '#080810'; + ctx.fillRect(0, 0, W, H); + + // subtle grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + for (let gx = 0; gx < W; gx += 30) { ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); } + for (let gy = 0; gy < H; gy += 30) { ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); } + + const rxn = this._qualReaction; + const comp = this._qualCompound; + const anim = this._qualAnim; + + // draw multiple test tubes + const tubes = rxn.compounds; + const tubeW = 56, tubeH = 150, gap = 20; + const totalW = tubes.length * (tubeW + gap) - gap; + let startX = (W - totalW) / 2; + + tubes.forEach((tube, i) => { + const tx = startX + i * (tubeW + gap); + const ty = (H - tubeH) / 2 - 10; + const isActive = comp && comp === tube; + const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0; + + this._drawTestTube(ctx, tx, ty, tubeW, tubeH, rxn, tube, progress, isActive); + + // label + ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)'; + ctx.font = `${isActive ? '700' : '400'} 10px Manrope,sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + const label = tube.name.length > 12 ? tube.name.substring(0,11)+'…' : tube.name; + ctx.fillText(label, tx + tubeW/2, ty + tubeH + 8); + }); + + // reagent label + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = '11px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, H - 4); + } + + _drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) { + const liqH = h * 0.55; + const liqY = y + h - liqH; + + // glass tube outline + ctx.save(); + ctx.strokeStyle = isActive ? '#9B5DE5' : 'rgba(255,255,255,0.25)'; + ctx.lineWidth = isActive ? 2 : 1.5; + + // tube shape: rect top + rounded bottom + ctx.beginPath(); + ctx.moveTo(x + 4, y); + ctx.lineTo(x + 4, y + h - w/2 + 4); + ctx.arcTo(x + 4, y + h, x + w/2, y + h, w/2 - 4); + ctx.arcTo(x + w - 4, y + h, x + w - 4, y + h - w/2 + 4, w/2 - 4); + ctx.lineTo(x + w - 4, y); + ctx.stroke(); + + // clip to tube for liquid + ctx.beginPath(); + ctx.rect(x + 4, liqY, w - 8, liqH - 8); + ctx.arc(x + w/2, y + h - (w/2 - 4), w/2 - 4, 0, Math.PI); + ctx.clip(); + + // base liquid (reagent color) + const baseColor = rxn.reagentLiquid; + ctx.fillStyle = baseColor + 'A0'; + ctx.fillRect(x + 4, liqY, w - 8, liqH); + + // result color overlay animated + if (progress > 0) { + const resColor = comp.resultColor; + ctx.globalAlpha = progress; + ctx.fillStyle = resColor; + ctx.fillRect(x + 4, liqY, w - 8, liqH); + ctx.globalAlpha = 1; + + // precipitate settling + if (comp.symbol === '+' && progress > 0.4) { + const precH = (progress - 0.4) / 0.6 * 20; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillRect(x + 4, y + h - (w/2-4) - precH, w - 8, precH); + } + + // silver mirror effect + if (comp.mirror && progress > 0.3) { + const mirrorA = (progress - 0.3) / 0.7; + const grad = ctx.createLinearGradient(x+4, liqY, x+w-4, liqY); + grad.addColorStop(0, `rgba(220,220,220,${mirrorA*0.3})`); + grad.addColorStop(0.5, `rgba(240,240,240,${mirrorA*0.8})`); + grad.addColorStop(1, `rgba(200,200,200,${mirrorA*0.3})`); + ctx.fillStyle = grad; + ctx.fillRect(x + 4, liqY, w - 8, 8); + } + + // gas bubbles + if (comp.gas && progress > 0.2) { + const numBubbles = Math.floor((progress - 0.2) / 0.8 * 8); + for (let b = 0; b < numBubbles; b++) { + const bx = x + 8 + (b * 7) % (w - 16); + const by = y + h - 30 - (((this._qualAnim ? this._qualAnim.t : 0) * 3 + b * 15) % 80); + ctx.beginPath(); + ctx.arc(bx, by, 3, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(200,220,255,0.6)'; + ctx.fill(); + } + } + + // heat shimmer lines + if (comp.heat && progress > 0.5) { + ctx.strokeStyle = 'rgba(255,120,50,0.4)'; + ctx.lineWidth = 1; + for (let ln = 0; ln < 3; ln++) { + ctx.beginPath(); + const lx = x + 10 + ln * 12; + ctx.moveTo(lx, liqY + 10); + ctx.quadraticCurveTo(lx + 4, liqY + 20, lx, liqY + 30); + ctx.stroke(); + } + } + } + + ctx.restore(); + + // result symbol badge + if (comp.symbol) { + ctx.fillStyle = comp.symbol === '+' ? '#34d399' : '#EF476F'; + ctx.font = 'bold 14px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(comp.symbol, x + w/2, y - 12); + } else { + // pending indicator + ctx.fillStyle = 'rgba(255,255,255,0.2)'; + ctx.font = '12px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('?', x + w/2, y - 12); + } + } + + /* ── Lifecycle ─────────────────────────────────────────────────── */ + start() { + this._buildHomologs(); + this._buildQualitative(); + this._setMode(this._mode); + } + + stop() { + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + fit() { + if (this._mode === 'constructor') this._drawMolecule(); + if (this._mode === 'homologs') this._drawHomologs(); + if (this._mode === 'qualitative') this._drawQual(); + } +} + +/* ─── lab UI init ─────────────────────────────────── */ +var organicSim = null; + +function _openOrganic() { + document.getElementById('sim-topbar-title').textContent = 'Органическая химия'; + _simShow('sim-organic'); + + requestAnimationFrame(() => requestAnimationFrame(() => { + const wrap = document.getElementById('sim-organic'); + if (!organicSim) { + organicSim = new OrganicSim(wrap); + organicSim.start(); + } else { + organicSim.fit(); + } + })); +} diff --git a/frontend/js/labs/periodic.js b/frontend/js/labs/periodic.js new file mode 100644 index 0000000..fb5f8cf --- /dev/null +++ b/frontend/js/labs/periodic.js @@ -0,0 +1,750 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + PeriodicTableSim — Периодическая таблица (118 элементов) + Режимы: стандартный вид, подсветка по типам/блокам, + графики свойств, боровские оболочки, поиск элементов + ══════════════════════════════════════════════════════════════ */ + +/* ── Element data ───────────────────────────────────────────── */ +const ELEMENTS = [ + { Z:1, symbol:'H', name:'Водород', mass:1.008, group:1, period:1, block:'s', config:'1s¹', oxStates:[-1,+1], En:2.20, density:0.0899, melt:14.01, boil:20.28, type:'nonmetal', discovered:1766, by:'Кавендиш' }, + { Z:2, symbol:'He', name:'Гелий', mass:4.003, group:18, period:1, block:'s', config:'1s²', oxStates:[0], En:null, density:0.1786, melt:0.95, boil:4.22, type:'noble', discovered:1868, by:'Жансен' }, + { Z:3, symbol:'Li', name:'Литий', mass:6.941, group:1, period:2, block:'s', config:'[He]2s¹', oxStates:[+1], En:0.98, density:0.534, melt:453.65, boil:1603, type:'alkali', discovered:1817, by:'Арфведсон' }, + { Z:4, symbol:'Be', name:'Бериллий', mass:9.012, group:2, period:2, block:'s', config:'[He]2s²', oxStates:[+2], En:1.57, density:1.85, melt:1560, boil:2742, type:'alkaline', discovered:1798, by:'Воклен' }, + { Z:5, symbol:'B', name:'Бор', mass:10.811, group:13, period:2, block:'p', config:'[He]2s²2p¹', oxStates:[+3], En:2.04, density:2.34, melt:2349, boil:4200, type:'metalloid', discovered:1808, by:'Гей-Люссак' }, + { Z:6, symbol:'C', name:'Углерод', mass:12.011, group:14, period:2, block:'p', config:'[He]2s²2p²', oxStates:[-4,+4], En:2.55, density:2.267, melt:3823, boil:4098, type:'nonmetal', discovered:null, by:'Древний мир' }, + { Z:7, symbol:'N', name:'Азот', mass:14.007, group:15, period:2, block:'p', config:'[He]2s²2p³', oxStates:[-3,+5], En:3.04, density:1.251, melt:63.15, boil:77.36, type:'nonmetal', discovered:1772, by:'Резерфорд' }, + { Z:8, symbol:'O', name:'Кислород', mass:15.999, group:16, period:2, block:'p', config:'[He]2s²2p⁴', oxStates:[-2], En:3.44, density:1.429, melt:54.36, boil:90.20, type:'nonmetal', discovered:1774, by:'Пристли' }, + { Z:9, symbol:'F', name:'Фтор', mass:18.998, group:17, period:2, block:'p', config:'[He]2s²2p⁵', oxStates:[-1], En:3.98, density:1.696, melt:53.48, boil:85.03, type:'halogen', discovered:1886, by:'Муассан' }, + { Z:10, symbol:'Ne', name:'Неон', mass:20.180, group:18, period:2, block:'p', config:'[He]2s²2p⁶', oxStates:[0], En:null, density:0.9002, melt:24.56, boil:27.07, type:'noble', discovered:1898, by:'Рамзай' }, + { Z:11, symbol:'Na', name:'Натрий', mass:22.990, group:1, period:3, block:'s', config:'[Ne]3s¹', oxStates:[+1], En:0.93, density:0.971, melt:370.87, boil:1156, type:'alkali', discovered:1807, by:'Дэви' }, + { Z:12, symbol:'Mg', name:'Магний', mass:24.305, group:2, period:3, block:'s', config:'[Ne]3s²', oxStates:[+2], En:1.31, density:1.738, melt:923, boil:1363, type:'alkaline', discovered:1755, by:'Блэк' }, + { Z:13, symbol:'Al', name:'Алюминий', mass:26.982, group:13, period:3, block:'p', config:'[Ne]3s²3p¹', oxStates:[+3], En:1.61, density:2.70, melt:933.47, boil:2792, type:'posttransition',discovered:1825, by:'Эрстед' }, + { Z:14, symbol:'Si', name:'Кремний', mass:28.086, group:14, period:3, block:'p', config:'[Ne]3s²3p²', oxStates:[-4,+4], En:1.90, density:2.329, melt:1687, boil:3538, type:'metalloid', discovered:1824, by:'Берцелиус' }, + { Z:15, symbol:'P', name:'Фосфор', mass:30.974, group:15, period:3, block:'p', config:'[Ne]3s²3p³', oxStates:[-3,+5], En:2.19, density:1.823, melt:317.30, boil:553.65, type:'nonmetal', discovered:1669, by:'Бранд' }, + { Z:16, symbol:'S', name:'Сера', mass:32.065, group:16, period:3, block:'p', config:'[Ne]3s²3p⁴', oxStates:[-2,+6], En:2.58, density:2.07, melt:388.36, boil:717.87, type:'nonmetal', discovered:null, by:'Древний мир' }, + { Z:17, symbol:'Cl', name:'Хлор', mass:35.453, group:17, period:3, block:'p', config:'[Ne]3s²3p⁵', oxStates:[-1,+7], En:3.16, density:3.214, melt:171.65, boil:239.11, type:'halogen', discovered:1774, by:'Шееле' }, + { Z:18, symbol:'Ar', name:'Аргон', mass:39.948, group:18, period:3, block:'p', config:'[Ne]3s²3p⁶', oxStates:[0], En:null, density:1.784, melt:83.80, boil:87.30, type:'noble', discovered:1894, by:'Рэлей' }, + { Z:19, symbol:'K', name:'Калий', mass:39.098, group:1, period:4, block:'s', config:'[Ar]4s¹', oxStates:[+1], En:0.82, density:0.862, melt:336.53, boil:1032, type:'alkali', discovered:1807, by:'Дэви' }, + { Z:20, symbol:'Ca', name:'Кальций', mass:40.078, group:2, period:4, block:'s', config:'[Ar]4s²', oxStates:[+2], En:1.00, density:1.55, melt:1115, boil:1757, type:'alkaline', discovered:1808, by:'Дэви' }, + { Z:21, symbol:'Sc', name:'Скандий', mass:44.956, group:3, period:4, block:'d', config:'[Ar]3d¹4s²', oxStates:[+3], En:1.36, density:2.985, melt:1814, boil:3109, type:'transition', discovered:1879, by:'Нильсон' }, + { Z:22, symbol:'Ti', name:'Титан', mass:47.867, group:4, period:4, block:'d', config:'[Ar]3d²4s²', oxStates:[+4], En:1.54, density:4.507, melt:1941, boil:3560, type:'transition', discovered:1791, by:'Грегор' }, + { Z:23, symbol:'V', name:'Ванадий', mass:50.942, group:5, period:4, block:'d', config:'[Ar]3d³4s²', oxStates:[+5], En:1.63, density:6.11, melt:2183, boil:3680, type:'transition', discovered:1830, by:'Сефстрём' }, + { Z:24, symbol:'Cr', name:'Хром', mass:51.996, group:6, period:4, block:'d', config:'[Ar]3d⁵4s¹', oxStates:[+3,+6], En:1.66, density:7.19, melt:2180, boil:2944, type:'transition', discovered:1798, by:'Воклен' }, + { Z:25, symbol:'Mn', name:'Марганец', mass:54.938, group:7, period:4, block:'d', config:'[Ar]3d⁵4s²', oxStates:[+2,+7], En:1.55, density:7.21, melt:1519, boil:2334, type:'transition', discovered:1774, by:'Ган' }, + { Z:26, symbol:'Fe', name:'Железо', mass:55.845, group:8, period:4, block:'d', config:'[Ar]3d⁶4s²', oxStates:[+2,+3], En:1.83, density:7.874, melt:1811, boil:3134, type:'transition', discovered:null, by:'Древний мир' }, + { Z:27, symbol:'Co', name:'Кобальт', mass:58.933, group:9, period:4, block:'d', config:'[Ar]3d⁷4s²', oxStates:[+2,+3], En:1.88, density:8.90, melt:1768, boil:3200, type:'transition', discovered:1735, by:'Брандт' }, + { Z:28, symbol:'Ni', name:'Никель', mass:58.693, group:10, period:4, block:'d', config:'[Ar]3d⁸4s²', oxStates:[+2], En:1.91, density:8.908, melt:1728, boil:3186, type:'transition', discovered:1751, by:'Кронстедт' }, + { Z:29, symbol:'Cu', name:'Медь', mass:63.546, group:11, period:4, block:'d', config:'[Ar]3d¹⁰4s¹', oxStates:[+1,+2], En:1.90, density:8.96, melt:1357.77,boil:2835, type:'transition', discovered:null, by:'Древний мир' }, + { Z:30, symbol:'Zn', name:'Цинк', mass:65.38, group:12, period:4, block:'d', config:'[Ar]3d¹⁰4s²', oxStates:[+2], En:1.65, density:7.14, melt:692.68, boil:1180, type:'transition', discovered:1746, by:'Марграф' }, + { Z:31, symbol:'Ga', name:'Галлий', mass:69.723, group:13, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p¹', oxStates:[+3], En:1.81, density:5.91, melt:302.91, boil:2477, type:'posttransition',discovered:1875, by:'Де Буабодран' }, + { Z:32, symbol:'Ge', name:'Германий', mass:72.630, group:14, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p²', oxStates:[+4], En:2.01, density:5.323, melt:1211.40,boil:3106, type:'metalloid', discovered:1886, by:'Винклер' }, + { Z:33, symbol:'As', name:'Мышьяк', mass:74.922, group:15, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p³', oxStates:[-3,+5], En:2.18, density:5.776, melt:1090, boil:887, type:'metalloid', discovered:1250, by:'Альберт Великий' }, + { Z:34, symbol:'Se', name:'Селен', mass:78.971, group:16, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁴', oxStates:[-2,+6], En:2.55, density:4.809, melt:493.65, boil:958, type:'nonmetal', discovered:1817, by:'Берцелиус' }, + { Z:35, symbol:'Br', name:'Бром', mass:79.904, group:17, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁵', oxStates:[-1,+5], En:2.96, density:3.122, melt:265.95, boil:332.00, type:'halogen', discovered:1826, by:'Балар' }, + { Z:36, symbol:'Kr', name:'Криптон', mass:83.798, group:18, period:4, block:'p', config:'[Ar]3d¹⁰4s²4p⁶', oxStates:[0], En:3.00, density:3.749, melt:115.79, boil:119.93, type:'noble', discovered:1898, by:'Рамзай' }, + { Z:37, symbol:'Rb', name:'Рубидий', mass:85.468, group:1, period:5, block:'s', config:'[Kr]5s¹', oxStates:[+1], En:0.82, density:1.532, melt:312.46, boil:961, type:'alkali', discovered:1861, by:'Бунзен' }, + { Z:38, symbol:'Sr', name:'Стронций', mass:87.62, group:2, period:5, block:'s', config:'[Kr]5s²', oxStates:[+2], En:0.95, density:2.64, melt:1050, boil:1655, type:'alkaline', discovered:1790, by:'Кроуфорд' }, + { Z:39, symbol:'Y', name:'Иттрий', mass:88.906, group:3, period:5, block:'d', config:'[Kr]4d¹5s²', oxStates:[+3], En:1.22, density:4.472, melt:1799, boil:3609, type:'transition', discovered:1794, by:'Гадолин' }, + { Z:40, symbol:'Zr', name:'Цирконий', mass:91.224, group:4, period:5, block:'d', config:'[Kr]4d²5s²', oxStates:[+4], En:1.33, density:6.52, melt:2128, boil:4682, type:'transition', discovered:1789, by:'Клапрот' }, + { Z:41, symbol:'Nb', name:'Ниобий', mass:92.906, group:5, period:5, block:'d', config:'[Kr]4d⁴5s¹', oxStates:[+5], En:1.6, density:8.57, melt:2750, boil:5017, type:'transition', discovered:1801, by:'Хатчетт' }, + { Z:42, symbol:'Mo', name:'Молибден', mass:95.95, group:6, period:5, block:'d', config:'[Kr]4d⁵5s¹', oxStates:[+6], En:2.16, density:10.28, melt:2896, boil:4912, type:'transition', discovered:1781, by:'Шееле' }, + { Z:43, symbol:'Tc', name:'Технеций', mass:98, group:7, period:5, block:'d', config:'[Kr]4d⁵5s²', oxStates:[+7], En:1.9, density:11.50, melt:2430, boil:4538, type:'transition', discovered:1937, by:'Перье' }, + { Z:44, symbol:'Ru', name:'Рутений', mass:101.07, group:8, period:5, block:'d', config:'[Kr]4d⁷5s¹', oxStates:[+4], En:2.2, density:12.45, melt:2607, boil:4423, type:'transition', discovered:1844, by:'Клаус' }, + { Z:45, symbol:'Rh', name:'Родий', mass:102.906, group:9, period:5, block:'d', config:'[Kr]4d⁸5s¹', oxStates:[+3], En:2.28, density:12.41, melt:2237, boil:3968, type:'transition', discovered:1803, by:'Воластон' }, + { Z:46, symbol:'Pd', name:'Палладий', mass:106.42, group:10, period:5, block:'d', config:'[Kr]4d¹⁰', oxStates:[+2], En:2.20, density:12.023, melt:1828.05,boil:3236, type:'transition', discovered:1803, by:'Воластон' }, + { Z:47, symbol:'Ag', name:'Серебро', mass:107.868, group:11, period:5, block:'d', config:'[Kr]4d¹⁰5s¹', oxStates:[+1], En:1.93, density:10.49, melt:1234.93,boil:2435, type:'transition', discovered:null, by:'Древний мир' }, + { Z:48, symbol:'Cd', name:'Кадмий', mass:112.414, group:12, period:5, block:'d', config:'[Kr]4d¹⁰5s²', oxStates:[+2], En:1.69, density:8.65, melt:594.22, boil:1040, type:'transition', discovered:1817, by:'Штромейер' }, + { Z:49, symbol:'In', name:'Индий', mass:114.818, group:13, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p¹', oxStates:[+3], En:1.78, density:7.31, melt:429.75, boil:2345, type:'posttransition',discovered:1863, by:'Рейх' }, + { Z:50, symbol:'Sn', name:'Олово', mass:118.710, group:14, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p²', oxStates:[+2,+4], En:1.96, density:7.287, melt:505.08, boil:2875, type:'posttransition',discovered:null, by:'Древний мир' }, + { Z:51, symbol:'Sb', name:'Сурьма', mass:121.760, group:15, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p³', oxStates:[-3,+5], En:2.05, density:6.697, melt:903.78, boil:1860, type:'metalloid', discovered:null, by:'Древний мир' }, + { Z:52, symbol:'Te', name:'Теллур', mass:127.60, group:16, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁴', oxStates:[-2,+6], En:2.1, density:6.24, melt:722.66, boil:1261, type:'metalloid', discovered:1782, by:'фон Райхенштайн' }, + { Z:53, symbol:'I', name:'Йод', mass:126.904, group:17, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁵', oxStates:[-1,+7], En:2.66, density:4.933, melt:386.85, boil:457.55, type:'halogen', discovered:1811, by:'Куртуа' }, + { Z:54, symbol:'Xe', name:'Ксенон', mass:131.293, group:18, period:5, block:'p', config:'[Kr]4d¹⁰5s²5p⁶', oxStates:[0], En:2.6, density:5.894, melt:161.40, boil:165.05, type:'noble', discovered:1898, by:'Рамзай' }, + { Z:55, symbol:'Cs', name:'Цезий', mass:132.905, group:1, period:6, block:'s', config:'[Xe]6s¹', oxStates:[+1], En:0.79, density:1.873, melt:301.59, boil:944, type:'alkali', discovered:1860, by:'Бунзен' }, + { Z:56, symbol:'Ba', name:'Барий', mass:137.327, group:2, period:6, block:'s', config:'[Xe]6s²', oxStates:[+2], En:0.89, density:3.594, melt:1000, boil:2170, type:'alkaline', discovered:1808, by:'Дэви' }, + { Z:57, symbol:'La', name:'Лантан', mass:138.905, group:3, period:6, block:'f', config:'[Xe]5d¹6s²', oxStates:[+3], En:1.10, density:6.162, melt:1193, boil:3737, type:'lanthanide', discovered:1839, by:'Мосандер' }, + { Z:58, symbol:'Ce', name:'Церий', mass:140.116, group:null,period:6, block:'f', config:'[Xe]4f¹5d¹6s²', oxStates:[+3,+4], En:1.12, density:6.770, melt:1068, boil:3716, type:'lanthanide', discovered:1803, by:'Берцелиус' }, + { Z:59, symbol:'Pr', name:'Празеодим', mass:140.908, group:null,period:6, block:'f', config:'[Xe]4f³6s²', oxStates:[+3], En:1.13, density:6.77, melt:1208, boil:3793, type:'lanthanide', discovered:1885, by:'фон Вельсбах' }, + { Z:60, symbol:'Nd', name:'Неодим', mass:144.242, group:null,period:6, block:'f', config:'[Xe]4f⁴6s²', oxStates:[+3], En:1.14, density:7.01, melt:1297, boil:3347, type:'lanthanide', discovered:1885, by:'фон Вельсбах' }, + { Z:61, symbol:'Pm', name:'Прометий', mass:145, group:null,period:6, block:'f', config:'[Xe]4f⁵6s²', oxStates:[+3], En:1.13, density:7.26, melt:1315, boil:3273, type:'lanthanide', discovered:1945, by:'Маринский' }, + { Z:62, symbol:'Sm', name:'Самарий', mass:150.36, group:null,period:6, block:'f', config:'[Xe]4f⁶6s²', oxStates:[+2,+3], En:1.17, density:7.52, melt:1345, boil:2067, type:'lanthanide', discovered:1879, by:'Буабодран' }, + { Z:63, symbol:'Eu', name:'Европий', mass:151.964, group:null,period:6, block:'f', config:'[Xe]4f⁷6s²', oxStates:[+2,+3], En:1.20, density:5.244, melt:1099, boil:1802, type:'lanthanide', discovered:1901, by:'Демарсе' }, + { Z:64, symbol:'Gd', name:'Гадолиний', mass:157.25, group:null,period:6, block:'f', config:'[Xe]4f⁷5d¹6s²', oxStates:[+3], En:1.20, density:7.90, melt:1585, boil:3546, type:'lanthanide', discovered:1880, by:'Мариньяк' }, + { Z:65, symbol:'Tb', name:'Тербий', mass:158.925, group:null,period:6, block:'f', config:'[Xe]4f⁹6s²', oxStates:[+3], En:1.10, density:8.23, melt:1629, boil:3503, type:'lanthanide', discovered:1843, by:'Мосандер' }, + { Z:66, symbol:'Dy', name:'Диспрозий', mass:162.500, group:null,period:6, block:'f', config:'[Xe]4f¹⁰6s²', oxStates:[+3], En:1.22, density:8.540, melt:1680, boil:2840, type:'lanthanide', discovered:1886, by:'Буабодран' }, + { Z:67, symbol:'Ho', name:'Гольмий', mass:164.930, group:null,period:6, block:'f', config:'[Xe]4f¹¹6s²', oxStates:[+3], En:1.23, density:8.795, melt:1734, boil:2993, type:'lanthanide', discovered:1878, by:'Клеве' }, + { Z:68, symbol:'Er', name:'Эрбий', mass:167.259, group:null,period:6, block:'f', config:'[Xe]4f¹²6s²', oxStates:[+3], En:1.24, density:9.066, melt:1802, boil:3141, type:'lanthanide', discovered:1843, by:'Мосандер' }, + { Z:69, symbol:'Tm', name:'Тулий', mass:168.934, group:null,period:6, block:'f', config:'[Xe]4f¹³6s²', oxStates:[+3], En:1.25, density:9.32, melt:1818, boil:2223, type:'lanthanide', discovered:1879, by:'Клеве' }, + { Z:70, symbol:'Yb', name:'Иттербий', mass:173.054, group:null,period:6, block:'f', config:'[Xe]4f¹⁴6s²', oxStates:[+2,+3], En:1.10, density:6.90, melt:1097, boil:1469, type:'lanthanide', discovered:1878, by:'Мариньяк' }, + { Z:71, symbol:'Lu', name:'Лютеций', mass:174.967, group:3, period:6, block:'d', config:'[Xe]4f¹⁴5d¹6s²', oxStates:[+3], En:1.27, density:9.841, melt:1925, boil:3675, type:'lanthanide', discovered:1907, by:'Урбен' }, + { Z:72, symbol:'Hf', name:'Гафний', mass:178.49, group:4, period:6, block:'d', config:'[Xe]4f¹⁴5d²6s²', oxStates:[+4], En:1.3, density:13.31, melt:2506, boil:4876, type:'transition', discovered:1923, by:'Костер' }, + { Z:73, symbol:'Ta', name:'Тантал', mass:180.948, group:5, period:6, block:'d', config:'[Xe]4f¹⁴5d³6s²', oxStates:[+5], En:1.5, density:16.69, melt:3290, boil:5731, type:'transition', discovered:1802, by:'Экеберг' }, + { Z:74, symbol:'W', name:'Вольфрам', mass:183.84, group:6, period:6, block:'d', config:'[Xe]4f¹⁴5d⁴6s²', oxStates:[+6], En:2.36, density:19.25, melt:3695, boil:5828, type:'transition', discovered:1783, by:'Братья дель Риo' }, + { Z:75, symbol:'Re', name:'Рений', mass:186.207, group:7, period:6, block:'d', config:'[Xe]4f¹⁴5d⁵6s²', oxStates:[+7], En:1.9, density:21.02, melt:3459, boil:5869, type:'transition', discovered:1925, by:'Ноддак' }, + { Z:76, symbol:'Os', name:'Осмий', mass:190.23, group:8, period:6, block:'d', config:'[Xe]4f¹⁴5d⁶6s²', oxStates:[+4], En:2.2, density:22.59, melt:3306, boil:5285, type:'transition', discovered:1803, by:'Теннант' }, + { Z:77, symbol:'Ir', name:'Иридий', mass:192.217, group:9, period:6, block:'d', config:'[Xe]4f¹⁴5d⁷6s²', oxStates:[+4], En:2.20, density:22.56, melt:2719, boil:4701, type:'transition', discovered:1803, by:'Теннант' }, + { Z:78, symbol:'Pt', name:'Платина', mass:195.084, group:10, period:6, block:'d', config:'[Xe]4f¹⁴5d⁹6s¹', oxStates:[+2,+4], En:2.28, density:21.45, melt:2041.4, boil:4098, type:'transition', discovered:1735, by:'де Улоа' }, + { Z:79, symbol:'Au', name:'Золото', mass:196.967, group:11, period:6, block:'d', config:'[Xe]4f¹⁴5d¹⁰6s¹',oxStates:[+1,+3], En:2.54, density:19.30, melt:1337.33,boil:3129, type:'transition', discovered:null, by:'Древний мир' }, + { Z:80, symbol:'Hg', name:'Ртуть', mass:200.592, group:12, period:6, block:'d', config:'[Xe]4f¹⁴5d¹⁰6s²',oxStates:[+1,+2], En:2.00, density:13.534, melt:234.32, boil:629.88, type:'transition', discovered:null, by:'Древний мир' }, + { Z:81, symbol:'Tl', name:'Таллий', mass:204.38, group:13, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p¹',oxStates:[+1,+3],En:1.62,density:11.85, melt:577, boil:1746, type:'posttransition',discovered:1861, by:'Крукс' }, + { Z:82, symbol:'Pb', name:'Свинец', mass:207.2, group:14, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p²',oxStates:[+2,+4],En:2.33,density:11.34, melt:600.61, boil:2022, type:'posttransition',discovered:null, by:'Древний мир' }, + { Z:83, symbol:'Bi', name:'Висмут', mass:208.980, group:15, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p³',oxStates:[+3], En:2.02,density:9.747, melt:544.55, boil:1837, type:'posttransition',discovered:1753, by:'Жоффруа' }, + { Z:84, symbol:'Po', name:'Полоний', mass:209, group:16, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁴',oxStates:[+4], En:2.0, density:9.32, melt:527, boil:1235, type:'metalloid', discovered:1898, by:'Кюри' }, + { Z:85, symbol:'At', name:'Астат', mass:210, group:17, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁵',oxStates:[-1,+1],En:2.2, density:null, melt:575, boil:null, type:'halogen', discovered:1940, by:'Корсон' }, + { Z:86, symbol:'Rn', name:'Радон', mass:222, group:18, period:6, block:'p', config:'[Xe]4f¹⁴5d¹⁰6s²6p⁶',oxStates:[0], En:null,density:9.73, melt:202, boil:211.45, type:'noble', discovered:1900, by:'Дорн' }, + { Z:87, symbol:'Fr', name:'Франций', mass:223, group:1, period:7, block:'s', config:'[Rn]7s¹', oxStates:[+1], En:0.7, density:null, melt:300, boil:950, type:'alkali', discovered:1939, by:'Перей' }, + { Z:88, symbol:'Ra', name:'Радий', mass:226, group:2, period:7, block:'s', config:'[Rn]7s²', oxStates:[+2], En:0.9, density:5.0, melt:973, boil:2010, type:'alkaline', discovered:1898, by:'Кюри' }, + { Z:89, symbol:'Ac', name:'Актиний', mass:227, group:3, period:7, block:'f', config:'[Rn]6d¹7s²', oxStates:[+3], En:1.1, density:10.07, melt:1323, boil:3471, type:'actinide', discovered:1899, by:'Дебьерн' }, + { Z:90, symbol:'Th', name:'Торий', mass:232.038, group:null,period:7, block:'f', config:'[Rn]6d²7s²', oxStates:[+4], En:1.3, density:11.72, melt:2115, boil:5061, type:'actinide', discovered:1828, by:'Берцелиус' }, + { Z:91, symbol:'Pa', name:'Протактиний', mass:231.036, group:null,period:7, block:'f', config:'[Rn]5f²6d¹7s²', oxStates:[+5], En:1.5, density:15.37, melt:1841, boil:4300, type:'actinide', discovered:1913, by:'Фаянс' }, + { Z:92, symbol:'U', name:'Уран', mass:238.029, group:null,period:7, block:'f', config:'[Rn]5f³6d¹7s²', oxStates:[+6], En:1.38, density:19.05, melt:1405.3, boil:4404, type:'actinide', discovered:1789, by:'Клапрот' }, + { Z:93, symbol:'Np', name:'Нептуний', mass:237, group:null,period:7, block:'f', config:'[Rn]5f⁴6d¹7s²', oxStates:[+5], En:1.36, density:20.25, melt:913, boil:4273, type:'actinide', discovered:1940, by:'МакМиллан' }, + { Z:94, symbol:'Pu', name:'Плутоний', mass:244, group:null,period:7, block:'f', config:'[Rn]5f⁶7s²', oxStates:[+4], En:1.28, density:19.84, melt:912.5, boil:3501, type:'actinide', discovered:1940, by:'Сиборг' }, + { Z:95, symbol:'Am', name:'Америций', mass:243, group:null,period:7, block:'f', config:'[Rn]5f⁷7s²', oxStates:[+3], En:1.3, density:13.67, melt:1449, boil:2880, type:'actinide', discovered:1944, by:'Сиборг' }, + { Z:96, symbol:'Cm', name:'Кюрий', mass:247, group:null,period:7, block:'f', config:'[Rn]5f⁷6d¹7s²', oxStates:[+3], En:1.3, density:13.51, melt:1613, boil:3383, type:'actinide', discovered:1944, by:'Сиборг' }, + { Z:97, symbol:'Bk', name:'Берклий', mass:247, group:null,period:7, block:'f', config:'[Rn]5f⁹7s²', oxStates:[+3], En:1.3, density:14.79, melt:1259, boil:null, type:'actinide', discovered:1949, by:'Сиборг' }, + { Z:98, symbol:'Cf', name:'Калифорний', mass:251, group:null,period:7, block:'f', config:'[Rn]5f¹⁰7s²', oxStates:[+3], En:1.3, density:15.1, melt:1173, boil:null, type:'actinide', discovered:1950, by:'Сиборг' }, + { Z:99, symbol:'Es', name:'Эйнштейний', mass:252, group:null,period:7, block:'f', config:'[Rn]5f¹¹7s²', oxStates:[+3], En:1.3, density:null, melt:1133, boil:null, type:'actinide', discovered:1952, by:'Гиорсо' }, + { Z:100,symbol:'Fm', name:'Фермий', mass:257, group:null,period:7, block:'f', config:'[Rn]5f¹²7s²', oxStates:[+3], En:1.3, density:null, melt:1800, boil:null, type:'actinide', discovered:1952, by:'Гиорсо' }, + { Z:101,symbol:'Md', name:'Менделевий', mass:258, group:null,period:7, block:'f', config:'[Rn]5f¹³7s²', oxStates:[+3], En:1.3, density:null, melt:1100, boil:null, type:'actinide', discovered:1955, by:'Гиорсо' }, + { Z:102,symbol:'No', name:'Нобелий', mass:259, group:null,period:7, block:'f', config:'[Rn]5f¹⁴7s²', oxStates:[+2], En:1.3, density:null, melt:1100, boil:null, type:'actinide', discovered:1958, by:'Флёров' }, + { Z:103,symbol:'Lr', name:'Лоуренсий', mass:266, group:3, period:7, block:'d', config:'[Rn]5f¹⁴7s²7p¹', oxStates:[+3], En:1.3, density:null, melt:1900, boil:null, type:'actinide', discovered:1961, by:'Гиорсо' }, + { Z:104,symbol:'Rf', name:'Резерфордий', mass:267, group:4, period:7, block:'d', config:'[Rn]5f¹⁴6d²7s²', oxStates:[+4], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1964, by:'Флёров' }, + { Z:105,symbol:'Db', name:'Дубний', mass:268, group:5, period:7, block:'d', config:'[Rn]5f¹⁴6d³7s²', oxStates:[+5], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1968, by:'Флёров' }, + { Z:106,symbol:'Sg', name:'Сиборгий', mass:269, group:6, period:7, block:'d', config:'[Rn]5f¹⁴6d⁴7s²', oxStates:[+6], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1974, by:'Флёров' }, + { Z:107,symbol:'Bh', name:'Борий', mass:270, group:7, period:7, block:'d', config:'[Rn]5f¹⁴6d⁵7s²', oxStates:[+7], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1981, by:'ГСИ' }, + { Z:108,symbol:'Hs', name:'Хассий', mass:277, group:8, period:7, block:'d', config:'[Rn]5f¹⁴6d⁶7s²', oxStates:[+8], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1984, by:'ГСИ' }, + { Z:109,symbol:'Mt', name:'Мейтнерий', mass:278, group:9, period:7, block:'d', config:'[Rn]5f¹⁴6d⁷7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1982, by:'ГСИ' }, + { Z:110,symbol:'Ds', name:'Дармштадтий', mass:281, group:10, period:7, block:'d', config:'[Rn]5f¹⁴6d⁸7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1994, by:'ГСИ' }, + { Z:111,symbol:'Rg', name:'Рентгений', mass:282, group:11, period:7, block:'d', config:'[Rn]5f¹⁴6d⁹7s²', oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1994, by:'ГСИ' }, + { Z:112,symbol:'Cn', name:'Коперниций', mass:285, group:12, period:7, block:'d', config:'[Rn]5f¹⁴6d¹⁰7s²',oxStates:[null], En:null, density:null, melt:null, boil:null, type:'transition', discovered:1996, by:'ГСИ' }, + { Z:113,symbol:'Nh', name:'Нихоний', mass:286, group:13, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p¹',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2004, by:'РИКЕН' }, + { Z:114,symbol:'Fl', name:'Флеровий', mass:289, group:14, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p²',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:1998, by:'Флёров' }, + { Z:115,symbol:'Mc', name:'Московий', mass:290, group:15, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p³',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2003, by:'Флёров' }, + { Z:116,symbol:'Lv', name:'Ливерморий', mass:293, group:16, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁴',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'posttransition',discovered:2000, by:'Флёров' }, + { Z:117,symbol:'Ts', name:'Теннессин', mass:294, group:17, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁵',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'halogen', discovered:2010, by:'Флёров' }, + { Z:118,symbol:'Og', name:'Оганессон', mass:294, group:18, period:7, block:'p', config:'[Rn]5f¹⁴6d¹⁰7s²7p⁶',oxStates:[null],En:null, density:null, melt:null, boil:null, type:'noble', discovered:2002, by:'Флёров' }, +]; + +/* ── Colour palette per type ──────────────────────────────── */ +const TYPE_COLORS = { + alkali: '#EF476F', + alkaline: '#FF6B35', + transition: '#7B8EF7', + posttransition:'#06D6E0', + metalloid: '#7BF5A4', + nonmetal: '#FFD166', + halogen: '#C77DFF', + noble: '#A8DADC', + lanthanide: '#9B5DE5', + actinide: '#F15BB5', + metal: '#7B8EF7', +}; +const TYPE_LABELS = { + alkali: 'Щелочные металлы', + alkaline: 'Щёлочноземельные', + transition: 'Переходные металлы', + posttransition:'Постпереходные', + metalloid: 'Металлоиды', + nonmetal: 'Неметаллы', + halogen: 'Галогены', + noble: 'Благородные газы', + lanthanide: 'Лантаноиды', + actinide: 'Актиноиды', +}; +const BLOCK_COLORS = { + s: '#EF476F', + p: '#06D6E0', + d: '#7B8EF7', + f: '#9B5DE5', +}; + +/* ── Электронные оболочки (K,L,M,N,O,P,Q) ── */ +const SHELL_CAPACITY = [2, 8, 18, 32, 32, 18, 8]; + +function getShellFill(Z) { + const caps = SHELL_CAPACITY; + const shells = []; + let rem = Z; + for (let i = 0; i < caps.length && rem > 0; i++) { + const n = Math.min(rem, caps[i]); + shells.push(n); + rem -= n; + } + return shells; +} + +/* ── Layout helpers ──────────────────────────────────────────── */ +/* Standard 18-column layout: returns {col, row} for each element */ +function getCell(el) { + if (el.type === 'lanthanide' && el.Z !== 57 && el.Z !== 71) { + return { col: el.Z - 57 + 3, row: 9 }; // lanthanide row + } + if (el.type === 'actinide' && el.Z !== 89 && el.Z !== 103) { + return { col: el.Z - 89 + 3, row: 10 }; // actinide row + } + const g = el.group; + const p = el.period; + if (!g) return null; + return { col: g, row: p }; +} + +/* ══════════════════════════════════════════════════════════════ + CLASS + ══════════════════════════════════════════════════════════════ */ +class PeriodicTableSim { + constructor(wrap) { + this._wrap = wrap; + this._mode = 'type'; // type | block | none + this._selected = null; // element Z + this._searchQ = ''; + this._highlighted = new Set(); // Zs matching search + this._propKey = 'En'; // property for chart + this._chartBy = 'period'; + this._chartN = 2; // period number or group number + this._bohrZ = null; // Z for Bohr shell panel + this._bohrRaf = null; + this._bohrAngle = 0; + + // build + this._buildUI(); + this._buildTable(); + this._updateCard(null); + + // chart defaults + this._drawChart(); + } + + /* ───────────────────────────────────────────────────── + UI BUILD + ───────────────────────────────────────────────────── */ + _buildUI() { + this._wrap.innerHTML = ''; + this._wrap.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:0;background:#0D0D1A;overflow:hidden;'; + + /* top toolbar */ + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,0.08);flex-wrap:wrap;flex-shrink:0;'; + toolbar.innerHTML = ` + Режим: + + + + Поиск: + + `; + this._wrap.appendChild(toolbar); + + /* mode buttons */ + toolbar.querySelectorAll('.ptbl-mode-btn').forEach(btn => { + btn.style.cssText = 'padding:4px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.15);background:transparent;color:#aaa;font-size:.75rem;cursor:pointer;transition:all .15s'; + btn.addEventListener('click', () => { + toolbar.querySelectorAll('.ptbl-mode-btn').forEach(b => { b.style.background='transparent'; b.style.color='#aaa'; b.classList.remove('active'); }); + btn.style.background='rgba(155,93,229,0.25)'; btn.style.color='#fff'; btn.classList.add('active'); + this._mode = btn.dataset.m; + if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.25 }); + this._colorTable(); + }); + }); + /* set initial active style */ + toolbar.querySelector('.ptbl-mode-btn.active').style.background = 'rgba(155,93,229,0.25)'; + toolbar.querySelector('.ptbl-mode-btn.active').style.color = '#fff'; + + /* search */ + const si = toolbar.querySelector('#ptbl-search'); + si.addEventListener('input', () => { this._searchQ = si.value.trim().toLowerCase(); this._applySearch(); }); + toolbar.querySelector('#ptbl-search-clear').addEventListener('click', () => { si.value=''; this._searchQ=''; this._applySearch(); }); + + /* main area: table + right panel */ + const main = document.createElement('div'); + main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden;'; + this._wrap.appendChild(main); + + /* left: table + legend */ + const leftCol = document.createElement('div'); + leftCol.style.cssText = 'flex:1;display:flex;flex-direction:column;min-width:0;overflow:auto;padding:8px 4px 8px 8px;'; + main.appendChild(leftCol); + + /* table grid container */ + this._tableEl = document.createElement('div'); + this._tableEl.id = 'ptbl-grid'; + this._tableEl.style.cssText = 'display:grid;grid-template-columns:repeat(18,1fr);gap:2px;min-width:540px;'; + leftCol.appendChild(this._tableEl); + + /* gap filler row */ + const gapDiv = document.createElement('div'); + gapDiv.style.cssText = 'height:8px;'; + leftCol.appendChild(gapDiv); + + /* f-block (lanthanide/actinide) */ + this._fblockEl = document.createElement('div'); + this._fblockEl.id = 'ptbl-fblock'; + this._fblockEl.style.cssText = 'display:grid;grid-template-columns:repeat(15,1fr);gap:2px;min-width:540px;margin-left:calc(2*100%/18 + 4px);'; + leftCol.appendChild(this._fblockEl); + + /* legend */ + this._legendEl = document.createElement('div'); + this._legendEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;min-width:540px;'; + leftCol.appendChild(this._legendEl); + + /* right panel */ + const rightCol = document.createElement('div'); + rightCol.style.cssText = 'width:260px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid rgba(255,255,255,0.07);overflow:hidden;'; + main.appendChild(rightCol); + + /* element card */ + this._cardEl = document.createElement('div'); + this._cardEl.id = 'ptbl-card'; + this._cardEl.style.cssText = 'flex:1;overflow-y:auto;padding:12px 10px 8px;font-size:.78rem;color:#ccc;'; + rightCol.appendChild(this._cardEl); + + /* Bohr shells canvas */ + const bohrWrap = document.createElement('div'); + bohrWrap.style.cssText = 'height:150px;flex-shrink:0;border-top:1px solid rgba(255,255,255,0.07);background:rgba(0,0,0,0.3);position:relative;'; + this._bohrCanvas = document.createElement('canvas'); + this._bohrCanvas.style.cssText = 'width:100%;height:100%;'; + bohrWrap.appendChild(this._bohrCanvas); + rightCol.appendChild(bohrWrap); + + /* chart panel */ + const chartPan = document.createElement('div'); + chartPan.style.cssText = 'flex-shrink:0;border-top:1px solid rgba(255,255,255,0.07);padding:8px;background:rgba(0,0,0,0.2);'; + chartPan.innerHTML = ` +
+ Свойство: + + + +
+ `; + rightCol.appendChild(chartPan); + + chartPan.querySelector('#ptbl-prop-sel').addEventListener('change', e => { this._propKey=e.target.value; this._drawChart(); }); + chartPan.querySelector('#ptbl-by-sel').addEventListener('change', e => { + this._chartBy=e.target.value; + const nSel = chartPan.querySelector('#ptbl-n-sel'); + if (this._chartBy === 'group') { + nSel.innerHTML = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18].map(n=>``).join(''); + this._chartN = 1; + } else { + nSel.innerHTML = [1,2,3,4,5,6,7].map(n=>``).join(''); + this._chartN = 2; + } + this._drawChart(); + }); + chartPan.querySelector('#ptbl-n-sel').addEventListener('change', e => { this._chartN=+e.target.value; this._drawChart(); }); + + this._chartCanvas = chartPan.querySelector('#ptbl-chart'); + new ResizeObserver(() => this._drawChart()).observe(this._chartCanvas); + new ResizeObserver(() => this._drawBohr()).observe(this._bohrCanvas); + } + + /* ───────────────────────────────────────────────────── + TABLE BUILD + ───────────────────────────────────────────────────── */ + _buildTable() { + /* create placeholder grid for rows 1-7 × cols 1-18 */ + const cells = {}; // 'row,col' → div + for (let r = 1; r <= 7; r++) { + for (let c = 1; c <= 18; c++) { + const d = document.createElement('div'); + d.style.cssText = 'aspect-ratio:1;border-radius:4px;'; + cells[`${r},${c}`] = d; + this._tableEl.appendChild(d); + } + } + + /* f-block rows */ + const fCells = { 9: {}, 10: {} }; // lanthanides / actinides + for (let fc = 1; fc <= 15; fc++) { + for (const fr of [9, 10]) { + const d = document.createElement('div'); + d.style.cssText = 'aspect-ratio:1;border-radius:4px;'; + fCells[fr][fc] = d; + this._fblockEl.appendChild(d); + } + } + + /* place elements */ + this._cellMap = {}; // Z → div + for (const el of ELEMENTS) { + const pos = getCell(el); + if (!pos) continue; + let div; + if (pos.row <= 7) { + div = cells[`${pos.row},${pos.col}`]; + } else { + const fCol = pos.col - 2; // 3..17 → 1..15 + div = fCells[pos.row][fCol]; + } + if (!div) continue; + this._cellMap[el.Z] = div; + div.dataset.z = el.Z; + div.title = `${el.name} (${el.symbol})`; + div.style.cssText += 'cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:filter .12s,transform .12s;position:relative;overflow:hidden;'; + div.innerHTML = ` + ${el.Z} + ${el.symbol} + ${el.name}`; + div.addEventListener('mouseenter', () => { div.style.filter='brightness(1.4)'; div.style.transform='scale(1.12)'; div.style.zIndex='10'; }); + div.addEventListener('mouseleave', () => { div.style.filter=''; div.style.transform=''; div.style.zIndex=''; }); + div.addEventListener('click', () => this._selectElement(el.Z)); + } + + this._colorTable(); + this._buildLegend(); + } + + _colorTable() { + for (const el of ELEMENTS) { + const div = this._cellMap[el.Z]; + if (!div) continue; + let bg, textC = '#fff'; + if (this._mode === 'type') { + bg = TYPE_COLORS[el.type] || '#555'; + } else if (this._mode === 'block') { + bg = BLOCK_COLORS[el.block] || '#555'; + } else { + bg = '#2a2a3e'; + } + div.style.background = bg + '33'; // 20% opacity base + div.style.border = `1px solid ${bg}88`; + div.style.color = '#fff'; + if (this._highlighted.size > 0) { + if (this._highlighted.has(el.Z)) { + div.style.background = bg + 'cc'; + div.style.border = `2px solid ${bg}`; + div.style.boxShadow = `0 0 8px ${bg}99`; + } else { + div.style.opacity = '0.25'; + } + } else { + div.style.opacity = '1'; + div.style.boxShadow = ''; + } + } + this._buildLegend(); + } + + _buildLegend() { + this._legendEl.innerHTML = ''; + const map = this._mode === 'block' ? BLOCK_COLORS : TYPE_COLORS; + const labels = this._mode === 'block' ? { s:'s-блок', p:'p-блок', d:'d-блок', f:'f-блок' } : TYPE_LABELS; + for (const [k, col] of Object.entries(map)) { + if (!labels[k]) continue; + const d = document.createElement('div'); + d.style.cssText = `display:flex;align-items:center;gap:4px;font-size:.68rem;color:#bbb;cursor:pointer;`; + d.innerHTML = `${labels[k]}`; + /* highlight on hover */ + d.addEventListener('mouseenter', () => this._highlightType(k)); + d.addEventListener('mouseleave', () => this._unhighlightType()); + this._legendEl.appendChild(d); + } + } + + _highlightType(key) { + for (const el of ELEMENTS) { + const div = this._cellMap[el.Z]; + if (!div) continue; + const match = this._mode === 'block' ? el.block === key : el.type === key; + if (match) { + div.style.filter = 'brightness(1.5)'; + div.style.transform = 'scale(1.05)'; + div.style.zIndex = '5'; + } else { + div.style.opacity = '0.2'; + } + } + } + _unhighlightType() { + for (const el of ELEMENTS) { + const div = this._cellMap[el.Z]; + if (!div) continue; + div.style.filter = ''; + div.style.transform = ''; + div.style.zIndex = ''; + div.style.opacity = this._highlighted.has(el.Z) ? '1' : (this._highlighted.size > 0 ? '0.25' : '1'); + } + } + + /* ───────────────────────────────────────────────────── + SELECT / SEARCH + ───────────────────────────────────────────────────── */ + _selectElement(Z) { + this._selected = Z; + if (window.LabFX) LabFX.sound.play('chime', { pitch: 0.8 + Z * 0.008, volume: 0.25 }); + /* highlight selected cell */ + for (const el of ELEMENTS) { + const div = this._cellMap[el.Z]; + if (!div) continue; + if (el.Z === Z) { + div.style.outline = '2px solid #fff'; + div.style.outlineOffset = '1px'; + } else { + div.style.outline = ''; + div.style.outlineOffset = ''; + } + } + this._updateCard(ELEMENTS.find(e => e.Z === Z)); + this._bohrZ = Z; + this._startBohr(); + } + + _applySearch() { + this._highlighted.clear(); + if (this._searchQ) { + for (const el of ELEMENTS) { + const q = this._searchQ; + if ( + el.symbol.toLowerCase() === q || + el.name.toLowerCase().includes(q) || + String(el.Z) === q || + String(el.mass).startsWith(q) + ) { + this._highlighted.add(el.Z); + } + } + } + this._colorTable(); + /* auto-select if single result */ + if (this._highlighted.size === 1) { + this._selectElement([...this._highlighted][0]); + } + } + + /* ───────────────────────────────────────────────────── + ELEMENT CARD + ───────────────────────────────────────────────────── */ + _updateCard(el) { + if (!el) { + this._cardEl.innerHTML = `
Кликните на элемент
`; + this._bohrZ = null; + cancelAnimationFrame(this._bohrRaf); + this._bohrRaf = null; + this._drawBohr(); + return; + } + const col = TYPE_COLORS[el.type] || '#888'; + const ox = el.oxStates && el.oxStates[0] !== null ? el.oxStates.map(s => (s > 0 ? '+' : '') + s).join(', ') : '—'; + const fmt = v => v !== null && v !== undefined ? v : '—'; + this._cardEl.innerHTML = ` +
+
${el.symbol}
+
${el.name}
+
Z = ${el.Z} · ${el.mass} а.е.м.
+
${TYPE_LABELS[el.type] || el.type}
+
+ + ${this._row('Конфигурация', `${el.config}`)} + ${this._row('Блок', el.block + '-блок')} + ${this._row('Период / Группа', `${el.period} / ${el.group || '—'}`)} + ${this._row('Ст. окисления', ox)} + ${this._row('ЭО (Полинг)', fmt(el.En))} + ${this._row('Плотность, г/см³', fmt(el.density))} + ${this._row('Tпл, K', fmt(el.melt))} + ${this._row('Tкип, K', fmt(el.boil))} + ${this._row('Открыт', el.discovered ? `${el.discovered}, ${el.by}` : el.by)} +
`; + } + + _row(label, val) { + return ` + ${label} + ${val} + `; + } + + /* ───────────────────────────────────────────────────── + BOHR SHELLS ANIMATION + ───────────────────────────────────────────────────── */ + _startBohr() { + cancelAnimationFrame(this._bohrRaf); + this._bohrAngle = 0; + this._animBohr(); + } + + _animBohr() { + this._bohrAngle += 0.018; + this._drawBohr(); + this._bohrRaf = requestAnimationFrame(() => this._animBohr()); + } + + _drawBohr() { + const canvas = this._bohrCanvas; + const dpr = window.devicePixelRatio || 1; + const W = canvas.offsetWidth || 240; + const H = canvas.offsetHeight || 150; + canvas.width = W * dpr; + canvas.height = H * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + if (!this._bohrZ) return; + + const el = ELEMENTS.find(e => e.Z === this._bohrZ); + if (!el) return; + + const shells = getShellFill(el.Z); + const cx = W / 2, cy = H / 2; + const maxR = Math.min(W, H) * 0.44; + const nShells = shells.length; + const col = TYPE_COLORS[el.type] || '#7B8EF7'; + + /* nucleus */ + ctx.beginPath(); + ctx.arc(cx, cy, nShells > 0 ? 5 + nShells * 1.5 : 6, 0, Math.PI * 2); + ctx.fillStyle = col; + ctx.fill(); + + shells.forEach((count, i) => { + const r = maxR * (i + 1) / nShells; + /* orbit ring */ + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + ctx.stroke(); + + /* electrons */ + const speed = 1 - i * 0.12; // inner shells faster + for (let e = 0; e < count; e++) { + const a = this._bohrAngle * speed + (2 * Math.PI * e) / count; + const ex = cx + r * Math.cos(a); + const ey = cy + r * Math.sin(a); + ctx.beginPath(); + ctx.arc(ex, ey, 2.5, 0, Math.PI * 2); + ctx.fillStyle = '#06D6E0'; + ctx.fill(); + } + }); + + /* label */ + ctx.font = `700 10px Manrope,sans-serif`; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.textAlign = 'center'; + ctx.fillText(shells.join(','), cx, H - 4); + } + + /* ───────────────────────────────────────────────────── + PROPERTY CHART + ───────────────────────────────────────────────────── */ + _drawChart() { + const canvas = this._chartCanvas; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + const W = canvas.offsetWidth || 240; + const H = canvas.offsetHeight || 90; + canvas.width = W * dpr; + canvas.height = H * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, W, H); + + /* filter elements */ + let els; + if (this._chartBy === 'period') { + els = ELEMENTS.filter(e => e.period === this._chartN && e.group !== null); + els.sort((a, b) => a.group - b.group); + } else { + els = ELEMENTS.filter(e => e.group === this._chartN); + els.sort((a, b) => a.period - b.period); + } + + const vals = els.map(e => e[this._propKey]); + const validVals = vals.filter(v => v !== null && v !== undefined && isFinite(v)); + if (validVals.length < 2) { + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; + ctx.fillText('Нет данных', W / 2, H / 2); + return; + } + + const minV = Math.min(...validVals); + const maxV = Math.max(...validVals); + const pad = { t: 10, r: 8, b: 20, l: 8 }; + const gW = W - pad.l - pad.r; + const gH = H - pad.t - pad.b; + + /* axes */ + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(pad.l, pad.t); + ctx.lineTo(pad.l, pad.t + gH); + ctx.lineTo(pad.l + gW, pad.t + gH); + ctx.stroke(); + + /* line */ + ctx.beginPath(); + let first = true; + const points = []; + els.forEach((el, i) => { + const v = el[this._propKey]; + if (v === null || v === undefined || !isFinite(v)) return; + const x = pad.l + (i / Math.max(els.length - 1, 1)) * gW; + const y = pad.t + gH - ((v - minV) / (maxV - minV || 1)) * gH; + points.push({ x, y, el }); + if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y); + }); + ctx.strokeStyle = '#9B5DE5'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + /* markers */ + points.forEach(({ x, y, el }) => { + const col = TYPE_COLORS[el.type] || '#7B8EF7'; + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = col; + ctx.fill(); + }); + + /* x labels (symbols) */ + ctx.font = '8px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'center'; + /* show at most 18 labels */ + const step = Math.ceil(els.length / 18); + els.forEach((el, i) => { + if (i % step === 0) { + const x = pad.l + (i / Math.max(els.length - 1, 1)) * gW; + ctx.fillText(el.symbol, x, H - 4); + } + }); + } + + /* ───────────────────────────────────────────────────── + LIFECYCLE + ───────────────────────────────────────────────────── */ + stop() { + cancelAnimationFrame(this._bohrRaf); + this._bohrRaf = null; + } +} + +/* ── global opener ─────────────────────────────────────────── */ +var periodicSim = null; + +function _openPeriodic() { + document.getElementById('sim-periodic').style.display = 'flex'; + if (!periodicSim) { + periodicSim = new PeriodicTableSim(document.getElementById('periodic-wrap')); + } +} diff --git a/frontend/js/labs/qualanalysis.js b/frontend/js/labs/qualanalysis.js new file mode 100644 index 0000000..c3ddb12 --- /dev/null +++ b/frontend/js/labs/qualanalysis.js @@ -0,0 +1,1062 @@ +'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(); + } + })); +} diff --git a/frontend/js/labs/redox.js b/frontend/js/labs/redox.js index f675136..6adf4c2 100644 --- a/frontend/js/labs/redox.js +++ b/frontend/js/labs/redox.js @@ -97,6 +97,12 @@ class RedoxSim { this._oParts = []; this._precip = []; this._gas = []; + /* edu-tooltip + product labels */ + this._eduTooltipAge = -1; + this._eduTooltipLines = []; + this._prodLabelAge = -1; + this._prodLabelText = ''; + this._prodLabelType = 'precip'; this.W = 0; this.H = 0; this.onUpdate = null; this.fit(); @@ -233,6 +239,39 @@ class RedoxSim { p.phase += dt; this._clamp(p); }); + /* trigger product label + edu-tooltip once */ + if (window.ChemVisuals && this._prodLabelAge < 0) { + const rxn = RedoxSim.RXN[this.rxnId]; + if (rxn.precip) { + this._prodLabelText = (rxn.pname || '') + ' '; + this._prodLabelType = 'precip'; + this._prodLabelAge = 0; + } else if (rxn.gas) { + this._prodLabelText = (rxn.gname || '') + ' '; + this._prodLabelType = 'gas'; + this._prodLabelAge = 0; + } + if (this._eduTooltipAge < 0 && rxn.eq_mol) { + const stripSVG = s => (s || '').replace(/<[^>]+>/g, '->'); + const eqClean = stripSVG(rxn.eq_ion || rxn.eq_mol).slice(0, 38); + this._eduTooltipLines = [ + (rxn.name || '').slice(0, 34), + 'e⁻: от восстановителя к окислителю', + eqClean, + ].filter(Boolean).slice(0, 4); + this._eduTooltipAge = 0; + } + } + } + + /* advance ages */ + if (this._eduTooltipAge >= 0) { + this._eduTooltipAge += dt / 4.0; + if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1; + } + if (this._prodLabelAge >= 0) { + this._prodLabelAge += dt / 3.0; + if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1; } /* Electrons — quadratic bezier arc */ @@ -343,6 +382,12 @@ class RedoxSim { } } + /* desk */ + if (window.ChemVisuals) { + ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.82); + ChemVisuals.drawVesselShadow(ctx, W / 2, H * 0.82, W * 0.38); + } + this._drawBeaker(ctx, W, H); this._drawParticles(ctx, rxn); this._drawElectrons(ctx); @@ -350,6 +395,22 @@ class RedoxSim { if (rxn.gas) this._drawGas(ctx, rxn); this._drawPanel(ctx, W, H, rxn); if (window.LabFX) LabFX.particles.draw(this.ctx); + + /* animated product label */ + if (window.ChemVisuals && this._prodLabelAge >= 0) { + const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.76; + ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge); + if (this._prodLabelType === 'gas') { + ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.16, rxn.gcolor || 'rgba(200,235,255,0.8)', this._t); + } else { + ChemVisuals.animatePrecipitateFall(ctx, W / 2, H * 0.72, rxn.pcolor || '#CCC', this._t); + } + } + + /* edu tooltip */ + if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) { + ChemVisuals.drawEduTooltip(ctx, W / 2, H * 0.10, 210, this._eduTooltipLines, this._eduTooltipAge); + } } _drawBeaker(ctx, W, H) { diff --git a/frontend/js/labs/solutions.js b/frontend/js/labs/solutions.js new file mode 100644 index 0000000..299ea77 --- /dev/null +++ b/frontend/js/labs/solutions.js @@ -0,0 +1,1139 @@ +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════════ + SolutionsSim — «Растворы» + 4 sub-modes: calculator, dilution, mixing, solubility curves + ═══════════════════════════════════════════════════════════════════════ */ + +/* ── helper: create element ── */ +function _slEl(tag, props) { + var el = document.createElement(tag); + if (props) { + Object.keys(props).forEach(function(k) { + if (k === 'style') { el.style.cssText = props[k]; } + else if (k === 'textContent') { el.textContent = props[k]; } + else if (k === 'innerHTML') { el.innerHTML = props[k]; } + else { el[k] = props[k]; } + }); + } + return el; +} + +class SolutionsSim { + + /* ── Вещества-пресеты ── */ + static SUBSTANCES = [ + { label: 'NaCl', M: 58.5, color: '#bde0f7' }, + { label: 'NaOH', M: 40.0, color: '#a8f0a8' }, + { label: 'KOH', M: 56.1, color: '#b8f5b8' }, + { label: 'HCl (газ)', M: 36.46, color: '#f5e0a0' }, + { label: 'H₂SO₄', M: 98.08, color: '#ffa07a' }, + { label: 'HNO₃', M: 63.01, color: '#f5d0a0' }, + { label: 'H₃PO₄', M: 97.99, color: '#f0c8a0' }, + { label: 'CuSO₄·5H₂O', M: 249.7, color: '#64b8f0' }, + { label: 'Глюкоза', M: 180.2, color: '#f0d080' }, + { label: 'Сахароза', M: 342.3, color: '#e8d0a0' }, + { label: 'Na₂SO₄', M: 142.0, color: '#c8d8f0' }, + { label: 'KCl', M: 74.55, color: '#d0f0d0' }, + { label: 'CaCl₂', M: 111.0, color: '#f0d8b8' }, + { label: 'AlCl₃', M: 133.3, color: '#e8d0c8' }, + { label: 'Другое', M: 1.0, color: '#aaaaaa' }, + ]; + + /* ── Кривые растворимости (г/100г H₂O при 0,10,20,30,40,50,60,70,80,90,100 °C) ── */ + static SOLUBILITY = [ + { label: 'NaCl', color: '#4CC9F0', data: [35.7,35.8,36.0,36.3,36.6,37.0,37.3,37.8,38.4,39.0,39.8] }, + { label: 'KNO₃', color: '#FFD166', data: [13.3,20.9,31.6,45.8,63.9,85.5,110,138,169,202,247] }, + { label: 'KCl', color: '#06D6E0', data: [27.6,31.0,34.0,37.0,40.0,42.6,45.5,48.3,51.1,54.0,56.7] }, + { label: 'Pb(NO₃)₂', color: '#EF476F', data: [37.6,44.3,54.3,64.5,74.5,84.0,93.3,100,109,116,127] }, + { label: 'CuSO₄', color: '#9B5DE5', data: [14.3,17.4,20.7,25.0,28.5,33.3,40.2,47.5,55.0,63.4,73.0] }, + { label: 'NH₄Cl', color: '#F15BB5', data: [29.4,33.3,37.2,41.4,45.8,50.4,55.2,60.2,65.6,71.3,77.3] }, + { label: 'NH₃', color: '#7BF5A4', data: [88.5,70.0,54.0,39.3,26.8,17.0,10.2,6.5,4.3,3.0,2.1] }, + { label: 'K₂Cr₂O₇', color: '#FF9F1C', data: [4.7,8.3,12.5,19.0,26.3,35.0,45.6,58.2,73.0,87.0,100] }, + ]; + + constructor(container) { + this._container = container; + this._mode = 'calc'; // 'calc' | 'dilution' | 'mixing' | 'solubility' + this._solEnabled = new Set([0, 1, 2, 3, 4]); // enabled solubility curves + this._solT = 20; + + // calc state + this._calc = { + m_solute: 58.5, // г + m_solution: 500, // г + rho: 1.08, // г/мл + M: 58.5, // г/моль (NaCl) + subIdx: 0, + T: 20, + }; + + // dilution state + this._dil = { m1: 200, omega1: 20, addWater: 100 }; + + // mixing state + this._mix = { m1: 200, omega1: 20, m2: 300, omega2: 10 }; + + this._init(); + this._setMode('calc'); + } + + /* ── Build root DOM ── */ + _init() { + var c = this._container; + c.innerHTML = ''; + c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;font-family:Manrope,sans-serif;'; + + // Mode tabs + var tabs = _slEl('div', { + style: 'flex:0 0 auto;display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.02);', + }); + var MODES = [ + { id: 'calc', label: 'Калькулятор' }, + { id: 'dilution', label: 'Разбавление' }, + { id: 'mixing', label: 'Смешивание' }, + { id: 'solubility', label: 'Растворимость' }, + ]; + this._tabBtns = {}; + MODES.forEach(function(m) { + var btn = _slEl('button', { + style: 'flex:1;padding:10px 4px;background:none;border:none;border-bottom:2px solid transparent;color:rgba(255,255,255,0.5);font-size:.78rem;font-weight:700;font-family:Manrope,sans-serif;cursor:pointer;transition:color .2s,border-color .2s;', + textContent: m.label, + }); + btn.addEventListener('click', this._setMode.bind(this, m.id)); + this._tabBtns[m.id] = btn; + tabs.appendChild(btn); + }.bind(this)); + c.appendChild(tabs); + + // Content area + this._content = _slEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' }); + c.appendChild(this._content); + + // Canvas for visualisation (shared) + this._canvas = document.createElement('canvas'); + this._ctx2d = this._canvas.getContext('2d'); + this._raf = null; + + if (window.ResizeObserver) { + this._ro = new ResizeObserver(function() { this._fitCanvas(); this._drawViz(); }.bind(this)); + } + } + + /* ── Switch mode ── */ + _setMode(mode) { + this._mode = mode; + Object.keys(this._tabBtns).forEach(function(id) { + var btn = this._tabBtns[id]; + btn.style.color = id === mode ? '#fff' : 'rgba(255,255,255,0.45)'; + btn.style.borderColor = id === mode ? 'var(--violet,#9B5DE5)' : 'transparent'; + btn.style.background = id === mode ? 'rgba(155,93,229,0.08)' : 'none'; + }.bind(this)); + + if (this._ro && this._canvas.parentElement) { + this._ro.unobserve(this._canvas); + } + + this._content.innerHTML = ''; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + + switch (mode) { + case 'calc': this._buildCalc(); break; + case 'dilution': this._buildDilution(); break; + case 'mixing': this._buildMixing(); break; + case 'solubility':this._buildSolubility(); break; + } + } + + /* ════════════════════════════════════════════════════════ + MODE 1 — КАЛЬКУЛЯТОР + ════════════════════════════════════════════════════════ */ + _buildCalc() { + var self = this; + var c = this._content; + c.style.flexDirection = ''; + + // Left panel + var left = _slEl('div', { + style: 'flex:0 0 270px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);', + }); + + // Section header + function sHead(txt) { + return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: txt }); + } + + // Substance selector + left.appendChild(sHead('Вещество (M, г/моль)')); + var subWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' }); + var subSel = document.createElement('select'); + subSel.style.cssText = 'flex:1;background:#1a1a2e;color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:5px 8px;font-size:.78rem;font-family:Manrope,sans-serif;cursor:pointer;'; + SolutionsSim.SUBSTANCES.forEach(function(s, i) { + var opt = document.createElement('option'); + opt.value = i; + opt.textContent = s.label + ' = ' + s.M; + if (i === self._calc.subIdx) opt.selected = true; + subSel.appendChild(opt); + }); + subSel.addEventListener('change', function() { + self._calc.subIdx = +subSel.value; + var sub = SolutionsSim.SUBSTANCES[self._calc.subIdx]; + self._calc.M = sub.M; + if (mInput) mInput.value = sub.M; + if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 }); + self._recalcCalc('M'); + }); + subWrap.appendChild(subSel); + left.appendChild(subWrap); + + // Slider row + function sliderRow(label, id, min, max, step, val, unit, color, onInput) { + var row = _slEl('div', { style: 'margin-bottom:10px;' }); + var labelRow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); + labelRow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); + var valSpan = _slEl('span', { id: id + '-val', style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); + labelRow.appendChild(valSpan); + row.appendChild(labelRow); + + var sl = document.createElement('input'); + sl.type = 'range'; sl.id = id + '-sl'; + sl.min = min; sl.max = max; sl.step = step; sl.value = val; + sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; + sl.addEventListener('input', function() { + if (window.LabFX) LabFX.sound.play('click', { pitch: 1.0, volume: 0.25 }); + onInput(+sl.value); + valSpan.textContent = (+sl.value).toFixed(step < 1 ? 2 : 0) + ' ' + unit; + }); + row.appendChild(sl); + + // number input + var ni = document.createElement('input'); + ni.type = 'number'; ni.min = min; ni.max = max; ni.step = step; ni.value = val; + ni.style.cssText = 'width:100%;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;margin-top:2px;box-sizing:border-box;'; + ni.addEventListener('change', function() { + var v = Math.min(max, Math.max(min, +ni.value || min)); + sl.value = v; ni.value = v; + valSpan.textContent = v.toFixed(step < 1 ? 2 : 0) + ' ' + unit; + onInput(v); + }); + row.appendChild(ni); + return { row: row, sl: sl, ni: ni, valSpan: valSpan }; + } + + left.appendChild(sHead('Входные параметры')); + + var mSoluteCtrl = sliderRow('mв — масса растворённого', 'sl-calc-ms', 0, 500, 1, self._calc.m_solute, 'г', '#4CC9F0', function(v) { + self._calc.m_solute = v; + self._recalcCalc('m_solute'); + }); + left.appendChild(mSoluteCtrl.row); + + var mSolCtrl = sliderRow('mр-ра — масса раствора', 'sl-calc-mr', 1, 1000, 1, self._calc.m_solution, 'г', '#FFD166', function(v) { + self._calc.m_solution = Math.max(v, self._calc.m_solute + 0.01); + self._recalcCalc('m_solution'); + }); + left.appendChild(mSolCtrl.row); + + var rhoCtrl = sliderRow('ρ — плотность раствора', 'sl-calc-rho', 0.8, 2.0, 0.01, self._calc.rho, 'г/мл', '#9B5DE5', function(v) { + self._calc.rho = v; + self._recalcCalc('rho'); + }); + left.appendChild(rhoCtrl.row); + + left.appendChild(sHead('Молярная масса')); + var mWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:10px;' }); + mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'M =' })); + var mInput = document.createElement('input'); + mInput.type = 'number'; mInput.min = 1; mInput.max = 500; mInput.step = 0.1; + mInput.value = self._calc.M; + mInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;'; + mInput.addEventListener('change', function() { + self._calc.M = Math.max(1, Math.min(500, +mInput.value || 1)); + mInput.value = self._calc.M; + self._recalcCalc('M'); + }); + mWrap.appendChild(mInput); + mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.5);', textContent: 'г/моль' })); + left.appendChild(mWrap); + + left.appendChild(sHead('Температура')); + var tCtrl = sliderRow('T — температура', 'sl-calc-t', 0, 100, 1, self._calc.T, '°C', '#F15BB5', function(v) { + self._calc.T = v; + self._recalcCalc('T'); + }); + left.appendChild(tCtrl.row); + + c.appendChild(left); + + // Center — canvas visualization + var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;position:relative;' }); + this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;'; + centerWrap.appendChild(this._canvas); + c.appendChild(centerWrap); + + // Right panel — results + var right = _slEl('div', { + style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-left:1px solid rgba(255,255,255,0.07);', + }); + right.appendChild(sHead('Вычисленные значения')); + this._calcResults = right; + c.appendChild(right); + + // attach ResizeObserver + if (this._ro) { this._ro.observe(this._canvas); } + requestAnimationFrame(function() { + self._fitCanvas(); + self._recalcCalc('init'); + }); + } + + _recalcCalc(changed) { + var s = this._calc; + if (s.m_solute > s.m_solution) s.m_solution = s.m_solute + 0.01; + + var omega = s.m_solution > 0 ? (s.m_solute / s.m_solution) * 100 : 0; + var m_water = s.m_solution - s.m_solute; + var V_liters = (s.m_solution / s.rho) / 1000; // л + var V_ml = s.m_solution / s.rho; // мл + var nu = s.M > 0 ? s.m_solute / s.M : 0; // моль + var cM = V_liters > 0 ? nu / V_liters : 0; // моль/л + // норм. концентрация — упрощённо, как молярная для однозарядных + var cN = cM; + + this._calcState = { omega, m_water, V_ml, V_liters, nu, cM, cN }; + + var r = this._calcResults; + if (!r) return; + + function resLine(label, value, unit, color, formula) { + var row = _slEl('div', { style: 'margin-bottom:10px;padding:8px;background:rgba(255,255,255,0.04);border-radius:8px;border-left:3px solid ' + color + ';' }); + row.appendChild(_slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.5);margin-bottom:2px;', innerHTML: label })); + row.appendChild(_slEl('div', { style: 'font-size:1.1rem;font-weight:800;color:' + color + ';', textContent: value + ' ' + unit })); + if (formula) { + var fd = _slEl('div', { style: 'margin-top:4px;font-size:.7rem;color:rgba(255,255,255,0.35);' }); + fd.setAttribute('data-formula', formula); + row.appendChild(fd); + } + return row; + } + + r.innerHTML = ''; + r.appendChild(_slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: 'Вычисленные значения' })); + + r.appendChild(resLine('ω — массовая доля', omega.toFixed(2), '%', '#4CC9F0', '\\omega = \\frac{m_в}{m_{р-ра}} \\times 100\\%')); + r.appendChild(resLine('mводы — масса воды', m_water.toFixed(1), 'г', '#06D6E0', '')); + r.appendChild(resLine('V — объём раствора', V_ml.toFixed(1), 'мл', '#FFD166', 'V = \\frac{m_{р-ра}}{\\rho}')); + r.appendChild(resLine('ν — количество вещества', nu.toFixed(4), 'моль', '#9B5DE5', '\\nu = \\frac{m_в}{M}')); + r.appendChild(resLine('CМ — молярная', cM.toFixed(4), 'моль/л', '#F15BB5', 'C_M = \\frac{\\nu}{V}')); + r.appendChild(resLine('CН — нормальность', cN.toFixed(4), 'моль-экв/л', '#EF476F', '')); + + // Render KaTeX formulas + r.querySelectorAll('[data-formula]').forEach(function(el) { + if (window.katex && el.getAttribute('data-formula')) { + try { + katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false }); + } catch(e) { /* ignore */ } + } + }); + + if (window.LabFX && changed !== 'init') { + LabFX.sound.play('chime', { pitch: 1.4, volume: 0.3 }); + } + + this._drawViz(); + } + + /* ════════════════════════════════════════════════════════ + MODE 2 — РАЗБАВЛЕНИЕ + ════════════════════════════════════════════════════════ */ + _buildDilution() { + var self = this; + var c = this._content; + c.style.flexDirection = ''; + + // Left controls + var left = _slEl('div', { + style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);', + }); + + function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); } + function numRow(label, val, min, max, step, unit, color, cb) { + var row = _slEl('div', { style: 'margin-bottom:10px;' }); + var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); + lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); + var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); + lrow.appendChild(vs); + row.appendChild(lrow); + var sl = document.createElement('input'); + sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val; + sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; + sl.addEventListener('input', function() { + vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit; + if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); + cb(+sl.value); + }); + row.appendChild(sl); + return { row, sl, vs }; + } + + left.appendChild(sh('Исходный раствор')); + numRow('m₁ — масса раствора', self._dil.m1, 50, 500, 1, 'г', '#FFD166', function(v) { self._dil.m1 = v; self._recalcDil(); }).row; + left.appendChild(left.lastChild); + var dil_m1 = left.lastChild; + + numRow('ω₁ — концентрация', self._dil.omega1, 1, 80, 0.5, '%', '#4CC9F0', function(v) { self._dil.omega1 = v; self._recalcDil(); }).row; + left.appendChild(left.lastChild); + + left.appendChild(sh('Добавляем воду')); + numRow('Vводы — объём воды', self._dil.addWater, 0, 1000, 1, 'мл', '#06D6E0', function(v) { + self._dil.addWater = v; + if (window.LabFX && v > 0) LabFX.sound.play('pour', { volume: 0.4 }); + self._recalcDil(); + }).row; + left.appendChild(left.lastChild); + + left.appendChild(sh('Результат')); + this._dilResultsEl = _slEl('div', {}); + left.appendChild(this._dilResultsEl); + + c.appendChild(left); + + // Center — canvas + var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); + this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; + centerWrap.appendChild(this._canvas); + c.appendChild(centerWrap); + + if (this._ro) { this._ro.observe(this._canvas); } + requestAnimationFrame(function() { self._fitCanvas(); self._recalcDil(); }); + } + + _recalcDil() { + var d = this._dil; + var m_solute = d.m1 * d.omega1 / 100; + var m_new = d.m1 + d.addWater; // плотность воды = 1 г/мл + var omega2 = m_new > 0 ? (m_solute / m_new) * 100 : 0; + + this._dilState = { m_solute, m_new, omega2 }; + + var r = this._dilResultsEl; + if (!r) return; + r.innerHTML = ''; + + function valLine(label, val, unit, color) { + var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' }); + el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label })); + el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit })); + return el; + } + r.appendChild(valLine('mв = const', m_solute.toFixed(2), 'г', '#4CC9F0')); + r.appendChild(valLine('mр-ра новая', m_new.toFixed(1), 'г', '#FFD166')); + r.appendChild(valLine('ω₂ (новая)', omega2.toFixed(3), '%', '#7BF5A4')); + + if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.25 }); + this._drawViz(); + } + + /* ════════════════════════════════════════════════════════ + MODE 3 — СМЕШИВАНИЕ + ════════════════════════════════════════════════════════ */ + _buildMixing() { + var self = this; + var c = this._content; + c.style.flexDirection = ''; + + var left = _slEl('div', { + style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);', + }); + + function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); } + function addSl(label, val, min, max, step, unit, color, cb) { + var row = _slEl('div', { style: 'margin-bottom:10px;' }); + var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); + lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); + var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); + lrow.appendChild(vs); + row.appendChild(lrow); + var sl = document.createElement('input'); + sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val; + sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; + sl.addEventListener('input', function() { + vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit; + if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); + cb(+sl.value); + }); + row.appendChild(sl); + left.appendChild(row); + } + + left.appendChild(sh('Раствор 1')); + addSl('m₁ — масса', self._mix.m1, 10, 500, 1, 'г', '#4CC9F0', function(v) { self._mix.m1 = v; self._recalcMix(); }); + addSl('ω₁ — концентрация', self._mix.omega1, 0, 90, 0.5, '%', '#4CC9F0', function(v) { self._mix.omega1 = v; self._recalcMix(); }); + + left.appendChild(sh('Раствор 2')); + addSl('m₂ — масса', self._mix.m2, 10, 500, 1, 'г', '#FFD166', function(v) { self._mix.m2 = v; self._recalcMix(); }); + addSl('ω₂ — концентрация', self._mix.omega2, 0, 90, 0.5, '%', '#FFD166', function(v) { self._mix.omega2 = v; self._recalcMix(); }); + + left.appendChild(sh('Результат')); + this._mixResultsEl = _slEl('div', {}); + left.appendChild(this._mixResultsEl); + + c.appendChild(left); + + var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); + this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; + centerWrap.appendChild(this._canvas); + c.appendChild(centerWrap); + + if (this._ro) { this._ro.observe(this._canvas); } + requestAnimationFrame(function() { self._fitCanvas(); self._recalcMix(); }); + } + + _recalcMix() { + var x = this._mix; + var m3 = x.m1 + x.m2; + var omega3 = m3 > 0 ? (x.m1 * x.omega1 + x.m2 * x.omega2) / m3 : 0; + this._mixState = { m3, omega3 }; + + var r = this._mixResultsEl; + if (!r) return; + r.innerHTML = ''; + + function valLine(label, val, unit, color) { + var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' }); + el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label })); + el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit })); + return el; + } + r.appendChild(valLine('m₃ = m₁ + m₂', m3.toFixed(1), 'г', '#7BF5A4')); + r.appendChild(valLine('ω₃ — итоговая', omega3.toFixed(3), '%', '#F15BB5')); + + // правило рычага + var fd = _slEl('div', { style: 'margin-top:10px;padding:8px;background:rgba(155,93,229,0.08);border-radius:8px;font-size:.75rem;color:rgba(255,255,255,0.5);' }); + fd.appendChild(_slEl('div', { style: 'margin-bottom:4px;color:rgba(155,93,229,0.9);font-weight:700;font-size:.72rem;', textContent: 'Правило рычага' })); + var formulaEl = _slEl('div', {}); + formulaEl.setAttribute('data-formula', 'm_1 \\cdot \\omega_1 + m_2 \\cdot \\omega_2 = m_3 \\cdot \\omega_3'); + fd.appendChild(formulaEl); + r.appendChild(fd); + + if (window.katex) { + r.querySelectorAll('[data-formula]').forEach(function(el) { + try { katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false }); } catch(e) {} + }); + } + + if (window.LabFX) LabFX.sound.play('pour', { volume: 0.5 }); + this._drawViz(); + } + + /* ════════════════════════════════════════════════════════ + MODE 4 — КРИВЫЕ РАСТВОРИМОСТИ + ════════════════════════════════════════════════════════ */ + _buildSolubility() { + var self = this; + var c = this._content; + c.style.flexDirection = ''; + + // Left panel + var left = _slEl('div', { + style: 'flex:0 0 220px;display:flex;flex-direction:column;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);', + }); + + function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: t }); } + + left.appendChild(sh('Вещества')); + SolutionsSim.SOLUBILITY.forEach(function(s, idx) { + var row = _slEl('div', { style: 'display:flex;align-items:center;gap:8px;margin-bottom:7px;cursor:pointer;' }); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = self._solEnabled.has(idx); + cb.style.cssText = 'accent-color:' + s.color + ';width:14px;height:14px;cursor:pointer;'; + cb.addEventListener('change', function() { + if (cb.checked) { self._solEnabled.add(idx); } else { self._solEnabled.delete(idx); } + if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 }); + self._drawViz(); + }); + var colorDot = _slEl('span', { style: 'width:10px;height:10px;border-radius:50%;background:' + s.color + ';flex-shrink:0;display:inline-block;' }); + row.appendChild(cb); + row.appendChild(colorDot); + row.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.8);', textContent: s.label })); + left.appendChild(row); + }); + + left.appendChild(sh('Температура')); + var tRow = _slEl('div', { style: 'margin-bottom:10px;' }); + var tRowL = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); + tRowL.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'T =' })); + var tValSpan = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:#F15BB5;', textContent: self._solT + ' °C' }); + tRowL.appendChild(tValSpan); + tRow.appendChild(tRowL); + var tSl = document.createElement('input'); + tSl.type = 'range'; tSl.min = 0; tSl.max = 100; tSl.step = 1; tSl.value = self._solT; + tSl.style.cssText = 'width:100%;accent-color:#F15BB5;cursor:pointer;'; + tSl.addEventListener('input', function() { + self._solT = +tSl.value; + tValSpan.textContent = self._solT + ' °C'; + if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); + self._drawViz(); + }); + tRow.appendChild(tSl); + left.appendChild(tRow); + + // Перекристаллизация задача + left.appendChild(sh('Задача: перекристаллизация')); + var kno3Hint = _slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.55);margin-bottom:8px;line-height:1.5;', textContent: 'Насыщенный раствор KNO₃ при 80°C охладили до 20°C.' }); + left.appendChild(kno3Hint); + + var crystRow = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' }); + crystRow.appendChild(_slEl('span', { style: 'font-size:.75rem;color:rgba(255,255,255,0.6);', textContent: 'Масса р-ра (г):' })); + var crystInput = document.createElement('input'); + crystInput.type = 'number'; crystInput.min = 10; crystInput.max = 10000; crystInput.step = 10; crystInput.value = 200; + crystInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 6px;font-size:.75rem;'; + crystRow.appendChild(crystInput); + left.appendChild(crystRow); + + var crystBtn = _slEl('button', { + style: 'width:100%;padding:8px;border-radius:7px;border:none;background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer;', + textContent: 'Рассчитать', + }); + this._crystAnswer = _slEl('div', { style: 'margin-top:8px;font-size:.75rem;line-height:1.5;color:#7BF5A4;', textContent: '' }); + crystBtn.addEventListener('click', function() { + var mSol = +crystInput.value || 200; + // S at 80°C = 169 g/100g H2O, at 20°C = 31.6 g/100g H2O + var S80 = 169, S20 = 31.6; + var kno3Index = 1; // KNO₃ is index 1 in SOLUBILITY + // Насыщенный раствор при 80°C: x г KNO3 на 100 г воды + // m_r = m_KNO3 + m_H2O => m_KNO3 = mSol * S80 / (100 + S80) + var m_kno3 = mSol * S80 / (100 + S80); + var m_h2o = mSol - m_kno3; + // при 20°C в m_h2o г воды растворится не более S20 * m_h2o / 100 + var dissolved20 = S20 * m_h2o / 100; + var precipitated = m_kno3 - dissolved20; + precipitated = Math.max(0, precipitated); + self._crystAnswer.textContent = + 'KNO₃ в р-ре: ' + m_kno3.toFixed(1) + ' г\n' + + 'H₂O: ' + m_h2o.toFixed(1) + ' г\n' + + 'Выпало осадка: ' + precipitated.toFixed(1) + ' г'; + if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.5 }); + }); + left.appendChild(crystBtn); + left.appendChild(this._crystAnswer); + + c.appendChild(left); + + // Center — canvas + var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); + this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; + centerWrap.appendChild(this._canvas); + c.appendChild(centerWrap); + + if (this._ro) { this._ro.observe(this._canvas); } + requestAnimationFrame(function() { self._fitCanvas(); self._drawViz(); }); + } + + /* ════════════════════════════════════════════════════════ + CANVAS — fitCanvas + drawViz dispatcher + ════════════════════════════════════════════════════════ */ + _fitCanvas() { + var cv = this._canvas; + if (!cv.parentElement) return; + var r = cv.parentElement.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return; + cv.width = r.width * (window.devicePixelRatio || 1); + cv.height = r.height * (window.devicePixelRatio || 1); + cv.style.width = r.width + 'px'; + cv.style.height = r.height + 'px'; + this._ctx2d.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0); + } + + _drawViz() { + switch (this._mode) { + case 'calc': this._drawCalcViz(); break; + case 'dilution': this._drawDilViz(); break; + case 'mixing': this._drawMixViz(); break; + case 'solubility': this._drawSolubility(); break; + } + } + + /* ── Draw helper: gradient beaker ── */ + _drawBeaker(ctx, x, y, w, h, fillH, color, label) { + var bw = w * 0.55; // beaker width + var bh = h * 0.72; // beaker body height + var bx = x + (w - bw) / 2; + var by = y + h * 0.1; + + // beaker body (trapezoid-ish, bottom wider) + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(bx + bw * 0.1, by); + ctx.lineTo(bx + bw * 0.9, by); + ctx.lineTo(bx + bw, by + bh); + ctx.lineTo(bx, by + bh); + ctx.closePath(); + ctx.stroke(); + + // liquid fill + var fillRatio = Math.min(1, Math.max(0, fillH)); + var fillY = by + bh * (1 - fillRatio); + ctx.save(); + ctx.beginPath(); + ctx.rect(bx - 2, fillY, bw + 4, bh - (fillY - by)); + ctx.clip(); + var grad = ctx.createLinearGradient(bx, fillY, bx + bw, fillY + bh); + grad.addColorStop(0, color.replace(')', ',0.85)').replace('rgb', 'rgba')); + grad.addColorStop(1, color.replace(')', ',0.5)').replace('rgb', 'rgba')); + ctx.fillStyle = color + 'cc'; + ctx.fill(); + ctx.restore(); + + // label + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.font = 'bold 13px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(label, x + w / 2, y + h + 16); + } + + /* ── Calc visualization: beaker + percentage ── */ + _drawCalcViz() { + var cv = this._canvas; + var ctx = this._ctx2d; + var W = cv.width / (window.devicePixelRatio || 1); + var H = cv.height / (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, W, H); + if (!this._calcState) return; + + var st = this._calcState; + var omega = st.omega; + var sub = SolutionsSim.SUBSTANCES[this._calc.subIdx]; + + // draw background gradient + var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W, H) * 0.7); + bg.addColorStop(0, '#14142a'); + bg.addColorStop(1, '#0D0D1A'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + // Beaker + var bW = Math.min(W * 0.45, 220); + var bH = Math.min(H * 0.7, 320); + var bX = (W - bW) / 2; + var bY = H * 0.08; + + var bw = bW * 0.55; + var bh = bH * 0.72; + var bx = bX + (bW - bw) / 2; + var by = bY + bH * 0.1; + + // outer glass + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(bx + bw * 0.08, by); + ctx.lineTo(bx + bw * 0.92, by); + ctx.lineTo(bx + bw, by + bh); + ctx.lineTo(bx, by + bh); + ctx.closePath(); + ctx.stroke(); + + // graduation marks + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + for (var i = 1; i <= 4; i++) { + var gy = by + bh * (i / 5); + ctx.beginPath(); ctx.moveTo(bx + 4, gy); ctx.lineTo(bx + 12, gy); ctx.stroke(); + } + + // liquid fill + var fillRatio = Math.min(1, Math.max(0.05, (this._calc.m_solution / 1000) * 0.85)); + var liquidTop = by + bh * (1 - fillRatio); + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(bx + bw * 0.08, by); + ctx.lineTo(bx + bw * 0.92, by); + ctx.lineTo(bx + bw, by + bh); + ctx.lineTo(bx, by + bh); + ctx.closePath(); + ctx.clip(); + + var lgrad = ctx.createLinearGradient(bx, liquidTop, bx + bw, by + bh); + var baseColor = sub.color || '#4CC9F0'; + lgrad.addColorStop(0, baseColor + 'bb'); + lgrad.addColorStop(1, baseColor + '77'); + ctx.fillStyle = lgrad; + ctx.beginPath(); + ctx.rect(bx - 5, liquidTop, bw + 10, bh - (liquidTop - by) + 5); + ctx.fill(); + + // concentration ripple effect on surface + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(bx + 4, liquidTop); + ctx.bezierCurveTo(bx + bw * 0.25, liquidTop - 4, bx + bw * 0.75, liquidTop + 4, bx + bw - 4, liquidTop); + ctx.stroke(); + + ctx.restore(); + + // % label inside beaker + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.font = 'bold 28px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(omega.toFixed(1) + '%', bx + bw / 2, liquidTop + (by + bh - liquidTop) * 0.45); + + // substance name below + ctx.font = '12px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText(sub.label, bx + bw / 2, by + bh + 22); + + // beaker label top + ctx.font = 'bold 11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillText('Мерный стакан', bx + bw / 2, by - 10); + + ctx.textBaseline = 'alphabetic'; + + // formula annotation + var fY = by + bh + 48; + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; + ctx.fillText('ω = ' + omega.toFixed(2) + '% · ν = ' + this._calcState.nu.toFixed(3) + ' моль · Cм = ' + this._calcState.cM.toFixed(3) + ' моль/л', W / 2, fY); + } + + /* ── Dilution visualization ── */ + _drawDilViz() { + var cv = this._canvas; + var ctx = this._ctx2d; + var W = cv.width / (window.devicePixelRatio || 1); + var H = cv.height / (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, W, H); + if (!this._dilState) return; + + var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); + bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + var d = this._dil; + var st = this._dilState; + + var bH = Math.min(H * 0.65, 280); + var bW = 100; + var gap = 60; + var totalW = bW * 2 + gap + 80; + var startX = (W - totalW) / 2; + var bY = H * 0.12; + + function drawSimpleBeaker(cx, cy, bw, bh, fill, color, label, omegaLabel) { + // glass + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(cx, cy); ctx.lineTo(cx + bw, cy); + ctx.lineTo(cx + bw, cy + bh); ctx.lineTo(cx, cy + bh); + ctx.closePath(); ctx.stroke(); + + // liquid + var lh = Math.max(4, bh * fill); + var ly = cy + bh - lh; + ctx.save(); + ctx.beginPath(); ctx.rect(cx + 1, cy + 1, bw - 2, bh - 2); ctx.clip(); + var g = ctx.createLinearGradient(cx, ly, cx + bw, cy + bh); + g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '77'); + ctx.fillStyle = g; + ctx.fillRect(cx + 1, ly, bw - 2, lh); ctx.restore(); + + // omega text + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.font = 'bold 14px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(omegaLabel, cx + bw / 2, ly + lh * 0.45); + + // label + ctx.font = '12px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(label, cx + bw / 2, cy + bh + 20); + } + + // Original beaker + var fill1 = Math.min(0.9, Math.max(0.1, d.m1 / 500)); + drawSimpleBeaker(startX, bY, bW, bH, fill1, '#4CC9F0', 'Исходный', d.omega1.toFixed(1) + '%'); + + // Arrow + "добавили воды" + var arrowX = startX + bW + 10; + var arrowY = bY + bH / 2; + ctx.strokeStyle = 'rgba(6,214,224,0.7)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(arrowX, arrowY); ctx.lineTo(arrowX + gap - 10, arrowY); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(arrowX + gap - 10, arrowY - 6); + ctx.lineTo(arrowX + gap, arrowY); + ctx.lineTo(arrowX + gap - 10, arrowY + 6); ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.font = '10px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; + ctx.fillText('+' + d.addWater + ' мл', arrowX + gap / 2, arrowY - 10); + ctx.fillText('H₂O', arrowX + gap / 2, arrowY + 20); + + // New beaker (lighter color) + var colorIntensity = Math.max(0.15, st.omega2 / Math.max(1, d.omega1)); + var r2 = Math.round(76 + (1 - colorIntensity) * 179); + var g2 = Math.round(201 + (1 - colorIntensity) * 54); + var b2 = Math.round(240 + 0); + var c2 = 'rgb(' + r2 + ',' + Math.min(255, g2) + ',' + Math.min(255, b2) + ')'; + var fill2 = Math.min(0.9, Math.max(0.1, st.m_new / 600)); + drawSimpleBeaker(startX + bW + gap, bY, bW, bH, fill2, c2, 'Разбавленный', st.omega2.toFixed(2) + '%'); + + // note + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; + ctx.fillText('m_в = const = ' + (d.m1 * d.omega1 / 100).toFixed(1) + ' г', W / 2, bY + bH + 50); + } + + /* ── Mixing visualization ── */ + _drawMixViz() { + var cv = this._canvas; + var ctx = this._ctx2d; + var W = cv.width / (window.devicePixelRatio || 1); + var H = cv.height / (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, W, H); + if (!this._mixState) return; + + var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); + bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + var x = this._mix; + var st = this._mixState; + + var bH = Math.min(H * 0.55, 240); + var bW = 90; + var bY = H * 0.1; + var totalW3 = bW * 3 + 140; + var sx = (W - totalW3) / 2; + + function drawBk(bx, by, bw, bh, fill, color, lbl, pct) { + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 2; ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by); + ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh); + ctx.closePath(); ctx.stroke(); + var lh = Math.max(4, bh * fill); + var ly = by + bh - lh; + ctx.save(); ctx.beginPath(); ctx.rect(bx+1, by+1, bw-2, bh-2); ctx.clip(); + var g = ctx.createLinearGradient(bx, ly, bx + bw, by + bh); + g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '66'); + ctx.fillStyle = g; ctx.fillRect(bx+1, ly, bw-2, lh); ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.font = 'bold 13px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(pct, bx + bw/2, ly + lh * 0.45); + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(lbl, bx + bw/2, by + bh + 18); + } + + var fill1 = Math.min(0.88, Math.max(0.08, x.m1 / 500)); + var fill2 = Math.min(0.88, Math.max(0.08, x.m2 / 500)); + var fill3 = Math.min(0.88, Math.max(0.08, st.m3 / 700)); + drawBk(sx, bY, bW, bH, fill1, '#4CC9F0', 'Р-р 1', x.omega1.toFixed(1) + '%'); + + // + sign + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = 'bold 24px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('+', sx + bW + 28, bY + bH / 2); + + drawBk(sx + bW + 55, bY, bW, bH, fill2, '#FFD166', 'Р-р 2', x.omega2.toFixed(1) + '%'); + + // = sign + ctx.font = 'bold 24px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('=', sx + bW * 2 + 55 + 30, bY + bH / 2); + + // mixed color + var r0 = 76, g0 = 201, b0 = 240; // #4CC9F0 + var r1x = 255, g1x = 209, b1x = 102; // #FFD166 + var t = x.m1 / Math.max(1, x.m1 + x.m2); + var mixR = Math.round(r0 * t + r1x * (1 - t)); + var mixG = Math.round(g0 * t + g1x * (1 - t)); + var mixB = Math.round(b0 * t + b1x * (1 - t)); + var mixColor = 'rgb(' + mixR + ',' + mixG + ',' + mixB + ')'; + drawBk(sx + bW * 2 + 55 + 55, bY, bW, bH, fill3, mixColor, 'Смесь', st.omega3.toFixed(2) + '%'); + + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; + ctx.fillText('m₃ = ' + st.m3.toFixed(0) + ' г · ω₃ = ' + st.omega3.toFixed(3) + '%', W / 2, bY + bH + 46); + } + + /* ── Solubility curve chart ── */ + _drawSolubility() { + var cv = this._canvas; + var ctx = this._ctx2d; + var W = cv.width / (window.devicePixelRatio || 1); + var H = cv.height / (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, W, H); + + var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); + bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + var pad = { l: 56, r: 24, t: 20, b: 46 }; + var cW = W - pad.l - pad.r; + var cH = H - pad.t - pad.b; + var temps = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + var maxS = 0; + SolutionsSim.SOLUBILITY.forEach(function(s, i) { + if (this._solEnabled.has(i)) { + s.data.forEach(function(v) { if (v > maxS) maxS = v; }); + } + }.bind(this)); + maxS = maxS > 0 ? Math.ceil(maxS / 50) * 50 : 250; + + function toX(T) { return pad.l + (T / 100) * cW; } + function toY(S) { return pad.t + cH - (S / maxS) * cH; } + + // Grid + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 1; + for (var gs = 0; gs <= maxS; gs += 50) { + ctx.beginPath(); ctx.moveTo(pad.l, toY(gs)); ctx.lineTo(pad.l + cW, toY(gs)); ctx.stroke(); + } + for (var gt = 0; gt <= 100; gt += 20) { + ctx.beginPath(); ctx.moveTo(toX(gt), pad.t); ctx.lineTo(toX(gt), pad.t + cH); ctx.stroke(); + } + + // Axes + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + cH); ctx.lineTo(pad.l + cW, pad.t + cH); ctx.stroke(); + + // Axis labels + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = '10px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + temps.forEach(function(t) { + if (t % 20 === 0) ctx.fillText(t + '°', toX(t), pad.t + cH + 6); + }); + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (var ys = 0; ys <= maxS; ys += 50) { + ctx.fillText(ys, pad.l - 6, toY(ys)); + } + + ctx.save(); + ctx.translate(pad.l - 40, pad.t + cH / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = '11px Manrope,sans-serif'; + ctx.fillText('S, г / 100г H₂O', 0, 0); + ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = '11px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; + ctx.fillText('T, °C', pad.l + cW / 2, H - 4); + + // Curves + SolutionsSim.SOLUBILITY.forEach(function(s, idx) { + if (!this._solEnabled.has(idx)) return; + ctx.strokeStyle = s.color; + ctx.lineWidth = 2.2; + ctx.lineJoin = 'round'; + ctx.beginPath(); + s.data.forEach(function(val, i) { + var px = toX(temps[i]); + var py = toY(val); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + }); + ctx.stroke(); + + // End label + var last = s.data[s.data.length - 1]; + var lx = toX(100) + 4; + var ly = toY(last); + ctx.fillStyle = s.color; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(s.label, lx, ly); + }.bind(this)); + + // Current temperature line + dots + var Tcur = this._solT; + ctx.strokeStyle = '#F15BB5'; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 3]); + ctx.beginPath(); ctx.moveTo(toX(Tcur), pad.t); ctx.lineTo(toX(Tcur), pad.t + cH); ctx.stroke(); + ctx.setLineDash([]); + + // Intersection dots + values + SolutionsSim.SOLUBILITY.forEach(function(s, idx) { + if (!this._solEnabled.has(idx)) return; + // interpolate S at Tcur + var ti = Tcur / 10; + var lo = Math.floor(ti); + var hi = Math.min(lo + 1, 10); + var frac = ti - lo; + var Sval = s.data[lo] * (1 - frac) + s.data[hi] * frac; + + var dotX = toX(Tcur); + var dotY = toY(Sval); + ctx.beginPath(); ctx.arc(dotX, dotY, 5, 0, Math.PI * 2); + ctx.fillStyle = s.color; ctx.fill(); + ctx.strokeStyle = '#0D0D1A'; ctx.lineWidth = 1.5; + ctx.stroke(); + + // value popup + ctx.fillStyle = s.color; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.textAlign = dotX > W * 0.6 ? 'right' : 'left'; + ctx.textBaseline = 'middle'; + var offset = dotX > W * 0.6 ? -8 : 8; + ctx.fillText(Sval.toFixed(1), dotX + offset, dotY - 10); + }.bind(this)); + + ctx.textAlign = 'left'; + } + + /* ── stop for closeSim() ── */ + stop() { + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + if (this._ro && this._canvas.parentElement) { this._ro.unobserve(this._canvas); } + } + + /* ── fit ── */ + fit() { + this._fitCanvas(); + this._drawViz(); + } +} + +if (typeof module !== 'undefined') module.exports = SolutionsSim; + +/* ─── lab UI init ─────────────────────────────────── */ +var _solutionsSim = null; + +function _openSolutions() { + document.getElementById('sim-topbar-title').textContent = 'Растворы'; + _simShow('sim-solutions'); + + requestAnimationFrame(function() { + requestAnimationFrame(function() { + var container = document.getElementById('solutions-wrap'); + if (!_solutionsSim) { + _solutionsSim = new SolutionsSim(container); + } else { + _solutionsSim.fit(); + } + }); + }); +} diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js index 5d7be55..cddd015 100644 --- a/frontend/js/labs/titration.js +++ b/frontend/js/labs/titration.js @@ -327,6 +327,11 @@ class TitrationSim { ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill(); } + /* desk surface behind glassware */ + if (window.ChemVisuals) { + ChemVisuals.drawDeskBackground(ctx, simW, H, H * 0.90); + } + /* divider */ ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke(); @@ -337,6 +342,13 @@ class TitrationSim { this._drawParticles(ctx); this._drawOverlay(ctx); this._drawPHCurve(ctx, simW, W, H); + + /* pH strip indicator on the right of flask */ + if (window.ChemVisuals) { + const pH = this._calcPH(this.baseAdded); + ChemVisuals.drawPHStrip(ctx, simW * 0.72, H * 0.24, pH); + } + if (window.LabFX) LabFX.particles.draw(ctx); } diff --git a/frontend/lab.html b/frontend/lab.html index 34827b3..ba7ebba 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -4169,6 +4169,14 @@
+ + + + + + + + + + + +
@@ -4281,6 +4299,7 @@ + @@ -4319,6 +4338,10 @@ + + + +