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>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 13:08:35 +03:00
parent add17b1bb4
commit ea2526dc73
15 changed files with 5738 additions and 21 deletions
+95 -15
View File
@@ -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();
}