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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user