feat(biochem): Фаза 7 — импорт SMILES + экспорт PNG/JSON

BIO.parseSmiles — парсер учебного подмножества SMILES (органические атомы
верхнего регистра, связи -=#, ветви (), замыкание циклов цифрами/%nn,
неявные H по валентности, 2D-укладка BFS). BIO.toJSON/download.

biochem.html: поле ввода SMILES + кнопка Импорт (Enter), кнопки экспорта
PNG (текущий холст 2D/3D) и JSON.

Проверено: CCO→C2H6O, CC(=O)O→C2H4O2, C1=CC=CC=C1→C6H6 (Кекуле),
ClC(Cl)(Cl)Cl→CCl4, OCC(O)CO→C3H8O3 (глицерин); мусор отсекается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 13:24:55 +03:00
parent a97896d293
commit af25a845c9
2 changed files with 164 additions and 1 deletions
+48
View File
@@ -435,6 +435,18 @@
<button class="bp-btn bp-btn-secondary" onclick="loadFromLibrary()">
Загрузить из библиотеки
</button>
<!-- SMILES import -->
<div style="display:flex;gap:6px;margin-top:8px">
<input type="text" id="smiles-in" placeholder="SMILES, напр. CCO" spellcheck="false"
onkeydown="if(event.key==='Enter')importSmiles()"
style="flex:1;min-width:0;padding:7px 10px;border-radius:8px;background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.12);color:#ddd;font:600 .78rem monospace">
<button class="bp-btn bp-btn-secondary" style="width:auto;margin:0;padding:0 14px" onclick="importSmiles()">Импорт</button>
</div>
<!-- Export -->
<div style="display:flex;gap:6px;margin-top:6px">
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportPNG()">PNG</button>
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportJSON()">JSON</button>
</div>
</div>
<div class="bp-section" id="bp-active-challenge" style="display:none">
<div class="bp-label" id="bp-chal-type-label">Текущее задание</div>
@@ -1260,6 +1272,42 @@ async function saveCurrentMolecule() {
} catch(e) { LS.toast('Ошибка: '+e.message, 'error'); }
}
// ── SMILES import ──
function importSmiles() {
const inp = document.getElementById('smiles-in');
const smi = (inp.value || '').trim();
if (!smi) return;
const parsed = BIO.parseSmiles(smi);
if (!parsed || !parsed.atoms.length) {
LS.toast('Не удалось разобрать SMILES (поддержан верхний регистр: CCO, C1=CC=CC=C1)', 'error');
return;
}
pushHistory();
// переносим в редактор (bonds в формате {from,to,order})
const idMap = {};
atoms = parsed.atoms.map(a => { const nid = nextId++; idMap[a.id] = nid; return { id: nid, s: a.s, x: a.x, y: a.y }; });
bonds = parsed.bonds.map(b => ({ id: nextId++, from: idMap[b.f], to: idMap[b.t], order: b.o }));
inp.value = '';
if (_is3D) _build3D();
centerView(); updateInfo();
LS.toast(`Импортировано: ${BIO.hillFormula(atoms)}`, 'success');
}
// ── Export ──
function exportPNG() {
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = (hillFormula() || 'molecule') + (_is3D ? '-3d' : '') + '.png';
a.click();
}
function exportJSON() {
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
BIO.download((hillFormula() || 'molecule') + '.json',
BIO.toJSON(atoms, bonds.map(b => ({ f: b.from, t: b.to, o: b.order })), hillFormula()),
'application/json');
}
// ── Library ──
async function loadFromLibrary() {
if (!_libAll.length) _libAll = await LS.biochemGetMolecules();
+116 -1
View File
@@ -304,6 +304,121 @@
return { coefficients: ints, reactants: ints.slice(0, nR), products: ints.slice(nR) };
}
/* ── Парсер SMILES (учебное подмножество) ─────────────────────────────────
* Поддержка: органические атомы в ВЕРХНЕМ регистре (B,C,N,O,P,S,F,Cl,Br,I,H),
* связи -, =, #, ветви ( ), замыкание циклов цифрами и %nn. Неявные H
* достраиваются по валентности. Возврат {atoms:[{id,s,x,y}], bonds:[{f,t,o}]}
* или null. Ароматика в нижнем регистре (c,n,o…) НЕ поддержана — используйте
* форму Кекуле (C1=CC=CC=C1). 2D-укладка — BFS с разводом углов.
*/
function parseSmiles(str) {
if (!str || typeof str !== 'string') return null;
str = str.trim().replace(/\s+/g, '');
if (!str) return null;
const atoms = [], bonds = [];
let id = 1;
const twoLetter = { C: ['Cl'], B: ['Br'] };
const known = new Set(['B','C','N','O','P','S','F','I','H','Cl','Br']);
const stack = []; // для ветвей: сохранённые «текущие» атомы
const ring = {}; // digit -> {atom, order}
let prev = null; // предыдущий атом для связи
let pendingOrder = 0; // 0 = по умолчанию (1)
let i = 0;
const addAtom = s => { const a = { id: id++, s, x: 0, y: 0 }; atoms.push(a); return a; };
const addBond = (f, t, o) => { if (f === t) return; bonds.push({ f, t, o: o || 1 }); };
while (i < str.length) {
const ch = str[i];
if (ch === '(') { stack.push(prev); i++; continue; }
if (ch === ')') { prev = stack.pop() ?? prev; i++; continue; }
if (ch === '-') { pendingOrder = 1; i++; continue; }
if (ch === '=') { pendingOrder = 2; i++; continue; }
if (ch === '#') { pendingOrder = 3; i++; continue; }
if (ch === '%') {
const d = str.slice(i + 1, i + 3);
i += 3;
_ringClose(d);
continue;
}
if (ch >= '0' && ch <= '9') { _ringClose(ch); i++; continue; }
// атом: пробуем двухбуквенный
let sym = null;
const pair = str.slice(i, i + 2);
if (twoLetter[ch] && twoLetter[ch].includes(pair)) { sym = pair; i += 2; }
else if (known.has(ch)) { sym = ch; i += 1; }
else return null; // неподдержанный символ (в т.ч. строчная ароматика, [..])
const a = addAtom(sym);
if (prev) addBond(prev.id, a.id, pendingOrder || 1);
pendingOrder = 0;
prev = a;
}
function _ringClose(d) {
if (ring[d]) { addBond(ring[d].atom.id, prev.id, ring[d].order || pendingOrder || 1); delete ring[d]; pendingOrder = 0; }
else { ring[d] = { atom: prev, order: pendingOrder || 0 }; pendingOrder = 0; }
}
if (!atoms.length) return null;
// неявные H по валентности
const sumOrder = {}; atoms.forEach(a => sumOrder[a.id] = 0);
for (const b of bonds) { sumOrder[b.f] += b.o; sumOrder[b.t] += b.o; }
const heavy = atoms.slice();
for (const a of heavy) {
if (a.s === 'H') continue;
const maxV = el(a.s).maxV || 4;
const need = maxV - (sumOrder[a.id] || 0);
for (let k = 0; k < need; k++) { const h = addAtom('H'); addBond(a.id, h.id, 1); }
}
_layout2D(atoms, bonds);
return { atoms, bonds };
}
// Простая 2D-укладка: BFS, развод связей по углам, длина ~55
function _layout2D(atoms, bonds) {
const byId = {}; atoms.forEach(a => byId[a.id] = a);
const adj = {}; atoms.forEach(a => adj[a.id] = []);
for (const b of bonds) { adj[b.f].push(b.t); adj[b.t].push(b.f); }
const placed = new Set();
const L = 55;
let root = atoms[0];
for (const a of atoms) if (adj[a.id].length > adj[root.id].length) root = a;
root.x = 0; root.y = 0; placed.add(root.id);
const q = [{ id: root.id, dir: 0 }];
while (q.length) {
const { id, dir } = q.shift();
const cur = byId[id];
const nb = adj[id].filter(n => !placed.has(n));
const n = nb.length;
// развод: вокруг направления «от родителя», сектор ~270°
const spread = Math.PI * 1.5;
nb.forEach((nid, k) => {
const ang = dir + (n === 1 ? 0.6 : (-spread / 2 + spread * (k / Math.max(1, n - 1)))) ;
const c = byId[nid];
c.x = cur.x + Math.cos(ang) * L;
c.y = cur.y + Math.sin(ang) * L;
placed.add(nid);
q.push({ id: nid, dir: ang });
});
}
}
/* ── Экспорт молекулы ───────────────────────────────────────────────────── */
function toJSON(atoms, bonds, name) {
return JSON.stringify({
name: name || hillFormula(atoms),
formula: hillFormula(atoms),
atoms: atoms.map(a => ({ id: a.id, s: a.s, x: Math.round(a.x), y: Math.round(a.y) })),
bonds: (bonds || []).map(b => ({ f: bF(b), t: bT(b), o: bO(b) })),
}, null, 2);
}
function download(filename, content, mime) {
const blob = new Blob([content], { type: mime || 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
/* ── 2D-рендер (ball-and-stick для превью) ────────────────────────────────
* atoms: [{s,x,y}] bonds: [{f,t,o}] | [{from,to,order}]
* opts: { fit:true|false, padding, bg, lineColor, showSymbols, hideH, scale }
@@ -802,7 +917,7 @@
bF, bT, bO,
counts, hillFormula, molarMass, parseFormula, dbe,
partialCharges, dipole, polarity, massFractions, functionalGroups, analyze,
balance,
balance, parseSmiles, toJSON, download,
render2D, vsepr, render3D, chargeColor,
safe, RING_TEMPLATES,
_hexRgb, _lighten, _darken,