ea2526dc73
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>
779 lines
25 KiB
JavaScript
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,
|
|
};
|
|
})();
|