import { ParserOptionsSpec } from "./ParserOptionsSpec"; /** * Apply a 3DMOL_STYLE JSON block to atom bond styles and model data. * * JSON format: * {"stick":{"radius":0.15},"bonds":{"1-5":{"color1":"red","color2":"blue"}}} * * - "bonds" maps "serial1-serial2" (1-based) to BondStyle properties, * set on atom.bondStyles[] directly. Supported properties: * - color1, color2: overall bond half-colors * - dashedBondConfig: { solidColor, dashedColor } for fractional bond color overrides * - dashedBondFlip: override which side gets the dashed line (boolean) * - singleBond, radius, iswire * When serials are given in reverse order, per-half color properties are swapped. * - All other keys are AtomStyleSpec, stored on atoms.modelData for the frame. */ const apply3DmolStyle = function ( jsonStr: string, atoms: any, serialToIndex: number[], start: number ) { let styleObj; try { styleObj = JSON.parse(jsonStr); } catch (e) { console.warn("3DMOL_STYLE: invalid JSON: " + jsonStr); return; } const frameIdx = atoms.length - 1; const frameAtoms = atoms[frameIdx]; if (styleObj.bonds && typeof styleObj.bonds === "object") { const bonds = styleObj.bonds; for (const bondKey in bonds) { if (!bonds.hasOwnProperty(bondKey)) continue; const parts = bondKey.split("-"); if (parts.length !== 2) continue; const serial1 = parseInt(parts[0]) - 1 + start; const serial2 = parseInt(parts[1]) - 1 + start; const idx1 = serialToIndex[serial1]; const idx2 = serialToIndex[serial2]; if (idx1 == null || idx2 == null) continue; const bondProps = bonds[bondKey]; // Rendering only checks bondStyles on the lower-indexed atom const lowerIdx = idx1 < idx2 ? idx1 : idx2; const higherIdx = idx1 < idx2 ? idx2 : idx1; const lowerAtom = frameAtoms[lowerIdx]; const bondPos = lowerAtom.bonds.indexOf(higherIdx); if (bondPos < 0) continue; if (!lowerAtom.bondStyles) lowerAtom.bondStyles = []; if (!lowerAtom.bondStyles[bondPos]) lowerAtom.bondStyles[bondPos] = {}; // If serials were given in reverse order, swap per-half color properties if (idx1 > idx2) { if (bondProps.color1 || bondProps.color2) { [bondProps.color1, bondProps.color2] = [bondProps.color2, bondProps.color1]; } } Object.assign(lowerAtom.bondStyles[bondPos], bondProps); } delete styleObj.bonds; } if (Object.keys(styleObj).length > 0) { atoms.modelData[frameIdx].style = styleObj; } }; /** * Parse SDF data items (property blocks) that follow M END. * * Per the CTfile spec, each data item consists of: * - A data header line starting with ">" containing a field name in < > * - One or more data lines (up to 200 chars each per spec) * - A blank line terminator * * The record ends at the $$$$ delimiter. * * @returns offset past $$$$ (or end of lines if missing) */ const parseSDProperties = function ( lines: string[], offset: number, atoms: any, serialToIndex: number[], start: number ): number { // Advance past M END (V3000 callers may still be inside structure blocks) while (offset < lines.length && !lines[offset].startsWith("M END")) offset++; if (offset < lines.length) offset++; // past M END while (offset < lines.length) { const line = lines[offset]; let trimmed = line.trim(); offset++; if (trimmed === "$$$$") return offset; // Data header: starts with >, field name in angle brackets const match = line.match(/^>\s*<([^>]+)>/); if (!match) continue; const propName = match[1].trim(); // Collect data lines until blank line, next header, or $$$$ const data: string[] = []; while (offset < lines.length) { trimmed = lines[offset].trim(); if (trimmed === "" || trimmed === "$$$$" || lines[offset].charAt(0) === '>') break; data.push(lines[offset]); offset++; } if (propName === "3DMOL_STYLE" && data.length > 0) { apply3DmolStyle(data.join(" ").trim(), atoms, serialToIndex, start); } } return offset; }; /** Add a bond between two atoms (bidirectional). */ const addBond = function (curFrame: any[], from: number, to: number, order: number) { curFrame[from].bonds.push(to); curFrame[from].bondOrder.push(order); curFrame[to].bonds.push(from); curFrame[to].bondOrder.push(order); }; const parseV2000 = function (lines: any, options: ParserOptionsSpec) { const atoms: any & Record = [[]]; const modelData = atoms.modelData = [] as any[]; modelData.push({}); // entry for frame 0 const noH = typeof options.keepH !== "undefined" ? !options.keepH : false; let lineIndex = 0; while (lineIndex < lines.length) { if (lines.length - lineIndex < 4) break; const atomCount = parseInt(lines[lineIndex + 3].substring(0, 3)); if (isNaN(atomCount) || atomCount <= 0) break; const bondCount = parseInt(lines[lineIndex + 3].substring(3, 6)); let offset = 4; if (lines.length - lineIndex < 4 + atomCount + bondCount) break; // Serial is atom's index in file; index is atoms index in 'atoms' const serialToIndex: number[] = []; const curFrame = atoms[atoms.length - 1]; const start = curFrame.length; const end = start + atomCount; for (let i = start; i < end; i++, offset++) { const line = lines[lineIndex + offset]; const atom: Record = {}; const elem = line.substring(31, 34).replace(/ /g, ""); atom.atom = atom.elem = elem[0].toUpperCase() + elem.substring(1).toLowerCase(); if (atom.elem !== "H" || !noH) { atom.serial = i; serialToIndex[i] = curFrame.length; atom.x = parseFloat(line.substring(0, 10)); atom.y = parseFloat(line.substring(10, 20)); atom.z = parseFloat(line.substring(20, 30)); atom.hetflag = true; atom.bonds = []; atom.bondOrder = []; atom.properties = {}; atom.index = curFrame.length; curFrame.push(atom); } } for (let i = 0; i < bondCount; i++, offset++) { const line = lines[lineIndex + offset]; const from = serialToIndex[parseInt(line.substring(0, 3)) - 1 + start]; const to = serialToIndex[parseInt(line.substring(3, 6)) - 1 + start]; const order = parseFloat(line.substring(6)); if (typeof from != "undefined" && typeof to != "undefined") { addBond(curFrame, from, to, order); } } // Parse M END and property blocks; returns absolute line index past $$$$ lineIndex = parseSDProperties(lines, lineIndex + offset, atoms, serialToIndex, start); if (!options.multimodel) break; if (!options.onemol) { atoms.push([]); modelData.push({}); // 1:1 with frames — prevents undefined on frame switch } } return atoms; }; /** * @param {!Array.} lines * @param {ParserOptionsSpec} options * @returns {!Array.>} */ const parseV3000 = function (lines: any, options: ParserOptionsSpec) { const atoms: any[][] & Record = [[]]; const modelData = atoms.modelData = [] as any[]; modelData.push({}); // entry for frame 0 const noH = typeof options.keepH !== "undefined" ? !options.keepH : false; let lineIndex = 0; while (lineIndex < lines.length) { if (lines.length - lineIndex < 8) break; if (!lines[lineIndex + 4].startsWith("M V30 BEGIN CTAB")) break; if (!lines[lineIndex + 5].startsWith("M V30 COUNTS") || lines[lineIndex + 5].length < 14) break; const counts = lines[lineIndex + 5].substring(13).match(/\S+/g); if (counts.length < 2) break; const atomCount = parseInt(counts[0]); if (isNaN(atomCount) || atomCount <= 0) break; const bondCount = parseInt(counts[1]); let offset = 7; if (lines.length - lineIndex < 8 + atomCount + bondCount) //header, bgn+end CTAB, counts, END break; // serial is atom's index in file; index is atoms index in 'atoms' const serialToIndex: number[] = []; const curFrame = atoms[atoms.length - 1]; const start = curFrame.length; const end = start + atomCount; for (let i = start; i < end; i++, offset++) { const line = lines[lineIndex + offset]; const atomParts = line.substring(6).match(/\S+/g); if (atomParts!.length > 4) { const atom: Record = {}; const elem = atomParts![1].replace(/ /g, ""); atom.atom = atom.elem = elem[0].toUpperCase() + elem.substring(1).toLowerCase(); if (atom.elem !== "H" || !noH) { atom.serial = i; serialToIndex[i] = curFrame.length; atom.x = parseFloat(atomParts![2]); atom.y = parseFloat(atomParts![3]); atom.z = parseFloat(atomParts![4]); atom.hetflag = true; atom.bonds = []; atom.bondOrder = []; atom.properties = {}; atom.index = curFrame.length; curFrame.push(atom); } } } if (lines[lineIndex + offset] === "M V30 END ATOM") offset++; else break; if (bondCount !== 0 && lines[lineIndex + offset] === "M V30 BEGIN BOND") offset++; else break; for (let i = 0; i < bondCount; i++, offset++) { const line = lines[lineIndex + offset]; const bondParts = line.substring(6).match(/\S+/g); if (bondParts!.length > 3) { const from = serialToIndex[parseInt(bondParts![2]) - 1 + start]; const to = serialToIndex[parseInt(bondParts![3]) - 1 + start]; const order = parseFloat(bondParts![1]); if (typeof from != "undefined" && typeof to != "undefined") { addBond(curFrame, from, to, order); } } } // Parse remaining V3000 structure blocks and property blocks lineIndex = parseSDProperties(lines, lineIndex + offset, atoms, serialToIndex, start); if (!options.multimodel) break; if (!options.onemol) { atoms.push([]); modelData.push({}); // 1:1 with frames } } return atoms; }; /** * @param {string} * str * @param {ParserOptionsSpec} * options * @category Parsers */ export function SDF(str: string, options: ParserOptionsSpec) { let molformat = "V2000"; const lines = str.split(/\r?\n|\r/); if (lines.length > 3 && lines[3].length > 38) { molformat = lines[3].substring(34, 39); } if (molformat === "V2000") { return parseV2000(lines, options); } else if (molformat === "V3000") { return parseV3000(lines, options); } return [['']]; }