Files
Learn_System/frontend/js/labs/_chem_visuals.js
T
Maxim Dolgolyov ea2526dc73 feat(labs): 4 школьные хим. симы + визуальная прокачка лаборатории
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) <noreply@anthropic.com>
2026-05-26 13:08:35 +03:00

779 lines
25 KiB
JavaScript

'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,
};
})();