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:
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Тень/отражение колбы на столе ── */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -490,6 +490,24 @@
|
||||
<text x="240" y="46" font-size="9" fill="rgba(255,209,102,0.9)" font-family="Manrope,sans-serif" text-anchor="middle">H₂</text>
|
||||
<text x="135" y="118" font-size="8" fill="rgba(239,71,111,0.7)" font-family="Manrope,sans-serif" text-anchor="middle">● лимит</text>`);
|
||||
|
||||
/* Periodic Table — 6×4 coloured cell grid */
|
||||
const P_PERIODIC = _svg(`
|
||||
<rect width="270" height="140" fill="#0D0D1A"/>
|
||||
${(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<rows;r++) for(let c=0;c<cols;c++){
|
||||
const x=pad+c*w, y=pad+r*h;
|
||||
const skip=(r===0&&c>=2&&c<=15)||(r===1&&c>=2&&c<=11);
|
||||
if(!skip) s+=`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${(w-1.5).toFixed(1)}" height="${(h-1.5).toFixed(1)}" rx="2" fill="${colors[c]}" opacity="0.7"/>`;
|
||||
}
|
||||
return s;
|
||||
})()}
|
||||
<text x="135" y="134" font-size="8" fill="rgba(255,255,255,0.35)" font-family="Manrope,sans-serif" text-anchor="middle">118 элементов</text>`);
|
||||
|
||||
const P_CRYSTAL = _svg(`
|
||||
<rect width="270" height="140" fill="#0D0D1A"/>
|
||||
${[
|
||||
@@ -656,6 +674,73 @@
|
||||
<text x="240" y="79" font-size="8" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="135" y="130" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">S = A⊕B · C = A∧B · Таблица истинности`);
|
||||
|
||||
|
||||
/* Qualitative Analysis preview */
|
||||
const P_QUALANALYSIS = _svg(`
|
||||
<rect width="270" height="140" fill="#0D0D1A"/>
|
||||
<rect x="30" y="25" width="30" height="80" rx="6" fill="none" stroke="rgba(200,210,255,0.55)" stroke-width="1.5"/>
|
||||
<rect x="32" y="42" width="26" height="60" rx="4" fill="rgba(255,255,255,0.12)"/>
|
||||
<rect x="32" y="77" width="26" height="25" rx="3" fill="rgba(100,180,255,0.4)"/>
|
||||
<rect x="100" y="25" width="30" height="80" rx="6" fill="none" stroke="rgba(200,210,255,0.55)" stroke-width="1.5"/>
|
||||
<rect x="102" y="42" width="26" height="60" rx="4" fill="rgba(255,200,80,0.1)"/>
|
||||
<rect x="102" y="62" width="26" height="40" rx="3" fill="rgba(200,20,20,0.55)"/>
|
||||
<rect x="170" y="25" width="30" height="80" rx="6" fill="none" stroke="rgba(200,210,255,0.55)" stroke-width="1.5"/>
|
||||
<rect x="172" y="42" width="26" height="60" rx="4" fill="rgba(80,200,80,0.1)"/>
|
||||
<rect x="172" y="70" width="26" height="32" rx="3" fill="rgba(255,255,150,0.35)"/>
|
||||
<text x="45" y="118" font-size="7" fill="rgba(100,180,255,0.9)" text-anchor="middle" font-family="Manrope,sans-serif">Cl</text>
|
||||
<text x="115" y="118" font-size="7" fill="rgba(200,80,80,0.9)" text-anchor="middle" font-family="Manrope,sans-serif">Fe(III)</text>
|
||||
<text x="185" y="118" font-size="7" fill="rgba(200,200,80,0.9)" text-anchor="middle" font-family="Manrope,sans-serif">SO4</text>
|
||||
<text x="135" y="18" font-size="8" fill="rgba(155,93,229,0.7)" text-anchor="middle" font-family="Manrope,sans-serif">AgNO3</text>
|
||||
<line x1="45" y1="22" x2="45" y2="27" stroke="rgba(200,200,200,0.5)" stroke-width="1"/>
|
||||
<line x1="115" y1="22" x2="115" y2="27" stroke="rgba(200,200,200,0.5)" stroke-width="1"/>
|
||||
<line x1="185" y1="22" x2="185" y2="27" stroke="rgba(200,200,200,0.5)" stroke-width="1"/>
|
||||
<text x="135" y="136" font-size="8" fill="rgba(255,255,255,0.3)" text-anchor="middle" font-family="Manrope,sans-serif">AgCl / Fe(SCN) / BaSO4`);
|
||||
|
||||
/* Organic Chemistry preview — benzene ring + OH group */
|
||||
const P_ORGANIC = _svg(`
|
||||
<rect width="270" height="140" fill="#0D0D1A"/>
|
||||
${_grid('rgba(255,255,255,0.03)')}
|
||||
<!-- benzene ring -->
|
||||
<polygon points="115,44 145,44 160,70 145,96 115,96 100,70" fill="rgba(155,93,229,0.08)" stroke="#9B5DE5" stroke-width="1.8"/>
|
||||
<!-- alternating double bonds (inner circle shorthand) -->
|
||||
<circle cx="130" cy="70" r="18" fill="none" stroke="#9B5DE5" stroke-width="1.2" stroke-dasharray="5,4" opacity="0.55"/>
|
||||
<!-- C labels -->
|
||||
<text x="108" y="46" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="145" y="46" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="164" y="73" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="145" y="99" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="108" y="99" font-size="9" font-family="Manrope,sans-serif" font-weight="700" fill="#9B5DE5">C</text>
|
||||
<text x="94" y="73" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<!-- -OH substituent -->
|
||||
<line x1="160" y1="70" x2="195" y2="55" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/>
|
||||
<circle cx="200" cy="52" r="9" fill="rgba(239,71,111,0.25)" stroke="#EF476F" stroke-width="1.5"/>
|
||||
<text x="200" y="56" font-size="9" fill="#EF476F" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">O</text>
|
||||
<line x1="209" y1="48" x2="218" y2="40" stroke="rgba(255,255,255,0.3)" stroke-width="1.2"/>
|
||||
<circle cx="222" cy="37" r="6" fill="rgba(224,224,224,0.2)" stroke="#E0E0E0" stroke-width="1.2"/>
|
||||
<text x="222" y="41" font-size="8" fill="#E0E0E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">H</text>
|
||||
<!-- chain fragment on right -->
|
||||
<line x1="100" y1="70" x2="65" y2="70" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>
|
||||
<circle cx="58" cy="70" r="9" fill="rgba(155,93,229,0.2)" stroke="#9B5DE5" stroke-width="1.5"/>
|
||||
<text x="58" y="74" font-size="9" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">C</text>
|
||||
<text x="135" y="134" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">Конструктор · Ряды · Качественные реакции</text>`);
|
||||
|
||||
/* Solutions preview */
|
||||
const P_SOLUTIONS = _svg(`
|
||||
<rect x="88" y="20" width="58" height="88" rx="3" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2"/>
|
||||
<rect x="89" y="52" width="56" height="55" rx="2" fill="rgba(76,201,240,0.55)"/>
|
||||
<path d="M89,52 Q108,47 117,52 Q127,57 145,52" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
|
||||
<line x1="90" y1="66" x2="96" y2="66" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<line x1="90" y1="81" x2="96" y2="81" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<line x1="90" y1="96" x2="96" y2="96" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<text x="117" y="80" font-size="15" fill="rgba(255,255,255,0.9)" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="800">20%</text>
|
||||
<path d="M172,32 Q175,24 179,32 Q183,41 179,45 Q175,49 172,45 Q168,41 172,32 Z" fill="#4CC9F0" opacity="0.85"/>
|
||||
<line x1="193" y1="20" x2="250" y2="90" stroke="rgba(255,255,255,0.08)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<text x="221" y="42" font-size="8" fill="rgba(76,201,240,0.9)" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">ω%</text>
|
||||
<text x="221" y="56" font-size="8" fill="rgba(155,93,229,0.9)" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">C-M</text>
|
||||
<text x="221" y="70" font-size="8" fill="rgba(241,91,181,0.9)" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">ν моль</text>
|
||||
<text x="135" y="123" font-size="8" fill="rgba(76,201,240,0.75)" text-anchor="middle" font-family="Manrope,sans-serif">ω = m₀/m​ · 100%</text>
|
||||
<text x="135" y="135" font-size="7" fill="rgba(255,255,255,0.3)" text-anchor="middle" font-family="Manrope,sans-serif">Калькулятор · Разбавление · Смешивание · S(T)</text>`);
|
||||
|
||||
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: 'Деление клетки',
|
||||
|
||||
@@ -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
|
||||
══════════════════════════════════════════════ */
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = `
|
||||
<span style="font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.07em">Режим:</span>
|
||||
<button class="ptbl-mode-btn active" data-m="type">По типу</button>
|
||||
<button class="ptbl-mode-btn" data-m="block">По блоку</button>
|
||||
<button class="ptbl-mode-btn" data-m="none">Без подсветки</button>
|
||||
<span style="margin-left:8px;font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.07em">Поиск:</span>
|
||||
<input id="ptbl-search" type="text" placeholder="Символ / название / Z / масса"
|
||||
style="padding:4px 8px;border-radius:6px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#fff;font-size:.78rem;width:200px;outline:none;">
|
||||
<button id="ptbl-search-clear" style="padding:3px 7px;border-radius:5px;border:1px solid rgba(255,255,255,0.15);background:transparent;color:#aaa;font-size:.72rem;cursor:pointer">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>`;
|
||||
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 = `
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px;">
|
||||
<span style="font-size:.7rem;color:rgba(255,255,255,0.4)">Свойство:</span>
|
||||
<select id="ptbl-prop-sel" style="background:#1a1a2e;border:1px solid rgba(255,255,255,0.15);color:#ccc;border-radius:5px;padding:2px 5px;font-size:.72rem;">
|
||||
<option value="En">ЭО (Полинг)</option>
|
||||
<option value="mass">Масса</option>
|
||||
<option value="melt">T плавл. (K)</option>
|
||||
<option value="boil">T кип. (K)</option>
|
||||
<option value="density">Плотность</option>
|
||||
</select>
|
||||
<select id="ptbl-by-sel" style="background:#1a1a2e;border:1px solid rgba(255,255,255,0.15);color:#ccc;border-radius:5px;padding:2px 5px;font-size:.72rem;">
|
||||
<option value="period">По периоду</option>
|
||||
<option value="group">По группе</option>
|
||||
</select>
|
||||
<select id="ptbl-n-sel" style="background:#1a1a2e;border:1px solid rgba(255,255,255,0.15);color:#ccc;border-radius:5px;padding:2px 5px;font-size:.72rem;">
|
||||
${[1,2,3,4,5,6,7].map(n=>`<option value="${n}" ${n===2?'selected':''}>№ ${n}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<canvas id="ptbl-chart" style="width:100%;height:90px;display:block;"></canvas>`;
|
||||
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=>`<option value="${n}" ${n===1?'selected':''}>Гр. ${n}</option>`).join('');
|
||||
this._chartN = 1;
|
||||
} else {
|
||||
nSel.innerHTML = [1,2,3,4,5,6,7].map(n=>`<option value="${n}" ${n===2?'selected':''}>№ ${n}</option>`).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 = `
|
||||
<span style="font-size:.55em;opacity:.7;line-height:1">${el.Z}</span>
|
||||
<span style="font-size:.85em;font-weight:800;line-height:1.1">${el.symbol}</span>
|
||||
<span style="font-size:.42em;opacity:.65;line-height:1.2;text-align:center;max-width:90%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">${el.name}</span>`;
|
||||
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 = `<span style="width:12px;height:12px;border-radius:3px;background:${col};display:inline-block;flex-shrink:0;"></span>${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 = `<div style="color:rgba(255,255,255,0.25);font-size:.8rem;text-align:center;padding:20px 0">Кликните на элемент</div>`;
|
||||
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 = `
|
||||
<div style="text-align:center;margin-bottom:10px;">
|
||||
<div style="font-size:2.5rem;font-weight:900;color:${col};line-height:1">${el.symbol}</div>
|
||||
<div style="font-size:.95rem;font-weight:700;color:#fff;margin-top:2px">${el.name}</div>
|
||||
<div style="font-size:.72rem;color:rgba(255,255,255,0.4)">Z = ${el.Z} · ${el.mass} а.е.м.</div>
|
||||
<div style="display:inline-block;margin-top:6px;padding:2px 8px;border-radius:12px;background:${col}22;border:1px solid ${col}55;font-size:.68rem;color:${col}">${TYPE_LABELS[el.type] || el.type}</div>
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.74rem;">
|
||||
${this._row('Конфигурация', `<code style="font-size:.72rem;color:${col}">${el.config}</code>`)}
|
||||
${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<sub>пл</sub>, K', fmt(el.melt))}
|
||||
${this._row('T<sub>кип</sub>, K', fmt(el.boil))}
|
||||
${this._row('Открыт', el.discovered ? `${el.discovered}, ${el.by}` : el.by)}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
_row(label, val) {
|
||||
return `<tr>
|
||||
<td style="padding:3px 4px 3px 0;color:rgba(255,255,255,0.45);border-bottom:1px solid rgba(255,255,255,0.05);white-space:nowrap">${label}</td>
|
||||
<td style="padding:3px 0 3px 4px;color:#ddd;border-bottom:1px solid rgba(255,255,255,0.05)">${val}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────
|
||||
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'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4169,6 +4169,14 @@
|
||||
<div id="stoichiometry-wrap" style="flex:1;min-height:0;overflow:hidden;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── QUALANALYSIS sim body ── -->
|
||||
<div id="sim-qualanalysis" class="sim-proj-wrap" style="display:none">
|
||||
<div id="qualanalysis-wrap" style="flex:1;min-height:0;overflow:hidden;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── ORGANIC CHEMISTRY sim body ── -->
|
||||
<div id="sim-organic" class="sim-proj-wrap" style="display:none;flex:1;min-height:0;overflow:hidden;"></div>
|
||||
|
||||
<!-- ── HEAT ENGINE sim body ── -->
|
||||
<div id="sim-heatengine" class="sim-proj-wrap" style="display:none">
|
||||
<div class="sim-body-wrap" style="flex-direction:column">
|
||||
@@ -4253,6 +4261,16 @@
|
||||
</div>
|
||||
</div><!-- /#sim-heatengine -->
|
||||
|
||||
<!-- ── PERIODIC TABLE sim body ── -->
|
||||
<div id="sim-periodic" class="sim-proj-wrap" style="display:none">
|
||||
<div id="periodic-wrap" style="flex:1;min-height:0;overflow:hidden;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── SOLUTIONS sim body ── -->
|
||||
<div id="sim-solutions" class="sim-proj-wrap" style="display:none">
|
||||
<div id="solutions-wrap" style="flex:1;min-height:0;overflow:hidden;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Theory panel (overlay right) ── -->
|
||||
<div class="theory-panel" id="theory-panel">
|
||||
<div class="theory-panel-inner" id="theory-content"></div>
|
||||
@@ -4281,6 +4299,7 @@
|
||||
<script src="/js/labs/diffusion.js"></script>
|
||||
<!-- coulomb.js removed: merged into emfield.js -->
|
||||
<script src="/js/labs/circuit.js"></script>
|
||||
<script src="/js/labs/_chem_visuals.js"></script>
|
||||
<script src="/js/labs/reactions.js"></script>
|
||||
<script src="/js/labs/flask.js"></script>
|
||||
<script src="/js/labs/redox.js"></script>
|
||||
@@ -4319,6 +4338,10 @@
|
||||
<script src="/js/labs/geometry.js"></script>
|
||||
<script src="/js/labs/logic.js"></script>
|
||||
<script src="/js/labs/heatengine.js"></script>
|
||||
<script src="/js/labs/solutions.js" defer></script>
|
||||
<script src="/js/labs/organic.js" defer></script>
|
||||
<script src="/js/labs/periodic.js" defer></script>
|
||||
<script src="/js/labs/qualanalysis.js" defer></script>
|
||||
<script>
|
||||
/* Sync sound toggle button icon with localStorage state on load */
|
||||
(function() {
|
||||
|
||||
Reference in New Issue
Block a user