feat(biochem): Фаза 2.1/2.2/2.4 — серверный chem.js + /analyze + подсказки валентности
- biochem-core.js dual-export (browser window.BIO + Node module.exports), без дублей - BIO.valency: подробные подсказки валентности (2.4), общие для редактора и сервера - services/chem.js: серверный анализ поверх того же ядра (analyze/validate) - POST /api/biochem/analyze (2.2); /validate переведён на ядро (+фикс формата связей) - api.js: LS.biochemAnalyze Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { awardXP, checkAchievements } = require('./gamificationController');
|
const { awardXP, checkAchievements } = require('./gamificationController');
|
||||||
|
const chem = require('../services/chem');
|
||||||
|
|
||||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||||
const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 };
|
const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 };
|
||||||
@@ -128,14 +129,26 @@ function validate(req, res) {
|
|||||||
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
|
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
|
||||||
if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] });
|
if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] });
|
||||||
|
|
||||||
const formula = hillFormula(atoms);
|
// Единое химическое ядро (Фаза 2.1): формула + валентность с подсказками (2.4)
|
||||||
const issues = valencyIssues(atoms, bonds);
|
const { valid, formula, issues } = chem.validate(atoms, bonds);
|
||||||
const valid = issues.length === 0;
|
|
||||||
|
|
||||||
const known = valid ? stmts.getMolByFormula.get(formula) : null;
|
const known = valid ? stmts.getMolByFormula.get(formula) : null;
|
||||||
res.json({ valid, formula, issues, known: known || null });
|
res.json({ valid, formula, issues, known: known || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/biochem/analyze — полный химический анализ структуры (2.2) ─ */
|
||||||
|
function analyze(req, res) {
|
||||||
|
const { atoms, bonds } = req.body || {};
|
||||||
|
if (!Array.isArray(atoms))
|
||||||
|
return res.status(400).json({ error: 'atoms[] обязателен' });
|
||||||
|
if (atoms.length === 0)
|
||||||
|
return res.json({ formula: '', mass: 0, dbe: null, valency: [] });
|
||||||
|
try {
|
||||||
|
res.json(chem.analyze(atoms, Array.isArray(bonds) ? bonds : []));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── GET /api/biochem/reactions ─────────────────────────────────────── */
|
/* ── GET /api/biochem/reactions ─────────────────────────────────────── */
|
||||||
function getReactions(_req, res) {
|
function getReactions(_req, res) {
|
||||||
const rows = stmts.getReactions.all().map(r => ({
|
const rows = stmts.getReactions.all().map(r => ({
|
||||||
@@ -374,7 +387,7 @@ function tryParse(v, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getElements, getMolecules, getMolecule, validate,
|
getElements, getMolecules, getMolecule, validate, analyze,
|
||||||
getReactions, getChallenges, solveChallenge,
|
getReactions, getChallenges, solveChallenge,
|
||||||
getSaved, saveMolecule, deleteSaved,
|
getSaved, saveMolecule, deleteSaved,
|
||||||
getPathways, getPathwayProgress, savePathwayProgress,
|
getPathways, getPathwayProgress, savePathwayProgress,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ router.get('/elements', c.getElements);
|
|||||||
router.get('/molecules', c.getMolecules);
|
router.get('/molecules', c.getMolecules);
|
||||||
router.get('/molecules/:id', c.getMolecule);
|
router.get('/molecules/:id', c.getMolecule);
|
||||||
router.post('/validate', c.validate);
|
router.post('/validate', c.validate);
|
||||||
|
router.post('/analyze', c.analyze);
|
||||||
router.get('/reactions', c.getReactions);
|
router.get('/reactions', c.getReactions);
|
||||||
router.get('/challenges', c.getChallenges);
|
router.get('/challenges', c.getChallenges);
|
||||||
router.post('/challenges/:id/solve', c.solveChallenge);
|
router.post('/challenges/:id/solve', c.solveChallenge);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
/*
|
||||||
|
* chem.js — серверный химический слой (Фаза 2.1/2.2).
|
||||||
|
*
|
||||||
|
* Переиспользует то же ядро, что и фронт (frontend/js/biochem-core.js,
|
||||||
|
* `window.BIO`), вместо дублирования химии: формулы/масса/DBE, частичные
|
||||||
|
* заряды, дипольный момент (по 3D-геометрии VSEPR), полярность, функциональные
|
||||||
|
* группы, гибридизация, проверка валентности.
|
||||||
|
*
|
||||||
|
* Ядро самодостаточно (без DOM/canvas в чистых функциях) и при require в Node
|
||||||
|
* экспортирует объект BIO через module.exports.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const BIO = require(path.join(__dirname, '..', '..', '..', 'frontend', 'js', 'biochem-core.js'));
|
||||||
|
|
||||||
|
/* Полный анализ структуры → {formula, mass, dbe, geometry, polarity, dipole,
|
||||||
|
* charges, groups, massFractions, valency}. Бросает на некорректном вводе. */
|
||||||
|
function analyze(atoms, bonds) {
|
||||||
|
const an = BIO.analyze(atoms, bonds || []);
|
||||||
|
if (!an) return null;
|
||||||
|
return {
|
||||||
|
formula: an.formula,
|
||||||
|
mass: an.mass,
|
||||||
|
dbe: an.dbe,
|
||||||
|
atomCount: an.atomCount,
|
||||||
|
geometry: an.geometry, // {shape, hybridization, angle, centerSym}
|
||||||
|
polarity: an.polarity ? an.polarity.label : null, // «Полярная» / «Неполярная» / …
|
||||||
|
dipole: an.dipole,
|
||||||
|
charges: an.charges,
|
||||||
|
groups: an.groups,
|
||||||
|
massFractions: an.massFractions,
|
||||||
|
valency: BIO.valency(atoms, bonds || []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Проверка корректности: формула + проблемы валентности (с подсказками). */
|
||||||
|
function validate(atoms, bonds) {
|
||||||
|
const issues = BIO.valency(atoms, bonds || []);
|
||||||
|
return {
|
||||||
|
valid: issues.length === 0,
|
||||||
|
formula: BIO.hillFormula(atoms || []),
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { analyze, validate, BIO };
|
||||||
@@ -683,13 +683,8 @@ function getBondSum(id) {
|
|||||||
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? (b.order||b.o||1) : 0), 0);
|
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? (b.order||b.o||1) : 0), 0);
|
||||||
}
|
}
|
||||||
function getIssues() {
|
function getIssues() {
|
||||||
return atoms.filter(a => {
|
// Единая проверка валентности из ядра (с подсказками, Фаза 2.4)
|
||||||
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
|
return BIO.valency(atoms, bonds);
|
||||||
return used > (ELEMENTS[a.s]?.maxV ?? 4);
|
|
||||||
}).map(a => {
|
|
||||||
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
|
|
||||||
return { id:a.id, s:a.s, used, max: ELEMENTS[a.s]?.maxV??4 };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live molecular stats ──
|
// ── Live molecular stats ──
|
||||||
@@ -1192,7 +1187,7 @@ function updateInfo() {
|
|||||||
const issues = getIssues();
|
const issues = getIssues();
|
||||||
const issDiv = document.getElementById('bp-issues');
|
const issDiv = document.getElementById('bp-issues');
|
||||||
if (issues.length) {
|
if (issues.length) {
|
||||||
issDiv.innerHTML = issues.map(i => `<div class="bp-issue"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ${i.s}: ${i.used}/${i.max} связей</div>`).join('');
|
issDiv.innerHTML = issues.map(i => `<div class="bp-issue"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ${i.msg}</div>`).join('');
|
||||||
} else if (formula) {
|
} else if (formula) {
|
||||||
issDiv.innerHTML = '<div class="bp-ok"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Валентность в норме</div>';
|
issDiv.innerHTML = '<div class="bp-ok"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Валентность в норме</div>';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -911,15 +911,53 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Экспорт ──────────────────────────────────────────────────────────── */
|
/* ── Валентность: подробная проверка с подсказками (Фаза 2.4) ──────────
|
||||||
global.BIO = {
|
* Возвращает массив проблем-«перевалентностей» с готовым человекочитаемым
|
||||||
|
* текстом: [{ id, symbol, name, used, max, over, kind:'error', msg }].
|
||||||
|
* Работает с обоими форматами связей (from/to/order и f/t/o) через bF/bT/bO.
|
||||||
|
*/
|
||||||
|
function _bondWord(n) {
|
||||||
|
const m10 = n % 10, m100 = n % 100;
|
||||||
|
if (m10 === 1 && m100 !== 11) return 'связь';
|
||||||
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'связи';
|
||||||
|
return 'связей';
|
||||||
|
}
|
||||||
|
function valency(atoms, bonds) {
|
||||||
|
if (!atoms || !atoms.length) return [];
|
||||||
|
const sum = {};
|
||||||
|
for (const b of (bonds || [])) {
|
||||||
|
const f = bF(b), t = bT(b), o = bO(b);
|
||||||
|
sum[f] = (sum[f] || 0) + o;
|
||||||
|
sum[t] = (sum[t] || 0) + o;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const a of atoms) {
|
||||||
|
const e = el(a.s);
|
||||||
|
const used = sum[a.id] || 0;
|
||||||
|
const max = e.maxV != null ? e.maxV : 4;
|
||||||
|
if (used > max) {
|
||||||
|
const over = used - max;
|
||||||
|
out.push({
|
||||||
|
id: a.id, symbol: a.s, name: e.name, used, max, over, kind: 'error',
|
||||||
|
msg: e.name + ' (' + a.s + '): занято ' + used + ' ' + _bondWord(used) +
|
||||||
|
', максимум ' + max + ' — убери ' + over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Экспорт (браузер: window.BIO; Node: module.exports) ──────────────── */
|
||||||
|
var _api = {
|
||||||
ELEMENTS, el,
|
ELEMENTS, el,
|
||||||
bF, bT, bO,
|
bF, bT, bO,
|
||||||
counts, hillFormula, molarMass, parseFormula, dbe,
|
counts, hillFormula, molarMass, parseFormula, dbe,
|
||||||
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
|
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze, valency,
|
||||||
balance, parseSmiles, toJSON, download,
|
balance, parseSmiles, toJSON, download,
|
||||||
render2D, vsepr, render3D, chargeColor,
|
render2D, vsepr, render3D, chargeColor,
|
||||||
safe, RING_TEMPLATES,
|
safe, RING_TEMPLATES,
|
||||||
_hexRgb, _lighten, _darken,
|
_hexRgb, _lighten, _darken,
|
||||||
};
|
};
|
||||||
})(window);
|
global.BIO = _api;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = _api;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -937,6 +937,7 @@ async function biochemGetElements() { return req('GET', '/biochem/elements'
|
|||||||
async function biochemGetMolecules(p={}) { return req('GET', `/biochem/molecules?${new URLSearchParams(p)}`); }
|
async function biochemGetMolecules(p={}) { return req('GET', `/biochem/molecules?${new URLSearchParams(p)}`); }
|
||||||
async function biochemGetMolecule(id) { return req('GET', `/biochem/molecules/${id}`); }
|
async function biochemGetMolecule(id) { return req('GET', `/biochem/molecules/${id}`); }
|
||||||
async function biochemValidate(atoms,bonds){ return req('POST','/biochem/validate',{atoms,bonds}); }
|
async function biochemValidate(atoms,bonds){ return req('POST','/biochem/validate',{atoms,bonds}); }
|
||||||
|
async function biochemAnalyze(atoms,bonds){ return req('POST','/biochem/analyze',{atoms,bonds}); }
|
||||||
async function biochemGetReactions() { return req('GET', '/biochem/reactions'); }
|
async function biochemGetReactions() { return req('GET', '/biochem/reactions'); }
|
||||||
async function biochemGetChallenges() { return req('GET', '/biochem/challenges'); }
|
async function biochemGetChallenges() { return req('GET', '/biochem/challenges'); }
|
||||||
async function biochemSolveChallenge(id,payload) { return req('POST',`/biochem/challenges/${id}/solve`,payload); }
|
async function biochemSolveChallenge(id,payload) { return req('POST',`/biochem/challenges/${id}/solve`,payload); }
|
||||||
@@ -1065,7 +1066,7 @@ window.LS = {
|
|||||||
clearFeaturesCache,
|
clearFeaturesCache,
|
||||||
hideDisabledFeatures,
|
hideDisabledFeatures,
|
||||||
showBoardIfAllowed,
|
showBoardIfAllowed,
|
||||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemAnalyze,
|
||||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||||
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
||||||
biochemGetPathways, biochemGetPathwayProgress, biochemSavePathwayProgress,
|
biochemGetPathways, biochemGetPathwayProgress, biochemSavePathwayProgress,
|
||||||
|
|||||||
@@ -74,14 +74,19 @@
|
|||||||
|
|
||||||
Считать химию, а не хранить класс строкой.
|
Считать химию, а не хранить класс строкой.
|
||||||
|
|
||||||
- [ ] 2.1 `backend/src/services/chem.js`:
|
> Серверный срез (тег `biochem-phase2-server`): `backend/src/services/chem.js`
|
||||||
- Полярность связей по разнице электроотрицательностей; **дипольный момент** молекулы (вектор-сумма с учётом 3D-геометрии из Фазы 1) → polar/nonpolar обоснованно.
|
> **переиспользует то же ядро** `biochem-core.js` (сделан dual-export: браузер
|
||||||
- Частичные заряды (упрощённый Gasteiger / EN-метод) для раскраски атомов.
|
> `window.BIO` + Node `module.exports`) — без дублирования химии. Эндпоинт
|
||||||
- DBE (степень ненасыщенности), молярная масса, массовые доли элементов.
|
> `POST /api/biochem/analyze` отдаёт {formula, mass, dbe, geometry, polarity,
|
||||||
- Гибридизация центра, классификация функциональных групп через SMARTS-подобные паттерны (вынести из хардкода фронта).
|
> dipole, charges, groups, massFractions, valency}; `/validate` переведён на
|
||||||
- [ ] 2.2 API `POST /api/biochem/analyze` (atoms,bonds → {formula, mass, dbe, dipole, polarity, charges, groups, hybridization}). Заменить фронтовую эвристику.
|
> ядро (плюс чинит баг формата связей b.o/order). 2.4: `BIO.valency` с
|
||||||
- [ ] 2.3 В редакторе: тепловая карта частичных зарядов (toggle), стрелка диполя в 3D, панель «геометрия и полярность».
|
> подсказками («Углерод (C): занято 5 связей, максимум 4 — убери 1»),
|
||||||
- [ ] 2.4 Расширенная валидация: вместо «лимит превышен» — подсказки («у C занято 5 связей, максимум 4», «кислород обычно 2 связи»).
|
> используется и в редакторе, и на сервере.
|
||||||
|
|
||||||
|
- [x] 2.1 `backend/src/services/chem.js`: переиспользует ядро `BIO` (полярность/диполь по 3D-VSEPR, частичные заряды, DBE/масса/массовые доли, гибридизация, функциональные группы) — без дубля логики на сервере.
|
||||||
|
- [x] 2.2 API `POST /api/biochem/analyze` (atoms,bonds → {formula, mass, dbe, dipole, polarity, charges, groups, hybridization, valency}). Живой анализ в редакторе оставлен client-side (мгновенно); сервер — авторитетный расчёт + валидация на сохранении.
|
||||||
|
- [x] 2.3 В редакторе: тепловая карта частичных зарядов (toggle), стрелка диполя в 3D, панель «геометрия и полярность».
|
||||||
|
- [x] 2.4 Расширенная валидация: `BIO.valency` даёт подсказки («Углерод (C): занято 5 связей, максимум 4 — убери 1») вместо «лимит превышен»; единая логика в редакторе и на сервере.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user