selfies-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/package.json +65 -0
- package/src/alphabet.js +150 -0
- package/src/alphabet.test.js +82 -0
- package/src/chemistryValidator.js +236 -0
- package/src/cli.js +206 -0
- package/src/constraints.js +186 -0
- package/src/constraints.test.js +126 -0
- package/src/decoder.js +636 -0
- package/src/decoder.test.js +560 -0
- package/src/dsl/analyzer.js +170 -0
- package/src/dsl/analyzer.test.js +139 -0
- package/src/dsl/dsl.test.js +146 -0
- package/src/dsl/importer.js +238 -0
- package/src/dsl/index.js +32 -0
- package/src/dsl/lexer.js +264 -0
- package/src/dsl/lexer.test.js +115 -0
- package/src/dsl/parser.js +201 -0
- package/src/dsl/parser.test.js +148 -0
- package/src/dsl/resolver.js +136 -0
- package/src/dsl/resolver.test.js +99 -0
- package/src/dsl/symbolTable.js +56 -0
- package/src/dsl/symbolTable.test.js +68 -0
- package/src/dsl/valenceValidator.js +147 -0
- package/src/encoder.js +467 -0
- package/src/encoder.test.js +61 -0
- package/src/errors.js +79 -0
- package/src/errors.test.js +91 -0
- package/src/grammar_rules.js +146 -0
- package/src/index.js +70 -0
- package/src/parser.js +96 -0
- package/src/parser.test.js +96 -0
- package/src/properties/atoms.js +69 -0
- package/src/properties/atoms.test.js +116 -0
- package/src/properties/formula.js +111 -0
- package/src/properties/formula.test.js +95 -0
- package/src/properties/molecularWeight.js +80 -0
- package/src/properties/molecularWeight.test.js +84 -0
- package/src/properties/properties.test.js +77 -0
- package/src/renderers/README.md +127 -0
- package/src/renderers/svg.js +113 -0
- package/src/renderers/svg.test.js +42 -0
- package/src/syntax.js +641 -0
- package/src/syntax.test.js +363 -0
- package/src/tokenizer.js +99 -0
- package/src/tokenizer.test.js +55 -0
- package/src/validator.js +70 -0
- package/src/validator.test.js +44 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for molecular formula generation
|
|
3
|
+
*
|
|
4
|
+
* Note: Also see properties.test.js for more tests
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from 'bun:test'
|
|
8
|
+
import { getFormula } from './formula.js'
|
|
9
|
+
|
|
10
|
+
describe('getFormula', () => {
|
|
11
|
+
// TODO: Simple molecules
|
|
12
|
+
test('generates methane formula', () => {
|
|
13
|
+
// TODO: expect(getFormula('[C]')).toBe('CH4')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('generates ethane formula', () => {
|
|
17
|
+
// TODO: expect(getFormula('[C][C]')).toBe('C2H6')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('generates ethanol formula', () => {
|
|
21
|
+
// TODO: expect(getFormula('[C][C][O]')).toBe('C2H6O')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('generates water formula', () => {
|
|
25
|
+
// TODO: expect(getFormula('[O]')).toBe('H2O')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// TODO: Hill notation rules
|
|
29
|
+
test('puts carbon first', () => {
|
|
30
|
+
// TODO: const formula = getFormula('[N][C][C]')
|
|
31
|
+
// TODO: expect(formula[0]).toBe('C')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('puts hydrogen second', () => {
|
|
35
|
+
// TODO: const formula = getFormula('[C][C][O]')
|
|
36
|
+
// TODO: expect(formula).toMatch(/^C\d+H/)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('orders other elements alphabetically', () => {
|
|
40
|
+
// TODO: const formula = getFormula('[C][S][N][O]')
|
|
41
|
+
// TODO: After C and H, should be N, O, S (alphabetical)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('omits count of 1', () => {
|
|
45
|
+
// TODO: expect(getFormula('[C][O][O]')).toBe('CH2O2')
|
|
46
|
+
// TODO: Not CH2O2 but C, not C1
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('handles no carbon', () => {
|
|
50
|
+
// TODO: const formula = getFormula('[N][O]')
|
|
51
|
+
// TODO: Should still have H first if present, then alphabetical
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// TODO: Different bond types
|
|
55
|
+
test('accounts for double bonds', () => {
|
|
56
|
+
// TODO: expect(getFormula('[C][=C]')).toBe('C2H4')
|
|
57
|
+
// TODO: Ethene, not ethane
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('accounts for triple bonds', () => {
|
|
61
|
+
// TODO: expect(getFormula('[C][#C]')).toBe('C2H2')
|
|
62
|
+
// TODO: Acetylene
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// TODO: Complex structures
|
|
66
|
+
test('handles branching', () => {
|
|
67
|
+
// TODO: const formula = getFormula('[C][C][Branch1][C][C][C]')
|
|
68
|
+
// TODO: expect(formula).toBe('C4H10') // Isobutane
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('handles rings', () => {
|
|
72
|
+
// TODO: const formula = getFormula('[C][=C][C][=C][C][=C][Ring1][=Branch1]')
|
|
73
|
+
// TODO: expect(formula).toBe('C6H6') // Benzene
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// TODO: Edge cases
|
|
77
|
+
test('handles single atom', () => {
|
|
78
|
+
// TODO: expect(getFormula('[C]')).toBe('CH4')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('handles no hydrogen', () => {
|
|
82
|
+
// TODO: const formula = getFormula('[C][Cl][Cl][Cl][Cl]')
|
|
83
|
+
// TODO: CCl4 - no hydrogen atoms
|
|
84
|
+
// TODO: expect(formula).toBe('CCl4')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// TODO: Error cases
|
|
88
|
+
test('throws on invalid SELFIES', () => {
|
|
89
|
+
// TODO: expect(() => getFormula('[Xyz]')).toThrow()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('throws on malformed SELFIES', () => {
|
|
93
|
+
// TODO: expect(() => getFormula('[C][C')).toThrow()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Molecular Weight - Calculates molecular weight from SELFIES
|
|
3
|
+
*
|
|
4
|
+
* Computes the molecular weight by parsing the SELFIES string,
|
|
5
|
+
* determining the molecular formula, and summing atomic masses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { tokenize } from '../tokenizer.js'
|
|
9
|
+
import { parse } from '../parser.js'
|
|
10
|
+
import { getAtomicMass } from './atoms.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calculates molecular weight of a SELFIES molecule
|
|
14
|
+
* @param {string} selfies - SELFIES string
|
|
15
|
+
* @returns {number} Molecular weight in g/mol
|
|
16
|
+
*
|
|
17
|
+
* Example:
|
|
18
|
+
* getMolecularWeight('[C][C][O]') // => 46.068 (ethanol: C2H6O)
|
|
19
|
+
* getMolecularWeight('[C]') // => 16.043 (methane: CH4)
|
|
20
|
+
*/
|
|
21
|
+
export function getMolecularWeight(selfies) {
|
|
22
|
+
const tokens = tokenize(selfies)
|
|
23
|
+
const ir = parse(tokens)
|
|
24
|
+
|
|
25
|
+
const counts = countAtoms(ir)
|
|
26
|
+
|
|
27
|
+
let totalWeight = 0
|
|
28
|
+
for (const [element, count] of Object.entries(counts)) {
|
|
29
|
+
totalWeight += getAtomicMass(element) * count
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return totalWeight
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counts atoms in molecule IR (including implicit hydrogens)
|
|
37
|
+
* @param {Object} ir - Molecule internal representation
|
|
38
|
+
* @returns {Object} Map of element to count
|
|
39
|
+
*
|
|
40
|
+
* Example return:
|
|
41
|
+
* { 'C': 2, 'H': 6, 'O': 1 } // for ethanol
|
|
42
|
+
*/
|
|
43
|
+
function countAtoms(ir) {
|
|
44
|
+
const counts = {}
|
|
45
|
+
|
|
46
|
+
// Count explicit atoms and calculate used valence
|
|
47
|
+
for (const atom of ir.atoms) {
|
|
48
|
+
counts[atom.element] = (counts[atom.element] || 0) + 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Calculate used valence from bonds
|
|
52
|
+
const usedValence = new Array(ir.atoms.length).fill(0)
|
|
53
|
+
for (const bond of ir.bonds) {
|
|
54
|
+
usedValence[bond.from] += bond.order
|
|
55
|
+
usedValence[bond.to] += bond.order
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Add implicit hydrogens
|
|
59
|
+
let totalH = 0
|
|
60
|
+
for (let i = 0; i < ir.atoms.length; i++) {
|
|
61
|
+
const atom = ir.atoms[i]
|
|
62
|
+
const implicitH = Math.max(0, atom.valence - usedValence[i])
|
|
63
|
+
totalH += implicitH
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (totalH > 0) {
|
|
67
|
+
counts['H'] = totalH
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return counts
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Calculates number of implicit hydrogens for an atom
|
|
75
|
+
* @param {Object} atom - Atom from IR
|
|
76
|
+
* @returns {number} Number of implicit hydrogens
|
|
77
|
+
*/
|
|
78
|
+
function getImplicitHydrogens(atom) {
|
|
79
|
+
return atom.valence - atom.usedValence
|
|
80
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for molecular weight calculation
|
|
3
|
+
*
|
|
4
|
+
* Note: Also see properties.test.js for more tests
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from 'bun:test'
|
|
8
|
+
import { getMolecularWeight } from './molecularWeight.js'
|
|
9
|
+
|
|
10
|
+
describe('getMolecularWeight', () => {
|
|
11
|
+
// TODO: Simple molecules
|
|
12
|
+
test('calculates methane (CH4)', () => {
|
|
13
|
+
// TODO: const mw = getMolecularWeight('[C]')
|
|
14
|
+
// TODO: expect(mw).toBeCloseTo(16.043, 2)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('calculates ethane (C2H6)', () => {
|
|
18
|
+
// TODO: const mw = getMolecularWeight('[C][C]')
|
|
19
|
+
// TODO: expect(mw).toBeCloseTo(30.070, 2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('calculates ethanol (C2H6O)', () => {
|
|
23
|
+
// TODO: const mw = getMolecularWeight('[C][C][O]')
|
|
24
|
+
// TODO: expect(mw).toBeCloseTo(46.068, 2)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// TODO: Different elements
|
|
28
|
+
test('includes nitrogen', () => {
|
|
29
|
+
// TODO: const mw = getMolecularWeight('[N][C][C]')
|
|
30
|
+
// TODO: Should include N mass (14.007) + 2 C + H atoms
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('includes oxygen', () => {
|
|
34
|
+
// TODO: const mw = getMolecularWeight('[C][=O]')
|
|
35
|
+
// TODO: Should calculate formaldehyde (CH2O)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('includes sulfur', () => {
|
|
39
|
+
// TODO: const mw = getMolecularWeight('[C][S][C]')
|
|
40
|
+
// TODO: Should include S mass (32.06)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('includes halogens', () => {
|
|
44
|
+
// TODO: const mw = getMolecularWeight('[C][Cl]')
|
|
45
|
+
// TODO: Should calculate chloromethane
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// TODO: Implicit hydrogens
|
|
49
|
+
test('accounts for implicit hydrogens', () => {
|
|
50
|
+
// TODO: const mw = getMolecularWeight('[C]')
|
|
51
|
+
// TODO: C has 4 valence, 0 used → 4 H atoms
|
|
52
|
+
// TODO: MW = 12.011 + 4*1.008 = 16.043
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('reduces H for double bonds', () => {
|
|
56
|
+
// TODO: const mw = getMolecularWeight('[C][=C]')
|
|
57
|
+
// TODO: Ethene: C2H4, not C2H6
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('reduces H for triple bonds', () => {
|
|
61
|
+
// TODO: const mw = getMolecularWeight('[C][#C]')
|
|
62
|
+
// TODO: Acetylene: C2H2
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// TODO: Complex structures
|
|
66
|
+
test('handles branching', () => {
|
|
67
|
+
// TODO: const mw = getMolecularWeight('[C][C][Branch1][C][C][C]')
|
|
68
|
+
// TODO: Isobutane: C4H10
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('handles rings', () => {
|
|
72
|
+
// TODO: const mw = getMolecularWeight('[C][=C][C][=C][C][=C][Ring1][=Branch1]')
|
|
73
|
+
// TODO: Benzene: C6H6 = 78.114
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// TODO: Error cases
|
|
77
|
+
test('throws on invalid SELFIES', () => {
|
|
78
|
+
// TODO: expect(() => getMolecularWeight('[Xyz]')).toThrow()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('throws on malformed SELFIES', () => {
|
|
82
|
+
// TODO: expect(() => getMolecularWeight('[C][C')).toThrow()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for molecular property calculations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test'
|
|
6
|
+
import { getMolecularWeight } from './molecularWeight.js'
|
|
7
|
+
import { getFormula } from './formula.js'
|
|
8
|
+
import { getAtomicMass, getValence } from './atoms.js'
|
|
9
|
+
|
|
10
|
+
describe('getMolecularWeight', () => {
|
|
11
|
+
test('calculates methane molecular weight', () => {
|
|
12
|
+
expect(getMolecularWeight('[C]')).toBeCloseTo(16.043, 1)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('calculates ethanol molecular weight', () => {
|
|
16
|
+
expect(getMolecularWeight('[C][C][O]')).toBeCloseTo(46.068, 1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('calculates benzene molecular weight', () => {
|
|
20
|
+
// TODO: expect(getMolecularWeight('[C][=C][C][=C][C][=C][Ring1][=Branch1]')).toBeCloseTo(78.114, 2)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// TODO: Different elements
|
|
24
|
+
test('calculates with nitrogen', () => {
|
|
25
|
+
// TODO: expect(getMolecularWeight('[N][C][C]')).toBeCloseTo(43.088, 2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('calculates with chlorine', () => {
|
|
29
|
+
// TODO: expect(getMolecularWeight('[Cl][C][C][Cl]')).toBeCloseTo(98.959, 2)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('getFormula', () => {
|
|
34
|
+
test('generates methane formula', () => {
|
|
35
|
+
expect(getFormula('[C]')).toBe('CH4')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('generates ethanol formula', () => {
|
|
39
|
+
expect(getFormula('[C][C][O]')).toBe('C2H6O')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('omits count of 1', () => {
|
|
43
|
+
expect(getFormula('[C][O][O]')).toBe('CH4O2')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('getAtomicMass', () => {
|
|
48
|
+
test('returns carbon mass', () => {
|
|
49
|
+
expect(getAtomicMass('C')).toBeCloseTo(12.011, 2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns oxygen mass', () => {
|
|
53
|
+
expect(getAtomicMass('O')).toBeCloseTo(15.999, 2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('throws on unsupported element', () => {
|
|
57
|
+
expect(() => getAtomicMass('Xx')).toThrow()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('getValence', () => {
|
|
62
|
+
test('returns carbon valence', () => {
|
|
63
|
+
expect(getValence('C')).toBe(4)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('returns nitrogen valence', () => {
|
|
67
|
+
expect(getValence('N')).toBe(3)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('returns oxygen valence', () => {
|
|
71
|
+
expect(getValence('O')).toBe(2)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('throws on unsupported element', () => {
|
|
75
|
+
expect(() => getValence('Xx')).toThrow()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SELFIES Renderers
|
|
2
|
+
|
|
3
|
+
This directory contains rendering modules for visualizing molecular structures from SELFIES AST (Abstract Syntax Tree) representations.
|
|
4
|
+
|
|
5
|
+
## SVG Renderer
|
|
6
|
+
|
|
7
|
+
The SVG renderer converts molecular AST structures into Scalable Vector Graphics (SVG) format for visualization.
|
|
8
|
+
|
|
9
|
+
### Usage
|
|
10
|
+
|
|
11
|
+
```javascript
|
|
12
|
+
import { decodeToAST, renderToSVG } from 'selfies-js'
|
|
13
|
+
|
|
14
|
+
// Decode SELFIES to AST
|
|
15
|
+
const ast = decodeToAST('[C][C][O]')
|
|
16
|
+
|
|
17
|
+
// Render to SVG
|
|
18
|
+
const svg = renderToSVG(ast)
|
|
19
|
+
|
|
20
|
+
// Save or use the SVG string
|
|
21
|
+
console.log(svg)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Options
|
|
25
|
+
|
|
26
|
+
The `renderToSVG` function accepts an optional options object:
|
|
27
|
+
|
|
28
|
+
```javascript
|
|
29
|
+
const svg = renderToSVG(ast, {
|
|
30
|
+
width: 400, // Canvas width (default: 400)
|
|
31
|
+
height: 300, // Canvas height (default: 300)
|
|
32
|
+
bondLength: 40, // Length of bonds in pixels (default: 40)
|
|
33
|
+
atomRadius: 15, // Radius of atom circles (default: 15)
|
|
34
|
+
fontSize: 14, // Font size for atom labels (default: 14)
|
|
35
|
+
strokeWidth: 2, // Bond line thickness (default: 2)
|
|
36
|
+
bondColor: '#333', // Color of bonds (default: '#333')
|
|
37
|
+
atomColor: '#000', // Color of atom text (default: '#000')
|
|
38
|
+
backgroundColor: 'transparent', // Background color (default: 'transparent')
|
|
39
|
+
padding: 40 // Padding around the molecule (default: 40)
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Features
|
|
44
|
+
|
|
45
|
+
- **Automatic Layout**: Molecules are automatically positioned using a simple layout algorithm
|
|
46
|
+
- **Bond Visualization**: Single, double, and triple bonds are rendered with appropriate styling
|
|
47
|
+
- **Element Colors**: Atoms are colored according to chemistry conventions:
|
|
48
|
+
- Carbon (C): Gray
|
|
49
|
+
- Nitrogen (N): Blue
|
|
50
|
+
- Oxygen (O): Red
|
|
51
|
+
- Sulfur (S): Yellow
|
|
52
|
+
- Phosphorus (P): Orange
|
|
53
|
+
- Halogens (F, Cl, Br, I): Various greens and purples
|
|
54
|
+
- **Ring Structures**: Properly handles ring closures
|
|
55
|
+
- **Stereo Chemistry**: Displays stereochemistry notation (e.g., C@, C@@)
|
|
56
|
+
- **Branching**: Correctly visualizes branched molecules
|
|
57
|
+
|
|
58
|
+
### Examples
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
// Simple molecule
|
|
62
|
+
const methane = decodeToAST('[C]')
|
|
63
|
+
const svg1 = renderToSVG(methane)
|
|
64
|
+
|
|
65
|
+
// Molecule with double bond
|
|
66
|
+
const ethene = decodeToAST('[C][=C]')
|
|
67
|
+
const svg2 = renderToSVG(ethene)
|
|
68
|
+
|
|
69
|
+
// Branched molecule
|
|
70
|
+
const isobutane = decodeToAST('[C][C][Branch1][C][C][C]')
|
|
71
|
+
const svg3 = renderToSVG(isobutane)
|
|
72
|
+
|
|
73
|
+
// Ring structure
|
|
74
|
+
const cyclopropane = decodeToAST('[C][C][C][Ring1][C]')
|
|
75
|
+
const svg4 = renderToSVG(cyclopropane)
|
|
76
|
+
|
|
77
|
+
// Complex aromatic ring
|
|
78
|
+
const benzene = decodeToAST('[C][=C][C][=C][C][=C][Ring1][=Branch1]')
|
|
79
|
+
const svg5 = renderToSVG(benzene)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Saving SVG Files
|
|
83
|
+
|
|
84
|
+
To save SVG output to files (Node.js):
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
import { writeFileSync } from 'fs'
|
|
88
|
+
import { decodeToAST, renderToSVG } from 'selfies-js'
|
|
89
|
+
|
|
90
|
+
const ast = decodeToAST('[C][C][O]')
|
|
91
|
+
const svg = renderToSVG(ast)
|
|
92
|
+
|
|
93
|
+
writeFileSync('molecule.svg', svg)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Browser Usage
|
|
97
|
+
|
|
98
|
+
In the browser, you can insert the SVG directly into the DOM:
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
import { decodeToAST, renderToSVG } from 'selfies-js'
|
|
102
|
+
|
|
103
|
+
const ast = decodeToAST('[C][C][O]')
|
|
104
|
+
const svg = renderToSVG(ast)
|
|
105
|
+
|
|
106
|
+
// Insert into page
|
|
107
|
+
document.getElementById('molecule-container').innerHTML = svg
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Future Renderers
|
|
111
|
+
|
|
112
|
+
This directory is designed to accommodate additional renderers in the future:
|
|
113
|
+
|
|
114
|
+
- **Canvas Renderer**: For rendering to HTML5 Canvas
|
|
115
|
+
- **3D Renderer**: For three-dimensional molecular visualization
|
|
116
|
+
- **ASCII Renderer**: For text-based terminal visualization
|
|
117
|
+
- **WebGL Renderer**: For high-performance 3D rendering
|
|
118
|
+
|
|
119
|
+
## Architecture
|
|
120
|
+
|
|
121
|
+
All renderers follow a common pattern:
|
|
122
|
+
|
|
123
|
+
1. Accept an AST object with `atoms`, `bonds`, and `rings` arrays
|
|
124
|
+
2. Accept an optional configuration object
|
|
125
|
+
3. Return a string or data structure representing the visualization
|
|
126
|
+
|
|
127
|
+
This allows for consistent usage across different rendering backends.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Renderer using RDKit.js
|
|
3
|
+
*
|
|
4
|
+
* Uses RDKit's MinimalLib to generate proper 2D coordinates and render molecules
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import initRDKitModule from '@rdkit/rdkit'
|
|
8
|
+
import { decode } from '../decoder.js'
|
|
9
|
+
|
|
10
|
+
let RDKitModule = null
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize RDKit module (async, only done once)
|
|
14
|
+
*/
|
|
15
|
+
async function initRDKit() {
|
|
16
|
+
if (!RDKitModule) {
|
|
17
|
+
RDKitModule = await initRDKitModule()
|
|
18
|
+
}
|
|
19
|
+
return RDKitModule
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default rendering options
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_OPTIONS = {
|
|
26
|
+
width: 300,
|
|
27
|
+
height: 300,
|
|
28
|
+
backgroundColor: 'transparent'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders a molecular structure to SVG using RDKit
|
|
33
|
+
* @param {Object} ast - AST object (not used, we use SMILES instead)
|
|
34
|
+
* @param {Object} options - Rendering options (optional)
|
|
35
|
+
* @returns {Promise<string>} SVG string representation
|
|
36
|
+
*/
|
|
37
|
+
export async function renderToSVG(ast, options = {}) {
|
|
38
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
39
|
+
|
|
40
|
+
// We need SMILES for RDKit, so if we have an AST, we need to convert back
|
|
41
|
+
// For now, we'll need the original SELFIES or generate SMILES from AST
|
|
42
|
+
throw new Error('renderToSVG with AST not yet implemented - use renderSelfies instead')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a SELFIES string to SVG using RDKit
|
|
47
|
+
* @param {string} selfies - SELFIES string
|
|
48
|
+
* @param {Object} options - Rendering options (optional)
|
|
49
|
+
* @returns {Promise<string>} SVG string representation
|
|
50
|
+
*/
|
|
51
|
+
export async function renderSelfies(selfies, options = {}) {
|
|
52
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
53
|
+
|
|
54
|
+
// Initialize RDKit
|
|
55
|
+
const RDKit = await initRDKit()
|
|
56
|
+
|
|
57
|
+
// Convert SELFIES to SMILES
|
|
58
|
+
const smiles = decode(selfies)
|
|
59
|
+
|
|
60
|
+
// Create molecule from SMILES
|
|
61
|
+
const mol = RDKit.get_mol(smiles)
|
|
62
|
+
|
|
63
|
+
if (!mol || !mol.is_valid()) {
|
|
64
|
+
throw new Error(`Invalid molecule from SMILES: ${smiles}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generate SVG with RDKit
|
|
68
|
+
const svg = mol.get_svg_with_highlights(JSON.stringify({
|
|
69
|
+
width: opts.width,
|
|
70
|
+
height: opts.height,
|
|
71
|
+
addStereoAnnotation: true,
|
|
72
|
+
kekulize: false
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
// Clean up
|
|
76
|
+
mol.delete()
|
|
77
|
+
|
|
78
|
+
return svg
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Synchronous version that returns SVG directly
|
|
83
|
+
* Note: This will throw if RDKit hasn't been initialized
|
|
84
|
+
* @param {string} selfies - SELFIES string
|
|
85
|
+
* @param {Object} options - Rendering options
|
|
86
|
+
* @returns {string} SVG string
|
|
87
|
+
*/
|
|
88
|
+
export function renderSelfiesSync(selfies, options = {}) {
|
|
89
|
+
if (!RDKitModule) {
|
|
90
|
+
throw new Error('RDKit not initialized. Call initRDKit() first or use renderSelfies()')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
94
|
+
const smiles = decode(selfies)
|
|
95
|
+
const mol = RDKitModule.get_mol(smiles)
|
|
96
|
+
|
|
97
|
+
if (!mol || !mol.is_valid()) {
|
|
98
|
+
throw new Error(`Invalid molecule from SMILES: ${smiles}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const svg = mol.get_svg_with_highlights(JSON.stringify({
|
|
102
|
+
width: opts.width,
|
|
103
|
+
height: opts.height,
|
|
104
|
+
addStereoAnnotation: true,
|
|
105
|
+
kekulize: false
|
|
106
|
+
}))
|
|
107
|
+
|
|
108
|
+
mol.delete()
|
|
109
|
+
return svg
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Export init function
|
|
113
|
+
export { initRDKit }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SVG renderer (using RDKit)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeAll } from 'bun:test'
|
|
6
|
+
import { renderSelfies, initRDKit } from './svg.js'
|
|
7
|
+
|
|
8
|
+
describe('SVG Renderer (RDKit)', () => {
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
// Initialize RDKit once before all tests
|
|
11
|
+
await initRDKit()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('renders methane', async () => {
|
|
15
|
+
const svg = await renderSelfies('[C]')
|
|
16
|
+
expect(svg).toContain('<svg')
|
|
17
|
+
expect(svg).toContain('</svg>')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('renders ethane', async () => {
|
|
21
|
+
const svg = await renderSelfies('[C][C]')
|
|
22
|
+
expect(svg).toContain('<svg')
|
|
23
|
+
expect(svg).toContain('path') // RDKit uses paths for bonds
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('renders benzene', async () => {
|
|
27
|
+
const svg = await renderSelfies('[C][=C][C][=C][C][=C][Ring1][=Branch1]')
|
|
28
|
+
expect(svg).toContain('<svg')
|
|
29
|
+
expect(svg).toContain('path')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('renders toluene', async () => {
|
|
33
|
+
const svg = await renderSelfies('[C][C][=C][C][=C][C][=C][Ring1][=N]')
|
|
34
|
+
expect(svg).toContain('<svg')
|
|
35
|
+
expect(svg).toContain('path')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('accepts custom width and height', async () => {
|
|
39
|
+
const svg = await renderSelfies('[C]', { width: 400, height: 400 })
|
|
40
|
+
expect(svg).toContain('400')
|
|
41
|
+
})
|
|
42
|
+
})
|