smiles-js 2.0.3 → 2.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.
@@ -12,8 +12,13 @@ import {
12
12
  cloneSubstitutions,
13
13
  cloneComponents,
14
14
  deepCloneRing,
15
+ deepCloneLinear,
16
+ deepCloneFusedRing,
17
+ deepCloneMolecule,
15
18
  } from './constructors.js';
16
- import { validatePosition, isLinearNode, isMoleculeNode } from './ast.js';
19
+ import {
20
+ validatePosition, isLinearNode, isMoleculeNode, isRingNode, isFusedRingNode,
21
+ } from './ast.js';
17
22
  import { computeFusedRingPositions } from './layout/index.js';
18
23
 
19
24
  /**
@@ -29,10 +34,16 @@ export function ringAttach(ring, position, attachment, options = {}) {
29
34
  updatedAttachments[position] = [];
30
35
  }
31
36
 
32
- // Clone the attachment and set metaIsSibling if provided in options
37
+ // Clone the attachment and set metaIsSibling/metaBeforeInline if provided in options
33
38
  let attachmentToAdd = attachment;
34
- if (options.sibling !== undefined) {
35
- attachmentToAdd = { ...attachment, metaIsSibling: options.sibling };
39
+ if (options.sibling !== undefined || options.beforeInline !== undefined) {
40
+ attachmentToAdd = { ...attachment };
41
+ if (options.sibling !== undefined) {
42
+ attachmentToAdd.metaIsSibling = options.sibling;
43
+ }
44
+ if (options.beforeInline !== undefined) {
45
+ attachmentToAdd.metaBeforeInline = options.beforeInline;
46
+ }
36
47
  }
37
48
 
38
49
  updatedAttachments[position] = [...updatedAttachments[position], attachmentToAdd];
@@ -627,3 +638,397 @@ export function moleculeReplaceComponent(molecule, index, newComponent) {
627
638
  newComponents[index] = newComponent;
628
639
  return createMoleculeNode(newComponents);
629
640
  }
641
+
642
+ /**
643
+ * Generic repeat / polymer methods
644
+ */
645
+
646
+ /**
647
+ * Deep-clone any AST node
648
+ */
649
+ function cloneNode(node) {
650
+ if (isRingNode(node)) return deepCloneRing(node);
651
+ if (isLinearNode(node)) return deepCloneLinear(node);
652
+ if (isFusedRingNode(node)) return deepCloneFusedRing(node);
653
+ if (isMoleculeNode(node)) return deepCloneMolecule(node);
654
+ throw new Error(`Unknown node type: ${node?.type}`);
655
+ }
656
+
657
+ /**
658
+ * Find the maximum ring number used anywhere in a node tree.
659
+ * This is needed to renumber ring copies so ring markers don't collide.
660
+ */
661
+ function maxRingNumber(node) {
662
+ if (isRingNode(node)) {
663
+ let max = node.ringNumber || 0;
664
+ Object.values(node.attachments || {}).forEach((list) => {
665
+ list.forEach((att) => { max = Math.max(max, maxRingNumber(att)); });
666
+ });
667
+ return max;
668
+ }
669
+ if (isFusedRingNode(node)) {
670
+ let max = 0;
671
+ (node.rings || []).forEach((r) => { max = Math.max(max, r.ringNumber || 0); });
672
+ return max;
673
+ }
674
+ if (isMoleculeNode(node)) {
675
+ let max = 0;
676
+ (node.components || []).forEach((c) => { max = Math.max(max, maxRingNumber(c)); });
677
+ return max;
678
+ }
679
+ if (isLinearNode(node)) {
680
+ let max = 0;
681
+ Object.values(node.attachments || {}).forEach((list) => {
682
+ list.forEach((att) => { max = Math.max(max, maxRingNumber(att)); });
683
+ });
684
+ return max;
685
+ }
686
+ return 0;
687
+ }
688
+
689
+ /**
690
+ * Shift all ring numbers in a node by a given delta.
691
+ * Returns a new node (immutable).
692
+ */
693
+ function shiftRingNumbers(node, delta) {
694
+ if (delta === 0) return node;
695
+
696
+ if (isRingNode(node)) {
697
+ const newAttachments = {};
698
+ Object.entries(node.attachments || {}).forEach(([pos, list]) => {
699
+ newAttachments[pos] = list.map((att) => shiftRingNumbers(att, delta));
700
+ });
701
+ return createRingNode(
702
+ node.atoms,
703
+ node.size,
704
+ node.ringNumber + delta,
705
+ node.offset,
706
+ node.substitutions,
707
+ newAttachments,
708
+ node.bonds || [],
709
+ node.metaBranchDepths || null,
710
+ );
711
+ }
712
+
713
+ if (isFusedRingNode(node)) {
714
+ const newRings = (node.rings || []).map((r) => ({
715
+ ...r,
716
+ ringNumber: (r.ringNumber || 1) + delta,
717
+ }));
718
+ return createFusedRingNode(newRings);
719
+ }
720
+
721
+ if (isMoleculeNode(node)) {
722
+ const newComponents = (node.components || []).map((c) => shiftRingNumbers(c, delta));
723
+ return createMoleculeNode(newComponents);
724
+ }
725
+
726
+ if (isLinearNode(node)) {
727
+ const newAttachments = {};
728
+ Object.entries(node.attachments || {}).forEach(([pos, list]) => {
729
+ newAttachments[pos] = list.map((att) => shiftRingNumbers(att, delta));
730
+ });
731
+ return createLinearNode(node.atoms, node.bonds, newAttachments, node.metaLeadingBond);
732
+ }
733
+
734
+ return node;
735
+ }
736
+
737
+ /**
738
+ * Repeat a monomer unit n times, creating a polymer chain.
739
+ *
740
+ * leftId and rightId are 1-indexed positions defining the attachment points.
741
+ * For Linear nodes, leftId=1 and rightId=atoms.length are the natural endpoints.
742
+ * For Ring nodes, leftId=1 and rightId=size are the natural SMILES endpoints
743
+ * (the first and last atoms emitted in the ring's SMILES).
744
+ *
745
+ * The resulting Molecule concatenates n copies. In SMILES, concatenation
746
+ * bonds the last atom of component i to the first atom of component i+1.
747
+ *
748
+ * @param {Object} node - Any AST node (Ring, Linear, FusedRing, Molecule)
749
+ * @param {number} n - Number of repeating units (>= 1)
750
+ * @param {number} leftId - 1-indexed left (incoming) attachment point
751
+ * @param {number} rightId - 1-indexed right (outgoing) attachment point
752
+ * @returns {Object} Molecule node (or clone for n=1)
753
+ */
754
+ export function repeat(node, n, leftId, rightId) {
755
+ if (!Number.isInteger(n) || n < 1) {
756
+ throw new Error('Repeat count n must be an integer >= 1');
757
+ }
758
+ if (!Number.isInteger(leftId) || leftId < 1) {
759
+ throw new Error('leftId must be a positive integer');
760
+ }
761
+ if (!Number.isInteger(rightId) || rightId < 1) {
762
+ throw new Error('rightId must be a positive integer');
763
+ }
764
+
765
+ if (n === 1) {
766
+ return cloneNode(node);
767
+ }
768
+
769
+ // For simple Linear nodes (no attachments), efficiently concatenate atoms arrays
770
+ const hasAttachments = isLinearNode(node) && Object.keys(node.attachments || {}).length > 0;
771
+ if (isLinearNode(node) && !hasAttachments && leftId === 1 && rightId === node.atoms.length) {
772
+ let atoms = [];
773
+ let bonds = [];
774
+ for (let i = 0; i < n; i += 1) {
775
+ atoms = [...atoms, ...node.atoms];
776
+ if (node.bonds.length > 0) {
777
+ bonds = [...bonds, ...node.bonds];
778
+ }
779
+ }
780
+ return createLinearNode(atoms, bonds, {});
781
+ }
782
+
783
+ const maxRing = maxRingNumber(node);
784
+ const copies = [];
785
+ for (let i = 0; i < n; i += 1) {
786
+ const delta = i * maxRing;
787
+ const copy = delta > 0 ? shiftRingNumbers(cloneNode(node), delta) : cloneNode(node);
788
+ copies.push(copy);
789
+ }
790
+
791
+ return createMoleculeNode(copies);
792
+ }
793
+
794
+ /**
795
+ * Repeat a ring n times by fusing, creating an acene-like system.
796
+ *
797
+ * Each new ring shares `offset` atoms with the previous ring,
798
+ * producing linear fused ring systems (naphthalene, anthracene, etc.).
799
+ *
800
+ * @param {Object} ring - Ring AST node
801
+ * @param {number} n - Total number of rings (>= 1)
802
+ * @param {number} offset - Fusion offset (shared atoms between adjacent rings)
803
+ * @returns {Object} Ring (n=1) or FusedRing (n>=2) node
804
+ */
805
+ export function fusedRepeat(ring, n, offset) {
806
+ if (!isRingNode(ring)) {
807
+ throw new Error('fusedRepeat requires a Ring node');
808
+ }
809
+ if (!Number.isInteger(n) || n < 1) {
810
+ throw new Error('Repeat count n must be an integer >= 1');
811
+ }
812
+ if (!Number.isInteger(offset) || offset < 1) {
813
+ throw new Error('Fusion offset must be a positive integer');
814
+ }
815
+
816
+ if (n === 1) {
817
+ return deepCloneRing(ring);
818
+ }
819
+
820
+ // First fusion: ring1.fuse(offset, ring2)
821
+ const ring1 = createRingNode(
822
+ ring.atoms,
823
+ ring.size,
824
+ 1,
825
+ 0,
826
+ ring.substitutions,
827
+ ring.attachments,
828
+ ring.bonds || [],
829
+ ring.metaBranchDepths || null,
830
+ );
831
+ const ring2 = createRingNode(
832
+ ring.atoms,
833
+ ring.size,
834
+ 2,
835
+ offset,
836
+ ring.substitutions,
837
+ {},
838
+ ring.bonds || [],
839
+ null,
840
+ );
841
+ let result = createFusedRingNode([ring1, ring2]);
842
+
843
+ // Additional fusions
844
+ for (let i = 2; i < n; i += 1) {
845
+ const nextRing = createRingNode(
846
+ ring.atoms,
847
+ ring.size,
848
+ i + 1,
849
+ offset,
850
+ ring.substitutions,
851
+ {},
852
+ ring.bonds || [],
853
+ null,
854
+ );
855
+ result = fusedRingAddRing(result, offset + i * (ring.size - offset), nextRing);
856
+ }
857
+
858
+ return result;
859
+ }
860
+
861
+ /**
862
+ * Mirror / symmetry methods
863
+ */
864
+
865
+ /**
866
+ * Mirror a linear chain around a pivot atom to create a palindromic structure.
867
+ *
868
+ * Takes atoms [0..pivotId-1] (left half including pivot), then appends
869
+ * atoms [pivotId-2..0] in reverse. The pivot atom appears once in the center.
870
+ * Bonds and attachments are mirrored accordingly.
871
+ *
872
+ * @param {Object} linear - Linear AST node
873
+ * @param {number} [pivotId] - 1-indexed pivot position (default: atoms.length)
874
+ * @returns {Object} New Linear or Molecule node
875
+ */
876
+ export function linearMirror(linear, pivotId) {
877
+ const pivot = pivotId !== undefined ? pivotId : linear.atoms.length;
878
+ if (!Number.isInteger(pivot) || pivot < 1 || pivot > linear.atoms.length) {
879
+ throw new Error(
880
+ `pivotId must be an integer between 1 and ${linear.atoms.length}`,
881
+ );
882
+ }
883
+
884
+ // Left half: atoms [0..pivot-1] (includes pivot)
885
+ const leftAtoms = linear.atoms.slice(0, pivot);
886
+ // Right half: atoms [0..pivot-2] reversed (excludes pivot)
887
+ const rightAtoms = linear.atoms.slice(0, pivot - 1).reverse();
888
+
889
+ const newAtoms = [...leftAtoms, ...rightAtoms];
890
+
891
+ // Mirror bonds
892
+ // In main-chain format, bonds[i] is the bond between atoms[i] and atoms[i+1]
893
+ // Pad bonds to full length (pivot-1 entries for the left half) so mirror is correct
894
+ const paddedLeftBonds = Array.from(
895
+ { length: pivot - 1 },
896
+ (_, i) => linear.bonds[i] || null,
897
+ );
898
+ const rightBonds = paddedLeftBonds.slice(0, pivot - 1).reverse();
899
+ const newBonds = [...paddedLeftBonds, ...rightBonds];
900
+
901
+ // Mirror attachments
902
+ const leftAttachments = linear.attachments || {};
903
+ const hasAtt = Object.keys(leftAttachments).length > 0;
904
+
905
+ if (!hasAtt) {
906
+ return createLinearNode(newAtoms, newBonds, {});
907
+ }
908
+
909
+ // Attachments need ring renumbering for the mirrored half
910
+ const totalMaxRing = maxRingNumber(linear);
911
+ const newAttachments = cloneAttachments(leftAttachments);
912
+
913
+ // Mirror each attachment position: position p mirrors to (2*pivot - p)
914
+ Object.entries(leftAttachments).forEach(([posStr, attList]) => {
915
+ const pos = Number(posStr);
916
+ if (pos === pivot) return; // pivot attachments stay, no mirror
917
+ const mirrorPos = 2 * pivot - pos;
918
+ if (mirrorPos < 1 || mirrorPos > newAtoms.length) return;
919
+ if (!newAttachments[mirrorPos]) {
920
+ newAttachments[mirrorPos] = [];
921
+ }
922
+ attList.forEach((att) => {
923
+ const shifted = totalMaxRing > 0
924
+ ? shiftRingNumbers(cloneNode(att), totalMaxRing)
925
+ : cloneNode(att);
926
+ newAttachments[mirrorPos] = [...newAttachments[mirrorPos], shifted];
927
+ });
928
+ });
929
+
930
+ return createLinearNode(newAtoms, newBonds, newAttachments);
931
+ }
932
+
933
+ /**
934
+ * Mirror a molecule's component sequence to create an ABA-like structure.
935
+ *
936
+ * Takes components [0..pivot], then appends components [pivot-1..0] in reverse
937
+ * with ring numbers shifted to avoid collisions.
938
+ *
939
+ * @param {Object} molecule - Molecule AST node
940
+ * @param {number} [pivotComponent] - 0-indexed pivot component (default: last)
941
+ * @returns {Object} New Molecule node
942
+ */
943
+ export function moleculeMirror(molecule, pivotComponent) {
944
+ const pivot = pivotComponent !== undefined
945
+ ? pivotComponent
946
+ : molecule.components.length - 1;
947
+ if (!Number.isInteger(pivot) || pivot < 0 || pivot >= molecule.components.length) {
948
+ throw new Error(
949
+ `pivotComponent must be an integer between 0 and ${molecule.components.length - 1}`,
950
+ );
951
+ }
952
+
953
+ // Left half: components [0..pivot] (includes pivot)
954
+ const leftComponents = molecule.components.slice(0, pivot + 1);
955
+ // Right half: components [0..pivot-1] reversed (excludes pivot)
956
+ const rightSources = molecule.components.slice(0, pivot).reverse();
957
+
958
+ // Shift ring numbers in mirrored components
959
+ let totalMaxRing = 0;
960
+ leftComponents.forEach((c) => {
961
+ totalMaxRing = Math.max(totalMaxRing, maxRingNumber(c));
962
+ });
963
+
964
+ const rightComponents = rightSources.map((c) => {
965
+ const cloned = cloneNode(c);
966
+ return totalMaxRing > 0 ? shiftRingNumbers(cloned, totalMaxRing) : cloned;
967
+ });
968
+
969
+ return createMoleculeNode([...leftComponents, ...rightComponents]);
970
+ }
971
+
972
+ /**
973
+ * Mirror a ring's attachments and substitutions to create symmetric patterns.
974
+ *
975
+ * For each attachment/substitution at position p, adds a copy at the mirror
976
+ * position relative to the pivot. The mirror position for p around pivot v
977
+ * on a ring of size s is: ((2*v - p - 1) mod s) + 1 (1-indexed).
978
+ *
979
+ * @param {Object} ring - Ring AST node
980
+ * @param {number} [pivotId=1] - 1-indexed pivot position
981
+ * @returns {Object} New Ring node
982
+ */
983
+ export function ringMirror(ring, pivotId) {
984
+ const pivot = pivotId !== undefined ? pivotId : 1;
985
+ validatePosition(pivot, ring.size);
986
+
987
+ const { size } = ring;
988
+
989
+ // Helper: compute mirror position (1-indexed)
990
+ // For a ring, position p mirrors around pivot v as:
991
+ // mirror = ((2*v - p - 1) mod s) + 1
992
+ // This reflects p across v on the ring.
993
+ const mirrorPos = (p) => ((((2 * (pivot - 1) - (p - 1)) % size) + size) % size) + 1;
994
+
995
+ // Mirror substitutions
996
+ const newSubstitutions = { ...ring.substitutions };
997
+ Object.entries(ring.substitutions).forEach(([posStr, atom]) => {
998
+ const pos = Number(posStr);
999
+ const mp = mirrorPos(pos);
1000
+ if (mp !== pos && !newSubstitutions[mp]) {
1001
+ newSubstitutions[mp] = atom;
1002
+ }
1003
+ });
1004
+
1005
+ // Mirror attachments
1006
+ const newAttachments = cloneAttachments(ring.attachments);
1007
+ const totalMaxRing = maxRingNumber(ring);
1008
+
1009
+ Object.entries(ring.attachments).forEach(([posStr, attList]) => {
1010
+ const pos = Number(posStr);
1011
+ const mp = mirrorPos(pos);
1012
+ if (mp === pos) return; // pivot position or self-symmetric, skip
1013
+ if (!newAttachments[mp]) {
1014
+ newAttachments[mp] = [];
1015
+ }
1016
+ attList.forEach((att) => {
1017
+ const shifted = totalMaxRing > 0
1018
+ ? shiftRingNumbers(cloneNode(att), totalMaxRing)
1019
+ : cloneNode(att);
1020
+ newAttachments[mp] = [...newAttachments[mp], shifted];
1021
+ });
1022
+ });
1023
+
1024
+ return createRingNode(
1025
+ ring.atoms,
1026
+ ring.size,
1027
+ ring.ringNumber,
1028
+ ring.offset,
1029
+ newSubstitutions,
1030
+ newAttachments,
1031
+ ring.bonds || [],
1032
+ ring.metaBranchDepths || null,
1033
+ );
1034
+ }