diff --git a/backend/scripts/gen_chem8_skeletons.js b/backend/scripts/gen_chem8_skeletons.js new file mode 100644 index 0000000..5161e94 --- /dev/null +++ b/backend/scripts/gen_chem8_skeletons.js @@ -0,0 +1,305 @@ +/* gen_chem8_skeletons.js — генерирует каркасы 7 глав «Химия 8» (Phase 0). + * Запуск: node backend/scripts/gen_chem8_skeletons.js + * Выход: frontend/textbooks/chemistry_8_intro.html, _ch1.html ... _ch6.html + * + * Каркас = валидная брендированная страница: header (водяной знак), hero, + * оглавление § (read-only), баннер «в разработке», ссылка назад в хаб, тема. + * Полный интерактивный SPA-контент каждой главы добавляется в Phase 1–6 + * (файлы перезаписываются), пока скелет обеспечивает навигацию и структуру. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks'); + +const P = (t, n) => ({ t, n }); // параграф +const NOTE = (note) => ({ note }); // лаб. опыт / практическая работа + +const CHAPTERS = [ + { + file: 'chemistry_8_intro.html', slug: 'chemistry-8-intro', + kicker: 'Вводный раздел', title: 'Количественные понятия в химии', + range: '§ 1–9', wm: 'mol', + color: { p:'#d97706', d:'#b45309', l:'#fbbf24', soft:'#fef3c7', bgd:'#1c1410', cardd:'#271c14', textd:'#fef3c7' }, + items: [ + P('§ 1', 'Атомы. Химические элементы. Относительная атомная масса'), + P('§ 2', 'Молекулы. Простые и сложные вещества. Химические формулы. Относительная молекулярная масса'), + P('§ 3', 'Химическое количество вещества'), + P('§ 4', 'Моль — единица химического количества вещества. Постоянная Авогадро'), + P('§ 5', 'Молярная масса. Молярный объём газов'), + P('§ 6', 'Вычисление химического количества вещества по его массе и массы вещества по его химическому количеству'), + P('§ 7', 'Вычисление химического количества газа по его объёму и объёма газа по его химическому количеству'), + NOTE('Практическая работа 1. Химическое количество вещества'), + P('§ 8', 'Химические реакции'), + P('§ 9', 'Количественные расчёты по уравнениям химических реакций') + ] + }, + { + file: 'chemistry_8_ch1.html', slug: 'chemistry-8-ch1', + kicker: 'Глава 1', title: 'Важнейшие классы неорганических соединений', + range: '§ 10–23', wm: 'OH', + color: { p:'#0d9488', d:'#0f766e', l:'#14b8a6', soft:'#ccfbf1', bgd:'#0c1a18', cardd:'#102825', textd:'#ccfbf1' }, + items: [ + P('§ 10', 'Оксиды. Состав и классификация оксидов'), + P('§ 11', 'Химические свойства оксидов'), + P('§ 12', 'Получение и применение оксидов'), + P('§ 13', 'Кислоты. Состав и классификация кислот'), + P('§ 14', 'Химические свойства кислот'), + P('§ 15', 'Получение и применение кислот'), + P('§ 16', 'Основания'), + P('§ 17', 'Химические свойства оснований'), + P('§ 18', 'Получение и применение оснований'), + NOTE('Лабораторный опыт 1. Получение нерастворимого основания'), + NOTE('Практическая работа 2. Изучение реакции нейтрализации'), + P('§ 19', 'Соли. Состав и классификация солей'), + P('§ 20', 'Химические свойства солей'), + NOTE('Лабораторный опыт 2. Взаимодействие растворов солей с металлами'), + P('§ 21', 'Получение и применение солей'), + P('§ 22', 'Взаимосвязь между классами основных неорганических веществ'), + NOTE('Практическая работа 3. Решение экспериментальных задач'), + P('§ 23', 'Решение расчётных задач по теме «Основные классы неорганических соединений»') + ] + }, + { + file: 'chemistry_8_ch2.html', slug: 'chemistry-8-ch2', + kicker: 'Глава 2', title: 'Периодический закон и периодическая система химических элементов', + range: '§ 24–28', wm: '№', + color: { p:'#4f46e5', d:'#4338ca', l:'#818cf8', soft:'#e0e7ff', bgd:'#12122b', cardd:'#1b1b3a', textd:'#e0e7ff' }, + items: [ + P('§ 24', 'Систематизация химических элементов'), + P('§ 25', 'Понятие об амфотерности'), + NOTE('Лабораторный опыт 3. Получение гидроксида цинка и изучение его амфотерных свойств'), + P('§ 26', 'Естественные семейства элементов'), + P('§ 27', 'Периодический закон Д. И. Менделеева'), + P('§ 28', 'Периодическая система химических элементов') + ] + }, + { + file: 'chemistry_8_ch3.html', slug: 'chemistry-8-ch3', + kicker: 'Глава 3', title: 'Строение атома и периодичность изменения свойств', + range: '§ 29–35', wm: 'e−', + color: { p:'#2563eb', d:'#1d4ed8', l:'#60a5fa', soft:'#dbeafe', bgd:'#0a1428', cardd:'#102137', textd:'#dbeafe' }, + items: [ + P('§ 29', 'Строение атома. Атомный номер химического элемента'), + P('§ 30', 'Массовое число атома. Нуклиды'), + P('§ 31', 'Изотопы. Явление радиоактивности'), + P('§ 32', 'Состояние электронов в атоме. Электронное облако. Атомная орбиталь'), + P('§ 33', 'Строение электронных оболочек атомов'), + P('§ 34', 'Периодичность изменения свойств атомов химических элементов'), + P('§ 35', 'Характеристика химического элемента по его положению в периодической системе') + ] + }, + { + file: 'chemistry_8_ch4.html', slug: 'chemistry-8-ch4', + kicker: 'Глава 4', title: 'Химическая связь', + range: '§ 36–41', wm: 'H₂O', + color: { p:'#059669', d:'#047857', l:'#34d399', soft:'#d1fae5', bgd:'#0a1a12', cardd:'#10271c', textd:'#d1fae5' }, + items: [ + P('§ 36', 'Природа химической связи'), + P('§ 37', 'Ковалентная связь'), + P('§ 38', 'Неполярная и полярная ковалентная связь. Электроотрицательность'), + NOTE('Лабораторный опыт 4. Составление моделей молекул'), + P('§ 39', 'Ионная связь'), + P('§ 40', 'Металлическая связь. Межмолекулярное взаимодействие'), + P('§ 41', 'Кристаллическое состояние вещества') + ] + }, + { + file: 'chemistry_8_ch5.html', slug: 'chemistry-8-ch5', + kicker: 'Глава 5', title: 'Окислительно-восстановительные реакции', + range: '§ 42–45', wm: 'O₂', + color: { p:'#ea580c', d:'#c2410c', l:'#fb923c', soft:'#ffedd5', bgd:'#1c1208', cardd:'#2a1c10', textd:'#ffedd5' }, + items: [ + P('§ 42', 'Степень окисления'), + P('§ 43', 'Процессы окисления и восстановления'), + P('§ 44', 'Окислительно-восстановительные реакции'), + P('§ 45', 'Окислительно-восстановительные реакции вокруг нас') + ] + }, + { + file: 'chemistry_8_ch6.html', slug: 'chemistry-8-ch6', + kicker: 'Глава 6', title: 'Растворы', + range: '§ 46–52', wm: 'aq', + color: { p:'#0891b2', d:'#0e7490', l:'#22d3ee', soft:'#cffafe', bgd:'#08191c', cardd:'#10282d', textd:'#cffafe' }, + items: [ + P('§ 46', 'Смеси веществ'), + P('§ 47', 'Растворение веществ в воде'), + P('§ 48', 'Характеристики растворимости веществ'), + P('§ 49', 'Качественные характеристики состава растворов'), + P('§ 50', 'Количественные характеристики растворённых веществ. Массовая доля растворённого вещества'), + P('§ 51', 'Молярная концентрация растворённых веществ'), + NOTE('Практическая работа 4. Приготовление раствора с заданной массовой долей и молярной концентрацией'), + P('§ 52', 'Вода и растворы в жизни и деятельности человека') + ] + } +]; + +function esc(s) { + return String(s).replace(/[&<>]/g, c => ({ '&':'&', '<':'<', '>':'>' }[c])); +} + +function outlineHtml(items) { + return items.map(it => { + if (it.note) { + return '
  • ' + + '' + + '' + esc(it.note) + '
  • '; + } + return '
  • ' + esc(it.t) + '' + esc(it.n) + '
  • '; + }).join('\n'); +} + +function pageHtml(ch) { + const c = ch.color; + const wmHeader = ch.kicker.toUpperCase(); + return ` + + + + + + + +Химия 8 · ${esc(ch.kicker)} · «${esc(ch.title)}» + + + + + + + + + + + + +
    +
    + + + К разделам + +
    +
    ${esc(ch.kicker)} · ${esc(ch.range)}
    +

    ${esc(ch.title)}

    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +

    Раздел в разработке

    +

    Интерактивное наглядное наполнение этого раздела (теория, модели, симуляторы, тренажёры и боссы) добавляется поэтапно. Ниже — план параграфов раздела согласно учебнику.

    +
    +
    + +
    + + Содержание раздела +
    + +
    + + + + + + + +`; +} + +// --force перезапишет уже существующие файлы; по умолчанию — пропускаем +// готовые (наполненные в фазах) страницы, чтобы не затереть контент. +const FORCE = process.argv.includes('--force'); +let count = 0, skipped = 0; +for (const ch of CHAPTERS) { + const target = path.join(OUT, ch.file); + if (!FORCE && fs.existsSync(target)) { + skipped++; + console.log('skip ', ch.file, '(уже существует — наполнен в фазе)'); + continue; + } + fs.writeFileSync(target, pageHtml(ch), 'utf8'); + count++; + console.log('written', ch.file, '(' + ch.items.filter(i => i.t).length + ' §)'); +} +console.log('done:', count, 'written,', skipped, 'skipped'); diff --git a/backend/src/controllers/testController.js b/backend/src/controllers/testController.js index c4196d0..284165f 100644 --- a/backend/src/controllers/testController.js +++ b/backend/src/controllers/testController.js @@ -8,6 +8,9 @@ function list(req, res) { let where = '1=1'; if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); } if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); } + // Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js), + // не показываем их во вкладке «Тесты (шаблоны)» админки. + where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)'; const rows = db.prepare(` SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, diff --git a/backend/src/db/migrations/041_chemistry8_hub.sql b/backend/src/db/migrations/041_chemistry8_hub.sql new file mode 100644 index 0000000..32e448e --- /dev/null +++ b/backend/src/db/migrations/041_chemistry8_hub.sql @@ -0,0 +1,56 @@ +-- Chemistry 8 hub migration. +-- Creates chemistry-8 as a full hub textbook (intro + 6 chapters) in the style of physics-9: +-- chemistry-8 (hub, html_path = chemistry_8_hub.html) +-- chemistry-8-intro (Количественные понятия, §§1–9) → chemistry_8_intro.html +-- chemistry-8-ch1 (Важнейшие классы соединений, §§10–23) → chemistry_8_ch1.html +-- chemistry-8-ch2 (Периодический закон и ПСХЭ, §§24–28) → chemistry_8_ch2.html +-- chemistry-8-ch3 (Строение атома, §§29–35) → chemistry_8_ch3.html +-- chemistry-8-ch4 (Химическая связь, §§36–41) → chemistry_8_ch4.html +-- chemistry-8-ch5 (ОВР, §§42–45) → chemistry_8_ch5.html +-- chemistry-8-ch6 (Растворы, §§46–52) → chemistry_8_ch6.html +-- +-- Source: Шиманович И. Е., Красицкий В. А., Сечко О. И., Хвалюк В. Н., +-- «Химия 8», Народная асвета, 2018. Контент авторский (наш). +-- Author left empty per project policy. + +-- 1. Insert the parent chemistry-8 hub row (does not exist yet in the catalog). +INSERT INTO textbooks + (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug) +VALUES + ('chemistry-8', 'chemistry', 8, 'Химия — 8 класс', + '', + 'Полный курс химии за 8 класс: количественные понятия (моль, молярная масса и объём, расчёты по уравнениям), важнейшие классы неорганических соединений, периодический закон и строение атома, химическая связь, окислительно-восстановительные реакции, растворы. 7 разделов, 52 параграфа, 4 лабораторных опыта, 4 практические работы.', + 'chemistry_8_hub.html', 52, 'amber', 8, 1, NULL); + +-- 2. Insert the 7 children (intro section + 6 chapters). +INSERT INTO textbooks + (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug) +VALUES + ('chemistry-8-intro', 'chemistry', 8, 'Химия 8 · Количественные понятия в химии', + '', + '§§1–9: атомы и химические элементы, простые и сложные вещества, химическое количество вещества, моль и постоянная Авогадро, молярная масса и молярный объём газов, расчёты по массе/объёму и по уравнениям реакций. Практическая работа 1.', + 'chemistry_8_intro.html', 9, 'amber', 1, 1, 'chemistry-8'), + ('chemistry-8-ch1', 'chemistry', 8, 'Химия 8 · Важнейшие классы неорганических соединений', + '', + '§§10–23: оксиды, кислоты, основания и соли — состав, классификация, химические свойства, получение и применение; генетическая связь между классами. 2 лабораторных опыта, 2 практические работы.', + 'chemistry_8_ch1.html', 14, 'teal', 2, 1, 'chemistry-8'), + ('chemistry-8-ch2', 'chemistry', 8, 'Химия 8 · Периодический закон и периодическая система', + '', + '§§24–28: систематизация элементов, амфотерность, естественные семейства элементов, периодический закон Д. И. Менделеева и строение периодической системы. Лабораторный опыт 3.', + 'chemistry_8_ch2.html', 5, 'indigo', 3, 1, 'chemistry-8'), + ('chemistry-8-ch3', 'chemistry', 8, 'Химия 8 · Строение атома', + '', + '§§29–35: строение атома и атомный номер, массовое число и нуклиды, изотопы и радиоактивность, электронное облако и атомная орбиталь, строение электронных оболочек, периодичность свойств, характеристика элемента по положению в ПС.', + 'chemistry_8_ch3.html', 7, 'blue', 4, 1, 'chemistry-8'), + ('chemistry-8-ch4', 'chemistry', 8, 'Химия 8 · Химическая связь', + '', + '§§36–41: природа химической связи, ковалентная связь (неполярная и полярная, электроотрицательность), ионная и металлическая связь, межмолекулярное взаимодействие, кристаллическое состояние вещества. Лабораторный опыт 4.', + 'chemistry_8_ch4.html', 6, 'green', 5, 1, 'chemistry-8'), + ('chemistry-8-ch5', 'chemistry', 8, 'Химия 8 · Окислительно-восстановительные реакции', + '', + '§§42–45: степень окисления, процессы окисления и восстановления, окислительно-восстановительные реакции и метод электронного баланса, ОВР вокруг нас.', + 'chemistry_8_ch5.html', 4, 'orange', 6, 1, 'chemistry-8'), + ('chemistry-8-ch6', 'chemistry', 8, 'Химия 8 · Растворы', + '', + '§§46–52: смеси веществ, растворение веществ в воде, характеристики растворимости, качественные и количественные характеристики состава растворов, массовая доля и молярная концентрация, вода и растворы в жизни человека. Практическая работа 4.', + 'chemistry_8_ch6.html', 7, 'cyan', 7, 1, 'chemistry-8'); diff --git a/backend/src/db/migrations/042_lab_sims.sql b/backend/src/db/migrations/042_lab_sims.sql new file mode 100644 index 0000000..36688a7 --- /dev/null +++ b/backend/src/db/migrations/042_lab_sims.sql @@ -0,0 +1,65 @@ +-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4. +-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги, +-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для +-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend). +-- +-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids +-- на уровне API, поэтому существующая логика lab.html не ломается. + +CREATE TABLE IF NOT EXISTS lab_sims ( + id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...) + cat TEXT NOT NULL, -- math | phys | chem | bio | game + title TEXT NOT NULL, + subject TEXT, -- курикулум (Фаза 5), напр. 'physics' + grade INTEGER, -- класс (Фаза 5) + sort_order INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге + featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая» + tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order); + +-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS). +INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES + ('graph', 'math', 'График функции', 1), + ('graphtransform', 'math', 'Трансформации графиков', 2), + ('geometry', 'math', 'Планиметрия', 3), + ('triangle', 'math', 'Геометрия треугольника', 4), + ('quadratic', 'math', 'Корни квадратного уравнения', 5), + ('stereo', 'math', 'Стереометрия 3D', 6), + ('probability', 'math', 'Теория вероятностей', 7), + ('trigcircle', 'math', 'Тригонометрическая окружность', 8), + ('normaldist', 'math', 'Нормальное распределение', 9), + ('projectile', 'phys', 'Бросок тела', 10), + ('pendulum', 'phys', 'Маятник', 11), + ('collision', 'phys', 'Столкновение шаров', 12), + ('emfield', 'phys', 'Электромагнитные поля', 13), + ('circuit', 'phys', 'Электрические цепи', 14), + ('hydrostatics', 'phys', 'Гидростатика', 15), + ('dynamics', 'phys', 'Динамика', 16), + ('opticsbench', 'phys', 'Оптическая скамья', 17), + ('isoprocess', 'phys', 'Изопроцессы', 18), + ('waves', 'phys', 'Волны и звук', 19), + ('radioactive', 'phys', 'Радиоактивный распад', 20), + ('race', 'phys', 'Гонка с задачами', 21), + ('heatengine', 'phys', 'Тепловые двигатели', 22), + ('logic', 'phys', 'Логические схемы', 23), + ('molphys', 'chem', 'Молекулярная физика', 24), + ('chemistry', 'chem', 'Химические реакции', 25), + ('equilibrium', 'chem', 'Химическое равновесие', 26), + ('electrolysis', 'chem', 'Электролиз', 27), + ('bohratom', 'chem', 'Атом Бора', 28), + ('orbitals', 'chem', 'Молекулярные орбитали', 29), + ('titration', 'chem', 'pH и кривая титрования', 30), + ('chemsandbox', 'chem', 'Химическая песочница', 31), + ('stoichiometry', 'chem', 'Стехиометрия', 32), + ('crystal', 'chem', 'Кристаллическая решётка', 33), + ('qualanalysis', 'chem', 'Качественный анализ', 34), + ('periodic', 'chem', 'Периодическая таблица', 35), + ('organic', 'chem', 'Органическая химия', 36), + ('solutions', 'chem', 'Растворы', 37), + ('celldivision', 'bio', 'Деление клетки', 38), + ('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39), + ('angrybirds', 'game', 'Angry Birds Physics', 40); diff --git a/backend/src/db/migrations/043_lab_sim_links.sql b/backend/src/db/migrations/043_lab_sim_links.sql new file mode 100644 index 0000000..3421f67 --- /dev/null +++ b/backend/src/db/migrations/043_lab_sim_links.sql @@ -0,0 +1,24 @@ +-- 043_lab_sim_links.sql — Контент-движок лаборатории, Фаза 5 (курикулумная привязка). +-- Связи симуляции с учебной программой: § учебника, тема, узел knowledge-map, +-- задача банка вопросов. Двусторонняя навигация (sim ↔ контент). +-- +-- kind: +-- 'textbook' — ref_id = textbooks.slug +-- 'topic' — ref_id = topics.id (как текст) +-- 'kmap' — ref_id = id узла графа знаний (свободная строка) +-- 'question' — ref_id = questions.id (как текст) +-- label — необязательная человекочитаемая подпись (если не резолвится из БД). + +CREATE TABLE IF NOT EXISTS lab_sim_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sim_id TEXT NOT NULL, + kind TEXT NOT NULL, -- textbook | topic | kmap | question + ref_id TEXT NOT NULL, + label TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sim_id, kind, ref_id) +); + +CREATE INDEX IF NOT EXISTS idx_lab_sim_links_sim ON lab_sim_links (sim_id); +CREATE INDEX IF NOT EXISTS idx_lab_sim_links_ref ON lab_sim_links (kind, ref_id); diff --git a/backend/src/routes/lab.js b/backend/src/routes/lab.js new file mode 100644 index 0000000..429083e --- /dev/null +++ b/backend/src/routes/lab.js @@ -0,0 +1,299 @@ +'use strict'; +/* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5). + * + * GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги. auth. + * PATCH /api/lab/sims/:id — enabled/featured/tags/subject/grade. admin. + * POST /api/lab/sims/reorder — задать порядок (массив id). admin. + * GET /api/lab/sims/:id/related — связанные § / темы / kmap / задачи. auth. (Ф5) + * POST /api/lab/sims/:id/links — добавить связь. admin. (Ф5) + * DELETE /api/lab/sims/:id/links/:linkId — удалить связь. admin. (Ф5) + * GET /api/lab/links?kind=&ref_id= — обратный поиск: какие симуляции привязаны + * к данному учебнику/теме. auth. (Ф5) + * + * Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому + * существующая логика lab.html (которая читает /api/settings/sims) продолжает + * корректно скрывать отключённые симуляции без правок фронта. */ +const router = require('express').Router(); +const db = require('../db/db'); +const { authMiddleware, requireRole } = require('../middleware/auth'); + +const CATS = ['math', 'phys', 'chem', 'bio', 'game']; +const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question']; + +router.use(authMiddleware); + +/* ── helpers ───────────────────────────────────────────────────────────── */ +function readModuleDisabled() { + const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get(); + return row ? row.value === '1' : false; +} +function readLegacyDisabledIds() { + const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get(); + try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); } +} +function writeLegacyDisabledIds(set) { + db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`) + .run(JSON.stringify([...set])); +} +function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } } + +function rowToSim(r) { + return { + id: r.id, cat: r.cat, title: r.title, + subject: r.subject || null, grade: r.grade != null ? r.grade : null, + sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured, + tags: parseTags(r.tags), + }; +} + +/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */ +router.get('/sims', (_req, res) => { + let rows; + try { + rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all(); + } catch (e) { + // Деградация вместо 500: если миграция lab_sims ещё не применена на этом + // инстансе (старый процесс/другая БД) — отдаём пустой каталог, чтобы админка + // не падала. Нужно применить миграцию и перезапустить сервер. + console.warn('[lab] lab_sims недоступна (нужна миграция/перезапуск):', e.message); + return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true }); + } + const legacyDisabled = readLegacyDisabledIds(); + const sims = rows.map(r => { + const s = rowToSim(r); + // Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке. + s.enabled = s.enabled && !legacyDisabled.has(r.id); + return s; + }); + res.json({ module_disabled: readModuleDisabled(), sims }); +}); + +/* ── admin mutations ─────────────────────────────────────────────────────── + ВАЖНО: НЕ используем blanket `router.use(requireRole('admin'))` — он применялся + бы и к ниже определённым READ-роутам Фазы 5 (/related, /links), которые должны + быть доступны любому авторизованному пользователю. Каждая мутация защищена + INLINE requireRole('admin') (так же видит route-auth линтер). */ + +/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */ +router.patch('/sims/:id', requireRole('admin'), (req, res) => { + const id = String(req.params.id || ''); + const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id); + if (!row) return res.status(404).json({ error: 'симуляция не найдена' }); + + const b = req.body || {}; + const sets = []; + const vals = []; + + if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); } + if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); } + if (b.title !== undefined) { + const t = String(b.title).trim(); + if (!t) return res.status(400).json({ error: 'пустой title' }); + sets.push('title = ?'); vals.push(t); + } + if (b.cat !== undefined) { + if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' }); + sets.push('cat = ?'); vals.push(b.cat); + } + if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); } + if (b.grade !== undefined) { + const g = b.grade === null || b.grade === '' ? null : Number(b.grade); + if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) { + return res.status(400).json({ error: 'grade должен быть 1..11 или null' }); + } + sets.push('grade = ?'); vals.push(g); + } + if (b.tags !== undefined) { + if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' }); + const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20); + sets.push('tags = ?'); vals.push(JSON.stringify(clean)); + } + + if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' }); + + sets.push("updated_at = datetime('now')"); + vals.push(id); + db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals); + + // Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html. + if (b.enabled !== undefined) { + const set = readLegacyDisabledIds(); + if (b.enabled) set.delete(id); else set.add(id); + writeLegacyDisabledIds(set); + } + + const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id); + res.json({ ok: true, sim: rowToSim(updated) }); +}); + +/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */ +router.post('/sims/reorder', requireRole('admin'), (req, res) => { + const order = (req.body && req.body.order) || []; + if (!Array.isArray(order) || !order.length) { + return res.status(400).json({ error: 'order должен быть непустым массивом id' }); + } + const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id)); + for (const id of order) { + if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id }); + } + const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?"); + db.transaction(() => { + order.forEach((id, i) => upd.run(i + 1, id)); + })(); + res.json({ ok: true, count: order.length }); +}); + +/* ════════════════════════════════════════════════════════════════════════ + Курикулумная привязка (Фаза 5) — связи симуляции ↔ контент. + ════════════════════════════════════════════════════════════════════════ */ + +// Безопасно прочитать связи симуляции (если таблицы ещё нет — пустой массив). +function readLinks(simId) { + try { + return db.prepare( + 'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id' + ).all(simId); + } catch (e) { + return null; // null => таблица недоступна (нужна миграция) + } +} + +// Обогатить связь человекочитаемой меткой и навигационным href. +function decorateLink(l) { + const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null }; + if (l.kind === 'textbook') { + const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id); + if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; } + out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id); + } else if (l.kind === 'topic') { + const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id)); + if (tp) out.label = out.label || tp.name; + } else if (l.kind === 'question') { + out.href = null; // задачи открываются в банке вопросов отдельным контекстом + } + if (!out.label) out.label = l.kind + ':' + l.ref_id; + return out; +} + +/* GET /api/lab/sims/:id/related → { sim, links:{ textbook:[], topic:[], kmap:[], question:[] } } */ +router.get('/sims/:id/related', authMiddleware, (req, res) => { + const id = String(req.params.id || ''); + const sim = db.prepare('SELECT id, title FROM lab_sims WHERE id = ?').get(id); + // sim может отсутствовать в lab_sims (если миграция 042 не применена) — не 404, + // т.к. связи всё равно могут существовать; вернём то, что есть. + const rows = readLinks(id); + if (rows === null) return res.json({ sim: sim || { id }, links: {}, needs_migration: true }); + const links = { textbook: [], topic: [], kmap: [], question: [] }; + for (const l of rows) { + const d = decorateLink(l); + (links[l.kind] || (links[l.kind] = [])).push(d); + } + res.json({ sim: sim || { id }, links }); +}); + +/* GET /api/lab/links?kind=textbook&ref_id=algebra-8 + → { sims:[{id,title,cat,enabled}] } — какие (включённые) симуляции привязаны. */ +router.get('/links', (req, res) => { + const kind = String(req.query.kind || ''); + const refId = String(req.query.ref_id || ''); + if (!LINK_KINDS.includes(kind) || !refId) { + return res.status(400).json({ error: 'kind и ref_id обязательны' }); + } + let rows; + try { + rows = db.prepare(` + SELECT s.id, s.title, s.cat, s.enabled + FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id + WHERE l.kind = ? AND l.ref_id = ? + ORDER BY s.sort_order, s.id + `).all(kind, refId); + } catch (e) { + return res.json({ sims: [], needs_migration: true }); + } + const legacyDisabled = readLegacyDisabledIds(); + const sims = rows + .map(r => ({ id: r.id, title: r.title, cat: r.cat, enabled: !!r.enabled && !legacyDisabled.has(r.id) })) + .filter(s => s.enabled); // наружу отдаём только доступные + res.json({ sims }); +}); + +/* GET /api/lab/links/all?kind=textbook + → { byRef: { : [{id,title,cat}] } } — пакетный обратный поиск для всех + ref_id данного типа за один запрос (избегаем N+1 на странице каталога учебников). + Отдаёт только включённые симуляции. */ +router.get('/links/all', (req, res) => { + const kind = String(req.query.kind || ''); + if (!LINK_KINDS.includes(kind)) { + return res.status(400).json({ error: 'неверный kind' }); + } + let rows; + try { + rows = db.prepare(` + SELECT l.ref_id, s.id, s.title, s.cat, s.enabled, s.sort_order + FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id + WHERE l.kind = ? + ORDER BY s.sort_order, s.id + `).all(kind); + } catch (e) { + return res.json({ byRef: {}, needs_migration: true }); + } + const legacyDisabled = readLegacyDisabledIds(); + const byRef = {}; + for (const r of rows) { + if (!r.enabled || legacyDisabled.has(r.id)) continue; + (byRef[r.ref_id] || (byRef[r.ref_id] = [])).push({ id: r.id, title: r.title, cat: r.cat }); + } + res.json({ byRef }); +}); + +/* ── admin: управление связями ─────────────────────────────────────────── */ + +/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */ +router.post('/sims/:id/links', requireRole('admin'), (req, res) => { + const simId = String(req.params.id || ''); + if (!db.prepare('SELECT 1 FROM lab_sims WHERE id = ?').get(simId)) { + return res.status(404).json({ error: 'симуляция не найдена' }); + } + const b = req.body || {}; + const kind = String(b.kind || ''); + const refId = String(b.ref_id || '').trim(); + if (!LINK_KINDS.includes(kind)) return res.status(400).json({ error: 'неверный kind' }); + if (!refId) return res.status(400).json({ error: 'ref_id обязателен' }); + + // Валидация существования цели (мягкая — kmap/question произвольны). + if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) { + return res.status(404).json({ error: 'учебник не найден: ' + refId }); + } + if (kind === 'topic') { + const tid = Number(refId); + if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) { + return res.status(404).json({ error: 'тема не найдена: ' + refId }); + } + } + + const label = b.label != null ? String(b.label).trim().slice(0, 200) || null : null; + try { + const info = db.prepare( + 'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)' + ).run(simId, kind, refId, label, req.user.id); + const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?') + .get(info.lastInsertRowid); + res.json({ ok: true, link: decorateLink(created) }); + } catch (e) { + if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' }); + throw e; + } +}); + +/* DELETE /api/lab/sims/:id/links/:linkId */ +router.delete('/sims/:id/links/:linkId', requireRole('admin'), (req, res) => { + const simId = String(req.params.id || ''); + const linkId = Number(req.params.linkId); + if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' }); + const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId); + if (!info.changes) return res.status(404).json({ error: 'связь не найдена' }); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 5a7e988..3fb3b07 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep'); const textbookRoutes = require('./routes/textbooks'); const accessRoutes = require('./routes/access'); const teacherStudentsRoutes = require('./routes/teacherStudents'); +const labRoutes = require('./routes/lab'); const { requestId, errorHandler } = require('./middleware/errorHandler'); @@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes); app.use('/api/textbooks', textbookRoutes); app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); +app.use('/api/lab', labRoutes); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/backend/tests/chemistry8-dom.test.js b/backend/tests/chemistry8-dom.test.js new file mode 100644 index 0000000..e4a21e4 --- /dev/null +++ b/backend/tests/chemistry8-dom.test.js @@ -0,0 +1,63 @@ +'use strict'; +/* + * jsdom-смоук виджетов chem8_svg.js: реальная отрисовка в DOM, ввод, проверка. + * Ловит рантайм-ошибки DOM-манипуляций, которые не видны в чистых юнит-тестах. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const SRC = fs.readFileSync( + path.join(__dirname, '..', '..', 'frontend', 'js', 'chem8_svg.js'), 'utf8'); + +function mkDom() { + const dom = new JSDOM('
    '); + // выполняем модуль так, что его `window` === jsdom-окно + new Function('window', SRC)(dom.window); + return { dom, C: dom.window.Chem8, doc: dom.window.document }; +} + +function fire(el, type) { + el.dispatchEvent(new el.ownerDocument.defaultView.Event(type, { bubbles: true })); +} + +test('moleTriangle монтируется и считает m = n·M', () => { + const { C, doc } = mkDom(); + const api = C.moleTriangle(doc.getElementById('m'), {}); + assert.ok(api && api.el, 'виджет смонтирован'); + const inputs = doc.querySelectorAll('#m input[data-k]'); + assert.equal(inputs.length, 3, '3 поля'); + const byKey = {}; + inputs.forEach(i => { byKey[i.getAttribute('data-k')] = i; }); + // вводим n=2, затем M=18 → ожидаем m=36 + byKey.n.value = '2'; fire(byKey.n, 'input'); + byKey.M.value = '18'; fire(byKey.M, 'input'); + const out = doc.querySelector('#m [data-out]'); + assert.ok(/36/.test(out.textContent), 'm = 36 вычислено: ' + out.textContent); +}); + +test('equationBalancer: неверные коэффициенты → дисбаланс, верные → баланс', () => { + const { C, doc } = mkDom(); + const api = C.equationBalancer(doc.getElementById('b'), + { skeleton: 'H2 + O2 -> H2O', solution: [2, 1, 2] }); + assert.ok(api && api.check, 'виджет смонтирован'); + // по умолчанию все коэффициенты = 1 → не сбалансировано + assert.equal(api.check(), false, '1·H2 + 1·O2 -> 1·H2O не сбалансировано'); + const out = doc.querySelector('#b [data-out]'); + assert.ok(out.className.includes('bad'), 'подсветка дисбаланса'); + // применяем решение через кнопку + doc.querySelector('#b [data-solve]').dispatchEvent( + new doc.defaultView.Event('click', { bubbles: true })); + assert.ok(out.className.includes('ok'), 'после решения — сбалансировано: ' + out.className); +}); + +test('equationBalancer считает атомы для сложной реакции', () => { + const { C, doc } = mkDom(); + const api = C.equationBalancer(doc.getElementById('b'), + { skeleton: 'Al + HCl -> AlCl3 + H2', solution: [2, 6, 2, 3] }); + const coefs = doc.querySelectorAll('#b .ceqb-coef'); + [2, 6, 2, 3].forEach((v, i) => { coefs[i].value = String(v); }); + assert.equal(api.check(), true, '2Al + 6HCl -> 2AlCl3 + 3H2 сбалансировано'); +}); diff --git a/backend/tests/chemistry8-page.test.js b/backend/tests/chemistry8-page.test.js new file mode 100644 index 0000000..39595d3 --- /dev/null +++ b/backend/tests/chemistry8-page.test.js @@ -0,0 +1,236 @@ +'use strict'; +/* + * Полностраничная jsdom-проверка глав «Химия 8» (SPA на chem8_engine.js): + * выполняем реальный HTML + движок + виджеты, даём таймерам отработать, проверяем + * para-selector, активный §, монтаж виджетов — без ошибок скриптов. + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const ROOT = path.join(__dirname, '..', '..'); +const readF = p => fs.readFileSync(path.join(ROOT, p), 'utf8'); +const wait = ms => new Promise(r => setTimeout(r, ms)); + +function buildPage(file, widgetsSrc) { + let html = readF('frontend/textbooks/' + file); + const inl = { + '/js/biochem-core.js': readF('frontend/js/biochem-core.js'), + '/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'), + '/js/chem8_mol.js': readF('frontend/js/chem8_mol.js'), + [widgetsSrc]: readF('frontend/js' + widgetsSrc.replace('/js', '')), + '/js/chem8_engine.js': readF('frontend/js/chem8_engine.js') + }; + html = html + .replace(/') + .replace(/'); + }); + return html; +} + +async function loadDom(file, widgetsSrc) { + const errors = []; + const vc = new VirtualConsole(); + vc.on('jsdomError', e => errors.push(e.message)); + const dom = new JSDOM(buildPage(file, widgetsSrc), { + runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/', + beforeParse(w) { w.scrollTo = function () {}; } + }); + await wait(180); + return { dom, errors, doc: dom.window.document }; +} + +/* ── Вводный раздел ── */ +test('intro: SPA без ошибок, 11 карточек, §1 активен, виджеты', async () => { + const { doc, errors } = await loadDom('chemistry_8_intro.html', '/js/chem8_intro_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 11, '11 карточек'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p1', '§1 активен'); + assert.ok(doc.querySelectorAll('#p1-el .el-cell').length > 10, 'карта элементов'); + doc.defaultView.goTo('p6'); await wait(120); + assert.ok(doc.querySelector('#p6-mount .mtri'), 'треугольник §6'); +}); + +/* ── Глава 1 ── */ +test('ch1: SPA без ошибок, 15 карточек, §10 активен', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 15, '14 § + финал'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p10', '§10 активен'); + assert.ok(doc.querySelector('#p10-body .para-hero'), 'para-hero §10'); +}); + +test('ch1: флагман-виджеты монтируются (классификатор, растворимость, ряд активности)', async () => { + const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js'); + doc.defaultView.goTo('p10'); await wait(120); + assert.ok(doc.querySelector('#c-ox-cls .cls-chip'), 'классификатор оксидов §10'); + doc.defaultView.goTo('p13'); await wait(120); + assert.ok(doc.querySelector('#c-acid-ind .ind-strip'), 'индикатор §13'); + doc.defaultView.goTo('p19'); await wait(120); + assert.ok(doc.querySelector('#c-salt-sol .sol-tab'), 'таблица растворимости §19'); + doc.defaultView.goTo('p14'); await wait(120); + assert.ok(doc.querySelector('#c-acid-act .act-cell'), 'ряд активности §14'); +}); + +test('ch1: тренажёр задач отрисован для §10', async () => { + const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js'); + await wait(150); + assert.ok(doc.querySelectorAll('#navDotsp10 .nav-dot').length >= 4, 'навигация по задачам §10'); +}); + +test('ch1: генетическая карта §22 монтируется (U3)', async () => { + const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js'); + doc.defaultView.goTo('p22'); await wait(120); + assert.ok(doc.querySelectorAll('#c-genetic .gm-edge').length >= 6, 'граф классов §22'); +}); + +test('ch1: карта связей в финале главы монтируется (U6)', async () => { + const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js'); + doc.defaultView.goTo('final1'); await wait(120); + assert.ok(doc.querySelectorAll('#c-concept .gm-edge').length >= 3, 'карта связей понятий финала'); +}); + +/* ── Глава 2 ── */ +test('ch2: SPA без ошибок, 6 карточек, §24 активен, ПСХЭ', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 6, '5 § + финал'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p24', '§24 активен'); + await wait(120); + assert.ok(doc.querySelectorAll('#c-pt-metals .pt-cell').length > 80, 'ПСХЭ §24 (90 элементов)'); +}); + +test('ch2: амфотерность §25 и семейства §26 монтируются', async () => { + const { doc } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js'); + doc.defaultView.goTo('p25'); await wait(120); + assert.ok(doc.querySelector('#c-amph .amph-btn'), 'амфотерность §25'); + doc.defaultView.goTo('p26'); await wait(120); + assert.ok(doc.querySelectorAll('#c-pt-fam .pt-cell').length > 80, 'ПСХЭ семейства §26'); +}); + +/* ── Глава 3 ── */ +test('ch3: SPA без ошибок, 8 карточек, §29 активен, модель атома', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 8, '7 § + финал'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p29', '§29 активен'); + await wait(120); + assert.ok(doc.querySelector('#c-atom .as-svg'), 'модель атома §29'); +}); + +test('ch3: нуклид §30 и паспорт §35 монтируются', async () => { + const { doc } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js'); + doc.defaultView.goTo('p30'); await wait(120); + assert.ok(doc.querySelector('#c-nuclide #nz'), 'калькулятор нуклида §30'); + doc.defaultView.goTo('p35'); await wait(120); + assert.ok(doc.querySelectorAll('#c-passport .pt-cell').length > 80, 'ПСХЭ паспорта §35'); +}); + +/* ── Глава 4 ── */ +test('ch4: SPA без ошибок, 7 карточек, §36 активен, тип связи', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 7, '6 § + финал'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p36', '§36 активен'); + doc.defaultView.goTo('p37'); await wait(120); + assert.ok(doc.querySelector('#c-bond1 .bt-svg'), 'виджет типа связи §37'); + doc.defaultView.goTo('p38'); await wait(120); + assert.ok(doc.querySelector('#c-bond2 .bt-out'), 'виджет полярности §38'); +}); + +test('ch4: 3D-модели молекул §38 и решётки §41 монтируются (U4)', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + doc.defaultView.goTo('p38'); await wait(140); + assert.ok(doc.querySelector('#c-mol .mol-sel'), 'выбор молекулы §38'); + assert.ok(doc.querySelector('#c-mol canvas'), 'canvas 3D-модели §38'); + assert.ok(doc.querySelector('#c-mol .mol-info'), 'инфо о молекуле §38'); + doc.defaultView.goTo('p41'); await wait(140); + assert.ok(doc.querySelector('#c-lattice .lat-sel'), 'выбор решётки §41'); + assert.ok(doc.querySelector('#c-lattice canvas'), 'canvas решётки §41'); +}); + +/* ── Глава 5 ── */ +test('ch5: SPA без ошибок, 5 карточек, §42 активен, с.о. и баланс', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch5.html', '/js/chem8_ch5_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 5, '4 § + финал'); + assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p42', '§42 активен'); + await wait(120); + assert.ok(doc.querySelector('#c-ox .ox-out'), 'калькулятор с.о. §42'); + doc.defaultView.goTo('p44'); await wait(120); + assert.ok(doc.querySelector('#c-redox-pick option'), 'электронный баланс §44'); +}); + +/* ── Глоссарий (U2/Phase 8) ── */ +test('glossary: кнопка, модалка, авто-подсветка терминов', async () => { + const src = readF('frontend/js/chem8_glossary.js'); + const dom = new JSDOM('

    Оксид — это сложное вещество. Кислота реагирует с основанием в реакции нейтрализации.

    ', + { runScripts: 'outside-only', pretendToBeVisual: true, url: 'http://localhost/' }); + new Function('window', src)(dom.window); + await wait(20); + const doc = dom.window.document; + assert.ok(dom.window.Chem8Glossary, 'window.Chem8Glossary определён'); + assert.ok(Object.keys(dom.window.Chem8Glossary.terms).length > 40, '>40 терминов'); + assert.ok(doc.querySelector('.gl-fab'), 'плавающая кнопка глоссария'); + // авто-подсветка терминов в .card-body + assert.ok(doc.querySelectorAll('.card-body .gloss').length >= 2, 'термины подсвечены: ' + doc.querySelectorAll('.gloss').length); + // открытие модалки + dom.window.Chem8Glossary.open(); + assert.ok(doc.querySelector('.gl-modal.show'), 'модалка открыта'); + assert.ok(doc.querySelectorAll('.gl-modal .gl-item').length > 40, 'список терминов в модалке'); +}); + +/* ── Хаб: финал курса (Phase 7) ── */ +function buildHub() { + let html = readF('frontend/textbooks/chemistry_8_hub.html'); + return html + .replace(/') + .replace(/. + */ +(function (W) { + 'use strict'; + var D = W.document; + + /* словарь: термин → {d: определение, see: [связанные]} */ + var G = { + 'атом': { d: 'Мельчайшая химически неделимая частица вещества: ядро (протоны и нейтроны) + электроны.', see: ['химический элемент', 'нуклид'] }, + 'химический элемент': { d: 'Вид атомов с одинаковым зарядом ядра (числом протонов).', see: ['атом'] }, + 'относительная атомная масса': { d: 'Безразмерная величина $A_r$ — во сколько раз масса атома больше 1/12 массы атома углерода-12.', see: ['относительная молекулярная масса'] }, + 'относительная молекулярная масса': { d: 'Сумма относительных атомных масс всех атомов в формуле ($M_r$).', see: ['молярная масса'] }, + 'простое вещество': { d: 'Вещество из атомов одного элемента (O₂, Fe).', see: ['сложное вещество'] }, + 'сложное вещество': { d: 'Вещество из атомов разных элементов (H₂O, CaCO₃).', see: ['простое вещество'] }, + 'химическая формула': { d: 'Запись состава вещества символами элементов с индексами.', see: [] }, + 'химическое количество': { d: 'Физическая величина $n$ (порция вещества), измеряется в молях.', see: ['моль', 'постоянная Авогадро'] }, + 'моль': { d: 'Единица химического количества: содержит $6{,}02\\cdot10^{23}$ частиц (число Авогадро).', see: ['постоянная Авогадро'] }, + 'постоянная Авогадро': { d: '$N_A = 6{,}02\\cdot10^{23}$ частиц/моль — число частиц в 1 моль.', see: ['моль'] }, + 'молярная масса': { d: 'Масса 1 моль вещества $M$ (г/моль); численно равна $M_r$.', see: ['относительная молекулярная масса'] }, + 'молярный объём': { d: 'Объём 1 моль газа; при н.у. $V_m = 22{,}4$ л/моль.', see: [] }, + 'оксид': { d: 'Сложное вещество из элемента и кислорода (с.о. −2): основный, кислотный, амфотерный, несолеобразующий.', see: ['основный оксид', 'кислотный оксид'] }, + 'основный оксид': { d: 'Оксид металла, реагирует с кислотами (CaO, Na₂O).', see: ['оксид'] }, + 'кислотный оксид': { d: 'Оксид неметалла, реагирует со щелочами (CO₂, SO₃).', see: ['оксид'] }, + 'амфотерность': { d: 'Способность вещества проявлять и кислотные, и основные свойства (Zn(OH)₂, Al(OH)₃).', see: ['оксид', 'основание'] }, + 'кислота': { d: 'Вещество с атомами водорода, способными замещаться металлом, и кислотным остатком.', see: ['основность'] }, + 'основность': { d: 'Число атомов водорода в кислоте, способных замещаться металлом.', see: ['кислота'] }, + 'основание': { d: 'Вещество из металла и гидроксогрупп OH; растворимые — щёлочи.', see: ['щёлочь', 'нейтрализация'] }, + 'щёлочь': { d: 'Растворимое в воде основание (NaOH, KOH, Ba(OH)₂).', see: ['основание'] }, + 'соль': { d: 'Вещество из катионов металла и анионов кислотного остатка (NaCl, CaCO₃).', see: ['реакция ионного обмена'] }, + 'нейтрализация': { d: 'Реакция кислоты с основанием: соль + вода.', see: ['кислота', 'основание'] }, + 'индикатор': { d: 'Вещество, меняющее окраску в зависимости от среды (лакмус, фенолфталеин, метилоранж).', see: [] }, + 'реакция ионного обмена': { d: 'Реакция между растворами, идущая до конца при образовании осадка ↓, газа ↑ или воды.', see: ['соль', 'растворимость'] }, + 'ряд активности металлов': { d: 'Ряд металлов по убыванию химической активности; металл вытесняет менее активные.', see: [] }, + 'генетическая связь': { d: 'Связь между классами веществ через цепочки превращений (металл→оксид→основание→соль).', see: [] }, + 'периодический закон': { d: 'Свойства элементов периодически зависят от заряда ядра их атомов (Д. И. Менделеев, 1869).', see: ['периодическая система'] }, + 'периодическая система': { d: 'Таблица элементов: периоды (строки) и группы (столбцы).', see: ['период', 'группа'] }, + 'период': { d: 'Горизонтальный ряд в ПСХЭ; номер = число электронных слоёв.', see: ['периодическая система'] }, + 'группа': { d: 'Вертикальный столбец ПСХЭ; номер = число внешних электронов.', see: ['периодическая система'] }, + 'нуклид': { d: 'Вид атомов с определёнными Z (протоны) и N (нейтроны).', see: ['изотопы', 'массовое число'] }, + 'массовое число': { d: 'Число протонов и нейтронов в ядре: $A = Z + N$.', see: ['нуклид'] }, + 'изотопы': { d: 'Атомы одного элемента с разным числом нейтронов (одинаковый Z, разный A).', see: ['нуклид'] }, + 'электронное облако': { d: 'Область вокруг ядра, где электрон бывает чаще всего.', see: ['орбиталь'] }, + 'орбиталь': { d: 'Форма электронного облака: s — сфера, p — гантель.', see: ['электронное облако'] }, + 'электроотрицательность': { d: 'Способность атома притягивать к себе общие электроны.', see: ['ковалентная связь'] }, + 'ковалентная связь': { d: 'Связь за счёт общих электронных пар (между неметаллами).', see: ['электроотрицательность', 'ионная связь'] }, + 'ионная связь': { d: 'Связь за счёт полной передачи электронов от металла к неметаллу; образуются ионы.', see: ['ковалентная связь'] }, + 'металлическая связь': { d: 'Связь ион-остовов металла «электронным газом» из общих электронов.', see: [] }, + 'кристаллическая решётка': { d: 'Упорядоченное расположение частиц в кристалле: ионная, атомная, молекулярная, металлическая.', see: [] }, + 'степень окисления': { d: 'Условный заряд атома в соединении (H +1, O −2, сумма = 0).', see: ['окисление', 'восстановление'] }, + 'окисление': { d: 'Процесс отдачи электронов (степень окисления повышается).', see: ['восстановление', 'степень окисления'] }, + 'восстановление': { d: 'Процесс приёма электронов (степень окисления понижается).', see: ['окисление'] }, + 'окислитель': { d: 'Частица, принимающая электроны (сама восстанавливается).', see: ['восстановитель'] }, + 'восстановитель': { d: 'Частица, отдающая электроны (сама окисляется).', see: ['окислитель'] }, + 'окислительно-восстановительная реакция': { d: 'Реакция с изменением степеней окисления (переход электронов).', see: ['степень окисления'] }, + 'смесь': { d: 'Несколько веществ вместе: однородная (раствор) или неоднородная.', see: ['раствор'] }, + 'раствор': { d: 'Однородная смесь растворителя и растворённого вещества.', see: ['растворимость', 'массовая доля'] }, + 'растворимость': { d: 'Масса вещества, растворяющаяся в 100 г воды при данной температуре.', see: ['раствор'] }, + 'насыщенный раствор': { d: 'Раствор, в котором вещество больше не растворяется при данной температуре.', see: ['раствор'] }, + 'массовая доля': { d: 'Отношение массы растворённого вещества к массе раствора: $w = m_{в-ва}/m_{р-ра}$.', see: ['раствор'] }, + 'молярная концентрация': { d: 'Химическое количество вещества в 1 л раствора: $c = n/V$ (моль/л).', see: ['раствор'] } + }; + + var TERMS = Object.keys(G).sort(function (a, b) { return b.length - a.length; }); // длинные раньше + + function injectCSS() { + if (D.getElementById('chem8-gloss-css')) return; + var s = D.createElement('style'); s.id = 'chem8-gloss-css'; + s.textContent = + '.gloss{border-bottom:1.5px dotted var(--pri,#d97706);cursor:help;text-decoration:none}' + + '.gl-fab{position:fixed;left:16px;bottom:16px;z-index:55;display:inline-flex;align-items:center;gap:7px;padding:9px 14px;border:none;border-radius:99px;background:var(--pri,#d97706);color:#fff;font-weight:700;font-size:.84rem;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}' + + '.gl-fab:hover{filter:brightness(1.08)}.gl-fab svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}' + + '.gl-modal{position:fixed;inset:0;z-index:80;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto}' + + '.gl-modal.show{display:flex}' + + '.gl-box{background:var(--card,#fff);color:var(--text,#1c1917);border-radius:16px;max-width:600px;width:100%;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.3)}' + + '.gl-h{display:flex;align-items:center;gap:10px;margin-bottom:12px}.gl-h h3{font-family:Outfit,sans-serif;font-size:1.15rem;font-weight:800;flex:1}' + + '.gl-close{border:none;background:transparent;font-size:1.4rem;cursor:pointer;color:var(--muted,#888);line-height:1}' + + '.gl-search{width:100%;padding:10px 13px;border:1.5px solid var(--border,#ddd);border-radius:10px;background:var(--card,#fff);color:var(--text,#1c1917);font-family:inherit;font-size:.95rem;margin-bottom:12px}' + + '.gl-list{max-height:60vh;overflow:auto}' + + '.gl-item{padding:10px 12px;border-bottom:1px solid var(--border,#eee)}.gl-item:last-child{border-bottom:0}' + + '.gl-term{font-weight:800;color:var(--pri-d,#b45309);text-transform:capitalize}' + + '.gl-def{font-size:.9rem;margin-top:3px;line-height:1.5}' + + '.gl-see{font-size:.8rem;color:var(--muted,#888);margin-top:4px}' + + '.gl-pop{position:absolute;z-index:90;max-width:280px;background:var(--card,#fff);color:var(--text,#1c1917);border:1.5px solid var(--pri,#d97706);border-radius:10px;padding:10px 13px;font-size:.86rem;line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,.2);display:none}' + + '.gl-pop.show{display:block}.gl-pop b{color:var(--pri-d,#b45309);text-transform:capitalize}'; + D.head.appendChild(s); + } + + function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + + /* авто-подсветка терминов в .card-body (первое вхождение каждого, в текстовых узлах) */ + function decorate(root) { + if (!root) return; + var bodies = root.matches && root.matches('.card-body') ? [root] : root.querySelectorAll ? root.querySelectorAll('.card-body') : []; + Array.prototype.forEach.call(bodies, function (body) { + if (body._glossed) return; body._glossed = 1; + var used = {}; + TERMS.forEach(function (term) { + if (used[term]) return; + var walker = D.createTreeWalker(body, W.NodeFilter.SHOW_TEXT, null); + var node, re = new RegExp('(^|[^а-яёА-ЯЁ-])(' + esc(term) + ')(?![а-яёА-ЯЁ])', 'i'); + while ((node = walker.nextNode())) { + if (node.parentNode && (node.parentNode.classList && (node.parentNode.classList.contains('gloss') || node.parentNode.closest('.gloss,abbr,a,.ph-formula,.main-f,code')))) continue; + var m = node.nodeValue.match(re); + if (m) { + var idx = m.index + m[1].length; + var before = node.nodeValue.slice(0, idx), word = node.nodeValue.slice(idx, idx + term.length), after = node.nodeValue.slice(idx + term.length); + var ab = D.createElement('abbr'); ab.className = 'gloss'; ab.setAttribute('data-term', term.toLowerCase()); ab.textContent = word; + var frag = D.createDocumentFragment(); + frag.appendChild(D.createTextNode(before)); frag.appendChild(ab); frag.appendChild(D.createTextNode(after)); + node.parentNode.replaceChild(frag, node); + used[term] = 1; break; + } + } + }); + }); + } + + /* popover при наведении/клике на .gloss */ + var pop; + function showPop(ab) { + var term = ab.getAttribute('data-term'); var g = G[term]; if (!g) return; + if (!pop) { pop = D.createElement('div'); pop.className = 'gl-pop'; D.body.appendChild(pop); } + pop.innerHTML = '' + term + '
    ' + g.d + (g.see && g.see.length ? '
    См.: ' + g.see.join(', ') + '
    ' : ''); + var r = ab.getBoundingClientRect(); + pop.style.left = Math.min(r.left, W.innerWidth - 300) + 'px'; + pop.style.top = (r.bottom + W.scrollY + 6) + 'px'; + pop.classList.add('show'); + renderMath(pop); + } + function hidePop() { if (pop) pop.classList.remove('show'); } + function renderMath(el) { if (typeof W.renderMathInElement === 'function') { try { W.renderMathInElement(el, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} } } + + /* модалка */ + var modal; + function buildModal() { + modal = D.createElement('div'); modal.className = 'gl-modal'; + modal.innerHTML = '

    Глоссарий — Химия 8

    ' + + '' + + '
    '; + D.body.appendChild(modal); + var list = modal.querySelector('.gl-list'), search = modal.querySelector('.gl-search'); + function render(q) { + q = (q || '').toLowerCase().trim(); + var keys = Object.keys(G).sort(); + list.innerHTML = keys.filter(function (t) { return !q || t.indexOf(q) >= 0 || G[t].d.toLowerCase().indexOf(q) >= 0; }) + .map(function (t) { return '
    ' + t + '
    ' + G[t].d + '
    ' + (G[t].see && G[t].see.length ? '
    См.: ' + G[t].see.join(', ') + '
    ' : '') + '
    '; }).join('') || '
    Ничего не найдено.
    '; + renderMath(list); + } + search.addEventListener('input', function () { render(search.value); }); + modal.querySelector('.gl-close').addEventListener('click', close); + modal.addEventListener('click', function (e) { if (e.target === modal) close(); }); + render(''); + } + function open() { if (!modal) buildModal(); modal.classList.add('show'); var s = modal.querySelector('.gl-search'); if (s) setTimeout(function () { s.focus(); }, 50); } + function close() { if (modal) modal.classList.remove('show'); } + + function init() { + injectCSS(); + var fab = D.createElement('button'); fab.className = 'gl-fab'; + fab.innerHTML = ' Глоссарий'; + fab.addEventListener('click', open); + D.body.appendChild(fab); + D.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); + // авто-подсветка терминов: при наведении/клике — popover + D.body.addEventListener('mouseover', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) showPop(e.target); }); + D.body.addEventListener('mouseout', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) hidePop(); }); + D.body.addEventListener('click', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) { e.preventDefault(); showPop(e.target); } }); + // первичная декорация + наблюдение за лениво строящимися § + decorate(D.body); + try { + var obs = new W.MutationObserver(function (muts) { + muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes, function (n) { if (n.nodeType === 1) decorate(n); }); }); + }); + obs.observe(D.body, { childList: true, subtree: true }); + } catch (e) {} + } + + W.Chem8Glossary = { open: open, decorate: decorate, terms: G }; + if (D.readyState === 'loading') D.addEventListener('DOMContentLoaded', init); else init(); +})(window); diff --git a/frontend/js/chem8_intro_widgets.js b/frontend/js/chem8_intro_widgets.js new file mode 100644 index 0000000..d1a2d7f --- /dev/null +++ b/frontend/js/chem8_intro_widgets.js @@ -0,0 +1,146 @@ +/* chem8_intro_widgets.js — виджеты вводного раздела «Химия 8». + * Монтируются движком chem8_engine.js: window.CHEM8_WIDGETS[id] / window.FLAG_MOUNTS[id]. + * Используют window.Chem8 (chem8_svg.js): molarMass, elementCounts, arOf, fmt, + * moleTriangle, equationBalancer. + */ +(function (W) { + 'use strict'; + function C() { return W.Chem8 || {}; } + function $(id) { return document.getElementById(id); } + function rr(v, d) { var p = Math.pow(10, d == null ? 3 : d); return (Math.round(v * p) / p).toString().replace('.', ','); } + + /* §1 — карта элементов */ + var EL = { + H: [1, 'Водород'], He: [2, 'Гелий'], Li: [3, 'Литий'], Be: [4, 'Бериллий'], B: [5, 'Бор'], C: [6, 'Углерод'], + N: [7, 'Азот'], O: [8, 'Кислород'], F: [9, 'Фтор'], Ne: [10, 'Неон'], Na: [11, 'Натрий'], Mg: [12, 'Магний'], + Al: [13, 'Алюминий'], Si: [14, 'Кремний'], P: [15, 'Фосфор'], S: [16, 'Сера'], Cl: [17, 'Хлор'], Ar: [18, 'Аргон'], + K: [19, 'Калий'], Ca: [20, 'Кальций'], Fe: [26, 'Железо'], Cu: [29, 'Медь'], Zn: [30, 'Цинк'], Ag: [47, 'Серебро'], Ba: [56, 'Барий'] + }; + function mount_p1() { + var grid = $('p1-el'), info = $('p1-elinfo'); if (!grid || grid._built) return; grid._built = 1; + Object.keys(EL).forEach(function (s) { + var ar = C().arOf ? C().arOf(s) : ''; + var c = document.createElement('div'); c.className = 'el-cell'; + c.innerHTML = '' + EL[s][0] + '' + s + '' + ar + ''; + c.addEventListener('click', function () { + grid.querySelectorAll('.el-cell').forEach(function (x) { x.classList.remove('on'); }); c.classList.add('on'); + info.innerHTML = '' + EL[s][1] + ' (' + s + ') · порядковый номер Z = ' + EL[s][0] + ' · A_r = ' + ar; + }); + grid.appendChild(c); + }); + } + + /* §2 — калькулятор Mr */ + function mount_p2() { + var inp = $('p2-mr-in'), out = $('p2-mr-out'), go = $('p2-mr-go'); if (!inp || inp._built) return; inp._built = 1; + function calc() { + var f = inp.value.trim(), cnt = C().elementCounts ? C().elementCounts(f) : null, mr = C().molarMass ? C().molarMass(f) : NaN; + if (!cnt || isNaN(mr)) { out.className = 'out bad'; out.textContent = 'Не удалось разобрать формулу. Проверьте символы элементов.'; return; } + out.className = 'out ok'; + out.innerHTML = 'M_r(' + f + ') = ' + C().fmt(mr) + '
    ' + + Object.keys(cnt).map(function (e) { return e + ': A_r=' + (C().arOf ? C().arOf(e) : '?') + ' × ' + cnt[e]; }).join('  |  ') + + '
    Σ = ' + Object.keys(cnt).map(function (e) { return (C().arOf ? C().arOf(e) : '?') + '·' + cnt[e]; }).join(' + ') + ' = ' + C().fmt(mr) + '
    '; + } + go.addEventListener('click', calc); + inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); + document.querySelectorAll('.p2-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); }); + calc(); + } + + /* §3 — порция вещества */ + function mount_p3() { + var sub = $('p3-sub'), rng = $('p3-n'), nv = $('p3-nv'), out = $('p3-out'); if (!sub || sub._built) return; sub._built = 1; + var M = { H2O: 18, O2: 32, CO2: 44, NaCl: 58.5 }; + function upd() { + var n = parseFloat(rng.value), s = sub.value, m = n * M[s], N = n * 6.02; + nv.textContent = n.toFixed(1).replace('.', ','); + out.innerHTML = 'n = ' + n.toFixed(1).replace('.', ',') + ' моль
    m = n·M = ' + n.toFixed(1).replace('.', ',') + ' · ' + String(M[s]).replace('.', ',') + ' = ' + rr(m, 1) + ' г
    N = n·N_A = ' + rr(N, 2) + '·10²³ частиц
    '; + } + sub.addEventListener('change', upd); rng.addEventListener('input', upd); upd(); + } + + /* §4 — счётчик частиц */ + function mount_p4() { + var rng = $('p4-n'), nv = $('p4-nv'), out = $('p4-out'); if (!rng || rng._built) return; rng._built = 1; + function upd() { var n = parseFloat(rng.value), N = n * 6.02; nv.textContent = n.toFixed(2).replace('.', ','); + out.innerHTML = 'N = n · N_A = ' + n.toFixed(2).replace('.', ',') + ' · 6,02·10²³ = ' + rr(N, 2) + '·10²³ частиц'; } + rng.addEventListener('input', upd); upd(); + } + + /* §5 — M + объём газа */ + function mount_p5() { + var inp = $('p5-in'), out = $('p5-out'), go = $('p5-go'); if (!inp || inp._built) return; inp._built = 1; + function calc() { + var f = inp.value.trim(), mr = C().molarMass ? C().molarMass(f) : NaN; + if (isNaN(mr)) { out.className = 'out bad'; out.textContent = 'Не удалось разобрать формулу.'; return; } + out.className = 'out ok'; + out.innerHTML = 'M(' + f + ') = ' + C().fmt(mr) + ' г/моль
    1 моль газа при н.у. → 22,4 л
    Плотность газа ≈ M/22,4 = ' + rr(mr / 22.4) + ' г/л
    '; + } + go.addEventListener('click', calc); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc(); + } + + /* §6 / ПР1 — треугольник n–m–M (флагман) */ + function mount_triangle(mountId, subId) { + var mount = $(mountId), sub = $(subId); if (!mount || mount._built || !C().moleTriangle) return; mount._built = 1; + var api = C().moleTriangle(mount, {}); + if (sub) sub.addEventListener('change', function () { + var f = sub.value; if (!f) return; var m = C().molarMass(f); + if (!isNaN(m) && api && api.set) api.set('M', m); + }); + } + function mount_p6() { mount_triangle('p6-mount', 'p6-sub'); } + function mount_pr1() { mount_triangle('pr1-mount', 'pr1-sub'); } + + /* §7 — универсальный калькулятор газа (флагман) */ + function mount_p7() { + var sub = $('p7-sub'), key = $('p7-key'), val = $('p7-val'), go = $('p7-go'), out = $('p7-out'); if (!sub || sub._built) return; sub._built = 1; + var Vm = 22.4, NA = 6.02; + function calc() { + var f = sub.value, M = C().molarMass(f), k = key.value, x = parseFloat((val.value || '').replace(',', '.')); + if (isNaN(x)) { out.className = 'out bad'; out.textContent = 'Введите число.'; return; } + var n; if (k === 'n') n = x; else if (k === 'm') n = x / M; else if (k === 'V') n = x / Vm; else n = x / NA; + var m = n * M, V = n * Vm, N = n * NA; + out.className = 'out ok'; + out.innerHTML = 'M(' + f + ')=' + M + ' г/моль
    n = ' + rr(n) + ' моль
    m = ' + rr(m) + ' г
    V(н.у.) = ' + rr(V) + ' л
    N = ' + rr(N) + '·10²³ частиц
    '; + } + go.addEventListener('click', calc); val.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc(); + } + + /* §8 — балансировщик (флагман) */ + function mount_p8() { + var pick = $('p8-pick'), mount = $('p8-mount'); if (!pick || pick._built || !C().equationBalancer) return; pick._built = 1; + function build() { var parts = pick.value.split('|'); C().equationBalancer(mount, { skeleton: parts[0], solution: parts[1].split(',').map(Number) }); } + pick.addEventListener('change', build); build(); + } + + /* §9 — пошаговый решатель (флагман) */ + var ST = [ + { eq: '2H₂ + O₂ → 2H₂O', given: 'Дано: m(H₂) = 4 г. Найти m(H₂O).', + steps: ['M(H₂)=2 г/моль, M(H₂O)=18 г/моль.', 'n(H₂) = m/M = 4/2 = 2 моль.', 'По уравнению n(H₂):n(H₂O) = 2:2 = 1:1 → n(H₂O)=2 моль.', 'm(H₂O) = n·M = 2·18 = 36 г. Ответ: 36 г.'] }, + { eq: 'CaCO₃ → CaO + CO₂↑', given: 'Дано: m(CaCO₃) = 100 г. Найти V(CO₂) при н.у.', + steps: ['M(CaCO₃)=100 г/моль.', 'n(CaCO₃) = 100/100 = 1 моль.', 'n(CaCO₃):n(CO₂) = 1:1 → n(CO₂)=1 моль.', 'V(CO₂) = n·Vm = 1·22,4 = 22,4 л. Ответ: 22,4 л.'] }, + { eq: 'Zn + 2HCl → ZnCl₂ + H₂↑', given: 'Дано: n(Zn) = 0,5 моль. Найти V(H₂) при н.у.', + steps: ['n(Zn):n(H₂) = 1:1 → n(H₂)=0,5 моль.', 'V(H₂) = n·Vm = 0,5·22,4 = 11,2 л. Ответ: 11,2 л.'] } + ]; + function mount_p9() { + var pick = $('p9-pick'), out = $('p9-out'), bStep = $('p9-step'), bAll = $('p9-all'); if (!pick || pick._built) return; pick._built = 1; + ST.forEach(function (p, i) { var o = document.createElement('option'); o.value = i; o.textContent = p.eq; pick.appendChild(o); }); + var cur = 0, shown = 0; + function render() { + var p = ST[cur]; + var html = '' + p.eq + '
    ' + p.given + '
    '; + for (var i = 0; i < shown; i++) html += '
    ' + p.steps[i] + '
    '; + if (shown === 0) html += 'Нажмите «Следующий шаг», чтобы решать пошагово.'; + html += '
    '; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html; + if (W.chem8RenderMath) try { W.chem8RenderMath(out); } catch (e) {} + } + pick.addEventListener('change', function () { cur = +pick.value; shown = 0; render(); }); + bStep.addEventListener('click', function () { if (shown < ST[cur].steps.length) { shown++; render(); } }); + bAll.addEventListener('click', function () { shown = ST[cur].steps.length; render(); }); + render(); + } + function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"n","t":"n, моль","x":170,"y":55,"c":"#d97706"},{"id":"m","t":"m, г","x":20,"y":22},{"id":"M","t":"M, г/моль","x":20,"y":95},{"id":"V","t":"V, л","x":330,"y":22},{"id":"N","t":"N частиц","x":330,"y":95}],"edges":[{"f":"m","t":"n","label":"n = m / M"},{"f":"M","t":"n","label":"M = m / n"},{"f":"n","t":"V","label":"V = n · 22,4 (газ, н.у.)"},{"f":"n","t":"N","label":"N = n · 6,02·10²³"}]}); } } + + W.CHEM8_WIDGETS = { p1: mount_p1, p2: mount_p2, p3: mount_p3, p4: mount_p4, p5: mount_p5, pr1: mount_pr1 }; + W.FLAG_MOUNTS = { final1: mount_final1, p6: mount_p6, p7: mount_p7, p8: mount_p8, p9: mount_p9 }; +})(window); diff --git a/frontend/js/chem8_mol.js b/frontend/js/chem8_mol.js new file mode 100644 index 0000000..06b842c --- /dev/null +++ b/frontend/js/chem8_mol.js @@ -0,0 +1,132 @@ +/* chem8_mol.js — 3D-модели молекул и кристаллических решёток (U4). + * Поверх biochem-core (window.BIO): vsepr + render3D. Вращение мышью/пальцем + * (window-listeners, без setPointerCapture). Экспорт: window.Chem8Mol. + */ +(function (W) { + 'use strict'; + var D = W.document; + function BIO() { return W.BIO; } + function C() { return W.Chem8 || {}; } + + /* предопределённые молекулы: atoms + bonds */ + var MOL = { + H2: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Водород H₂' }, + Cl2: { atoms: [{ id: 1, s: 'Cl' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлор Cl₂' }, + O2: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }], name: 'Кислород O₂' }, + N2: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'N' }], bonds: [{ f: 1, t: 2, o: 3 }], name: 'Азот N₂' }, + HCl: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлороводород HCl' }, + H2O: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }], name: 'Вода H₂O' }, + CO2: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'O' }, { id: 3, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }, { f: 1, t: 3, o: 2 }], name: 'Углекислый газ CO₂' }, + NH3: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }], name: 'Аммиак NH₃' }, + CH4: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }, { id: 5, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }, { f: 1, t: 5, o: 1 }], name: 'Метан CH₄' } + }; + + function mkCanvas(host, h) { + var cv = D.createElement('canvas'); cv.className = 'mol-cv'; + cv.style.width = '100%'; cv.style.height = (h || 200) + 'px'; cv.style.touchAction = 'none'; + cv.style.borderRadius = '12px'; cv.style.display = 'block'; + host.appendChild(cv); return cv; + } + function fit(cv) { + var dpr = W.devicePixelRatio || 1, w = cv.offsetWidth || 280, h = cv.offsetHeight || 200; + cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); + var ctx = cv.getContext && cv.getContext('2d'); if (!ctx) return null; // jsdom без canvas + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx: ctx, W: w, H: h }; + } + + /* общий движок вращения: state выше redraw, window-listeners */ + function attachRotate(cv, state, redraw) { + var dragging = false, lx = 0, ly = 0; + cv.addEventListener('pointerdown', function (e) { dragging = true; lx = e.clientX; ly = e.clientY; state.spin = false; }); + W.addEventListener('pointermove', function (e) { + if (!dragging) return; + state.rotY += (e.clientX - lx) * 0.01; state.rotX += (e.clientY - ly) * 0.01; + lx = e.clientX; ly = e.clientY; redraw(); + }); + W.addEventListener('pointerup', function () { dragging = false; }); + } + + /* ── 3D-модель молекулы ── */ + function molModel(mount, key) { + var host = typeof mount === 'string' ? D.querySelector(mount) : mount; + if (!host || !BIO()) return null; + var keys = Object.keys(MOL); + host.innerHTML = '
    ' + + '
    '; + var stage = D.createElement('div'); host.appendChild(stage); + var cv = mkCanvas(stage, 200); + var info = D.createElement('div'); info.className = 'out mol-info'; host.appendChild(info); + var sel = host.querySelector('.mol-sel'), spinBtn = host.querySelector('.mol-spin'); + var state = { rotX: -0.35, rotY: 0.6, scale: 2.6, spin: true }; + var cur; + function load(k) { + cur = MOL[k]; var g = BIO().vsepr(cur.atoms, cur.bonds); cur.g = g; + var pol = BIO().polarity(cur.atoms, cur.bonds); + var mr = C().molarMass ? C().molarMass(k) : BIO().molarMass(cur.atoms); + var bondTxt = cur.atoms.length === 2 && C().bondClass + ? C().bondClass(cur.atoms[0].s, cur.atoms[1].s).type + : (pol.label === 'Ионная' ? 'ионная' : 'ковалентная'); + info.className = 'out mol-info ok'; + info.innerHTML = '' + cur.name + ' · M = ' + (C().fmt ? C().fmt(mr) : mr) + ' г/моль
    ' + + 'Связь: ' + bondTxt + ' · молекула: ' + pol.label.toLowerCase() + '' + + (g.shape ? ' · форма: ' + g.shape : '') + '
    '; + } + function redraw() { + var d = fit(cv); if (!d) return; + BIO().render3D(d.ctx, cur.g.atoms3d, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' }); + } + sel.addEventListener('change', function () { load(sel.value); redraw(); }); + spinBtn.addEventListener('click', function () { state.spin = !state.spin; spinBtn.classList.toggle('primary', state.spin); }); + attachRotate(cv, state, redraw); + load(key && MOL[key] ? key : keys[0]); + redraw(); + if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.012; redraw(); } W.requestAnimationFrame(loop); })(); // не стартуем цикл без canvas-контекста (jsdom) + return { el: host }; + } + + /* ── кристаллические решётки (§41) ── */ + var LAT = { + ionic: { name: 'Ионная (NaCl)', build: function () { return cube(['Na', 'Cl']); }, note: 'Узлы — ионы Na⁺ и Cl⁻. Прочная решётка → тугоплавкие, твёрдые вещества.' }, + atomic: { name: 'Атомная (алмаз)', build: function () { return cube(['C', 'C']); }, note: 'Узлы — атомы, связанные ковалентно. Очень твёрдые, тугоплавкие.' }, + molecular: { name: 'Молекулярная (лёд)', build: function () { return cube(['O', 'O']); }, note: 'Узлы — молекулы со слабым притяжением. Летучие, легкоплавкие.' }, + metallic: { name: 'Металлическая (Fe)', build: function () { return cube(['Fe', 'Fe'], true); }, note: 'Ион-остовы металла в «электронном газе». Ковкие, проводят ток.' } + }; + function cube(symPair, electrons) { + var L = 16, atoms = [], id = 1; + for (var xi = -1; xi <= 1; xi += 2) for (var yi = -1; yi <= 1; yi += 2) for (var zi = -1; zi <= 1; zi += 2) { + var parity = ((xi + yi + zi) / 2 + 3) % 2; + atoms.push({ id: id++, s: symPair[parity], x: xi * L, y: yi * L, z: zi * L }); + } + var bonds = []; + for (var i = 0; i < atoms.length; i++) for (var j = i + 1; j < atoms.length; j++) { + var a = atoms[i], b = atoms[j], dd = Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.z - b.z); + if (dd === 2 * L) bonds.push({ f: a.id, t: b.id, o: 1 }); + } + if (electrons) for (var e = 0; e < 6; e++) atoms.push({ id: id++, s: 'H', x: (e % 3 - 1) * L, y: ((e / 3 | 0) * 2 - 1) * L * 0.5, z: 0 }); // «электроны» как мелкие точки (H — мелкий радиус) + return { atoms: atoms, bonds: bonds }; + } + function latticeViewer(mount, type) { + var host = typeof mount === 'string' ? D.querySelector(mount) : mount; + if (!host || !BIO()) return null; + var keys = Object.keys(LAT); + host.innerHTML = '
    '; + var stage = D.createElement('div'); host.appendChild(stage); + var cv = mkCanvas(stage, 200); + var info = D.createElement('div'); info.className = 'out'; host.appendChild(info); + var sel = host.querySelector('.lat-sel'); + var state = { rotX: -0.4, rotY: 0.5, scale: 2.4, spin: true }; + var cur; + function load(k) { var l = LAT[k]; cur = l.build(); info.className = 'out ok'; info.innerHTML = '' + l.name + '
    ' + l.note + '
    '; } + function redraw() { var d = fit(cv); if (!d) return; BIO().render3D(d.ctx, cur.atoms, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' }); } + sel.addEventListener('change', function () { load(sel.value); redraw(); }); + attachRotate(cv, state, redraw); + load(type && LAT[type] ? type : keys[0]); redraw(); + if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.01; redraw(); } W.requestAnimationFrame(loop); })(); + return { el: host }; + } + + W.Chem8Mol = { molModel: molModel, latticeViewer: latticeViewer, MOL: MOL }; +})(window); diff --git a/frontend/js/chem8_svg.js b/frontend/js/chem8_svg.js new file mode 100644 index 0000000..4b61ebb --- /dev/null +++ b/frontend/js/chem8_svg.js @@ -0,0 +1,980 @@ +/* chem8_svg.js — химические наглядные примитивы для учебника «Химия 8». + * + * Неймспейс: window.Chem8.* + * Молекулярные модели (структурные / шаростержневые / 3D) — НЕ здесь, а через + * biochem-core.js (window.BioChem). Здесь только то, чего там нет: рендер формул и + * уравнений, ионы, степени окисления, интерактивные виджеты (растворимость, ряд + * активности, индикаторы, классификаторы, калькуляторы расчётов и т. п.). + * + * Phase 0: реализованы чистые текстовые примитивы (ionLabel, chemEq, formula). + * Остальные хелперы — каркасы-заглушки, наполняются по фазам (см. PLAN_CHEMISTRY_8.md, разд. B). + * + * Правила (CLAUDE.md / план): + * - без эмоджи, только inline SVG .ic; + * - в KaTeX-шаблонах двойной backslash (\\to, \\downarrow, \\rightleftharpoons); + * - drag/слайдеры: window-listeners + state ВЫШЕ redraw(), без setPointerCapture. + */ +(function (global) { + 'use strict'; + + var SUB = { '0':'₀','1':'₁','2':'₂','3':'₃','4':'₄', + '5':'₅','6':'₆','7':'₇','8':'₈','9':'₉' }; + var SUP = { '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴', + '5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹', + '+':'⁺','-':'⁻' }; + + function toSub(digits) { + return String(digits).replace(/[0-9]/g, function (d) { return SUB[d]; }); + } + function toSup(s) { + return String(s).replace(/[0-9+\-]/g, function (c) { return SUP[c] || c; }); + } + + /* formula('CaCO3') -> 'CaCO₃' : числовые индексы атомов в подстрочные. + Не трогает множители-коэффициенты в начале (их рендерит chemEq). */ + function formula(src) { + if (src == null) return ''; + return String(src).replace(/([A-Za-z\)\]])(\d+)/g, function (_, a, n) { + return a + toSub(n); + }); + } + + /* ionLabel('SO4', -2) -> 'SO₄²⁻' ; ionLabel('Ca', 2) -> 'Ca²⁺' ; ionLabel('Na', 1) -> 'Na⁺' */ + function ionLabel(form, charge) { + var body = formula(form); + var c = Number(charge) || 0; + if (c === 0) return body; + var mag = Math.abs(c); + var sign = c > 0 ? '+' : '-'; + var num = mag === 1 ? '' : String(mag); + return body + toSup(num + sign); + } + + /* chemEq('2Na + 2H2O -> 2NaOH + H2^', {arrow:'->'}) -> HTML-строка с индексами, + стрелками (= → ⇌), значками газа (↑) и осадка (↓), условием над стрелкой. + Токены: '->'/'=' необратимая, '<->'/'<=>' обратимая, '^' газ, 'v' осадок. + opts.cond — подпись над стрелкой (например 't', 'кат.', 'эл. ток'). */ + function chemEq(src, opts) { + opts = opts || {}; + var s = String(src == null ? '' : src).trim(); + var arrowHtml = ' ' + arrowGlyph(s, opts) + condHtml(opts) + ' '; + // выделяем стрелку + var parts = s.split(/<->|<=>|->|⇌|=(?![^(]*\))|→/); + var left = parts[0] || ''; + var right = parts.length > 1 ? parts.slice(1).join(' ') : ''; + var html = renderSide(left); + if (right) html += arrowHtml + renderSide(right); + return '' + html + ''; + } + + function arrowGlyph(s, opts) { + if (opts.arrow === '<->' || opts.arrow === '<=>' || /<->|<=>|⇌/.test(s)) return '⇌'; + return '→'; // → + } + function condHtml(opts) { + if (!opts.cond) return ''; + return '' + escapeHtml(opts.cond) + ''; + } + + /* одна сторона уравнения: разбор на вещества по '+', значки ↑/↓ */ + function renderSide(side) { + return side.split('+').map(function (term) { + var t = term.trim(); + if (!t) return ''; + var gas = false, prec = false; + t = t.replace(/\^|↑/g, function () { gas = true; return ''; }) + .replace(/(^|[A-Za-z0-9\)])v(\b|$)|↓/g, function (m) { + prec = true; return m.replace(/v|↓/, ''); + }); + // коэффициент в начале + var coef = ''; + t = t.replace(/^(\d+)/, function (_, n) { coef = n; return ''; }); + var out = (coef ? coef : '') + formula(t.trim()); + if (gas) out += '↑'; + if (prec) out += '↓'; + return out; + }).filter(Boolean).join(' + '); + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]; + }); + } + + /* ── Относительные атомные массы Ar (школьно-округлённые, как в учебнике РБ). + Намеренно НЕ берём точные массы biochem-core: для 8 класса Mr(H₂O)=18, + Mr(CaCO₃)=100 и т. п. — иначе расходимся с ответами учебника. ── */ + var AR = { + H:1, He:4, Li:7, Be:9, B:11, C:12, N:14, O:16, F:19, Ne:20, + Na:23, Mg:24, Al:27, Si:28, P:31, S:32, Cl:35.5, Ar:40, K:39, Ca:40, + Sc:45, Ti:48, V:51, Cr:52, Mn:55, Fe:56, Co:59, Ni:59, Cu:64, Zn:65, + Ga:70, Ge:73, As:75, Se:79, Br:80, Kr:84, Rb:85, Sr:88, Ag:108, Cd:112, + Sn:119, Sb:122, I:127, Xe:131, Ba:137, Pt:195, Au:197, Hg:201, Pb:207, Bi:209 + }; + function arOf(sym) { + if (Object.prototype.hasOwnProperty.call(AR, sym)) return AR[sym]; + // запасной путь — точная масса из biochem-core, если элемента нет в школьной таблице + if (global.BIO && global.BIO.ELEMENTS && global.BIO.ELEMENTS[sym]) { + return Math.round(global.BIO.ELEMENTS[sym].mass); + } + return 0; + } + + /* elementCounts('Ca(OH)2') -> {Ca:1, O:2, H:2} (скобки и индексы) */ + function elementCounts(str) { + var out = {}, stack = [out]; + var re = /([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)/g, m; + while ((m = re.exec(str)) !== null) { + if (m[1]) { + var n = m[2] ? parseInt(m[2], 10) : 1; + var top = stack[stack.length - 1]; + top[m[1]] = (top[m[1]] || 0) + n; + } else if (m[3]) { + stack.push({}); + } else if (m[4] !== undefined) { + var grp = stack.pop(), mult = m[5] ? parseInt(m[5], 10) : 1, t2 = stack[stack.length - 1]; + for (var k in grp) t2[k] = (t2[k] || 0) + grp[k] * mult; + } + } + return out; + } + + /* molarMass('CaCO3') -> 100 (г/моль), на школьных Ar. NaN при неизвестном элементе. */ + function molarMass(str) { + var c = elementCounts(String(str || '').replace(/\s+/g, '')); + var keys = Object.keys(c); + if (!keys.length) return NaN; + var m = 0; + for (var i = 0; i < keys.length; i++) { + var a = arOf(keys[i]); + if (!a) return NaN; + m += a * c[keys[i]]; + } + return Math.round(m * 1000) / 1000; + } + + /* Округление до значащих для вывода (избегаем 18.000000002). */ + function fmt(x, d) { + if (!isFinite(x)) return '—'; + var p = Math.pow(10, d == null ? 3 : d); + return String(Math.round(x * p) / p); + } + + /* ────────────────────────────────────────────────────────────────────────── + moleTriangle(mount, opts) — интерактивный калькулятор-треугольник n–m–M. + Пользователь вводит любые два из {n, m, M} — третье считается (n=m/M, + m=n·M, M=m/n). opts.substance — предзаполнить M по формуле (через molarMass). + Возвращает {el, get, set}. Без setPointerCapture, чистый DOM. + ────────────────────────────────────────────────────────────────────────── */ + function moleTriangle(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var state = { n: '', m: '', M: opts.substance ? molarMass(opts.substance) : '' }; + var lastEdited = []; // последние два редактированных поля → третье вычисляем + + host.innerHTML = + '
    ' + + '' + + '
    ' + + fieldHtml('n', 'n, моль', 'химическое количество') + + fieldHtml('m', 'm, г', 'масса вещества') + + fieldHtml('M', 'M, г/моль', 'молярная масса') + + '
    ' + + '
    Введите любые два значения — третье вычислится.
    ' + + '
    '; + + function fieldHtml(key, label, hint) { + return ''; + } + + var inputs = host.querySelectorAll('input[data-k]'); + var out = host.querySelector('[data-out]'); + + function num(v) { var x = parseFloat(String(v).replace(',', '.')); return isFinite(x) ? x : null; } + + function recompute(changedKey) { + if (lastEdited[0] !== changedKey) { lastEdited.unshift(changedKey); lastEdited = lastEdited.slice(0, 2); } + var known = ['n', 'm', 'M'].filter(function (k) { return num(state[k]) !== null; }); + // целевое поле — то, что НЕ редактировали последним и пусто/производно + var target = ['n', 'm', 'M'].filter(function (k) { return lastEdited.indexOf(k) === -1; })[0]; + if (!target) return; + var n = num(state.n), m = num(state.m), M = num(state.M); + var res = null, formula = ''; + if (target === 'n' && m !== null && M) { res = m / M; formula = 'n = m / M = ' + fmt(m) + ' / ' + fmt(M); } + else if (target === 'm' && n !== null && M !== null) { res = n * M; formula = 'm = n · M = ' + fmt(n) + ' · ' + fmt(M); } + else if (target === 'M' && m !== null && n) { res = m / n; formula = 'M = m / n = ' + fmt(m) + ' / ' + fmt(n); } + if (res === null) { + out.className = 'mtri-out'; + out.textContent = (known.length >= 2) + ? 'Проверьте: на ноль делить нельзя.' + : 'Введите любые два значения — третье вычислится.'; + return; + } + var unit = target === 'n' ? ' моль' : target === 'm' ? ' г' : ' г/моль'; + setField(target, fmt(res)); + out.className = 'mtri-out ok'; + out.innerHTML = '' + target + ' = ' + fmt(res) + unit + '' + formula + ''; + } + + function setField(key, val) { + state[key] = val; + for (var i = 0; i < inputs.length; i++) { + if (inputs[i].getAttribute('data-k') === key && global.document.activeElement !== inputs[i]) { + inputs[i].value = val; + } + } + } + + for (var i = 0; i < inputs.length; i++) { + (function (inp) { + inp.addEventListener('input', function () { + var k = inp.getAttribute('data-k'); + state[k] = inp.value; + // если поле очистили — сбросить производное + recompute(k); + }); + })(inputs[i]); + } + + if (state.M) setField('M', fmt(state.M)); + + return { + el: host, + get: function () { return { n: num(state.n), m: num(state.m), M: num(state.M) }; }, + set: function (k, v) { setField(k, String(v)); recompute(k === 'n' ? 'm' : 'n'); } + }; + } + + /* ────────────────────────────────────────────────────────────────────────── + equationBalancer(mount, {skeleton}) — проверка расстановки коэффициентов. + skeleton: 'H2 + O2 -> H2O'. Рендерит поля коэффициентов перед каждым + веществом, кнопку «Проверить»; считает баланс атомов по сторонам и + подсвечивает несбалансированные элементы. opts.solution — массив верных + коэффициентов (для кнопки «Показать решение»). + ────────────────────────────────────────────────────────────────────────── */ + function equationBalancer(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var skel = String(opts.skeleton || ''); + var sides = skel.split(/->|=|→/); + var left = parseSide(sides[0] || ''), right = parseSide(sides[1] || ''); + var all = left.concat(right); + + host.innerHTML = + '
    ' + + '
    ' + + renderSpecies(left) + '' + renderSpecies(right) + + '
    ' + + '
    ' + + '' + + (opts.solution ? '' : '') + + '' + + '
    ' + + '
    ' + + '
    '; + + function renderSpecies(list) { + return list.map(function (sp, i) { + var gi = all.indexOf(sp); + return (i ? '+' : '') + + '' + formula(sp.raw) + ''; + }).join(''); + } + + var out = host.querySelector('[data-out]'); + var coefs = host.querySelectorAll('.ceqb-coef'); + + function getCoef(i) { var v = parseInt((coefs[i] && coefs[i].value) || '1', 10); return v > 0 ? v : 1; } + + function tally(list, fromIdx) { + var acc = {}; + list.forEach(function (sp, j) { + var c = getCoef(all.indexOf(sp)); + for (var e in sp.counts) acc[e] = (acc[e] || 0) + sp.counts[e] * c; + }); + return acc; + } + + function check() { + var L = tally(left), R = tally(right); + var elems = {}; Object.keys(L).forEach(function (e) { elems[e] = 1; }); Object.keys(R).forEach(function (e) { elems[e] = 1; }); + var rows = '', ok = true; + Object.keys(elems).sort().forEach(function (e) { + var l = L[e] || 0, r = R[e] || 0, eq = l === r; + if (!eq) ok = false; + rows += '' + e + '' + l + '' + r + '' + + '' + (eq ? '✓' : '≠') + ''; + }); + out.className = 'ceqb-out ' + (ok ? 'ok' : 'bad'); + out.innerHTML = (ok ? '
    Уравнение сбалансировано.
    ' + : '
    Не сходится — выровняйте выделенные элементы.
    ') + + '' + + rows + '
    ЭлементСлеваСправа
    '; + return ok; + } + + var btnCheck = host.querySelector('[data-check]'); + var btnSolve = host.querySelector('[data-solve]'); + var btnReset = host.querySelector('[data-reset]'); + if (btnCheck) btnCheck.addEventListener('click', check); + if (btnReset) btnReset.addEventListener('click', function () { + for (var i = 0; i < coefs.length; i++) coefs[i].value = '1'; + out.className = 'ceqb-out'; out.innerHTML = ''; + }); + if (btnSolve && opts.solution) btnSolve.addEventListener('click', function () { + for (var i = 0; i < coefs.length && i < opts.solution.length; i++) coefs[i].value = String(opts.solution[i]); + check(); + }); + + return { el: host, check: check }; + } + + /* 'H2 + O2' -> [{raw:'H2', counts:{H:2}}, {raw:'O2', counts:{O:2}}] */ + function parseSide(side) { + return String(side).split('+').map(function (t) { return t.trim(); }).filter(Boolean) + .map(function (raw) { + var r = raw.replace(/^\d+/, '').trim(); // коэффициент в скелете игнорируем + return { raw: r, counts: elementCounts(r) }; + }); + } + + /* ────────────────────────────────────────────────────────────────────────── + testTube(opts) -> SVG-строка пробирки. opts: {fill, color, precipitate, gas, + label}. fill/color — цвет раствора; precipitate — цвет осадка на дне; + gas:true — пузырьки; label — подпись под пробиркой. + ────────────────────────────────────────────────────────────────────────── */ + function testTube(opts) { + opts = opts || {}; + var liq = opts.color || opts.fill || '#dbeafe'; + var prec = opts.precipitate || null; + var gas = !!opts.gas; + var bubbles = ''; + if (gas) for (var i = 0; i < 5; i++) { + var cx = 26 + (i % 3) * 7, cy = 60 - i * 8; + bubbles += ''; + } + var precSvg = prec ? '' : ''; + return '' + + '' + + '' + + precSvg + + '' + bubbles + '' + + '' + + '' + + (opts.label ? '' + escapeHtml(opts.label) + '' : '') + + ''; + } + + /* ────────────────────────────────────────────────────────────────────────── + indicatorScale(mount, opts) — индикатор + шкала pH. Слайдер pH 0–14, + выбор индикатора (лакмус/фенолфталеин/метилоранж), окраска полоски. + ────────────────────────────────────────────────────────────────────────── */ + var INDICATORS = { + 'лакмус': function (ph) { return ph < 5 ? ['#dc2626', 'красный (кислота)'] : ph > 8 ? ['#2563eb', 'синий (щёлочь)'] : ['#7c3aed', 'фиолетовый (нейтр.)']; }, + 'фенолфталеин': function (ph) { return ph >= 8.2 ? ['#db2777', 'малиновый (щёлочь)'] : ['#f8fafc', 'бесцветный']; }, + 'метилоранж': function (ph) { return ph < 3.1 ? ['#dc2626', 'красный (кислота)'] : ph > 4.4 ? ['#f59e0b', 'жёлтый'] : ['#fb923c', 'оранжевый']; } + }; + function indicatorScale(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var inds = Object.keys(INDICATORS); + host.innerHTML = + '
    ' + + '
    '; + var sel = host.querySelector('.ind-sel'), ph = host.querySelector('.ind-ph'), + phv = host.querySelector('.ind-phv'), strip = host.querySelector('.ind-strip'), lab = host.querySelector('.ind-label'); + function upd() { + var v = parseFloat(ph.value), pair = INDICATORS[sel.value](v); + phv.textContent = 'pH ' + String(v).replace('.', ','); + strip.style.background = pair[0]; + strip.style.color = (pair[0] === '#f8fafc' || pair[0] === '#f59e0b') ? '#1c1917' : '#fff'; + strip.textContent = pair[1]; + lab.innerHTML = 'Среда: ' + (v < 7 ? 'кислая' : v > 7 ? 'щелочная' : 'нейтральная') + ' · ' + sel.value + ' → ' + pair[1]; + } + sel.addEventListener('change', upd); ph.addEventListener('input', upd); upd(); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + classifier(mount, {items, buckets, onCheck}) — клик-классификатор (DnD без drag). + items: [{id,label,cat}]; buckets: [{cat,label}]. Клик по чипу → выбран; клик + по корзине → положить. «Проверить» подсвечивает верно/неверно. +XP по onCheck. + ────────────────────────────────────────────────────────────────────────── */ + function classifier(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; var items = opts.items || [], buckets = opts.buckets || []; + var placed = {}; // id -> cat + var sel = null; + host.innerHTML = + '
    ' + items.map(function (it) { + return ''; + }).join('') + '
    ' + + '
    ' + buckets.map(function (b) { + return '
    ' + b.label + '
    '; + }).join('') + '
    ' + + '
    ' + + ''; + var out = host.querySelector('.cls-out'); + function findItem(id) { return items.filter(function (x) { return x.id === id; })[0]; } + function selectChip(chip) { + if (sel) sel.classList.remove('on'); sel = chip; chip.classList.add('on'); + } + host.querySelectorAll('.cls-chip').forEach(function (chip) { + chip.addEventListener('click', function () { selectChip(chip); }); + }); + host.querySelectorAll('.cls-zone').forEach(function (zone) { + zone.addEventListener('click', function () { + if (!sel) return; + var id = sel.getAttribute('data-id'); + placed[id] = zone.getAttribute('data-cat'); + zone.querySelector('.cls-items').appendChild(sel); + sel.classList.remove('on'); sel.classList.add('placed'); sel = null; + }); + }); + host.querySelector('.cls-check').addEventListener('click', function () { + var ok = 0, total = items.length; + items.forEach(function (it) { + var chip = host.querySelector('.cls-chip[data-id="' + it.id + '"]'); + var correct = placed[it.id] === it.cat; + chip.classList.remove('cls-ok', 'cls-bad'); + chip.classList.add(correct ? 'cls-ok' : 'cls-bad'); + if (correct) ok++; + }); + out.style.display = 'block'; + out.className = 'out cls-out ' + (ok === total ? 'ok' : 'bad'); + out.textContent = 'Верно: ' + ok + ' из ' + total + (ok === total ? '. Отлично!' : '. Исправь выделенные.'); + if (typeof opts.onCheck === 'function') opts.onCheck(ok === total, ok, total); + }); + host.querySelector('.cls-reset').addEventListener('click', function () { + placed = {}; sel = null; + var pool = host.querySelector('.cls-pool'); + host.querySelectorAll('.cls-chip').forEach(function (c) { c.classList.remove('placed', 'on', 'cls-ok', 'cls-bad'); pool.appendChild(c); }); + out.style.display = 'none'; + }); + return { el: host, result: function () { return placed; } }; + } + + /* ────────────────────────────────────────────────────────────────────────── + solubilityTable(mount, opts) — таблица растворимости (катион×анион). + Клик по катиону и аниону → подсветка ячейки + вердикт (Р/М/Н/—). + ────────────────────────────────────────────────────────────────────────── */ + var SOL_ANIONS = ['OH', 'Cl', 'NO3', 'SO4', 'CO3', 'PO4', 'S']; + var SOL_CATIONS = ['Na', 'K', 'NH4', 'Ba', 'Ca', 'Mg', 'Al', 'Zn', 'Fe2', 'Fe3', 'Cu', 'Ag', 'Pb']; + // P раств., M малораств., H нераств., '-' не существует/разлагается + var SOL = { + OH: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'M',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'-',Pb:'H'}, + Cl: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'H',Pb:'M'}, + NO3: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'P',Pb:'P'}, + SO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'M',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'M',Pb:'H'}, + CO3: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'}, + PO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'H',Pb:'H'}, + S: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'} + }; + var SOL_LABEL = { P: ['Р', 'растворимо'], M: ['М', 'малорастворимо'], H: ['Н', 'нерастворимо'], '-': ['—', 'не существует / разлагается'] }; + var CAT_HTML = { Na:'Na⁺', K:'K⁺', NH4:'NH₄⁺', Ba:'Ba²⁺', Ca:'Ca²⁺', Mg:'Mg²⁺', Al:'Al³⁺', Zn:'Zn²⁺', Fe2:'Fe²⁺', Fe3:'Fe³⁺', Cu:'Cu²⁺', Ag:'Ag⁺', Pb:'Pb²⁺' }; + var AN_HTML = { OH:'OH⁻', Cl:'Cl⁻', NO3:'NO₃⁻', SO4:'SO₄²⁻', CO3:'CO₃²⁻', PO4:'PO₄³⁻', S:'S²⁻' }; + function solubilityTable(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var th = 'ион' + SOL_CATIONS.map(function (c) { return '' + CAT_HTML[c] + ''; }).join('') + ''; + var rows = SOL_ANIONS.map(function (an) { + return '' + AN_HTML[an] + '' + SOL_CATIONS.map(function (c) { + var v = SOL[an][c]; var cls = v === 'P' ? 'sP' : v === 'M' ? 'sM' : v === 'H' ? 'sH' : 'sX'; + return '' + SOL_LABEL[v][0] + ''; + }).join('') + ''; + }).join(''); + host.innerHTML = '
    ' + th + '' + rows + '
    ' + + '
    Кликни по катиону и аниону — узнаешь растворимость соли/основания.
    '; + var out = host.querySelector('.sol-out'), selCat = null, selAn = null; + function upd() { + host.querySelectorAll('.sol-tab td').forEach(function (td) { + var on = (!selCat || td.getAttribute('data-cat') === selCat) && (!selAn || td.getAttribute('data-an') === selAn); + td.classList.toggle('sol-dim', (selCat || selAn) && !on); + td.classList.toggle('sol-hot', selCat && selAn && td.getAttribute('data-cat') === selCat && td.getAttribute('data-an') === selAn); + }); + if (selCat && selAn) { + var v = SOL[selAn][selCat]; + out.className = 'out sol-out ' + (v === 'H' ? 'ok' : ''); + out.innerHTML = CAT_HTML[selCat] + ' + ' + AN_HTML[selAn] + ' → ' + SOL_LABEL[v][1] + '' + + (v === 'H' ? ' (выпадает осадок ↓ — реакция идёт)' : v === 'P' ? ' (осадок не образуется)' : ''); + } + } + host.querySelectorAll('[data-cat]').forEach(function (el) { + if (el.tagName === 'TH') el.addEventListener('click', function () { selCat = el.getAttribute('data-cat'); upd(); }); + }); + host.querySelectorAll('th[data-an]').forEach(function (el) { el.addEventListener('click', function () { selAn = el.getAttribute('data-an'); upd(); }); }); + host.querySelectorAll('.sol-tab td').forEach(function (td) { + td.addEventListener('click', function () { selCat = td.getAttribute('data-cat'); selAn = td.getAttribute('data-an'); upd(); }); + }); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + activitySeries(mount, opts) — ряд активности металлов. Клик по металлу → + подсветка; показывает, какие металлы он вытесняет и реакцию с кислотой. + ────────────────────────────────────────────────────────────────────────── */ + var ACT = ['K', 'Ca', 'Na', 'Mg', 'Al', 'Zn', 'Fe', 'Ni', 'Sn', 'Pb', 'H', 'Cu', 'Hg', 'Ag', 'Pt', 'Au']; + function activitySeries(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + host.innerHTML = '
    ' + ACT.map(function (m) { + return ''; + }).join('') + '
    ← восстановит. свойства растутактивность падает →
    ' + + '
    Кликни по металлу — узнаешь его активность и реакцию с кислотами.
    '; + var out = host.querySelector('.act-out'); + host.querySelectorAll('.act-cell').forEach(function (c) { + c.addEventListener('click', function () { + var m = c.getAttribute('data-m'); if (m === 'H') return; + var idx = ACT.indexOf(m), hIdx = ACT.indexOf('H'); + host.querySelectorAll('.act-cell').forEach(function (x) { x.classList.remove('act-on', 'act-disp'); }); + c.classList.add('act-on'); + ACT.forEach(function (mm, i) { if (i > idx && mm !== 'H') host.querySelector('.act-cell[data-m="' + mm + '"]').classList.add('act-disp'); }); + var withAcid = idx < hIdx ? 'вытесняет водород $\\text{H}_2$ из растворов кислот' : 'НЕ вытесняет водород из кислот (стоит после H)'; + out.className = 'out act-out'; + out.innerHTML = '' + m + ': ' + withAcid + '. Вытесняет из растворов солей все металлы, стоящие правее (подсвечены).'; + if (global.window && global.window.chem8RenderMath) try { global.window.chem8RenderMath(out); } catch (e) {} + }); + }); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + miniPeriodic(mount, opts) — интерактивная периодическая система. + opts.highlight: 'metals'|'nonmetals'|'metalloids'|'alkali'|'alkaline'| + 'halogens'|'noble'|{group:N}|{period:N}. opts.onClick(sym, info). + Стандартная раскладка 18×7; f-блок свёрнут в плейсхолдеры La и Ac. + ────────────────────────────────────────────────────────────────────────── */ + // [sym, group, period, Z] + var PT = [ + ['H',1,1,1],['He',18,1,2], + ['Li',1,2,3],['Be',2,2,4],['B',13,2,5],['C',14,2,6],['N',15,2,7],['O',16,2,8],['F',17,2,9],['Ne',18,2,10], + ['Na',1,3,11],['Mg',2,3,12],['Al',13,3,13],['Si',14,3,14],['P',15,3,15],['S',16,3,16],['Cl',17,3,17],['Ar',18,3,18], + ['K',1,4,19],['Ca',2,4,20],['Sc',3,4,21],['Ti',4,4,22],['V',5,4,23],['Cr',6,4,24],['Mn',7,4,25],['Fe',8,4,26],['Co',9,4,27],['Ni',10,4,28],['Cu',11,4,29],['Zn',12,4,30],['Ga',13,4,31],['Ge',14,4,32],['As',15,4,33],['Se',16,4,34],['Br',17,4,35],['Kr',18,4,36], + ['Rb',1,5,37],['Sr',2,5,38],['Y',3,5,39],['Zr',4,5,40],['Nb',5,5,41],['Mo',6,5,42],['Tc',7,5,43],['Ru',8,5,44],['Rh',9,5,45],['Pd',10,5,46],['Ag',11,5,47],['Cd',12,5,48],['In',13,5,49],['Sn',14,5,50],['Sb',15,5,51],['Te',16,5,52],['I',17,5,53],['Xe',18,5,54], + ['Cs',1,6,55],['Ba',2,6,56],['La',3,6,57],['Hf',4,6,72],['Ta',5,6,73],['W',6,6,74],['Re',7,6,75],['Os',8,6,76],['Ir',9,6,77],['Pt',10,6,78],['Au',11,6,79],['Hg',12,6,80],['Tl',13,6,81],['Pb',14,6,82],['Bi',15,6,83],['Po',16,6,84],['At',17,6,85],['Rn',18,6,86], + ['Cs',1,6,55] + ]; + // период 7 (главная часть) + var PT7 = [['Fr',1,7,87],['Ra',2,7,88],['Ac',3,7,89],['Rf',4,7,104],['Db',5,7,105],['Sg',6,7,106],['Bh',7,7,107],['Hs',8,7,108],['Mt',9,7,109],['Ds',10,7,110],['Rg',11,7,111],['Cn',12,7,112],['Nh',13,7,113],['Fl',14,7,114],['Mc',15,7,115],['Lv',16,7,116],['Ts',17,7,117],['Og',18,7,118]]; + var PT_NAMES = { H:'Водород', He:'Гелий', Li:'Литий', Be:'Бериллий', B:'Бор', C:'Углерод', N:'Азот', O:'Кислород', F:'Фтор', Ne:'Неон', Na:'Натрий', Mg:'Магний', Al:'Алюминий', Si:'Кремний', P:'Фосфор', S:'Сера', Cl:'Хлор', Ar:'Аргон', K:'Калий', Ca:'Кальций', Fe:'Железо', Cu:'Медь', Zn:'Цинк', Br:'Бром', Ag:'Серебро', I:'Йод', Ba:'Барий', Au:'Золото', Hg:'Ртуть', Pb:'Свинец' }; + var NONMETALS = { H:1, He:1, C:1, N:1, O:1, F:1, Ne:1, P:1, S:1, Cl:1, Ar:1, Se:1, Br:1, Kr:1, I:1, Xe:1, At:1, Rn:1, Ts:1, Og:1 }; + var METALLOIDS = { B:1, Si:1, Ge:1, As:1, Sb:1, Te:1, Po:1 }; + function ptCategory(sym, g) { + if (g === 18) return 'noble'; + if (METALLOIDS[sym]) return 'metalloid'; + if (NONMETALS[sym]) return 'nonmetal'; + return 'metal'; + } + function ptMatch(hl, sym, g, p) { + if (!hl) return false; + if (typeof hl === 'object') { if (hl.group) return g === hl.group; if (hl.period) return p === hl.period; return false; } + var cat = ptCategory(sym, g); + if (hl === 'metals') return cat === 'metal'; + if (hl === 'nonmetals') return cat === 'nonmetal'; + if (hl === 'metalloids') return cat === 'metalloid'; + if (hl === 'noble') return g === 18; + if (hl === 'halogens') return g === 17; + if (hl === 'alkali') return g === 1 && sym !== 'H'; + if (hl === 'alkaline') return g === 2; + return false; + } + function miniPeriodic(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var all = PT.slice(0, PT.length - 1).concat(PT7); // убрать дубль Cs-стоппер + // фильтр дубликата Cs (вставлен как маркер конца) — оставляем уникальные по Z + var seen = {}, els = []; + all.forEach(function (e) { if (!seen[e[3]]) { seen[e[3]] = 1; els.push(e); } }); + function cell(e) { + var sym = e[0], g = e[1], p = e[2], z = e[3], cat = ptCategory(sym, g); + var hot = ptMatch(opts.highlight, sym, g, p); + return ''; + } + var cells = els.map(cell).join(''); + // плейсхолдер f-блока + var fph = '' + + ''; + host.innerHTML = '
    ' + cells + fph + '
    ' + + '
    Кликни элемент — увидишь название, $Z$ и $A_r$.
    '; + var info = host.querySelector('.pt-info'); + host.querySelectorAll('.pt-cell').forEach(function (c) { + c.addEventListener('click', function () { + host.querySelectorAll('.pt-cell').forEach(function (x) { x.classList.remove('pt-sel'); }); + c.classList.add('pt-sel'); + var sym = c.getAttribute('data-sym'), z = c.getAttribute('data-z'), g = +c.getAttribute('data-g'), p = +c.getAttribute('data-p'); + var ar = arOf(sym), cat = ptCategory(sym, g); + var catRu = cat === 'metal' ? 'металл' : cat === 'nonmetal' ? 'неметалл' : cat === 'metalloid' ? 'металлоид' : 'инертный газ'; + var fam = g === 1 && sym !== 'H' ? ' · щелочной металл' : g === 2 ? ' · щёлочноземельный' : g === 17 ? ' · галоген' : g === 18 ? ' · инертный газ' : ''; + info.innerHTML = '' + (PT_NAMES[sym] || sym) + ' (' + sym + ') · Z = ' + z + (ar ? ' · A_r = ' + ar : '') + ' · группа ' + g + ', период ' + p + ' · ' + catRu + fam; + if (typeof opts.onClick === 'function') opts.onClick(sym, { z: z, g: g, p: p, ar: ar, cat: cat }); + }); + }); + return { + el: host, + highlight: function (hl) { + host.querySelectorAll('.pt-cell').forEach(function (c) { + c.classList.toggle('pt-hot', ptMatch(hl, c.getAttribute('data-sym'), +c.getAttribute('data-g'), +c.getAttribute('data-p'))); + }); + } + }; + } + + /* ────────────────────────────────────────────────────────────────────────── + Строение атома (Phase 4). + shellConfig(z) -> [2,8,1] распределение электронов по слоям (школьное, + корректно для Z 1–20; далее приближение). zSym(z) -> символ из ПСХЭ. + ────────────────────────────────────────────────────────────────────────── */ + var _ZSYM = null; + function zSym(z) { + if (!_ZSYM) { _ZSYM = {}; PT.concat(PT7).forEach(function (e) { _ZSYM[e[3]] = e[0]; }); } + return _ZSYM[z] || '?'; + } + function shellConfig(z) { + var caps = [2, 8, 8, 18, 18, 32], out = [], rem = z; + for (var i = 0; i < caps.length && rem > 0; i++) { var t = Math.min(caps[i], rem); out.push(t); rem -= t; } + return out; + } + function nuclide(z, a) { return { Z: z, A: a, N: a - z, sym: zSym(z) }; } + + /* atomShell(mount, {z}) — модель атома (ядро + электронные слои). Слайдер Z 1–20. */ + function atomShell(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + host.innerHTML = '
    '; + var zr = host.querySelector('.as-z'), zl = host.querySelector('.as-zl'), stage = host.querySelector('.as-stage'), cfg = host.querySelector('.as-cfg'); + function draw() { + var z = +zr.value, sym = zSym(z), ar = arOf(sym), n = Math.max(0, Math.round(ar) - z), sh = shellConfig(z); + zl.textContent = sym + ' (Z=' + z + ')'; + var cx = 150, cy = 110, R = 18 + sh.length * 26; + var svg = ''; + // слои + for (var s = 0; s < sh.length; s++) { + var r = 30 + s * 26; + svg += ''; + var cnt = sh[s]; + for (var e = 0; e < cnt; e++) { + var ang = (e / cnt) * Math.PI * 2 - Math.PI / 2; + var ex = cx + r * Math.cos(ang), ey = cy + r * Math.sin(ang); + svg += ''; + } + } + svg += ''; + svg += '' + z + 'p⁺'; + svg += '' + n + 'n⁰'; + svg += ''; + stage.innerHTML = svg; + cfg.className = 'out as-cfg'; + cfg.innerHTML = '' + sym + ': распределение электронов по слоям — ' + sh.join(' ) ') + '
    Слоёв: ' + sh.length + ' · внешних электронов: ' + sh[sh.length - 1] + ' · протонов: ' + z + ', нейтронов: ' + n + '
    '; + } + zr.addEventListener('input', draw); draw(); + return { el: host, draw: draw }; + } + + /* ────────────────────────────────────────────────────────────────────────── + Химическая связь (Phase 5). + EN — электроотрицательность (Полинг, школьные значения). bondClass(da,db) + по разнице ЭО → тип связи. bondType(mount) — интерактивный виджет. + ────────────────────────────────────────────────────────────────────────── */ + var EN = { + H:2.1, Li:1.0, Be:1.5, B:2.0, C:2.5, N:3.0, O:3.5, F:4.0, + Na:0.9, Mg:1.2, Al:1.5, Si:1.8, P:2.1, S:2.5, Cl:3.0, + K:0.8, Ca:1.0, Br:2.8, I:2.5, Zn:1.6, Fe:1.8, Cu:1.9, Ag:1.9 + }; + function enOf(sym) { return EN[sym] != null ? EN[sym] : 2.0; } + function bondClass(a, b) { + var d = Math.abs(enOf(a) - enOf(b)); + if (a !== b && (a in EN) && (b in EN) && enOf(a) <= 1.6 && enOf(b) <= 1.6) { + // два металла → металлическая + if (METALS_EN[a] && METALS_EN[b]) return { type: 'металлическая', cls: 'warn', d: d }; + } + if (d >= 1.7) return { type: 'ионная', cls: 'bad', d: d }; + if (d < 0.4) return { type: 'ковалентная неполярная', cls: 'good', d: d }; + return { type: 'ковалентная полярная', cls: 'mid', d: d }; + } + var METALS_EN = { Li:1, Be:1, Na:1, Mg:1, Al:1, K:1, Ca:1, Zn:1, Fe:1, Cu:1, Ag:1 }; + + function bondType(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var syms = Object.keys(EN); + function optList(sel) { return syms.map(function (s) { return ''; }).join(''); } + host.innerHTML = '
    ' + + '
    ' + + '
    '; + var sa = host.querySelector('.bt-a'), sb = host.querySelector('.bt-b'), stage = host.querySelector('.bt-stage'), out = host.querySelector('.bt-out'); + function upd() { + var a = sa.value, b = sb.value, r = bondClass(a, b), d = Math.round(r.d * 10) / 10; + // δ-заряды: более ЭО атом — δ− + var aMore = enOf(a) > enOf(b), polar = r.type.indexOf('полярная') >= 0; + var da = (r.type === 'ионная') ? (aMore ? '−' : '+') : (polar ? (aMore ? 'δ−' : 'δ+') : ''); + var db = (r.type === 'ионная') ? (aMore ? '+' : '−') : (polar ? (aMore ? 'δ+' : 'δ−') : ''); + var color = r.cls === 'good' ? 'var(--ok)' : r.cls === 'bad' ? 'var(--fail)' : 'var(--pri)'; + stage.innerHTML = '' + + '' + + '' + + '' + + '' + a + '' + + '' + b + '' + + (da ? '' + da + '' : '') + + (db ? '' + db + '' : '') + + ''; + out.className = 'out bt-out ' + (r.cls === 'good' ? 'ok' : r.cls === 'bad' ? 'bad' : ''); + out.innerHTML = 'ΔЭО = |' + enOf(a) + ' − ' + enOf(b) + '| = ' + d + ' → связь ' + r.type + '' + + (r.type === 'ионная' ? '
    Электрон полностью переходит к более электроотрицательному атому.' : polar ? '
    Общая пара смещена к более электроотрицательному атому (' + (aMore ? a : b) + ').' : r.type.indexOf('металл') >= 0 ? '
    Общие электроны принадлежат всем атомам («электронный газ»).' : '
    Общая пара поделена поровну.') + '
    '; + } + sa.addEventListener('change', upd); sb.addEventListener('change', upd); upd(); + return { el: host, update: upd }; + } + + /* ────────────────────────────────────────────────────────────────────────── + Степень окисления (Phase 6). + oxStates(formula) -> {el: oxidation} для типичных нейтральных соединений. + Правила: F=−1, O=−2, H=+1, щелочные=+1, ЩЗМ=+2, Al=+3; галогены=−1 без O; + остаток решается из условия Σ(с.о.·индекс)=0. oxStateCalc — виджет. + ────────────────────────────────────────────────────────────────────────── */ + var OX_FIX = { F:-1, O:-2, H:1, Li:1, Na:1, K:1, Rb:1, Cs:1, Be:2, Mg:2, Ca:2, Sr:2, Ba:2, Al:3, Zn:2, Ag:1 }; + function oxStates(formula) { + var c = elementCounts(String(formula || '').replace(/\s+/g, '')); + var keys = Object.keys(c); if (!keys.length) return null; + var hasO = !!c.O, res = {}, unknown = [], sumFixed = 0; + keys.forEach(function (el) { + var v; + if (Object.prototype.hasOwnProperty.call(OX_FIX, el)) v = OX_FIX[el]; + else if ((el === 'Cl' || el === 'Br' || el === 'I') && !hasO) v = -1; + else { unknown.push(el); return; } + res[el] = v; sumFixed += v * c[el]; + }); + if (unknown.length === 1) { + var el = unknown[0]; + res[el] = -sumFixed / c[el]; + } else if (unknown.length > 1) { + return { partial: true, known: res, unknown: unknown }; + } + return res; + } + function oxSign(v) { return (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v); } + function oxStateCalc(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + host.innerHTML = '
    ' + + '
    ' + + '
    '; + var inp = host.querySelector('.ox-in'), out = host.querySelector('.ox-out'), go = host.querySelector('.ox-go'); + function calc() { + var f = inp.value.trim(), r = oxStates(f); + if (!r) { out.className = 'out ox-out bad'; out.textContent = 'Не удалось разобрать формулу.'; return; } + if (r.partial) { + out.className = 'out ox-out bad'; + out.innerHTML = 'Несколько неизвестных элементов (' + r.unknown.join(', ') + ') — для 8 класса возьми более простое соединение.'; + return; + } + out.className = 'out ox-out ok'; + out.innerHTML = '' + Object.keys(r).map(function (el) { return el + ': ' + oxSign(r[el]) + ''; }).join('   ') + ''; + } + go.addEventListener('click', calc); + inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); + host.querySelectorAll('.ox-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); }); + calc(); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + geneticMap(mount) — интерактивный граф генетической связи классов веществ. + Клик по переходу (ребру) → реакция-пример. §22. + ────────────────────────────────────────────────────────────────────────── */ + var GM_NODES = [ + { id: 'me', t: 'Металл', x: 20, y: 22, c: '#0d9488' }, + { id: 'mox', t: 'Осн. оксид', x: 120, y: 22, c: '#0d9488' }, + { id: 'base', t: 'Основание', x: 228, y: 22, c: '#0d9488' }, + { id: 'salt', t: 'Соль', x: 336, y: 55, c: '#d97706' }, + { id: 'nm', t: 'Неметалл', x: 20, y: 90, c: '#2563eb' }, + { id: 'nox', t: 'Кисл. оксид', x: 120, y: 90, c: '#2563eb' }, + { id: 'acid', t: 'Кислота', x: 228, y: 90, c: '#2563eb' } + ]; + var GM_EDGES = [ + { f: 'me', t: 'mox', r: '2Mg + O2 -> 2MgO', d: 'Металл + кислород → основный оксид' }, + { f: 'mox', t: 'base', r: 'CaO + H2O -> Ca(OH)2', d: 'Основный оксид + вода → основание (щёлочь)' }, + { f: 'base', t: 'salt', r: '2NaOH + H2SO4 -> Na2SO4 + 2H2O', d: 'Основание + кислота → соль + вода (нейтрализация)' }, + { f: 'nm', t: 'nox', r: 'S + O2 -> SO2', d: 'Неметалл + кислород → кислотный оксид' }, + { f: 'nox', t: 'acid', r: 'SO3 + H2O -> H2SO4', d: 'Кислотный оксид + вода → кислота' }, + { f: 'acid', t: 'salt', r: '2HCl + Ca(OH)2 -> CaCl2 + 2H2O', d: 'Кислота + основание → соль + вода' } + ]; + function geneticMap(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + var byId = {}; GM_NODES.forEach(function (n) { byId[n.id] = n; }); + function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; } + var edgesSvg = GM_EDGES.map(function (e, i) { + var a = byId[e.f], b = byId[e.t]; + return ''; + }).join(''); + var nodesSvg = GM_NODES.map(function (n) { + return '' + + '' + n.t + ''; + }).join(''); + host.innerHTML = '
    ' + edgesSvg + nodesSvg + '
    ' + + '
    Кликни по стрелке-переходу — увидишь реакцию-пример.
    '; + var out = host.querySelector('.gm-out'); + host.querySelectorAll('.gm-edge').forEach(function (ln) { + ln.style.cursor = 'pointer'; + ln.addEventListener('click', function () { + host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); }); + ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4'); + var e = GM_EDGES[+ln.getAttribute('data-i')]; + out.className = 'out gm-out ok'; + out.innerHTML = '' + e.d + '
    ' + chemEq(e.r) + ''; + }); + }); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + conceptMap(mount, {nodes, edges}) — обобщённая карта связей понятий главы. + nodes: [{id, t, x, y, c?}]; edges: [{f, t, label}]. Клик по ребру → подпись. + Используется в финалах глав (U6). + ────────────────────────────────────────────────────────────────────────── */ + function conceptMap(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host || !opts) return null; + var nodes = opts.nodes || [], edges = opts.edges || []; + var byId = {}; nodes.forEach(function (n) { byId[n.id] = n; }); + var W0 = opts.w || 430, H0 = opts.h || 150; + function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; } + var edgesSvg = edges.map(function (e, i) { + var a = byId[e.f], b = byId[e.t]; if (!a || !b) return ''; + return ''; + }).join(''); + var nodesSvg = nodes.map(function (n) { + var c = n.c || 'var(--pri,#d97706)'; + return '' + + '' + n.t + ''; + }).join(''); + host.innerHTML = '
    ' + edgesSvg + nodesSvg + '
    ' + + '
    Кликни по связи — увидишь, как понятия главы связаны.
    '; + var out = host.querySelector('.gm-out'); + host.querySelectorAll('.gm-edge').forEach(function (ln) { + ln.style.cursor = 'pointer'; + ln.addEventListener('click', function () { + host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); }); + ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4'); + out.className = 'out gm-out ok'; out.innerHTML = '' + edges[+ln.getAttribute('data-i')].label + ''; + }); + }); + return { el: host }; + } + + /* ────────────────────────────────────────────────────────────────────────── + dissociationAnim(mount, {substance}) — анимация растворения/диссоциации: + вещество распадается на ионы, окружённые молекулами воды. §47. + ────────────────────────────────────────────────────────────────────────── */ + var DISS = { + NaCl: { cat: 'Na⁺', an: 'Cl⁻', cc: '#d97706', ac: '#0891b2' }, + KCl: { cat: 'K⁺', an: 'Cl⁻', cc: '#7c3aed', ac: '#0891b2' }, + CuSO4: { cat: 'Cu²⁺', an: 'SO₄²⁻', cc: '#0891b2', ac: '#059669' }, + HCl: { cat: 'H⁺', an: 'Cl⁻', cc: '#dc2626', ac: '#0891b2' } + }; + function dissociationAnim(mount, opts) { + var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; + if (!host) return null; + opts = opts || {}; + var subs = Object.keys(DISS); + host.innerHTML = '
    ' + + '
    '; + var sel = host.querySelector('.ds-sel'), stage = host.querySelector('.ds-stage'), out = host.querySelector('.ds-out'); + function draw() { + var s = sel.value, d = DISS[s]; + // молекулы воды (фон) + катион + анион, разлетающиеся + var water = ''; + for (var i = 0; i < 7; i++) { var wx = 30 + i * 35, wy = 25 + (i % 3) * 30; water += ''; } + stage.innerHTML = '' + + '' + water + + '' + + '' + d.cat + '' + + '' + + '' + d.an + '' + + ''; + out.className = 'out ds-out ok'; + out.innerHTML = '' + formula(s) + ' → ' + d.cat + ' + ' + d.an + '
    Молекулы воды окружают ионы и «растаскивают» их (гидратация).
    '; + } + sel.addEventListener('change', draw); draw(); + return { el: host }; + } + + /* ---- Каркасы-заглушки интерактивных виджетов (реализуются по фазам) ---- */ + function notImplemented(name) { + return function () { + if (global.console && console.warn) { + console.warn('[Chem8] ' + name + ' ещё не реализован (Phase 0 заглушка)'); + } + return null; + }; + } + + var Chem8 = { + // готово (Phase 0) + formula: formula, + ionLabel: ionLabel, + chemEq: chemEq, + toSub: toSub, + toSup: toSup, + // готово (Phase 1 — движки расчётов) + elementCounts: elementCounts, + molarMass: molarMass, // school-rounded Ar: Mr(H2O)=18 + arOf: arOf, + fmt: fmt, + moleTriangle: moleTriangle, // §6 — треугольник n–m–M + equationBalancer: equationBalancer, // §8 — балансировщик уравнений + // готово (Phase 2 — классы неорганических соединений) + testTube: testTube, // §18,25 — пробирка: осадок/газ/окраска + indicatorScale: indicatorScale, // §13,14,16,17 — индикатор + шкала pH + classifier: classifier, // §10,13,16,19 — клик-классификатор + solubilityTable: solubilityTable, // §19,20 — таблица растворимости + activitySeries: activitySeries, // §14,20 — ряд активности металлов + // готово (Phase 3 — периодический закон) + miniPeriodic: miniPeriodic, // §26,28,34 — интерактивная ПСХЭ с подсветкой + // готово (Phase 4 — строение атома) + atomShell: atomShell, // §29,33 — модель атома (слои электронов) + shellConfig: shellConfig, // распределение электронов по слоям + nuclide: nuclide, // §30 — A=Z+N, нуклид + zSym: zSym, // Z → символ элемента + // готово (Phase 5 — химическая связь) + bondType: bondType, // §37,38 — ЭО → тип связи + bondClass: bondClass, // классификация связи по ΔЭО + enOf: enOf, // электроотрицательность + // готово (Phase 6 — ОВР) + oxStateCalc: oxStateCalc, // §42 — калькулятор степени окисления + oxStates: oxStates, // степени окисления (чистая функция) + // готово (Phase 8/U3,U6 — апгрейд) + geneticMap: geneticMap, // §22 — генетическая карта-граф классов + conceptMap: conceptMap, // финалы глав — карта связей понятий (U6) + dissociationAnim: dissociationAnim, // §47 — анимация растворения/диссоциации + // редокс-баланс §44 реализован пошагово в chem8_ch5_widgets (преднабор) + redoxBalancer: notImplemented('redoxBalancer'), + orbitalDiagram: notImplemented('orbitalDiagram') // §33 — покрыто atomShell + }; + + global.Chem8 = Chem8; +})(typeof window !== 'undefined' ? window : this); diff --git a/frontend/js/labs/_loader.js b/frontend/js/labs/_loader.js new file mode 100644 index 0000000..f8435a1 --- /dev/null +++ b/frontend/js/labs/_loader.js @@ -0,0 +1,76 @@ +'use strict'; +/* + * LabLoader — ленивый загрузчик кода симуляций (контент-движок, Фаза 3). + * + * Тяжёлый код симуляций (~2.5 МБ) и three.js (~600 КБ) больше НЕ грузятся на старте. + * При открытии симуляции LabLoader.ensure(id) подгружает её файлы (по манифесту + * window.SIM_DEPS из _sim_deps.js) и, при необходимости, three.js — затем резолвит. + * + * Гарантия корректности (самовосстановление): если после загрузки указанных файлов + * глобальная open-функция (SIM_DEPS[id].open, напр. "_openPendulum") всё ещё не + * определена, грузятся ВСЕ ленивые файлы (window.LAB_LAZY_FILES). Поэтому ошибка в + * манифесте не может «сломать» симуляцию — в худшем случае грузится больше файлов + * (поведение как до Фазы 3). Манифест лишь оптимизирует объём загрузки. + * + * Все загрузки кешируются (по URL) и дедуплицируются. + */ +(function () { + var BASE = '/js/labs/'; + var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js'; + var _cache = {}; // url -> Promise + var _allLoaded = false; + + function loadScript(url) { + if (_cache[url]) return _cache[url]; + _cache[url] = new Promise(function (resolve, reject) { + var s = document.createElement('script'); + s.src = url; + s.async = false; // сохранить порядок при добавлении нескольких сразу + s.onload = function () { resolve(url); }; + s.onerror = function () { delete _cache[url]; reject(new Error('LabLoader: не удалось загрузить ' + url)); }; + document.head.appendChild(s); + }); + return _cache[url]; + } + + function ensureThree() { + if (typeof window.THREE !== 'undefined') return Promise.resolve(); + return loadScript(THREE_URL); + } + + function loadFiles(files) { + return Promise.all((files || []).map(function (f) { return loadScript(BASE + f); })); + } + + function loadAllLazy() { + if (_allLoaded) return Promise.resolve(); + var list = window.LAB_LAZY_FILES || []; + return loadFiles(list).then(function () { _allLoaded = true; }); + } + + // ensure(id): загрузить всё необходимое для симуляции id, вернуть Promise. + function ensure(id) { + var dep = (window.SIM_DEPS && window.SIM_DEPS[id]) || null; + if (!dep) { + // нет манифеста для id — безопасно грузим всё + return loadAllLazy(); + } + var p = dep.three ? ensureThree() : Promise.resolve(); + return p + .then(function () { return loadFiles(dep.files); }) + .then(function () { + var openName = dep.open; + if (openName && typeof window[openName] !== 'function') { + if (window.console) console.warn('[LabLoader] самовосстановление для "' + id + '": ' + openName + ' не найдена после загрузки ' + JSON.stringify(dep.files) + ' — гружу все ленивые файлы'); + return loadAllLazy(); + } + }); + } + + window.LabLoader = { + ensure: ensure, + ensureThree: ensureThree, + loadScript: loadScript, + loadAllLazy: loadAllLazy + }; +})(); diff --git a/frontend/js/labs/_register-all.js b/frontend/js/labs/_register-all.js new file mode 100644 index 0000000..c6ddd1b --- /dev/null +++ b/frontend/js/labs/_register-all.js @@ -0,0 +1,109 @@ +'use strict'; +/* + * Контент-движок, Фаза 1 — data-driven регистрация ВСЕХ симуляций в LabRegistry. + * + * Вместо ручного переписывания 40 манифестов модуль строит их из единых источников: + * - метаданные (id/cat/title/desc) и preview — из массива SIMS (lab-glue.js); + * - теория — из объекта THEORY (lab-init.js); + * - поведение open(ctx) — из карты OPEN ниже (обёртки над глобальными _openXxx). + * Это структурно гарантирует паритет с прежним каталогом и диспетчеризацией. + * + * Подключается ПОСЛЕДНИМ среди labs-скриптов (defer), поэтому SIMS, THEORY и все + * _openXxx уже определены. Останов/закрытие симуляций по-прежнему выполняет + * «дробовик» _pauseAllSims()/closeSim() (точный паритет) — поэтому stop/destroy + * в манифестах не задаются на этом этапе. + * + * После регистрации if-цепочка в openSim() становится мёртвой и удалена. + * + * В Фазе 1 заменил пилотный _pilots.js. SIMS/THEORY остаются источниками данных + * (SIMS → БД в Фазе 4, THEORY сворачивается в манифесты позже). + */ +(function () { + if (!window.LabRegistry) return; + if (typeof SIMS === 'undefined') return; + var R = window.LabRegistry; + var T = (typeof THEORY !== 'undefined') ? THEORY : {}; + + // id -> open(ctx). ctx.arg — параметр deep-link (после двоеточия): stereo:cube и т.п. + var OPEN = { + graph: function (c) { _openGraph(); }, + projectile: function (c) { _openProjectile(); }, + collision: function (c) { _openCollision(); }, + triangle: function (c) { _openTriangle(); }, + trigcircle: function (c) { _openTrigCircle(); }, + emfield: function (c) { _openEMField(c.arg || 'E'); }, + molphys: function (c) { _openMolPhys(c.arg); }, + circuit: function (c) { _openCircuit(); }, + chemistry: function (c) { _openChemistry(c.arg); }, + dynamics: function (c) { _openDynamics(c.arg); }, + crystal: function (c) { _openCrystal(); }, + orbitals: function (c) { _openOrbitals(); }, + stereo: function (c) { _openStereo(c.arg); }, + chemsandbox: function (c) { _openChemSandbox(); }, + celldivision: function (c) { _openCellDivision(); }, + photosynthesis: function (c) { _openPhotosynthesis(); }, + angrybirds: function (c) { _openAngryBirds(); }, + quadratic: function (c) { _openQuadratic(); }, + normaldist: function (c) { _openNormalDist(); }, + graphtransform: function (c) { _openGraphTransform(); }, + pendulum: function (c) { _openPendulum(); }, + equilibrium: function (c) { _openEquilibrium(); }, + opticsbench: function (c) { _openOpticsBench(c.arg || 'lens'); }, + isoprocess: function (c) { _openIsoprocess(); }, + titration: function (c) { _openTitration(); }, + probability: function (c) { _openProbability(); }, + bohratom: function (c) { _openBohrAtom(); }, + electrolysis: function (c) { _openElectrolysis(); }, + race: function (c) { _openRace(); }, + waves: function (c) { _openWaves(); }, + hydrostatics: function (c) { _openHydro(c.arg); }, + radioactive: function (c) { _openRadioactive(); }, + geometry: function (c) { _openGeometry(); }, + logic: function (c) { _openLogic(); }, + heatengine: function (c) { _openHeatEngine(); }, + stoichiometry: function (c) { _openStoich(); }, + qualanalysis: function (c) { _openQualAnalysis(); }, + periodic: function (c) { _openPeriodic(); }, + organic: function (c) { _openOrganic(); }, + solutions: function (c) { _openSolutions(); } + }; + + SIMS.forEach(function (s) { + if (!s.id) return; // "Скоро" — карточка без id + var open = OPEN[s.id]; + if (!open) { // подстраховка: незамапленный id оставляем legacy-пути + if (window.console) console.warn('[LabRegistry] нет open() для', s.id); + return; + } + R.register({ + id: s.id, + cat: s.cat, + title: s.title, + desc: s.desc, + preview: s.preview, // уже готовая SVG-строка (P_* вычислены в SIMS) + theory: T[s.id] || null, + // Фаза 3: ленивая загрузка кода. LabLoader.ensure(id) подгружает файлы + // симуляции (+ three.js при необходимости), затем выполняется raw-open. + // Если LabLoader недоступен — открываем синхронно как раньше (фолбэк). + open: (function (rawOpen, simId) { + return function (c) { + if (window.LabLoader && window.LabLoader.ensure) { + return window.LabLoader.ensure(simId).then(function () { rawOpen(c); }); + } + rawOpen(c); + }; + })(open, s.id) + // stop/destroy: глобальный «дробовик» _pauseAllSims()/closeSim() — паритет + }); + }); + + // Алиасы deep-link → канонический id[:arg]. Диспетчер openSim() нормализует их + // перед обращением к реестру (карточек у алиасов нет — только прямые ссылки). + window.LAB_SIM_ALIASES = { + magnetic: 'emfield:B', + coulomb: 'emfield:E', + thinlens: 'opticsbench:lens', + mirrors: 'opticsbench:mirror', + refraction: 'opticsbench:refraction' + }; +})(); diff --git a/frontend/js/labs/_registry.js b/frontend/js/labs/_registry.js new file mode 100644 index 0000000..386cfdf --- /dev/null +++ b/frontend/js/labs/_registry.js @@ -0,0 +1,101 @@ +'use strict'; +/* + * LabRegistry — единый реестр симуляций лаборатории (контент-движок). + * + * Цель: симуляции описываются декларативным манифестом и сами себя регистрируют, + * вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY). + * + * Манифест: + * { + * id: 'pendulum', // уникальный, без ':arg' + * cat: 'phys', // math | phys | chem | bio | game + * title: 'Маятник', + * desc: 'Колебания, период…', + * preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво) + * theory: { title, sections[] },// объект для панели теории (как в THEORY) + * bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2) + * mount: function(host){}, // (опц.) ленивое монтирование тела + * open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать + * stop: function(){}, // (опц.) остановить анимации (не разрушая) + * destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop + * subject, grade, topics // (опц.) курикулумные поля (Фаза 5) + * } + * + * Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал + * к моменту исполнения тел остальных модулей. + */ +(function () { + var _list = []; // манифесты в порядке регистрации + var _byId = {}; // id -> манифест + var _active = null; // текущая открытая симуляция + + function _baseId(id) { + return id == null ? id : String(id).split(':')[0]; + } + + function register(m) { + if (!m || !m.id) return null; + if (Object.prototype.hasOwnProperty.call(_byId, m.id)) { + // перерегистрация: заменить на месте, сохранив позицию + for (var i = 0; i < _list.length; i++) { + if (_list[i].id === m.id) { _list[i] = m; break; } + } + } else { + _list.push(m); + } + _byId[m.id] = m; + return m; + } + + function get(id) { + var b = _baseId(id); + return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null; + } + + function has(id) { return !!get(id); } + + function all() { return _list.slice(); } + + function setActive(m) { _active = m || null; } + + function stopActive() { + if (_active && typeof _active.stop === 'function') { + try { _active.stop(); } catch (e) { /* noop */ } + } + } + + function destroyActive() { + if (_active) { + if (typeof _active.destroy === 'function') { + try { _active.destroy(); } catch (e) { /* noop */ } + } else if (typeof _active.stop === 'function') { + try { _active.stop(); } catch (e) { /* noop */ } + } + } + _active = null; + } + + function active() { return _active; } + + // Разрешить preview (строка или функция) в готовую разметку. + function resolvePreview(m) { + if (!m) return ''; + var p = m.preview; + if (typeof p === 'function') { + try { return p() || ''; } catch (e) { return ''; } + } + return p || ''; + } + + window.LabRegistry = { + register: register, + get: get, + has: has, + all: all, + setActive: setActive, + stopActive: stopActive, + destroyActive: destroyActive, + active: active, + resolvePreview: resolvePreview + }; +})(); diff --git a/frontend/js/labs/_sim_deps.js b/frontend/js/labs/_sim_deps.js new file mode 100644 index 0000000..114a8b9 --- /dev/null +++ b/frontend/js/labs/_sim_deps.js @@ -0,0 +1,300 @@ +'use strict'; +/* Контент-движок, Фаза 3 — манифест зависимостей симуляций (СГЕНЕРИРОВАН). + id -> { open: имя глобальной _openX, files: [ленивые файлы], three: нужен ли three.js }. + Файлы загружаются лениво по клику (см. _loader.js). three.js — только для 3D-симуляций. + Самовосстановление в _loader: если после загрузки open-функция не определена, + грузятся ВСЕ ленивые файлы -> корректность не зависит от точности манифеста. + Регенерация: node tools/gen-sim-deps.js (см. CONTEXT). НЕ редактировать вручную. */ +window.SIM_DEPS = { + "graph": { + "open": "_openGraph", + "files": [], + "three": false + }, + "projectile": { + "open": "_openProjectile", + "files": [ + "projectile.js" + ], + "three": false + }, + "collision": { + "open": "_openCollision", + "files": [ + "collision.js" + ], + "three": false + }, + "triangle": { + "open": "_openTriangle", + "files": [ + "triangle.js" + ], + "three": false + }, + "trigcircle": { + "open": "_openTrigCircle", + "files": [ + "trigcircle.js" + ], + "three": false + }, + "emfield": { + "open": "_openEMField", + "files": [ + "emfield.js", + "logic.js" + ], + "three": false + }, + "molphys": { + "open": "_openMolPhys", + "files": [ + "brownian.js", + "diffusion.js", + "gas.js", + "states.js" + ], + "three": false + }, + "circuit": { + "open": "_openCircuit", + "files": [ + "circuit.js" + ], + "three": false + }, + "chemistry": { + "open": "_openChemistry", + "files": [ + "circuit.js", + "flask.js", + "ionexchange.js", + "reactions.js", + "redox.js" + ], + "three": false + }, + "dynamics": { + "open": "_openDynamics", + "files": [ + "forcesandbox.js", + "newton.js" + ], + "three": false + }, + "crystal": { + "open": "_openCrystal", + "files": [ + "crystal.js" + ], + "three": true + }, + "orbitals": { + "open": "_openOrbitals", + "files": [ + "orbitals.js" + ], + "three": true + }, + "stereo": { + "open": "_openStereo", + "files": [ + "stereo.js" + ], + "three": true + }, + "chemsandbox": { + "open": "_openChemSandbox", + "files": [ + "chemsandbox.js", + "collision.js" + ], + "three": false + }, + "celldivision": { + "open": "_openCellDivision", + "files": [ + "celldivision.js" + ], + "three": false + }, + "photosynthesis": { + "open": "_openPhotosynthesis", + "files": [ + "photosynthesis.js" + ], + "three": false + }, + "angrybirds": { + "open": "_openAngryBirds", + "files": [ + "angrybirds.js" + ], + "three": false + }, + "quadratic": { + "open": "_openQuadratic", + "files": [ + "quadratic.js" + ], + "three": false + }, + "normaldist": { + "open": "_openNormalDist", + "files": [ + "normaldist.js" + ], + "three": false + }, + "graphtransform": { + "open": "_openGraphTransform", + "files": [ + "graphtransform.js" + ], + "three": false + }, + "pendulum": { + "open": "_openPendulum", + "files": [ + "pendulum.js" + ], + "three": false + }, + "equilibrium": { + "open": "_openEquilibrium", + "files": [ + "equilibrium.js" + ], + "three": false + }, + "opticsbench": { + "open": "_openOpticsBench", + "files": [ + "opticsbench.js" + ], + "three": false + }, + "isoprocess": { + "open": "_openIsoprocess", + "files": [ + "isoprocess.js" + ], + "three": false + }, + "titration": { + "open": "_openTitration", + "files": [ + "titration.js" + ], + "three": false + }, + "probability": { + "open": "_openProbability", + "files": [ + "probability.js" + ], + "three": false + }, + "bohratom": { + "open": "_openBohrAtom", + "files": [ + "bohratom.js" + ], + "three": false + }, + "electrolysis": { + "open": "_openElectrolysis", + "files": [ + "electrolysis.js" + ], + "three": false + }, + "race": { + "open": "_openRace", + "files": [ + "race.js" + ], + "three": false + }, + "waves": { + "open": "_openWaves", + "files": [ + "waves.js" + ], + "three": false + }, + "hydrostatics": { + "open": "_openHydro", + "files": [ + "hydrostatics.js" + ], + "three": false + }, + "radioactive": { + "open": "_openRadioactive", + "files": [ + "radioactive.js" + ], + "three": false + }, + "geometry": { + "open": "_openGeometry", + "files": [ + "geometry.js", + "triangle.js" + ], + "three": false + }, + "logic": { + "open": "_openLogic", + "files": [ + "logic.js" + ], + "three": false + }, + "heatengine": { + "open": "_openHeatEngine", + "files": [ + "heatengine.js" + ], + "three": false + }, + "stoichiometry": { + "open": "_openStoich", + "files": [ + "stoichiometry.js" + ], + "three": false + }, + "qualanalysis": { + "open": "_openQualAnalysis", + "files": [ + "qualanalysis.js" + ], + "three": false + }, + "periodic": { + "open": "_openPeriodic", + "files": [ + "_periodic_data.js", + "periodic.js" + ], + "three": true + }, + "organic": { + "open": "_openOrganic", + "files": [ + "organic.js" + ], + "three": false + }, + "solutions": { + "open": "_openSolutions", + "files": [ + "solutions.js" + ], + "three": false + } +}; +window.LAB_LAZY_FILES = ["angrybirds.js","bohratom.js","brownian.js","celldivision.js","chemsandbox.js","circuit.js","collision.js","crystal.js","diffusion.js","electrolysis.js","emfield.js","equilibrium.js","flask.js","forcesandbox.js","gas.js","geometry.js","graphtransform.js","heatengine.js","hydrostatics.js","ionexchange.js","isoprocess.js","logic.js","newton.js","normaldist.js","opticsbench.js","orbitals.js","organic.js","pendulum.js","periodic.js","photosynthesis.js","probability.js","projectile.js","quadratic.js","qualanalysis.js","race.js","radioactive.js","reactions.js","redox.js","solutions.js","states.js","stereo.js","stoichiometry.js","titration.js","triangle.js","trigcircle.js","waves.js","_periodic_data.js"]; diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 221d586..3317a05 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -20,11 +20,25 @@ } function renderSims() { - const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter); + // Контент-движок: мёрж код-реестра поверх legacy SIMS. + // Порядок берём из SIMS; для мигрированных id используем манифест реестра; + // registry-only записи добавляем в конец. + const _reg = (window.LabRegistry ? window.LabRegistry.all() : []); + const _regById = {}; + _reg.forEach(m => { _regById[m.id] = m; }); + const _seen = {}; + const _merged = []; + SIMS.forEach(s => { + _merged.push(s.id && _regById[s.id] ? _regById[s.id] : s); + if (s.id) _seen[s.id] = 1; + }); + _reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); }); + + const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter); const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id)); document.getElementById('sim-grid').innerHTML = list.map(s => `
    - ${s.preview} + ${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
    ${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? ' Химия' : s.cat === 'bio' ? ' Биология' : s.cat === 'game' ? ' Игры' : LS.icon('zap',14) + ' Физика'}
    ${s.title}
    @@ -935,7 +949,9 @@ } function loadTheory(simId) { - const t = THEORY[simId]; + // Контент-движок: теория мигрированных симуляций берётся из манифеста реестра. + const _rm = window.LabRegistry ? window.LabRegistry.get(simId) : null; + const t = (_rm && _rm.theory) ? _rm.theory : THEORY[simId]; const el = document.getElementById('theory-content'); if (!t) { el.innerHTML = '
    Теория для этой симуляции пока не добавлена
    '; return; } let html = `
    ${LS.icon('book-open',16)} ${t.title}
    `; @@ -955,6 +971,58 @@ }); } + /* ── Контент-движок, Фаза 5: чип «Связано с программой» ────────────────── + Подтягивает курикулумные связи симуляции (GET /api/lab/sims/:id/related) и + рендерит чипы-ссылки рядом с заголовком симуляции. Самодостаточно: создаёт + контейнер #sim-related динамически (без правок lab.html/CSS — меньше риск + конфликта с параллельными сессиями). Тихо прячется, если связей нет/ошибка. */ + var _LAB_LINK_ICON = ''; + function _labRelEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function _ensureRelatedHost() { + var host = document.getElementById('sim-related'); + if (host) return host; + host = document.createElement('div'); + host.id = 'sim-related'; + host.style.cssText = 'display:none;align-items:center;gap:6px;flex-wrap:wrap;margin-left:14px;min-width:0'; + var title = document.getElementById('sim-topbar-title'); + if (title && title.parentNode) title.parentNode.insertBefore(host, title.nextSibling); + return host; + } + function _loadRelated(simId) { + var host = _ensureRelatedHost(); + host.style.display = 'none'; + host.innerHTML = ''; + if (!window.LS || !LS.api) return; + LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related') + .then(function (data) { + var links = (data && data.links) || {}; + var all = [].concat(links.textbook || [], links.topic || [], links.kmap || [], links.question || []); + if (!all.length) return; + var chipBase = 'display:inline-flex;align-items:center;gap:4px;font-size:.72rem;padding:3px 9px;border-radius:999px;'; + var html = '' + + _LAB_LINK_ICON + ' Связано с программой'; + all.forEach(function (l) { + var label = _labRelEsc(l.label || (l.kind + ':' + l.ref_id)); + if (l.href) { + html += '' + label + ''; + } else { + html += '' + label + ''; + } + }); + host.innerHTML = html; + host.style.display = 'flex'; + if (window.lucide) lucide.createIcons(); + }) + .catch(function () { /* нет связей или ошибка — чип просто не показываем */ }); + } + window._loadRelated = _loadRelated; + /* ── embed mode + auto-open from ?sim= ── */ const _qp = new URLSearchParams(location.search); var _embedMode = _qp.get('embed') === '1'; diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index 9a46798..b48303a 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -30,6 +30,19 @@ var geomSim = null; var qualSim = null; + /* Контент-движок, Фаза 3 (ленивая загрузка): часть глобалов с экземплярами + симуляций объявляется внутри их собственных НЫНЕ ЛЕНИВЫХ файлов, поэтому до + первого открытия такой симуляции они не существуют. Legacy-«дробовик» + _pauseAllSims()/closeSim() ссылается на них по голому имени, что до загрузки + любого файла бросало ReferenceError (напр. cirSim). Предсоздаём эти имена как + свойства window (null), чтобы guard'ы безопасно давали false; при загрузке + файла симуляции его собственный var/присваивание обновит тот же глобал. */ + ['cirSim','reacSim','flaskSim','newtonSim','sandboxSim','crystalSim','orbitalsSim', + 'stereoSim','angryBirdsSim','trigSim','pendSim','radioactiveSim','heSim', + 'periodicSim','organicSim','_solutionsSim','mirrorSim'].forEach(function (_n) { + if (!(_n in window)) window[_n] = null; + }); + var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield', 'sim-molphys', 'sim-circuit','sim-chemistry','sim-dynamics', @@ -52,6 +65,7 @@ // Pause all animation-loop sims (non-destructive). Called when switching // between sims so a previously opened sim doesn't keep rendering offscreen. function _pauseAllSims() { + if (window.LabRegistry) window.LabRegistry.stopActive(); if (pSim) pSim.pause(); if (cSim) cSim.pause(); if (gasSim) gasSim.stop(); @@ -105,58 +119,34 @@ // load theory for this sim loadTheory(id.includes(':') ? id.split(':')[0] : id); - if (id === 'graph') _openGraph(); - if (id === 'projectile') _openProjectile(); - if (id === 'collision') _openCollision(); - if (id === 'triangle') _openTriangle(); - if (id === 'trigcircle') _openTrigCircle(); - if (id === 'magnetic') _openEMField('B'); // backward compat: #magnetic → emfield B-mode - if (id === 'coulomb') _openEMField('E'); // backward compat: #coulomb → emfield E-mode - if (id === 'emfield') _openEMField('E'); - if (id.startsWith('emfield:')) { _openEMField(id.split(':')[1]); } - if (id === 'molphys') _openMolPhys(); - if (id.startsWith('molphys:')) { _openMolPhys(id.split(':')[1]); } - if (id === 'circuit') _openCircuit(); - if (id === 'chemistry') _openChemistry(); - if (id.startsWith('chemistry:')) { _openChemistry(id.split(':')[1]); } - if (id === 'dynamics') _openDynamics(); - if (id.startsWith('dynamics:')) { _openDynamics(id.split(':')[1]); } - if (id === 'crystal') _openCrystal(); - if (id === 'orbitals') _openOrbitals(); - if (id === 'stereo') _openStereo(); - if (id.startsWith('stereo:')) { _openStereo(id.split(':')[1]); } - if (id === 'chemsandbox') _openChemSandbox(); - if (id === 'celldivision') _openCellDivision(); - if (id === 'photosynthesis') _openPhotosynthesis(); - if (id === 'angrybirds') _openAngryBirds(); - if (id === 'quadratic') _openQuadratic(); - if (id === 'normaldist') _openNormalDist(); - if (id === 'graphtransform') _openGraphTransform(); - if (id === 'pendulum') _openPendulum(); - if (id === 'equilibrium') _openEquilibrium(); - if (id === 'opticsbench') _openOpticsBench('lens'); - if (id.startsWith('opticsbench:')) _openOpticsBench(id.split(':')[1]); - if (id === 'thinlens') _openOpticsBench('lens'); // backward compat - if (id === 'mirrors') _openOpticsBench('mirror'); // backward compat - if (id === 'refraction') _openOpticsBench('refraction'); // backward compat - if (id === 'isoprocess') _openIsoprocess(); - if (id === 'titration') _openTitration(); - if (id === 'probability') _openProbability(); - if (id === 'bohratom') _openBohrAtom(); - if (id === 'electrolysis') _openElectrolysis(); - if (id === 'race') _openRace(); - if (id === 'waves') _openWaves(); - if (id === 'hydrostatics') _openHydro(); - if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]); - if (id === 'radioactive') _openRadioactive(); - if (id === 'geometry') _openGeometry(); - 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(); + // Фаза 5: чип «Связано с программой» (курикулумные связи симуляции). + if (typeof _loadRelated === 'function') _loadRelated(id.includes(':') ? id.split(':')[0] : id); + + // ── Контент-движок (Фаза 1): диспетчеризация через реестр ── + // Все каталожные симуляции зарегистрированы в _register-all.js. + // Алиасы deep-link (magnetic/coulomb/thinlens/mirrors/refraction) нормализуем + // в канонический id[:arg] перед обращением к реестру. + var _aliases = window.LAB_SIM_ALIASES || {}; + var _cid = _aliases[id.split(':')[0]] || id; + if (window.LabRegistry && window.LabRegistry.has(_cid)) { + const _m = window.LabRegistry.get(_cid); + const _arg = _cid.includes(':') ? _cid.split(':')[1] : undefined; + window.LabRegistry.setActive(_m); + // Фаза 3: open() может вернуть Promise (ленивая загрузка кода). Иконки + // перерисовываем после фактической инициализации тела симуляции; ошибку + // асинхронной загрузки ловим через .catch (sync try/catch её не поймает). + try { + const _r = _m.open({ id: _cid, arg: _arg }); + if (_r && typeof _r.then === 'function') { + _r.then(function () { if (window.lucide) lucide.createIcons(); }) + .catch(function (e) { console.error('[LabRegistry] open failed:', _cid, e); }); + } else if (window.lucide) { + lucide.createIcons(); + } + } catch (e) { console.error('[LabRegistry] open failed:', _cid, e); } + return; + } + if (window.console) console.warn('[LabRegistry] неизвестная симуляция:', id); } function _simShow(elId) { @@ -210,6 +200,7 @@ } function closeSim() { + if (window.LabRegistry) window.LabRegistry.destroyActive(); if (pSim) pSim.pause(); if (cSim) cSim.pause(); if (mSim && mSim.particleOn) mSim.toggleParticle(); diff --git a/frontend/lab.html b/frontend/lab.html index e5ab83c..abc74da 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -364,4429 +364,29 @@
    - -
    - - -
    -
    Функции
    - - -
    -
    -
    - y = - -
    -
    -
    Синтаксическая ошибка
    -
    - - -
    -
    -
    - y = - -
    -
    -
    Синтаксическая ошибка
    -
    - - -
    -
    -
    - y = - -
    -
    -
    Синтаксическая ошибка
    -
    - -
    -
    Примеры
    - -
    -
    Линейные / степенные
    -
    - - - - - -
    -
    - -
    -
    Тригонометрия
    -
    - - - - - - -
    -
    - -
    -
    Показательные / логарифмы
    -
    - - - - -
    -
    - -
    -
    Прочие
    -
    - - - - - -
    -
    - -
    - -
    - - -
    -
    - -
    -
    -
    - x = - -
    -
    -
    - y₁ = - -
    -
    -
    - y₂ = - -
    -
    -
    - y₃ = - -
    -
    Скролл — зум · Перетащи — панорама
    -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
    +
    @@ -4800,69 +400,33 @@ - + + + + - - - - - - - - - - - - + - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 1

    +
    Оксиды, кислоты, основания и соли: состав, классификация, свойства, получение и генетическая связь
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Четыре класса, из которых построена неорганическая химия

    +

    Оксиды, кислоты, основания и соли связаны между собой превращениями. Научившись узнавать класс вещества по формуле и предсказывать его реакции, ты сможешь «читать» химию как язык.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 10

    Оксиды. Состав и классификация

    +
    § 11

    Химические свойства оксидов

    +
    § 12

    Получение и применение оксидов

    +
    § 13

    Кислоты. Состав и классификация

    +
    § 14

    Химические свойства кислот

    +
    § 15

    Получение и применение кислот

    +
    § 16

    Основания

    +
    § 17

    Химические свойства оснований

    +
    § 18

    Получение оснований · Лаб. 1 · ПР 2

    +
    § 19

    Соли. Состав и классификация

    +
    § 20

    Химические свойства солей · Лаб. 2

    +
    § 21

    Получение и применение солей

    +
    § 22

    Взаимосвязь классов · ПР 3

    +
    § 23

    Решение расчётных задач

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 1 · «Важнейшие классы неорганических соединений» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_ch2.html b/frontend/textbooks/chemistry_8_ch2.html new file mode 100644 index 0000000..f7d2937 --- /dev/null +++ b/frontend/textbooks/chemistry_8_ch2.html @@ -0,0 +1,198 @@ + + + + + + + + +Химия 8 · Глава 2 · «Периодический закон и периодическая система» + + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 2

    +
    Систематизация элементов, амфотерность, естественные семейства, периодический закон Д. И. Менделеева
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Главный закон химии

    +

    В 1869 году Д. И. Менделеев расположил элементы в порядке возрастания атомной массы — и увидел, что их свойства повторяются периодически. Так родилась периодическая система, по которой можно предсказывать свойства веществ.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 24

    Систематизация химических элементов

    +
    § 25

    Понятие об амфотерности · Лаб. 3

    +
    § 26

    Естественные семейства элементов

    +
    § 27

    Периодический закон Д. И. Менделеева

    +
    § 28

    Периодическая система химических элементов

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 2 · «Периодический закон и периодическая система» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_ch3.html b/frontend/textbooks/chemistry_8_ch3.html new file mode 100644 index 0000000..05105c2 --- /dev/null +++ b/frontend/textbooks/chemistry_8_ch3.html @@ -0,0 +1,233 @@ + + + + + + + + +Химия 8 · Глава 3 · «Строение атома» + + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 3

    +
    Строение атома, нуклиды и изотопы, электронные облака и орбитали, электронные оболочки, периодичность
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Что внутри атома

    +

    Атом неделим химически, но состоит из ядра (протоны и нейтроны) и движущихся вокруг электронов. Именно строение электронных оболочек объясняет, почему элементы ведут себя так, а не иначе — и почему работает периодический закон.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 29

    Строение атома. Атомный номер

    +
    § 30

    Массовое число атома. Нуклиды

    +
    § 31

    Изотопы. Явление радиоактивности

    +
    § 32

    Состояние электронов. Электронное облако. Орбиталь

    +
    § 33

    Строение электронных оболочек атомов

    +
    § 34

    Периодичность изменения свойств атомов

    +
    § 35

    Характеристика элемента по положению в ПС

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 3 · «Строение атома» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_ch4.html b/frontend/textbooks/chemistry_8_ch4.html new file mode 100644 index 0000000..62c5b88 --- /dev/null +++ b/frontend/textbooks/chemistry_8_ch4.html @@ -0,0 +1,227 @@ + + + + + + + + +Химия 8 · Глава 4 · «Химическая связь» + + + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 4

    +
    Природа связи, ковалентная (полярная и неполярная), ионная и металлическая связь, кристаллические решётки
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Почему атомы держатся вместе

    +

    Атомы соединяются, чтобы завершить внешний электронный слой и стать устойчивее. В зависимости от того, как именно они «делят» электроны, возникают разные типы химической связи — а от них зависят свойства веществ.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 36

    Природа химической связи

    +
    § 37

    Ковалентная связь

    +
    § 38

    Полярная и неполярная связь. Электроотрицательность · Лаб. 4

    +
    § 39

    Ионная связь

    +
    § 40

    Металлическая связь. Межмолекулярное взаимодействие

    +
    § 41

    Кристаллическое состояние вещества

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 4 · «Химическая связь» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_ch5.html b/frontend/textbooks/chemistry_8_ch5.html new file mode 100644 index 0000000..8f3836a --- /dev/null +++ b/frontend/textbooks/chemistry_8_ch5.html @@ -0,0 +1,187 @@ + + + + + + + + +Химия 8 · Глава 5 · «Окислительно-восстановительные реакции» + + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 5

    +
    Степень окисления, процессы окисления и восстановления, ОВР и метод электронного баланса
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Реакции, в которых электроны меняют хозяина

    +

    Горение, ржавление, дыхание, работа батарейки — всё это окислительно-восстановительные реакции. В них одни атомы отдают электроны, другие принимают, и степени окисления меняются.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 42

    Степень окисления

    +
    § 43

    Процессы окисления и восстановления

    +
    § 44

    Окислительно-восстановительные реакции

    +
    § 45

    Окислительно-восстановительные реакции вокруг нас

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 5 · «Окислительно-восстановительные реакции» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_ch6.html b/frontend/textbooks/chemistry_8_ch6.html new file mode 100644 index 0000000..fcd8e65 --- /dev/null +++ b/frontend/textbooks/chemistry_8_ch6.html @@ -0,0 +1,232 @@ + + + + + + + + +Химия 8 · Глава 6 · «Растворы» + + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Глава 6

    +
    Смеси и растворы, растворимость, массовая доля и молярная концентрация, вода в жизни человека
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    +
    +

    Самые важные смеси на Земле

    +

    Морская вода, кровь, лимонад, воздух — это всё растворы и смеси. Химик умеет описывать их состав количественно: массовой долей и молярной концентрацией.

    +
    + +
    Прогресс главы
    0%
    +
    +
    +
    + +
    Параграфы главы
    + +
    § 46

    Смеси веществ

    +
    § 47

    Растворение веществ в воде

    +
    § 48

    Характеристики растворимости веществ

    +
    § 49

    Качественные характеристики состава растворов

    +
    § 50

    Массовая доля растворённого вещества

    +
    § 51

    Молярная концентрация · ПР 4

    +
    § 52

    Вода и растворы в жизни человека

    +

    Финал главы

    +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Глава 6 · «Растворы» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/frontend/textbooks/chemistry_8_hub.html b/frontend/textbooks/chemistry_8_hub.html new file mode 100644 index 0000000..a1b3560 --- /dev/null +++ b/frontend/textbooks/chemistry_8_hub.html @@ -0,0 +1,622 @@ + + + + + + + + +Химия 8 класс — учебник + + + + + + + + + + + +
    +
    + +
    +

    Химия — 8 класс

    +
    Количественные понятия, классы соединений, периодический закон, строение атома, химическая связь, ОВР, растворы. 7 разделов, 52 параграфа.
    +
    +
    + +
    +
    +
    + +
    + +
    +
    Х
    +
    +
    Общий прогресс по курсу
    +
    Загрузка...
    +
    +
    + +
    + +
    + + +
    +
    mol
    +
    Вводный раздел
    +
    Количественные понятия в химии
    +
    §1–§9 · ПР 1
    +
    +
    +
    Атомы и элементы, простые и сложные вещества, химическое количество вещества, моль и постоянная Авогадро, молярная масса и объём газов, расчёты по массе, объёму и уравнениям реакций.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть раздел
    +
    +
    + + +
    +
    OH−
    +
    Глава 1
    +
    Важнейшие классы неорганических соединений
    +
    §10–§23 · 2 лаб · ПР 2,3
    +
    +
    +
    Оксиды, кислоты, основания и соли: состав, классификация, химические свойства, получение и применение; генетическая связь между классами неорганических веществ.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + + +
    +
    +
    Глава 2
    +
    Периодический закон и периодическая система
    +
    §24–§28 · 1 лаб
    +
    +
    +
    Систематизация элементов, амфотерность, естественные семейства элементов, периодический закон Д. И. Менделеева и строение периодической системы.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + + +
    +
    e−
    +
    Глава 3
    +
    Строение атома
    +
    §29–§35
    +
    +
    +
    Строение атома и атомный номер, массовое число и нуклиды, изотопы и радиоактивность, электронное облако и орбиталь, электронные оболочки, периодичность свойств.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + + +
    +
    H₂O
    +
    Глава 4
    +
    Химическая связь
    +
    §36–§41 · 1 лаб
    +
    +
    +
    Природа химической связи, ковалентная связь (неполярная и полярная, электроотрицательность), ионная и металлическая связь, межмолекулярное взаимодействие, кристаллические решётки.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + + +
    +
    O₂
    +
    Глава 5
    +
    Окислительно-восстановительные реакции
    +
    §42–§45
    +
    +
    +
    Степень окисления, процессы окисления и восстановления, окислительно-восстановительные реакции и метод электронного баланса, ОВР вокруг нас.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + + +
    +
    aq
    +
    Глава 6
    +
    Растворы
    +
    §46–§52 · ПР 4
    +
    +
    +
    Смеси веществ, растворение в воде, характеристики растворимости, качественные и количественные характеристики состава растворов, массовая доля и молярная концентрация, вода в жизни человека.
    +
    +
    Прогресс0%
    +
    +
    +
    Открыть главу
    +
    +
    + +
    + +
    + +
    + +
    Шпаргалка курса
    +
    +
    ВводныйКоличество вещества
    • $n=\dfrac{m}{M}$, $\;M=M_r$
    • $V=n\cdot22{,}4$ л/моль (н.у.)
    • $N=n\cdot6{,}02\cdot10^{23}$
    • Расчёт по уравнению — по коэффициентам
    +
    Гл. 1Классы соединений
    • Оксиды: осн./кисл./амфот.
    • Кислоты: основность = число H
    • Основания: щёлочи / нераств.
    • Соль + щёлочь/кислота/Me (РИО)
    +
    Гл. 2Периодический закон
    • Период = число слоёв
    • Группа = внешние электроны
    • Амфотерность: Zn(OH)₂, Al(OH)₃
    • Семейства: щелочные, галогены
    +
    Гл. 3Строение атома
    • $A=Z+N$; $Z=p^+=e^-$
    • Изотопы — разный N
    • Слой: $2n^2$ электронов
    • Свойства — внешний слой
    +
    Гл. 4Химическая связь
    • Ковалентная — общие пары
    • Ионная — передача e⁻
    • Металлическая — электронный газ
    • Решётка → свойства
    +
    Гл. 5ОВР
    • С.о.: H +1, O −2, Σ=0
    • Окисление −e⁻; восстановление +e⁻
    • Баланс: отдано = принято e⁻
    +
    Гл. 6Растворы
    • $w=\dfrac{m_{в-ва}}{m_{р-ра}}$
    • $c=\dfrac{n}{V}$ (моль/л)
    • Растворимость: г / 100 г воды
    • Смеси: однород./неоднород.
    +
    + +
    10 интегрированных боссов
    +
    Боссов побеждено: 0 / 10
    +
    + +
    +
    +
    Курс «Химия 8» пройден!
    Вы прошли итоговую проверку по всем 7 разделам. +150 XP, ачивка «Химик 8 класса» получена.
    + К каталогу +
    + +
    +
    + +
    +
    + +
    +
    +
    Химик 8 класса
    +
    Изучите все 52 параграфа курса, чтобы получить достижение.
    +
    +
    + +
    + +
    + Интерактивный учебник «Химия — 8 класс» · Шиманович, Красицкий, Сечко, Хвалюк · LearnSpace +
    + + + + + diff --git a/frontend/textbooks/chemistry_8_intro.html b/frontend/textbooks/chemistry_8_intro.html new file mode 100644 index 0000000..9f33630 --- /dev/null +++ b/frontend/textbooks/chemistry_8_intro.html @@ -0,0 +1,393 @@ + + + + + + + + +Химия 8 · Вводный раздел · «Количественные понятия в химии» + + + + + + + + + + + + + + + +
    +
    +
    +

    Химия 8 · Вводный раздел

    +
    Количественные понятия: атомы, формулы, моль, молярная масса и объём, расчёты по уравнениям
    +
    +
    + К разделам + +
    +
    +
    + +
    +
    + +
    +

    Химия начинается со счёта

    +

    Прежде чем изучать вещества и реакции, химик учится их «считать»: переходить от массы к числу частиц, от объёма газа — к количеству вещества, рассчитывать продукты реакции по уравнению. Эти количественные понятия — фундамент всего курса.

    +
    + +
    + Прогресс раздела +
    + 0% +
    +
    +
    +
    + +
    +
    Параграфы раздела
    +
    +
    + +
    § 1

    Атомы. Химические элементы. Относительная атомная масса

    +
    § 2

    Молекулы. Простые и сложные вещества. Формулы. $M_r$

    +
    § 3

    Химическое количество вещества

    +
    § 4

    Моль. Постоянная Авогадро

    +
    § 5

    Молярная масса. Молярный объём газов

    +
    § 6

    Вычисление $n$ по массе и массы по $n$

    +
    § 7

    Вычисление количества и объёма газа

    +
    ПР 1

    Практическая работа: химическое количество вещества

    +
    § 8

    Химические реакции

    +
    § 9

    Количественные расчёты по уравнениям реакций

    +

    Финал раздела

    + +
    + +
    + +
    Интерактивный учебник «Химия — 8 класс» · Вводный раздел · «Количественные понятия в химии» · LearnSpace
    +
    Достижение!
    + + + + + diff --git a/plans/lab-content-engine/CONTEXT.md b/plans/lab-content-engine/CONTEXT.md new file mode 100644 index 0000000..ed59534 --- /dev/null +++ b/plans/lab-content-engine/CONTEXT.md @@ -0,0 +1,116 @@ +# Feature Context: Контент-движок лаборатории + +## Current State +- Лаборатория работает на захардкоженной регистрации (см. PLAN.md Summary). +- Ветка `feature/lab-content-engine` создана от `master`. + +## Architecture map (как было ДО рефактора) +- `frontend/lab.html` — sim-тела `
    ` (inline HTML, ~3000 строк) + 58 `