Интерактивное наглядное наполнение этого раздела (теория, модели, симуляторы, тренажёры и боссы) добавляется поэтапно. Ниже — план параграфов раздела согласно учебнику.
+
+
+
+
+
+ Содержание раздела
+
+
+${outlineHtml(ch.items)}
+
+
+
+
+
+
+
+
+
+`;
+}
+
+// --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 ? '
@@ -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₃ =
- —
-
-
Скролл — зум · Перетащи — панорама
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Заряд
-
-
-
-
-
-
Слои E
-
-
-
-
-
-
-
-
-
Поверхность Гаусса
-
-
-
- Радиус поверхности
- 70 пкс
-
-
-
-
-
Пресеты E
-
-
-
-
-
-
-
-
-
-
-
Провод
-
-
-
-
-
-
-
- Сила тока I
- 6 А
-
-
-
-
-
Слои B
-
-
-
-
-
-
-
Проводник в поле
-
-
-
- Ток проводника I*
- 8 А
-
-
-
-
-
Магнитный поток
-
-
-
ЭДС индукции
-
-
-
Пресеты B
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Тип добавляемого
-
-
-
-
-
-
-
-
Частица
-
-
-
-
-
Зарядов
-
0
-
Проводов
-
0
-
Курсор |E|
-
—
-
Курсор V
-
—
-
Курсор |B|
-
—
-
Φₐ Гаусса
-
—
-
ЭДС ε
-
—
-
-
- Клик — добавить · ПКМ / 2×клик — удалить
- Перетащи источник для перемещения
-
-
-
-
-
-
-
-
-
-
-
-
-
Зарядов
0
-
Проводов
0
-
Частица
выкл
-
|E| курсора
—
-
|B| курсора
—
-
Сила Ампера
—
-
Поток Φ
—
-
Φₐ Гаусса
—
-
ЭДС ε
—
-
-
-
-
-
-
-
-
-
-
-
-
Слои
-
-
-
-
-
-
-
-
-
-
Теоремы
-
-
-
-
-
-
-
-
Стороны
-
- a—
- b—
- c—
-
-
-
Углы
-
- ∠A—
- ∠B—
- ∠C—
-
-
-
Вычисляемые
-
- S—
- P—
- R—
- r—
-
-
-
-
-
Тип
-
—
-
- Перетащи вершины A, B, C для изменения
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Сторона a
-
—
-
-
-
Сторона b
-
—
-
-
-
Сторона c
-
—
-
-
-
Площадь S
-
—
-
-
-
Периметр P
-
—
-
-
-
R / r
-
—
-
-
-
-
-
-
-
-
-
-
-
-
-
Отрезки
-
-
-
-
-
-
-
-
-
График
-
-
-
-
-
-
-
-
-
Значения
-
- sin—
- cos—
- tg—
- ctg—
-
-
-
-
Табличные углы
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Угол
-
45° = π/4
-
- Перетащи точку по окружности или выбери табличный угол
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Угол
-
45°
-
-
-
sin
-
—
-
-
-
cos
-
—
-
-
-
tg
-
—
-
-
-
ctg
-
—
-
-
-
Четверть
-
—
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Параметры газа
-
-
Число молекул N80
-
-
-
-
Температура T1.0 у.е.
-
-
-
-
Поршень (объём)100%
-
-
-
-
-
-
Состояние
-
-
Давление P
-
—
-
Объём V
-
—
-
PV
-
—
-
⟨v⟩ средн.
-
—
-
-
Стенки светятся по P · Поршень перетащи мышью
-
-
-
-
-
Параметры
-
-
Молекул газа N120
-
-
-
-
Температура T1.0 у.е.
-
-
-
Статистика частицы B
-
-
|Δr| смещение
-
0
-
MSD
-
0
-
Скорость v
-
0
-
Шагов
-
0
-
-
-
-
-
График MSD нарастает линейно — закон диффузии
-
-
-
-
-
Управление
-
-
Температура T0.15
-
-
-
-
Частиц N64
-
-
-
-
-
-
-
-
-
Фаза и энергия
-
-
Фаза
-
—
-
Кин. энергия
-
—
-
Пот. энергия
-
—
-
Давление
-
—
-
-
LJ потенциал · g(r) — структура · цвет = скорость
-
-
-
-
-
Параметры
-
-
Молекул каждого вида60
-
-
-
-
Температура T1.0 у.е.
-
-
-
-
-
Концентрации
-
-
Лево A
-
—
-
Лево B
-
—
-
Право A
-
—
-
Право B
-
—
-
Смешивание
-
—
-
-
A (cyan) — лево · B (розовый) — право
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
—
—
-
—
—
-
—
—
-
—
—
-
—
—
-
-
-
-
-
-
-
-
-
-
-
-
-
Инструмент
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Параметры
-
-
-
- Сопротивление R
- 10 Ω
-
-
-
-
-
-
- Напряжение U
- 9 В
-
-
-
-
-
-
- Ёмкость C
- 100 µF
-
-
-
-
-
-
- Индуктивность L
- 10 мГн
-
-
-
-
-
-
- Частота AC
- 2 Гц
-
-
-
-
-
Пресеты
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Тяни узлы для рисования · ПКМ — удалить
- 2×клик по выключателю — вкл/выкл · Ctrl+Z отмена
-
-
-
-
-
-
-
-
Осциллограф
-
-
-
-
-
-
-
-
Компонентов
0
-
Напряжение U
—
-
Ток I
—
-
Мощность P
—
-
Статус
—
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Инструмент
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Пресеты
-
-
-
-
-
-
-
-
-
-
-
-
-
- Клик = добавить элемент
- Перетащи выход (кружок) на вход
- 2×клик по INPUT — переключить 0/1
- ПКМ — удалить | Ctrl+Z отмена
-
Четыре класса, из которых построена неорганическая химия
+
Оксиды, кислоты, основания и соли связаны между собой превращениями. Научившись узнавать класс вещества по формуле и предсказывать его реакции, ты сможешь «читать» химию как язык.