matterviz 0.4.0 → 0.4.2

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.
Files changed (527) hide show
  1. package/dist/EmptyState.svelte +10 -4
  2. package/dist/FilePicker.svelte +12 -14
  3. package/dist/Icon.svelte +7 -4
  4. package/dist/MillerIndexInput.svelte +2 -2
  5. package/dist/app.css +53 -0
  6. package/dist/brillouin/BrillouinZone.svelte +132 -219
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +9 -20
  8. package/dist/brillouin/BrillouinZoneControls.svelte +3 -6
  9. package/dist/brillouin/BrillouinZoneExportPane.svelte +47 -96
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  11. package/dist/brillouin/BrillouinZoneInfoPane.svelte +15 -42
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte.d.ts +2 -3
  13. package/dist/brillouin/BrillouinZoneScene.svelte +63 -230
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +3 -23
  15. package/dist/brillouin/ReciprocalVectors.svelte +39 -0
  16. package/dist/brillouin/ReciprocalVectors.svelte.d.ts +9 -0
  17. package/dist/brillouin/compute.d.ts +2 -0
  18. package/dist/brillouin/compute.js +80 -77
  19. package/dist/brillouin/geometry.d.ts +8 -0
  20. package/dist/brillouin/geometry.js +57 -0
  21. package/dist/brillouin/index.d.ts +2 -0
  22. package/dist/brillouin/index.js +2 -0
  23. package/dist/brillouin/types.d.ts +2 -2
  24. package/dist/chempot-diagram/ChemPotDiagram.svelte +33 -38
  25. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +1 -1
  26. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +143 -246
  27. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +4 -1
  28. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +652 -976
  29. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +7 -1
  30. package/dist/chempot-diagram/async-compute.svelte.js +1 -1
  31. package/dist/chempot-diagram/color.d.ts +3 -6
  32. package/dist/chempot-diagram/color.js +5 -5
  33. package/dist/chempot-diagram/compute.d.ts +3 -3
  34. package/dist/chempot-diagram/compute.js +3 -1
  35. package/dist/chempot-diagram/controls-state.svelte.d.ts +10 -0
  36. package/dist/chempot-diagram/controls-state.svelte.js +42 -0
  37. package/dist/chempot-diagram/export.d.ts +47 -0
  38. package/dist/chempot-diagram/export.js +133 -0
  39. package/dist/chempot-diagram/index.d.ts +1 -0
  40. package/dist/chempot-diagram/index.js +1 -0
  41. package/dist/chempot-diagram/pointer.d.ts +0 -10
  42. package/dist/chempot-diagram/pointer.js +4 -4
  43. package/dist/chempot-diagram/types.d.ts +3 -3
  44. package/dist/colors/index.d.ts +0 -4
  45. package/dist/colors/index.js +21 -22
  46. package/dist/composition/BarChart.svelte +37 -31
  47. package/dist/composition/BubbleChart.svelte +17 -17
  48. package/dist/composition/Composition.svelte +7 -11
  49. package/dist/composition/Formula.svelte +18 -25
  50. package/dist/composition/FormulaFilter.svelte +96 -98
  51. package/dist/composition/PieChart.svelte +60 -46
  52. package/dist/composition/chem-sys.js +3 -2
  53. package/dist/composition/format.js +4 -3
  54. package/dist/composition/index.d.ts +2 -0
  55. package/dist/composition/index.js +14 -0
  56. package/dist/composition/parse.d.ts +2 -3
  57. package/dist/composition/parse.js +27 -28
  58. package/dist/constants.js +11 -9
  59. package/dist/controls.d.ts +1 -0
  60. package/dist/controls.js +0 -1
  61. package/dist/convex-hull/ConvexHull.svelte +20 -25
  62. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -4
  63. package/dist/convex-hull/ConvexHull2D.svelte +176 -256
  64. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  65. package/dist/convex-hull/ConvexHull3D.svelte +269 -804
  66. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  67. package/dist/convex-hull/ConvexHull4D.svelte +236 -755
  68. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  69. package/dist/convex-hull/ConvexHullChrome.svelte +264 -0
  70. package/dist/convex-hull/ConvexHullChrome.svelte.d.ts +30 -0
  71. package/dist/convex-hull/ConvexHullControls.svelte +128 -49
  72. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +7 -6
  73. package/dist/convex-hull/ConvexHullInfoPane.svelte +18 -5
  74. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +6 -5
  75. package/dist/convex-hull/ConvexHullStats.svelte +210 -351
  76. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +3 -1
  77. package/dist/convex-hull/ConvexHullTooltip.svelte +16 -14
  78. package/dist/convex-hull/ConvexHullTooltip.svelte.d.ts +2 -1
  79. package/dist/convex-hull/GasPressureControls.svelte +5 -12
  80. package/dist/convex-hull/StructurePopup.svelte +17 -21
  81. package/dist/convex-hull/StructurePopup.svelte.d.ts +2 -0
  82. package/dist/convex-hull/TemperatureSlider.svelte +4 -10
  83. package/dist/convex-hull/barycentric-coords.d.ts +2 -4
  84. package/dist/convex-hull/barycentric-coords.js +6 -33
  85. package/dist/convex-hull/canvas-interactions.svelte.d.ts +79 -0
  86. package/dist/convex-hull/canvas-interactions.svelte.js +279 -0
  87. package/dist/convex-hull/helpers.d.ts +47 -17
  88. package/dist/convex-hull/helpers.js +186 -101
  89. package/dist/convex-hull/hull-state.svelte.d.ts +44 -0
  90. package/dist/convex-hull/hull-state.svelte.js +124 -0
  91. package/dist/convex-hull/index.d.ts +9 -7
  92. package/dist/convex-hull/index.js +7 -2
  93. package/dist/convex-hull/thermodynamics.js +109 -937
  94. package/dist/convex-hull/types.d.ts +12 -4
  95. package/dist/convex-hull/types.js +12 -0
  96. package/dist/coordination/CoordinationBarPlot.svelte +41 -43
  97. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +1 -1
  98. package/dist/coordination/calc-coordination.d.ts +1 -1
  99. package/dist/coordination/calc-coordination.js +28 -25
  100. package/dist/element/BohrAtom.svelte +6 -8
  101. package/dist/element/ElementHeading.svelte +4 -1
  102. package/dist/element/ElementPhoto.svelte +11 -9
  103. package/dist/element/ElementStats.svelte +12 -10
  104. package/dist/element/ElementTile.svelte +24 -29
  105. package/dist/element/ElementTile.svelte.d.ts +1 -1
  106. package/dist/element/Nucleus.svelte +4 -2
  107. package/dist/element/data.d.ts +1 -2
  108. package/dist/element/helpers.d.ts +4 -0
  109. package/dist/element/helpers.js +18 -0
  110. package/dist/element/index.d.ts +1 -0
  111. package/dist/element/index.js +3 -0
  112. package/dist/feedback/DragOverlay.svelte +5 -3
  113. package/dist/feedback/DragOverlay.svelte.d.ts +2 -1
  114. package/dist/feedback/StatusMessage.svelte +20 -7
  115. package/dist/fermi-surface/FermiSlice.svelte +21 -18
  116. package/dist/fermi-surface/FermiSurface.svelte +78 -165
  117. package/dist/fermi-surface/FermiSurface.svelte.d.ts +9 -20
  118. package/dist/fermi-surface/FermiSurfaceControls.svelte +6 -20
  119. package/dist/fermi-surface/FermiSurfaceScene.svelte +93 -272
  120. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +3 -23
  121. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +9 -6
  122. package/dist/fermi-surface/compute.js +14 -13
  123. package/dist/fermi-surface/export.js +4 -15
  124. package/dist/fermi-surface/index.d.ts +0 -1
  125. package/dist/fermi-surface/index.js +0 -1
  126. package/dist/fermi-surface/parse.d.ts +1 -1
  127. package/dist/fermi-surface/parse.js +84 -97
  128. package/dist/fermi-surface/types.d.ts +2 -2
  129. package/dist/heatmap-matrix/HeatmapMatrix.svelte +161 -185
  130. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +6 -5
  131. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +12 -9
  132. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +5 -5
  133. package/dist/heatmap-matrix/index.d.ts +8 -2
  134. package/dist/icons.d.ts +4 -0
  135. package/dist/icons.js +4 -0
  136. package/dist/index.d.ts +1 -0
  137. package/dist/index.js +1 -0
  138. package/dist/io/ExportPane.svelte +170 -0
  139. package/dist/io/ExportPane.svelte.d.ts +17 -0
  140. package/dist/io/decompress.d.ts +2 -1
  141. package/dist/io/decompress.js +22 -36
  142. package/dist/io/export.d.ts +9 -1
  143. package/dist/io/export.js +37 -29
  144. package/dist/io/fetch.d.ts +2 -4
  145. package/dist/io/fetch.js +0 -11
  146. package/dist/io/file-drop.d.ts +7 -0
  147. package/dist/io/file-drop.js +13 -0
  148. package/dist/io/index.d.ts +1 -0
  149. package/dist/io/index.js +3 -0
  150. package/dist/io/is-binary.d.ts +9 -0
  151. package/dist/io/is-binary.js +45 -1
  152. package/dist/io/types.d.ts +13 -0
  153. package/dist/io/url-drop.js +13 -25
  154. package/dist/isosurface/Isosurface.svelte +17 -23
  155. package/dist/isosurface/IsosurfaceControls.svelte +37 -42
  156. package/dist/isosurface/parse-vaspwave.d.ts +3 -0
  157. package/dist/isosurface/parse-vaspwave.js +138 -0
  158. package/dist/isosurface/parse.js +50 -48
  159. package/dist/labels.d.ts +6 -2
  160. package/dist/labels.js +44 -14
  161. package/dist/layout/FullscreenButton.svelte +34 -0
  162. package/dist/layout/FullscreenButton.svelte.d.ts +10 -0
  163. package/dist/layout/FullscreenToggle.svelte +12 -12
  164. package/dist/layout/InfoCard.svelte +7 -18
  165. package/dist/layout/InfoTag.svelte +1 -3
  166. package/dist/layout/NumberRangeInput.svelte +46 -0
  167. package/dist/layout/NumberRangeInput.svelte.d.ts +12 -0
  168. package/dist/layout/PropertyFilter.svelte +2 -6
  169. package/dist/layout/SettingsSection.svelte +21 -17
  170. package/dist/layout/SubpageGrid.svelte +3 -1
  171. package/dist/layout/ViewerChrome.svelte +115 -0
  172. package/dist/layout/ViewerChrome.svelte.d.ts +18 -0
  173. package/dist/layout/fullscreen.d.ts +4 -0
  174. package/dist/layout/fullscreen.js +3 -5
  175. package/dist/layout/fullscreen.svelte.d.ts +8 -0
  176. package/dist/layout/fullscreen.svelte.js +36 -0
  177. package/dist/layout/index.d.ts +4 -0
  178. package/dist/layout/index.js +4 -0
  179. package/dist/layout/json-tree/JsonNode.svelte +8 -11
  180. package/dist/layout/json-tree/JsonTree.svelte +53 -77
  181. package/dist/layout/json-tree/JsonValue.svelte +7 -15
  182. package/dist/layout/json-tree/utils.js +14 -23
  183. package/dist/math.d.ts +9 -3
  184. package/dist/math.js +37 -21
  185. package/dist/overlays/ContextMenu.svelte +4 -2
  186. package/dist/overlays/CopyButton.svelte +11 -5
  187. package/dist/overlays/DragControlTab.svelte +6 -1
  188. package/dist/overlays/DraggablePane.svelte +81 -26
  189. package/dist/overlays/DraggablePane.svelte.d.ts +1 -0
  190. package/dist/overlays/GlassChip.svelte +30 -0
  191. package/dist/overlays/GlassChip.svelte.d.ts +8 -0
  192. package/dist/overlays/InfoPaneCards.svelte +11 -23
  193. package/dist/overlays/index.d.ts +11 -0
  194. package/dist/overlays/index.js +26 -0
  195. package/dist/overlays/portal.d.ts +2 -0
  196. package/dist/overlays/portal.js +14 -0
  197. package/dist/periodic-table/PeriodicTable.svelte +115 -146
  198. package/dist/periodic-table/PeriodicTable.svelte.d.ts +5 -3
  199. package/dist/periodic-table/PeriodicTableControls.svelte +11 -63
  200. package/dist/periodic-table/TableInset.svelte +9 -4
  201. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +50 -71
  202. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -8
  203. package/dist/phase-diagram/PhaseDiagramControls.svelte +73 -96
  204. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +4 -3
  205. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +4 -2
  206. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +2 -3
  207. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +49 -134
  208. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +3 -4
  209. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +54 -58
  210. package/dist/phase-diagram/TdbInfoPanel.svelte +21 -15
  211. package/dist/phase-diagram/build-diagram.js +3 -3
  212. package/dist/phase-diagram/colors.js +1 -1
  213. package/dist/phase-diagram/diagram-input.d.ts +1 -2
  214. package/dist/phase-diagram/parse.d.ts +2 -1
  215. package/dist/phase-diagram/parse.js +15 -15
  216. package/dist/phase-diagram/svg-to-diagram.js +21 -15
  217. package/dist/phase-diagram/utils.js +2 -3
  218. package/dist/plot/bar/BarPlot.svelte +494 -692
  219. package/dist/plot/bar/BarPlot.svelte.d.ts +9 -15
  220. package/dist/plot/bar/BarPlotControls.svelte +1 -3
  221. package/dist/plot/bar/BarPlotControls.svelte.d.ts +5 -7
  222. package/dist/plot/bar/SpacegroupBarPlot.svelte +31 -37
  223. package/dist/plot/bar/data.d.ts +5 -4
  224. package/dist/plot/bar/data.js +2 -1
  225. package/dist/plot/box/BoxPlot.svelte +361 -439
  226. package/dist/plot/box/BoxPlot.svelte.d.ts +6 -3
  227. package/dist/plot/box/BoxPlotControls.svelte.d.ts +1 -1
  228. package/dist/plot/box/Violin.svelte.d.ts +2 -1
  229. package/dist/plot/box/box-plot.d.ts +3 -2
  230. package/dist/plot/box/box-plot.js +6 -3
  231. package/dist/plot/box/kde.d.ts +2 -1
  232. package/dist/plot/box/kde.js +4 -4
  233. package/dist/plot/core/auto-place.d.ts +1 -1
  234. package/dist/plot/core/auto-place.js +5 -2
  235. package/dist/plot/core/axis-utils.d.ts +12 -7
  236. package/dist/plot/core/axis-utils.js +48 -48
  237. package/dist/plot/core/components/AxisLabel.svelte +82 -20
  238. package/dist/plot/core/components/AxisLabel.svelte.d.ts +1 -0
  239. package/dist/plot/core/components/ColorBar.svelte +106 -111
  240. package/dist/plot/core/components/ColorBar.svelte.d.ts +5 -4
  241. package/dist/plot/core/components/ColorScaleSelect.svelte +1 -1
  242. package/dist/plot/core/components/FillArea.svelte +10 -17
  243. package/dist/plot/core/components/HierarchyControls.svelte +249 -0
  244. package/dist/plot/{sunburst/SunburstControls.svelte.d.ts → core/components/HierarchyControls.svelte.d.ts} +12 -8
  245. package/dist/plot/core/components/InteractiveAxisLabel.svelte +1 -1
  246. package/dist/plot/core/components/InteractiveAxisLabel.svelte.d.ts +1 -1
  247. package/dist/plot/core/components/Line.svelte +21 -11
  248. package/dist/plot/core/components/Line.svelte.d.ts +5 -2
  249. package/dist/plot/core/components/PlotAxis.svelte +42 -18
  250. package/dist/plot/core/components/PlotAxis.svelte.d.ts +2 -1
  251. package/dist/plot/core/components/PlotControls.svelte +65 -41
  252. package/dist/plot/core/components/PlotControls.svelte.d.ts +1 -1
  253. package/dist/plot/core/components/PlotLegend.svelte +31 -35
  254. package/dist/plot/core/components/PlotMarginals.svelte +616 -0
  255. package/dist/plot/core/components/PlotMarginals.svelte.d.ts +15 -0
  256. package/dist/plot/core/components/PlotTooltip.svelte +1 -1
  257. package/dist/plot/core/components/PortalSelect.svelte +4 -8
  258. package/dist/plot/core/components/ReferenceLine.svelte +27 -24
  259. package/dist/plot/core/components/ReferenceLine3D.svelte +56 -59
  260. package/dist/plot/core/components/ReferenceLine3D.svelte.d.ts +4 -4
  261. package/dist/plot/core/components/ReferencePlane.svelte +9 -10
  262. package/dist/plot/core/components/ReferencePlane.svelte.d.ts +4 -4
  263. package/dist/plot/core/components/ZeroLines.svelte +4 -8
  264. package/dist/plot/core/components/ZoomRect.svelte +1 -2
  265. package/dist/plot/core/components/index.d.ts +1 -0
  266. package/dist/plot/core/components/index.js +1 -0
  267. package/dist/plot/core/data-cleaning-signal.d.ts +46 -0
  268. package/dist/plot/core/data-cleaning-signal.js +467 -0
  269. package/dist/plot/core/data-cleaning.d.ts +2 -42
  270. package/dist/plot/core/data-cleaning.js +29 -476
  271. package/dist/plot/core/data-transform.js +9 -5
  272. package/dist/plot/core/fill-utils.d.ts +7 -4
  273. package/dist/plot/core/fill-utils.js +34 -9
  274. package/dist/plot/core/index.d.ts +1 -0
  275. package/dist/plot/core/index.js +1 -0
  276. package/dist/plot/core/interactions.d.ts +5 -1
  277. package/dist/plot/core/interactions.js +14 -2
  278. package/dist/plot/core/layout.d.ts +3 -1
  279. package/dist/plot/core/layout.js +22 -13
  280. package/dist/plot/core/marginals.d.ts +170 -0
  281. package/dist/plot/core/marginals.js +436 -0
  282. package/dist/plot/core/pan-zoom.svelte.d.ts +35 -0
  283. package/dist/plot/core/pan-zoom.svelte.js +221 -0
  284. package/dist/plot/core/placed-tween.svelte.d.ts +21 -0
  285. package/dist/plot/core/placed-tween.svelte.js +68 -0
  286. package/dist/plot/core/reference-line.d.ts +10 -10
  287. package/dist/plot/core/reference-line.js +7 -7
  288. package/dist/plot/core/scales.d.ts +40 -25
  289. package/dist/plot/core/scales.js +49 -21
  290. package/dist/plot/core/svg.d.ts +2 -1
  291. package/dist/plot/core/types/fills.d.ts +101 -0
  292. package/dist/plot/core/types/fills.js +15 -0
  293. package/dist/plot/core/types/plot-3d.d.ts +87 -0
  294. package/dist/plot/core/types/plot-3d.js +4 -0
  295. package/dist/plot/core/types/reference-lines.d.ts +140 -0
  296. package/dist/plot/core/types/reference-lines.js +9 -0
  297. package/dist/plot/core/types.d.ts +47 -331
  298. package/dist/plot/core/types.js +23 -20
  299. package/dist/plot/core/utils/hierarchy-chart.d.ts +51 -0
  300. package/dist/plot/core/utils/hierarchy-chart.js +179 -0
  301. package/dist/plot/core/utils/hierarchy-labels.d.ts +20 -0
  302. package/dist/plot/core/utils/hierarchy-labels.js +99 -0
  303. package/dist/plot/core/utils/label-placement.d.ts +1 -1
  304. package/dist/plot/core/utils/label-placement.js +5 -5
  305. package/dist/plot/core/utils.d.ts +2 -1
  306. package/dist/plot/histogram/Histogram.svelte +373 -631
  307. package/dist/plot/histogram/Histogram.svelte.d.ts +7 -7
  308. package/dist/plot/histogram/HistogramControls.svelte +30 -28
  309. package/dist/plot/histogram/HistogramControls.svelte.d.ts +1 -1
  310. package/dist/plot/histogram/histogram.d.ts +31 -0
  311. package/dist/plot/histogram/histogram.js +70 -0
  312. package/dist/plot/histogram/index.d.ts +1 -0
  313. package/dist/plot/histogram/index.js +1 -0
  314. package/dist/plot/index.d.ts +1 -0
  315. package/dist/plot/index.js +1 -0
  316. package/dist/plot/sankey/Sankey.svelte +79 -73
  317. package/dist/plot/sankey/Sankey.svelte.d.ts +3 -3
  318. package/dist/plot/sankey/SankeyControls.svelte +22 -16
  319. package/dist/plot/sankey/sankey.js +3 -1
  320. package/dist/plot/scatter/BinnedScatterPlot.svelte +264 -112
  321. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +11 -4
  322. package/dist/plot/scatter/ElementScatter.svelte +4 -1
  323. package/dist/plot/scatter/ScatterPlot.svelte +623 -821
  324. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +9 -15
  325. package/dist/plot/scatter/ScatterPlotControls.svelte +18 -19
  326. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  327. package/dist/plot/scatter/ScatterPoint.svelte +16 -9
  328. package/dist/plot/scatter/binned-scatter-types.d.ts +6 -12
  329. package/dist/plot/scatter/binned-scatter-types.js +6 -1
  330. package/dist/plot/scatter/index.d.ts +1 -1
  331. package/dist/plot/scatter/scatter-data.d.ts +1 -1
  332. package/dist/plot/scatter/scatter-data.js +8 -7
  333. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +40 -60
  334. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +6 -14
  335. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +17 -15
  336. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte.d.ts +5 -5
  337. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +232 -234
  338. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +5 -14
  339. package/dist/plot/scatter-3d/Surface3D.svelte +9 -17
  340. package/dist/plot/scatter-3d/Surface3D.svelte.d.ts +4 -3
  341. package/dist/plot/sunburst/Sunburst.svelte +256 -338
  342. package/dist/plot/sunburst/Sunburst.svelte.d.ts +4 -3
  343. package/dist/plot/sunburst/index.d.ts +1 -1
  344. package/dist/plot/sunburst/index.js +2 -1
  345. package/dist/plot/sunburst/render.d.ts +2 -2
  346. package/dist/plot/sunburst/render.js +47 -22
  347. package/dist/plot/sunburst/sunburst.d.ts +5 -1
  348. package/dist/plot/sunburst/sunburst.js +14 -4
  349. package/dist/plot/treemap/Treemap.svelte +907 -0
  350. package/dist/plot/treemap/Treemap.svelte.d.ts +84 -0
  351. package/dist/plot/treemap/index.d.ts +3 -0
  352. package/dist/plot/treemap/index.js +4 -0
  353. package/dist/plot/treemap/treemap.d.ts +26 -0
  354. package/dist/plot/treemap/treemap.js +99 -0
  355. package/dist/rdf/RdfPlot.svelte +22 -26
  356. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  357. package/dist/rdf/calc-rdf.js +4 -8
  358. package/dist/sanitize.js +14 -3
  359. package/dist/scene/SceneCamera.svelte +62 -0
  360. package/dist/scene/SceneCamera.svelte.d.ts +19 -0
  361. package/dist/scene/bind-renderer.svelte.d.ts +2 -0
  362. package/dist/scene/bind-renderer.svelte.js +22 -0
  363. package/dist/scene/index.d.ts +4 -0
  364. package/dist/scene/index.js +5 -0
  365. package/dist/scene/props.js +52 -0
  366. package/dist/scene/types.d.ts +26 -0
  367. package/dist/scene/types.js +1 -0
  368. package/dist/settings.d.ts +33 -11
  369. package/dist/settings.js +136 -51
  370. package/dist/spectral/Bands.svelte +231 -237
  371. package/dist/spectral/Bands.svelte.d.ts +3 -2
  372. package/dist/spectral/BandsAndDos.svelte +29 -29
  373. package/dist/spectral/BrillouinBandsDos.svelte +21 -29
  374. package/dist/spectral/Dos.svelte +75 -77
  375. package/dist/spectral/Dos.svelte.d.ts +2 -1
  376. package/dist/spectral/helpers.d.ts +9 -9
  377. package/dist/spectral/helpers.js +66 -70
  378. package/dist/state.svelte.d.ts +0 -7
  379. package/dist/state.svelte.js +5 -24
  380. package/dist/structure/Arrow.svelte +6 -21
  381. package/dist/structure/ArrowInstances.svelte +160 -0
  382. package/dist/structure/ArrowInstances.svelte.d.ts +16 -0
  383. package/dist/structure/AtomLegend.svelte +115 -75
  384. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  385. package/dist/structure/Bond.svelte +31 -23
  386. package/dist/structure/CanvasTooltip.svelte +16 -3
  387. package/dist/structure/CellSelect.svelte +22 -29
  388. package/dist/structure/CellSelect.svelte.d.ts +2 -1
  389. package/dist/structure/Cylinder.svelte +24 -40
  390. package/dist/structure/InstancedAtoms.svelte +114 -0
  391. package/dist/structure/InstancedAtoms.svelte.d.ts +15 -0
  392. package/dist/structure/Lattice.svelte +85 -149
  393. package/dist/structure/SiteLabels.svelte +122 -0
  394. package/dist/structure/SiteLabels.svelte.d.ts +18 -0
  395. package/dist/structure/Structure.svelte +725 -735
  396. package/dist/structure/Structure.svelte.d.ts +8 -16
  397. package/dist/structure/StructureCarousel.svelte +630 -0
  398. package/dist/structure/StructureCarousel.svelte.d.ts +19 -0
  399. package/dist/structure/StructureControls.svelte +298 -325
  400. package/dist/structure/StructureControls.svelte.d.ts +5 -3
  401. package/dist/structure/StructureExportPane.svelte +62 -168
  402. package/dist/structure/StructureExportPane.svelte.d.ts +4 -5
  403. package/dist/structure/StructureInfoPane.svelte +77 -69
  404. package/dist/structure/StructureInfoPane.svelte.d.ts +6 -6
  405. package/dist/structure/StructureScene.svelte +758 -600
  406. package/dist/structure/StructureScene.svelte.d.ts +24 -20
  407. package/dist/structure/StructureViewport.svelte +386 -0
  408. package/dist/structure/StructureViewport.svelte.d.ts +63 -0
  409. package/dist/structure/atom-label-placement.d.ts +18 -0
  410. package/dist/structure/atom-label-placement.js +115 -0
  411. package/dist/structure/atom-properties.d.ts +3 -1
  412. package/dist/structure/atom-properties.js +115 -54
  413. package/dist/structure/bond-order-perception.js +2 -4
  414. package/dist/structure/bonding.d.ts +12 -2
  415. package/dist/structure/bonding.js +172 -73
  416. package/dist/structure/edit-history.d.ts +6 -0
  417. package/dist/structure/edit-history.js +27 -0
  418. package/dist/structure/export.d.ts +25 -4
  419. package/dist/structure/export.js +103 -153
  420. package/dist/structure/format-detect.d.ts +1 -0
  421. package/dist/structure/format-detect.js +22 -0
  422. package/dist/structure/geometry.d.ts +9 -0
  423. package/dist/structure/geometry.js +27 -0
  424. package/dist/structure/index.d.ts +18 -1
  425. package/dist/structure/index.js +15 -2
  426. package/dist/structure/measure.d.ts +1 -1
  427. package/dist/structure/measure.js +6 -12
  428. package/dist/structure/parse.d.ts +5 -3
  429. package/dist/structure/parse.js +551 -567
  430. package/dist/structure/partial-occupancy.d.ts +0 -1
  431. package/dist/structure/partial-occupancy.js +5 -3
  432. package/dist/structure/pbc.d.ts +1 -1
  433. package/dist/structure/pbc.js +204 -51
  434. package/dist/structure/polyhedra.d.ts +40 -0
  435. package/dist/structure/polyhedra.js +587 -0
  436. package/dist/structure/site.d.ts +4 -0
  437. package/dist/structure/site.js +1 -0
  438. package/dist/structure/supercell.d.ts +1 -2
  439. package/dist/structure/supercell.js +10 -15
  440. package/dist/structure/validation.js +5 -6
  441. package/dist/symmetry/SymmetryElementControls.svelte +69 -0
  442. package/dist/symmetry/SymmetryElementControls.svelte.d.ts +9 -0
  443. package/dist/symmetry/SymmetryElements.svelte +350 -0
  444. package/dist/symmetry/SymmetryElements.svelte.d.ts +24 -0
  445. package/dist/symmetry/SymmetryStats.svelte +142 -49
  446. package/dist/symmetry/WyckoffTable.svelte +58 -16
  447. package/dist/symmetry/WyckoffTable.svelte.d.ts +3 -0
  448. package/dist/symmetry/cell-transform.js +8 -14
  449. package/dist/symmetry/index.d.ts +15 -4
  450. package/dist/symmetry/index.js +308 -81
  451. package/dist/symmetry/spacegroups.d.ts +5 -1
  452. package/dist/symmetry/spacegroups.js +18 -3
  453. package/dist/symmetry/symmetry-elements.d.ts +33 -0
  454. package/dist/symmetry/symmetry-elements.js +521 -0
  455. package/dist/symmetry/wyckoff-db.d.ts +9 -0
  456. package/dist/symmetry/wyckoff-db.js +87 -0
  457. package/dist/table/HeatmapTable.svelte +1104 -378
  458. package/dist/table/HeatmapTable.svelte.d.ts +12 -2
  459. package/dist/table/ToggleMenu.svelte +5 -12
  460. package/dist/table/index.d.ts +21 -6
  461. package/dist/table/index.js +34 -21
  462. package/dist/theme/ThemeControl.svelte +16 -8
  463. package/dist/theme/index.d.ts +1 -5
  464. package/dist/theme/index.js +4 -10
  465. package/dist/theme/themes.mjs +14 -7
  466. package/dist/tooltip/KCoords.svelte +4 -1
  467. package/dist/tooltip/TooltipContent.svelte +2 -5
  468. package/dist/trajectory/Trajectory.svelte +556 -352
  469. package/dist/trajectory/Trajectory.svelte.d.ts +10 -22
  470. package/dist/trajectory/TrajectoryError.svelte +11 -8
  471. package/dist/trajectory/TrajectoryExportPane.svelte +28 -54
  472. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +4 -5
  473. package/dist/trajectory/TrajectoryInfoPane.svelte +88 -99
  474. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +3 -2
  475. package/dist/trajectory/constants.js +6 -2
  476. package/dist/trajectory/extract.js +73 -71
  477. package/dist/trajectory/format-detect.d.ts +0 -1
  478. package/dist/trajectory/format-detect.js +13 -17
  479. package/dist/trajectory/frame-reader.d.ts +2 -1
  480. package/dist/trajectory/frame-reader.js +74 -136
  481. package/dist/trajectory/helpers.d.ts +12 -2
  482. package/dist/trajectory/helpers.js +68 -37
  483. package/dist/trajectory/index.d.ts +1 -0
  484. package/dist/trajectory/index.js +3 -0
  485. package/dist/trajectory/parse/ase.d.ts +9 -1
  486. package/dist/trajectory/parse/ase.js +47 -32
  487. package/dist/trajectory/parse/diagnostics.d.ts +3 -0
  488. package/dist/trajectory/parse/diagnostics.js +14 -0
  489. package/dist/trajectory/parse/h5-utils.d.ts +13 -0
  490. package/dist/trajectory/parse/h5-utils.js +109 -0
  491. package/dist/trajectory/parse/hdf5.d.ts +1 -1
  492. package/dist/trajectory/parse/hdf5.js +116 -106
  493. package/dist/trajectory/parse/index.d.ts +3 -4
  494. package/dist/trajectory/parse/index.js +68 -119
  495. package/dist/trajectory/parse/lammps.d.ts +0 -2
  496. package/dist/trajectory/parse/lammps.js +14 -16
  497. package/dist/trajectory/parse/pymatgen.d.ts +2 -0
  498. package/dist/trajectory/parse/pymatgen.js +74 -0
  499. package/dist/trajectory/parse/vasp.js +13 -9
  500. package/dist/trajectory/parse/vaspout-electronic.d.ts +13 -0
  501. package/dist/trajectory/parse/vaspout-electronic.js +207 -0
  502. package/dist/trajectory/parse/vaspout-h5.d.ts +5 -0
  503. package/dist/trajectory/parse/vaspout-h5.js +284 -0
  504. package/dist/trajectory/parse/xyz.d.ts +9 -21
  505. package/dist/trajectory/parse/xyz.js +38 -39
  506. package/dist/trajectory/plotting.d.ts +8 -10
  507. package/dist/trajectory/plotting.js +102 -127
  508. package/dist/utils.d.ts +4 -0
  509. package/dist/utils.js +8 -1
  510. package/dist/xrd/XrdPlot.svelte +56 -85
  511. package/dist/xrd/broadening.d.ts +2 -1
  512. package/dist/xrd/broadening.js +2 -1
  513. package/dist/xrd/calc-xrd.js +8 -15
  514. package/dist/xrd/index.d.ts +2 -2
  515. package/dist/xrd/parse.d.ts +0 -1
  516. package/dist/xrd/parse.js +56 -73
  517. package/package.json +77 -36
  518. package/dist/api/mp.d.ts +0 -6
  519. package/dist/api/mp.js +0 -22
  520. package/dist/element/data.json +0 -11864
  521. package/dist/fermi-surface/marching-cubes.d.ts +0 -2
  522. package/dist/fermi-surface/marching-cubes.js +0 -2
  523. package/dist/plot/core/hover-lock.svelte.d.ts +0 -14
  524. package/dist/plot/core/hover-lock.svelte.js +0 -45
  525. package/dist/plot/sunburst/SunburstControls.svelte +0 -200
  526. package/dist/structure/label-placement.d.ts +0 -14
  527. package/dist/structure/label-placement.js +0 -72
@@ -1,14 +1,72 @@
1
- import { COMPRESSION_EXTENSIONS_REGEX, CONFIG_DIRS_REGEX, STRUCT_KEYWORDS_REGEX, STRUCT_KEYWORDS_STRICT_REGEX, STRUCTURE_EXTENSIONS_REGEX, TRAJ_KEYWORDS_REGEX, VASP_FILES_REGEX, XYZ_EXTXYZ_REGEX, } from '../constants';
2
- import { ELEM_SYMBOLS } from '../labels';
1
+ import { XYZ_EXTXYZ_REGEX } from '../constants';
2
+ import { FALLBACK_ELEMENTS, is_elem_symbol } from '../element/helpers';
3
+ import { strip_compression_extensions } from '../io/decompress';
3
4
  import * as math from '../math';
4
5
  import { wrap_to_unit_cell } from './pbc';
5
- import { normalize_scientific_notation } from '../utils';
6
+ import { make_site } from './site';
7
+ import { is_xyz_atom_line, iter_xyz_frames } from '../trajectory/helpers';
8
+ import { normalize_scientific_notation, parse_leading_num, parse_num_token, to_error, } from '../utils';
6
9
  import { load as yaml_load } from 'js-yaml';
10
+ export { is_structure_file } from './format-detect';
11
+ // === Parse error contract ===
12
+ // Individual format parsers (parse_poscar, parse_cif, parse_xyz, parse_phonopy_yaml,
13
+ // parse_optimade_json, ...) return `T | null` on failure and record failure reasons in a
14
+ // module-level collector (mirrored to the console). The top-level entry points
15
+ // parse_structure_file and parse_any_structure reset the collector on entry and THROW a
16
+ // descriptive Error aggregating the recorded reasons when nothing parses, so failure
17
+ // causes can reach the UI (callers surface error.message). Warnings (element-symbol
18
+ // fallbacks, skipped atoms, ...) never fail a parse and only go to the console.
19
+ let parse_errors = [];
20
+ const reset_parse_diagnostics = () => {
21
+ parse_errors = [];
22
+ };
23
+ // Record a failure reason; with `error` present, logs in `console.error('msg:', error)` form
24
+ const diag_error = (message, error) => {
25
+ const detail = error === undefined ? `` : `: ${to_error(error).message}`;
26
+ parse_errors.push(`${message}${detail}`);
27
+ if (error === undefined)
28
+ console.error(message);
29
+ else
30
+ console.error(`${message}:`, error);
31
+ };
32
+ const diag_warn = (message) => console.warn(message);
33
+ // Aggregate recorded failure reasons into the Error thrown by top-level entry points
34
+ const aggregate_parse_error = (filename) => {
35
+ const reasons = [...new Set(parse_errors)];
36
+ const detail = reasons.length ? `: ${reasons.join(`; `)}` : ``;
37
+ return new Error(`Failed to parse structure${filename ? ` from '${filename}'` : ``}${detail}`);
38
+ };
7
39
  const cif_coords_key = (coords) => `${coords[0].toFixed(6)},${coords[1].toFixed(6)},${coords[2].toFixed(6)}`;
8
40
  const cif_site_key = (element, abc, label) => `${element}|${label}|${cif_coords_key(abc)}`;
9
- const clone_structure_properties = (properties) => structuredClone(properties);
10
- const FALLBACK_ELEMENTS = [`H`, `He`, `Li`, `Be`, `B`, `C`, `N`, `O`, `F`, `Ne`];
11
- const is_known_element_symbol = (symbol) => ELEM_SYMBOLS.includes(symbol);
41
+ // Bravais lattice centering translations (excluding the identity) keyed by the
42
+ // leading letter of a space-group Hermann-Mauguin symbol. R is the obverse
43
+ // hexagonal setting.
44
+ const CENTERING_VECTORS = {
45
+ P: [],
46
+ I: [[0.5, 0.5, 0.5]],
47
+ F: [
48
+ [0, 0.5, 0.5],
49
+ [0.5, 0, 0.5],
50
+ [0.5, 0.5, 0],
51
+ ],
52
+ A: [[0, 0.5, 0.5]],
53
+ B: [[0.5, 0, 0.5]],
54
+ C: [[0.5, 0.5, 0]],
55
+ R: [
56
+ [2 / 3, 1 / 3, 1 / 3],
57
+ [1 / 3, 2 / 3, 2 / 3],
58
+ ],
59
+ };
60
+ // Detect the centering letter from a CIF's space-group H-M symbol, if present.
61
+ const extract_cif_centering = (text) => {
62
+ for (const line of text.split(`\n`)) {
63
+ const match = /^_(?:symmetry_space_group_name_h-m|space_group_name_h-m(?:_alt)?)\s+(?<symbol>.+)/i.exec(line.trim());
64
+ const letter = match?.groups?.symbol.replaceAll(/['"]/g, ``).trim()[0]?.toUpperCase();
65
+ if (letter && letter in CENTERING_VECTORS)
66
+ return letter;
67
+ }
68
+ return null;
69
+ };
12
70
  const vec3_from_values = (values, context) => {
13
71
  if (values?.length !== 3) {
14
72
  throw new Error(`Invalid ${context}: expected 3 coordinates, got ${values?.length ?? 0}`);
@@ -27,7 +85,7 @@ const vec3_from_values = (values, context) => {
27
85
  // Parse a coordinate value that might be in various scientific notation formats
28
86
  function parse_coordinate(str) {
29
87
  const normalized = normalize_scientific_notation(str.trim());
30
- const value = parseFloat(normalized);
88
+ const value = Number(normalized);
31
89
  if (isNaN(value))
32
90
  throw new Error(`Invalid coordinate value: ${str}`);
33
91
  return value;
@@ -41,9 +99,9 @@ function parse_coordinate_line(line) {
41
99
  const sanitized = line
42
100
  .trim()
43
101
  // Add space when '-' follows a digit and precedes a digit or dot
44
- .replaceAll(/(\d)-(?=[\d.])/g, `$1 -`)
102
+ .replaceAll(/(?<digit>\d)-(?=[\d.])/g, `$1 -`)
45
103
  // Revert accidental spaces after exponent markers
46
- .replaceAll(/([eE])\s-\s/g, `$1-`);
104
+ .replaceAll(/(?<exp_marker>[eE])\s-\s/g, `$1-`);
47
105
  tokens = sanitized.split(/\s+/);
48
106
  }
49
107
  if (tokens.length < 3)
@@ -54,11 +112,11 @@ function parse_coordinate_line(line) {
54
112
  function validate_element_symbol(symbol, index) {
55
113
  // Clean symbol (remove suffixes like _pv, /hash)
56
114
  const clean_symbol = symbol.split(/[_/]/)[0];
57
- if (is_known_element_symbol(clean_symbol))
115
+ if (is_elem_symbol(clean_symbol))
58
116
  return clean_symbol;
59
117
  // Fallback to default elements by atomic number
60
118
  const fallback = FALLBACK_ELEMENTS[index % FALLBACK_ELEMENTS.length] ?? `H`;
61
- console.warn(`Invalid element symbol '${symbol}', using fallback '${fallback}'`);
119
+ diag_warn(`Invalid element symbol '${symbol}', using fallback '${fallback}'`);
62
120
  return fallback;
63
121
  }
64
122
  // Per OPTIMADE spec, species_at_sites holds species NAMES (e.g. 'Si1') resolved via the
@@ -70,7 +128,7 @@ function resolve_optimade_element(species_name, species_list, index) {
70
128
  const spec = species_list?.find((entry) => entry.name === species_name);
71
129
  let best;
72
130
  for (const [sym_idx, symbol] of (spec?.chemical_symbols ?? []).entries()) {
73
- if (!is_known_element_symbol(symbol))
131
+ if (!is_elem_symbol(symbol))
74
132
  continue;
75
133
  const conc = spec?.concentration?.[sym_idx] ?? 0;
76
134
  if (!best || conc > best.conc)
@@ -81,37 +139,51 @@ function resolve_optimade_element(species_name, species_list, index) {
81
139
  // Fallback: the name may be an element with a trailing atom index (e.g. 'O1');
82
140
  // element symbols never contain digits, so stripping them is safe
83
141
  const stripped = species_name.replace(/\d+$/, ``);
84
- if (is_known_element_symbol(stripped))
142
+ if (is_elem_symbol(stripped))
85
143
  return { symbol: stripped, sym_idx: -1 };
86
144
  return { symbol: validate_element_symbol(species_name, index), sym_idx: -1 };
87
145
  }
88
- const try_create_cart_to_frac = (lattice_matrix) => {
89
- try {
90
- return math.create_cart_to_frac(lattice_matrix);
91
- }
92
- catch {
93
- return null;
94
- }
95
- };
96
146
  const approximate_cart_to_frac = (xyz, axis_lengths) => [
97
147
  Math.abs(axis_lengths[0]) > math.EPS ? xyz[0] / axis_lengths[0] : 0,
98
148
  Math.abs(axis_lengths[1]) > math.EPS ? xyz[1] / axis_lengths[1] : 0,
99
149
  Math.abs(axis_lengths[2]) > math.EPS ? xyz[2] / axis_lengths[2] : 0,
100
150
  ];
101
- // Parse VASP POSCAR file format
151
+ // Build a 3x3 matrix from 3 row vectors; error context is suffixed with the 1-based row index
152
+ const matrix3x3_from_rows = (rows, context) => [
153
+ vec3_from_values(rows[0], `${context} 1`),
154
+ vec3_from_values(rows[1], `${context} 2`),
155
+ vec3_from_values(rows[2], `${context} 3`),
156
+ ];
157
+ // cart→frac converter that falls back to per-axis-length division for singular lattices.
158
+ // axis_lengths defaults to the row norms of the lattice matrix.
159
+ const cart_to_frac_with_fallback = (matrix, axis_lengths) => {
160
+ try {
161
+ return { convert: math.create_cart_to_frac(matrix), exact: true };
162
+ }
163
+ catch {
164
+ // fall through to the per-axis-length approximation below
165
+ }
166
+ const lengths = axis_lengths ?? [
167
+ Math.hypot(...matrix[0]),
168
+ Math.hypot(...matrix[1]),
169
+ Math.hypot(...matrix[2]),
170
+ ];
171
+ return { convert: (xyz) => approximate_cart_to_frac(xyz, lengths), exact: false };
172
+ };
173
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse VASP POSCAR.
102
174
  export function parse_poscar(content) {
103
175
  try {
104
176
  // Strip only horizontal whitespace: a blank first (comment) line is valid POSCAR
105
177
  const lines = content.replace(/^[ \t]+/, ``).split(/\r?\n/);
106
178
  if (lines.length < 8) {
107
- console.error(`POSCAR file too short`);
179
+ diag_error(`POSCAR file too short`);
108
180
  return null;
109
181
  }
110
182
  // Scale line: one value (negative = target volume) or three per-axis Cartesian factors
111
183
  const scale_tokens = lines[1].trim().split(/\s+/).map(parseFloat);
112
184
  let scale_factor = scale_tokens[0];
113
185
  if (isNaN(scale_factor)) {
114
- console.error(`Invalid scaling factor in POSCAR`);
186
+ diag_error(`Invalid scaling factor in POSCAR`);
115
187
  return null;
116
188
  }
117
189
  const scale_vec = scale_tokens.slice(0, 3);
@@ -129,6 +201,10 @@ export function parse_poscar(content) {
129
201
  // Handle negative scale factor (volume-based scaling, single-factor form only)
130
202
  if (!per_axis_scale && scale_factor < 0) {
131
203
  const volume = Math.abs(math.det_3x3(lattice_vecs));
204
+ if (volume < math.EPS) {
205
+ diag_error(`POSCAR target-volume scaling requires a non-singular lattice`);
206
+ return null;
207
+ }
132
208
  scale_factor = (-scale_factor / volume) ** (1 / 3);
133
209
  }
134
210
  // Scale lattice vectors (per-axis factors multiply Cartesian components)
@@ -141,9 +217,7 @@ export function parse_poscar(content) {
141
217
  let atom_counts = [];
142
218
  // Detect if this is VASP 5+ format (has element symbols)
143
219
  // Try to parse the first token as a number - if it succeeds, it's VASP 4 format
144
- const first_token = lines[line_index].trim().split(/\s+/)[0];
145
- const first_token_as_number = parseInt(first_token, 10);
146
- const has_element_symbols = isNaN(first_token_as_number);
220
+ const has_element_symbols = isNaN(parse_leading_num(lines[line_index]));
147
221
  if (has_element_symbols) {
148
222
  // VASP 5+ format - parse element symbols (may span multiple lines)
149
223
  let symbol_lines = 1;
@@ -151,9 +225,7 @@ export function parse_poscar(content) {
151
225
  for (let lookahead_idx = 1; lookahead_idx < 10; lookahead_idx++) {
152
226
  if (line_index + lookahead_idx >= lines.length)
153
227
  break;
154
- const next_line_first_token = lines[line_index + lookahead_idx].trim().split(/\s+/)[0];
155
- const next_token_as_number = parseInt(next_line_first_token, 10);
156
- if (!isNaN(next_token_as_number)) {
228
+ if (!isNaN(parse_leading_num(lines[line_index + lookahead_idx]))) {
157
229
  symbol_lines = lookahead_idx;
158
230
  break;
159
231
  }
@@ -183,201 +255,163 @@ export function parse_poscar(content) {
183
255
  line_index += 1;
184
256
  }
185
257
  if (element_symbols.length !== atom_counts.length) {
186
- console.error(`Mismatch between element symbols and atom counts`);
258
+ diag_error(`Mismatch between element symbols and atom counts`);
259
+ return null;
260
+ }
261
+ if (line_index >= lines.length) {
262
+ diag_error(`Missing coordinate mode line in POSCAR`);
187
263
  return null;
188
264
  }
189
265
  // Check for selective dynamics
190
266
  let has_selective_dynamics = false;
191
- if (line_index < lines.length) {
192
- let coordinate_mode = lines[line_index].trim().toUpperCase();
193
- if (coordinate_mode.startsWith(`S`)) {
194
- has_selective_dynamics = true;
195
- line_index += 1;
196
- if (line_index < lines.length) {
197
- coordinate_mode = lines[line_index].trim().toUpperCase();
198
- }
199
- else {
200
- console.error(`Missing coordinate mode after selective dynamics`);
201
- return null;
202
- }
267
+ let coordinate_mode = lines[line_index].trim().toUpperCase();
268
+ if (coordinate_mode.startsWith(`S`)) {
269
+ has_selective_dynamics = true;
270
+ line_index += 1;
271
+ if (line_index < lines.length) {
272
+ coordinate_mode = lines[line_index].trim().toUpperCase();
203
273
  }
204
- // Determine coordinate mode
205
- const is_direct = coordinate_mode.startsWith(`D`);
206
- const is_cartesian = coordinate_mode.startsWith(`C`) || coordinate_mode.startsWith(`K`);
207
- if (!is_direct && !is_cartesian) {
208
- console.error(`Unknown coordinate mode in POSCAR: ${coordinate_mode}`);
274
+ else {
275
+ diag_error(`Missing coordinate mode after selective dynamics`);
209
276
  return null;
210
277
  }
211
- // Parse atomic positions
212
- const poscar_axis_lengths = [
213
- Math.hypot(...scaled_lattice[0]),
214
- Math.hypot(...scaled_lattice[1]),
215
- Math.hypot(...scaled_lattice[2]),
216
- ];
217
- const poscar_frac_to_cart = math.create_frac_to_cart(scaled_lattice);
218
- const poscar_cart_to_frac = try_create_cart_to_frac(scaled_lattice);
219
- if (!is_direct && !poscar_cart_to_frac) {
220
- console.warn(`POSCAR: singular lattice, using axis-length fallback for cart→frac`);
221
- }
222
- const sites = [];
223
- let atom_index = 0;
224
- for (let elem_idx = 0; elem_idx < element_symbols.length; elem_idx++) {
225
- const element = validate_element_symbol(element_symbols[elem_idx], elem_idx);
226
- const count = atom_counts[elem_idx];
227
- for (let atom_count_idx = 0; atom_count_idx < count; atom_count_idx++) {
228
- const coord_line_idx = line_index + 1 + atom_index + atom_count_idx;
229
- if (coord_line_idx >= lines.length) {
230
- console.error(`Not enough coordinate lines in POSCAR`);
231
- return null;
232
- }
233
- const coords = vec3_from_values(parse_coordinate_line(lines[coord_line_idx]), `POSCAR atom coordinates on line ${coord_line_idx + 1}`);
234
- // Parse selective dynamics if present
235
- let selective_dynamics;
236
- if (has_selective_dynamics) {
237
- const tokens = lines[coord_line_idx].trim().split(/\s+/);
238
- if (tokens.length >= 6) {
239
- selective_dynamics = [tokens[3] === `T`, tokens[4] === `T`, tokens[5] === `T`];
240
- }
278
+ }
279
+ // Determine coordinate mode
280
+ const is_direct = coordinate_mode.startsWith(`D`);
281
+ const is_cartesian = coordinate_mode.startsWith(`C`) || coordinate_mode.startsWith(`K`);
282
+ if (!is_direct && !is_cartesian) {
283
+ diag_error(`Unknown coordinate mode in POSCAR: ${coordinate_mode}`);
284
+ return null;
285
+ }
286
+ // Parse atomic positions
287
+ const poscar_frac_to_cart = math.create_frac_to_cart(scaled_lattice);
288
+ const poscar_cart_to_frac = cart_to_frac_with_fallback(scaled_lattice);
289
+ if (!is_direct && !poscar_cart_to_frac.exact) {
290
+ diag_warn(`POSCAR: singular lattice, using axis-length fallback for cart→frac`);
291
+ }
292
+ const sites = [];
293
+ let atom_index = 0;
294
+ for (let elem_idx = 0; elem_idx < element_symbols.length; elem_idx++) {
295
+ const element = validate_element_symbol(element_symbols[elem_idx], elem_idx);
296
+ const count = atom_counts[elem_idx];
297
+ for (let atom_count_idx = 0; atom_count_idx < count; atom_count_idx++) {
298
+ const coord_line_idx = line_index + 1 + atom_index + atom_count_idx;
299
+ if (coord_line_idx >= lines.length) {
300
+ diag_error(`Not enough coordinate lines in POSCAR`);
301
+ return null;
302
+ }
303
+ const coords = vec3_from_values(parse_coordinate_line(lines[coord_line_idx]), `POSCAR atom coordinates on line ${coord_line_idx + 1}`);
304
+ // Parse selective dynamics if present
305
+ let selective_dynamics;
306
+ if (has_selective_dynamics) {
307
+ const tokens = lines[coord_line_idx].trim().split(/\s+/);
308
+ if (tokens.length >= 6) {
309
+ selective_dynamics = [tokens[3] === `T`, tokens[4] === `T`, tokens[5] === `T`];
241
310
  }
242
- // Cartesian input is scaled then converted to fractional (axis-length fallback
243
- // for singular lattices); abc wraps to [0, 1) and xyz is recomputed from it so
244
- // both stay consistent (singular Cartesian keeps the scaled input as xyz)
245
- const cart = is_direct ? null : apply_axis_scale(coords);
246
- const raw_abc = cart
247
- ? (poscar_cart_to_frac?.(cart) ??
248
- approximate_cart_to_frac(cart, poscar_axis_lengths))
249
- : coords;
250
- const abc = wrap_to_unit_cell(raw_abc);
251
- const xyz = cart && !poscar_cart_to_frac ? cart : poscar_frac_to_cart(abc);
252
- const site = {
253
- species: [{ element, occu: 1, oxidation_state: 0 }],
254
- abc,
255
- xyz,
256
- label: `${element}${atom_index + atom_count_idx + 1}`,
257
- properties: selective_dynamics ? { selective_dynamics } : {},
258
- };
259
- sites.push(site);
260
311
  }
261
- atom_index += count;
312
+ // Cartesian input is scaled then converted to fractional (axis-length fallback
313
+ // for singular lattices); abc wraps to [0, 1) and xyz is recomputed from it so
314
+ // both stay consistent (singular Cartesian keeps the scaled input as xyz)
315
+ const cart = is_direct ? null : apply_axis_scale(coords);
316
+ const raw_abc = cart ? poscar_cart_to_frac.convert(cart) : coords;
317
+ const abc = wrap_to_unit_cell(raw_abc);
318
+ const xyz = cart && !poscar_cart_to_frac.exact ? cart : poscar_frac_to_cart(abc);
319
+ sites.push(make_site(element, abc, xyz, `${element}${atom_index + atom_count_idx + 1}`, selective_dynamics ? { selective_dynamics } : {}));
262
320
  }
263
- const lattice_params = math.calc_lattice_params(scaled_lattice);
264
- const structure = {
265
- sites,
266
- lattice: { matrix: scaled_lattice, ...lattice_params },
267
- };
268
- return structure;
321
+ atom_index += count;
269
322
  }
270
- console.error(`Missing coordinate mode line in POSCAR`);
271
- return null;
323
+ const lattice_params = math.calc_lattice_params(scaled_lattice);
324
+ return { sites, lattice: { matrix: scaled_lattice, ...lattice_params } };
272
325
  }
273
326
  catch (error) {
274
- console.error(`Error parsing POSCAR file:`, error);
327
+ diag_error(`Error parsing POSCAR file`, error);
275
328
  return null;
276
329
  }
277
330
  }
278
- // Parse XYZ file format. Supports both standard XYZ and extended XYZ formats with multi-frame support
331
+ // @internal parser exported for tests + trajectory parser; public entry points: parse_structure_file/parse_any_structure. Parse standard/extended XYZ (multi-frame).
279
332
  export function parse_xyz(content) {
280
333
  try {
281
334
  const normalized_content = content.trim();
282
335
  if (!normalized_content) {
283
- console.error(`Empty XYZ file`);
336
+ diag_error(`Empty XYZ file`);
284
337
  return null;
285
338
  }
286
- // Split into frames by reading the atom count and slicing lines
339
+ // Walk frames by reading atom counts; multi-frame XYZ parses only the last frame
287
340
  const all_lines = normalized_content.split(/\r?\n/);
288
- const frames = [];
289
- let frame_line_idx = 0;
290
- while (frame_line_idx < all_lines.length) {
291
- const numAtoms = parseInt(all_lines[frame_line_idx].trim(), 10);
292
- if (!isNaN(numAtoms) &&
293
- numAtoms > 0 &&
294
- frame_line_idx + numAtoms + 1 < all_lines.length) {
295
- const frameLines = all_lines.slice(frame_line_idx, frame_line_idx + numAtoms + 2);
296
- frames.push(frameLines.join(`\n`));
297
- frame_line_idx += numAtoms + 2;
298
- }
299
- else
300
- frame_line_idx++;
301
- }
302
- // If no frames found, try simple parsing
303
- if (frames.length === 0)
304
- frames.push(normalized_content);
305
- // Parse the last frame (or only frame)
306
- const frame_content = frames.at(-1) ?? ``;
307
- const lines = frame_content.trim().split(/\r?\n/);
341
+ let last_frame = null;
342
+ for (const frame of iter_xyz_frames(all_lines))
343
+ last_frame = frame;
344
+ // If no complete frame found, fall back to parsing the whole content as one frame
345
+ const lines = last_frame
346
+ ? all_lines.slice(last_frame.start, last_frame.start + last_frame.num_atoms + 2)
347
+ : all_lines;
308
348
  if (lines.length < 2) {
309
- console.error(`XYZ frame too short`);
349
+ diag_error(`XYZ frame too short`);
310
350
  return null;
311
351
  }
312
- // Parse number of atoms (line 1)
313
- const num_atoms = parseInt(lines[0].trim(), 10);
352
+ // Parse number of atoms (line 1). Only the first token counts: Tinker-style
353
+ // XYZ files put a title after the count (e.g. `6 methane`)
354
+ const num_atoms = Math.trunc(parse_leading_num(lines[0]));
314
355
  if (isNaN(num_atoms) || num_atoms <= 0) {
315
- console.error(`Invalid number of atoms in XYZ file`);
356
+ diag_error(`Invalid number of atoms in XYZ file`);
316
357
  return null;
317
358
  }
318
359
  // Parse comment line (line 2) - may contain lattice info for extended XYZ
319
360
  const comment_line = lines[1];
320
361
  let lattice;
321
362
  // Check for extended XYZ lattice information in comment line
322
- const lattice_match = /Lattice="([^"]+)"/.exec(comment_line);
363
+ const lattice_match = /Lattice="(?<lattice>[^"]+)"/.exec(comment_line);
323
364
  if (lattice_match) {
324
365
  const lattice_values = lattice_match[1].split(/\s+/).map(parse_coordinate);
325
366
  if (lattice_values.length === 9) {
326
- const lattice_vectors = [
327
- vec3_from_values(lattice_values.slice(0, 3), `XYZ lattice vector 1`),
328
- vec3_from_values(lattice_values.slice(3, 6), `XYZ lattice vector 2`),
329
- vec3_from_values(lattice_values.slice(6, 9), `XYZ lattice vector 3`),
330
- ];
367
+ const lattice_vectors = matrix3x3_from_rows([lattice_values.slice(0, 3), lattice_values.slice(3, 6), lattice_values.slice(6, 9)], `XYZ lattice vector`);
331
368
  const lattice_params = math.calc_lattice_params(lattice_vectors);
332
369
  lattice = { matrix: lattice_vectors, ...lattice_params };
333
370
  }
334
371
  }
335
372
  // Parse atomic coordinates (starting from line 3)
336
- const xyz_axis_lengths = lattice ? [lattice.a, lattice.b, lattice.c] : null;
337
373
  let xyz_frac_to_cart = null;
338
374
  let xyz_cart_to_frac = null;
339
375
  if (lattice) {
340
376
  xyz_frac_to_cart = math.create_frac_to_cart(lattice.matrix);
341
- xyz_cart_to_frac = try_create_cart_to_frac(lattice.matrix);
377
+ xyz_cart_to_frac = cart_to_frac_with_fallback(lattice.matrix, [
378
+ lattice.a,
379
+ lattice.b,
380
+ lattice.c,
381
+ ]).convert;
342
382
  }
343
383
  const sites = [];
344
384
  for (let atom_idx = 0; atom_idx < num_atoms; atom_idx++) {
345
385
  const line_idx = atom_idx + 2;
346
386
  if (line_idx >= lines.length) {
347
- console.error(`Not enough coordinate lines in XYZ file`);
387
+ diag_error(`Not enough coordinate lines in XYZ file`);
348
388
  return null;
349
389
  }
350
390
  const parts = lines[line_idx].trim().split(/\s+/);
351
391
  if (parts.length < 4) {
352
- console.error(`Invalid coordinate line in XYZ file`);
392
+ diag_error(`Invalid coordinate line in XYZ file`);
353
393
  return null;
354
394
  }
355
395
  const element = validate_element_symbol(parts[0], atom_idx);
356
396
  const xyz = vec3_from_values(parts.slice(1, 4).map(parse_coordinate), `XYZ atom position ${atom_idx + 1}`);
357
397
  // Calculate fractional coordinates if lattice is available
358
398
  let abc = [0, 0, 0];
359
- if (lattice && xyz_frac_to_cart && xyz_axis_lengths) {
360
- abc = xyz_cart_to_frac
361
- ? xyz_cart_to_frac(xyz)
362
- : approximate_cart_to_frac(xyz, xyz_axis_lengths);
399
+ if (lattice && xyz_frac_to_cart && xyz_cart_to_frac) {
363
400
  // Ensure fractional coordinates are wrapped into [0, 1) for consistency
364
- abc = wrap_to_unit_cell(abc);
401
+ abc = wrap_to_unit_cell(xyz_cart_to_frac(xyz));
365
402
  // Keep rendered atoms inside primary unit cell by recomputing xyz
366
403
  const wrapped_xyz = xyz_frac_to_cart(abc);
367
404
  xyz[0] = wrapped_xyz[0];
368
405
  xyz[1] = wrapped_xyz[1];
369
406
  xyz[2] = wrapped_xyz[2];
370
407
  }
371
- const species = [{ element, occu: 1, oxidation_state: 0 }];
372
- const label = `${element}${atom_idx + 1}`;
373
- const site = { species, abc, xyz, label, properties: {} };
374
- sites.push(site);
408
+ sites.push(make_site(element, abc, xyz, `${element}${atom_idx + 1}`));
375
409
  }
376
410
  const structure = { sites, ...(lattice && { lattice }) };
377
411
  return structure;
378
412
  }
379
413
  catch (error) {
380
- console.error(`Error parsing XYZ file:`, error);
414
+ diag_error(`Error parsing XYZ file`, error);
381
415
  return null;
382
416
  }
383
417
  }
@@ -394,8 +428,7 @@ const parse_symmetry_expression = (expr_input) => {
394
428
  // E.g., "x-y+1/3" → ["x", "-y", "+1/3"] or "-x+y" → ["-x", "+y"]
395
429
  const tokens = [];
396
430
  let current_token = ``;
397
- for (let idx = 0; idx < expr.length; idx++) {
398
- const char = expr[idx];
431
+ for (const char of expr) {
399
432
  if ((char === `+` || char === `-`) && current_token.length > 0) {
400
433
  tokens.push(current_token);
401
434
  current_token = char;
@@ -408,7 +441,7 @@ const parse_symmetry_expression = (expr_input) => {
408
441
  tokens.push(current_token);
409
442
  for (const token of tokens) {
410
443
  // Check if this token is a variable term (x, y, or z with optional sign)
411
- const var_match = /^([+-]?)([xyz])$/.exec(token);
444
+ const var_match = /^(?<sign>[+-]?)(?<axis>[xyz])$/.exec(token);
412
445
  if (var_match) {
413
446
  const sign = var_match[1] === `-` ? -1 : 1;
414
447
  const var_char = var_match[2];
@@ -434,8 +467,8 @@ const parse_symmetry_expression = (expr_input) => {
434
467
  // Fraction
435
468
  const parts = num_str.split(`/`);
436
469
  if (parts.length === 2) {
437
- const numerator = parseFloat(parts[0]);
438
- const denominator = parseFloat(parts[1]);
470
+ const numerator = Number(parts[0]);
471
+ const denominator = Number(parts[1]);
439
472
  if (!isNaN(numerator) && !isNaN(denominator) && denominator !== 0) {
440
473
  translation += sign * (numerator / denominator);
441
474
  }
@@ -443,7 +476,7 @@ const parse_symmetry_expression = (expr_input) => {
443
476
  }
444
477
  else {
445
478
  // Integer or decimal
446
- const val = parseFloat(num_str);
479
+ const val = Number(num_str);
447
480
  if (!isNaN(val)) {
448
481
  translation += sign * val;
449
482
  }
@@ -451,49 +484,50 @@ const parse_symmetry_expression = (expr_input) => {
451
484
  }
452
485
  return { coefficients, translation };
453
486
  };
454
- // Apply symmetry operations to generate equivalent positions
455
- const apply_symmetry_ops = (atom, symmetry_ops, wrap_fractional_coords) => {
456
- if (symmetry_ops.length === 0)
487
+ // Apply symmetry operations (and optional lattice-centering translations) to
488
+ // generate all equivalent positions. Deduplication uses 6 decimal places to
489
+ // absorb floating point error from compound ops like x-y, -x+y.
490
+ const apply_symmetry_ops = (atom, symmetry_ops, wrap_fractional_coords, centering = []) => {
491
+ if (symmetry_ops.length === 0 && centering.length === 0)
457
492
  return [atom];
458
493
  const equivalent_atoms = [];
459
494
  const seen = new Set();
460
495
  const wrap = (coords) => wrap_fractional_coords ? wrap_to_unit_cell(coords) : coords;
461
- // Use 6 decimal places for deduplication to handle floating point imprecision
462
- // from compound symmetry operations like x-y, -x+y which can produce small errors
463
- // Always include base atom (optionally wrapped)
464
- const base_coords = wrap(atom.coords);
465
- seen.add(cif_coords_key(base_coords));
466
- equivalent_atoms.push({ ...atom, coords: base_coords });
496
+ // Every generated position is also offset by each centering translation
497
+ const shifts = [[0, 0, 0], ...centering];
498
+ // Record a position plus its centering images, deduplicating on wrapped coords
499
+ const add_position = (coords) => {
500
+ for (const [dx, dy, dz] of shifts) {
501
+ const wrapped = wrap([coords[0] + dx, coords[1] + dy, coords[2] + dz]);
502
+ const key = cif_coords_key(wrapped);
503
+ if (seen.has(key))
504
+ continue;
505
+ seen.add(key);
506
+ const id = equivalent_atoms.length === 0 ? atom.id : `${atom.id}_${equivalent_atoms.length}`;
507
+ equivalent_atoms.push({ ...atom, coords: wrapped, id });
508
+ }
509
+ };
510
+ add_position(atom.coords); // base atom (+ centering images)
511
+ // ops arrive pre-normalized (quotes + whitespace already stripped, see normalized_ops)
467
512
  for (const operation of symmetry_ops) {
468
- const operation_match = /['"]([^'"]+)['"]/.exec(operation);
469
- const expr_str = operation_match ? operation_match[1] : operation.trim();
470
- const parts = expr_str.split(`,`).map((part) => part.trim());
513
+ const parts = operation.split(`,`);
471
514
  if (parts.length !== 3)
472
515
  continue;
473
516
  const new_coords = [0, 0, 0];
474
517
  for (let dim = 0; dim < 3; dim++) {
475
518
  const { coefficients, translation } = parse_symmetry_expression(parts[dim]);
476
- // Apply: new_coord = coeff_x * x + coeff_y * y + coeff_z * z + translation
477
- new_coords[dim] =
478
- coefficients[0] * atom.coords[0] +
479
- coefficients[1] * atom.coords[1] +
480
- coefficients[2] * atom.coords[2] +
481
- translation;
519
+ // new_coord = coeff_x * x + coeff_y * y + coeff_z * z + translation
520
+ new_coords[dim] = math.dot(coefficients, atom.coords) + translation;
482
521
  }
483
- // Wrap and deduplicate transformed coordinates
484
- const wrapped = wrap(new_coords);
485
- const cache_key = cif_coords_key(wrapped);
486
- if (seen.has(cache_key))
487
- continue;
488
- seen.add(cache_key);
489
- equivalent_atoms.push({
490
- ...atom,
491
- coords: wrapped,
492
- id: `${atom.id}_${equivalent_atoms.length}`,
493
- });
522
+ add_position(new_coords);
494
523
  }
495
524
  return equivalent_atoms;
496
525
  };
526
+ // Parse a CIF numeric token, stripping a trailing uncertainty like "1.234(5)"
527
+ const parse_cif_uncertain_number = (token) => {
528
+ const value = parse_num_token(token.split(`(`)[0]);
529
+ return isNaN(value) ? null : value;
530
+ };
497
531
  const extract_cif_cell_parameters = (text, type, strict = true) => text
498
532
  .split(`\n`)
499
533
  .filter((line) => line.startsWith(`_${type}`))
@@ -506,11 +540,9 @@ const extract_cif_cell_parameters = (text, type, strict = true) => text
506
540
  throw new Error(`Invalid CIF cell parameter line format: ${line}`);
507
541
  return null;
508
542
  }
509
- const value = parseFloat(tokens[1].split(`(`)[0]);
510
- if (isNaN(value)) {
511
- if (strict)
512
- throw new Error(`Invalid CIF cell parameter in line: ${line}`);
513
- return null; // Return null for invalid values in non-strict mode
543
+ const value = parse_cif_uncertain_number(tokens[1]);
544
+ if (value === null && strict) {
545
+ throw new Error(`Invalid CIF cell parameter in line: ${line}`);
514
546
  }
515
547
  return value;
516
548
  })
@@ -538,31 +570,63 @@ const build_cif_atom_site_header_indices = (headers) => {
538
570
  });
539
571
  return indices;
540
572
  };
573
+ // Which coordinate triple a CIF atom-site loop provides (fractional preferred), or null
574
+ const cif_coords_type = (indices) => {
575
+ if (indices.x !== undefined && indices.y !== undefined && indices.z !== undefined) {
576
+ return `fract`;
577
+ }
578
+ if (indices.cart_x !== undefined &&
579
+ indices.cart_y !== undefined &&
580
+ indices.cart_z !== undefined) {
581
+ return `cart`;
582
+ }
583
+ return null;
584
+ };
585
+ // The 3 column indices for the requested coordinate type
586
+ const cif_coord_indices = (indices, coords_type) => coords_type === `fract`
587
+ ? [indices.x, indices.y, indices.z]
588
+ : [indices.cart_x, indices.cart_y, indices.cart_z];
589
+ // Walk CIF loop_ blocks: yields each loop's header tags plus the index of its first data line
590
+ function* iter_cif_loops(lines) {
591
+ for (let idx = 0; idx < lines.length; idx++) {
592
+ if (lines[idx].trim() !== `loop_`)
593
+ continue;
594
+ const headers = [];
595
+ let jj = idx + 1;
596
+ while (jj < lines.length && lines[jj].trim().startsWith(`_`)) {
597
+ headers.push(lines[jj].trim());
598
+ jj++;
599
+ }
600
+ yield { headers, data_start: jj };
601
+ }
602
+ }
603
+ // Split a CIF data line into whitespace-separated tokens, keeping quoted multi-word
604
+ // values as single tokens and stripping the quotes
605
+ const split_cif_tokens = (line) => (line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((token) => token.replaceAll(/['"]/g, ``));
541
606
  // Parse atom data from CIF with robust error handling
542
607
  const parse_cif_atom_data = (raw_data, indices, coords_type) => {
543
608
  const { label = 0, symbol = -1, occupancy = -1 } = indices;
544
- const coord_indices = coords_type === `fract`
545
- ? [indices.x, indices.y, indices.z]
546
- : [indices.cart_x, indices.cart_y, indices.cart_z];
609
+ const coord_indices = cif_coord_indices(indices, coords_type);
547
610
  if (coord_indices.some((idx) => idx === undefined)) {
548
611
  throw new Error(`Missing coordinate indices`);
549
612
  }
550
613
  const coords_triplet = vec3_from_values(coord_indices.map((idx) => {
551
- if (idx === undefined)
552
- throw new Error(`Invalid coordinate index`);
614
+ // idx cannot be undefined: the `.some` guard above already threw
553
615
  const coord_str = raw_data[idx];
554
616
  if (!coord_str)
555
617
  throw new Error(`Missing coordinate at index ${idx}`);
556
- const coord = parseFloat(coord_str.split(`(`)[0]);
557
- if (isNaN(coord))
618
+ const coord = parse_cif_uncertain_number(coord_str);
619
+ if (coord === null)
558
620
  throw new Error(`Invalid coordinate: ${coord_str}`);
559
621
  return coord;
560
622
  }), `CIF atom coordinates`);
561
- const occu = occupancy >= 0 && raw_data[occupancy]
562
- ? parseFloat(raw_data[occupancy].split(`(`)[0]) || 1.0
563
- : 1.0;
564
- const from_symbol = symbol >= 0 ? /^([A-Z][a-z]*)/.exec(raw_data[symbol])?.[1] : undefined;
565
- const element_symbol = from_symbol ?? raw_data[label]?.match(/([A-Z][a-z]*)/g)?.[0];
623
+ const raw_occu = occupancy >= 0 && raw_data[occupancy]
624
+ ? parse_cif_uncertain_number(raw_data[occupancy])
625
+ : null;
626
+ // invalid or zero occupancy defaults to fully occupied
627
+ const occu = raw_occu == null || raw_occu === 0 ? 1.0 : raw_occu;
628
+ const from_symbol = symbol >= 0 ? /^(?<element>[A-Z][a-z]*)/.exec(raw_data[symbol])?.[1] : undefined;
629
+ const element_symbol = from_symbol ?? raw_data[label]?.match(/(?:[A-Z][a-z]*)/g)?.[0];
566
630
  if (!element_symbol) {
567
631
  throw new Error(`Could not extract element symbol from: ${raw_data.join(` `)}`);
568
632
  }
@@ -574,12 +638,12 @@ const parse_cif_atom_data = (raw_data, indices, coords_type) => {
574
638
  occupancy: occu,
575
639
  };
576
640
  };
577
- // Parse CIF (Crystallographic Information File) format
641
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse CIF (Crystallographic Information File).
578
642
  export function parse_cif(content, wrap_fractional_coords = true, strict = true) {
579
643
  try {
580
644
  const text = content.trim();
581
645
  if (!text) {
582
- console.error(`CIF file is empty`);
646
+ diag_error(`CIF file is empty`);
583
647
  return null;
584
648
  }
585
649
  // Find atom site loop that actually contains coordinates (fract or Cartn)
@@ -587,16 +651,8 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
587
651
  let atom_headers = [];
588
652
  const atom_data_lines = [];
589
653
  const symmetry_ops = [];
590
- for (let ii = 0; ii < lines.length; ii++) {
591
- if (lines[ii].trim() !== `loop_`)
592
- continue;
593
- let jj = ii + 1;
594
- const headers = [];
595
- // Collect headers for this loop
596
- while (jj < lines.length && lines[jj].trim().startsWith(`_`)) {
597
- headers.push(lines[jj].trim());
598
- jj++;
599
- }
654
+ for (const { headers, data_start } of iter_cif_loops(lines)) {
655
+ let jj = data_start;
600
656
  // Check if this is a symmetry operations loop
601
657
  if (headers.some((header) => header.includes(`_symmetry_equiv_pos_as_xyz`) ||
602
658
  header.includes(`_space_group_symop_operation_xyz`))) {
@@ -617,16 +673,8 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
617
673
  continue;
618
674
  // Check if this loop contains coordinate headers
619
675
  const indices_preview = build_cif_atom_site_header_indices(headers);
620
- const has_coords = (indices_preview.x !== undefined &&
621
- indices_preview.y !== undefined &&
622
- indices_preview.z !== undefined) ||
623
- (indices_preview.cart_x !== undefined &&
624
- indices_preview.cart_y !== undefined &&
625
- indices_preview.cart_z !== undefined);
626
- if (!has_coords) {
627
- ii = jj - 1;
676
+ if (cif_coords_type(indices_preview) === null)
628
677
  continue;
629
- }
630
678
  // This is the desired atom-site loop with coordinates: collect data lines
631
679
  atom_headers = headers;
632
680
  while (jj < lines.length) {
@@ -653,36 +701,21 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
653
701
  break;
654
702
  }
655
703
  if (atom_headers.length === 0 || atom_data_lines.length === 0) {
656
- console.error(`No valid atom site loop found in CIF file`);
704
+ diag_error(`No valid atom site loop found in CIF file`);
657
705
  return null;
658
706
  }
659
707
  // Parse atom data with error handling
660
708
  const header_indices = build_cif_atom_site_header_indices(atom_headers);
661
709
  // Determine available coordinate type
662
- const coords_type = header_indices.x !== undefined &&
663
- header_indices.y !== undefined &&
664
- header_indices.z !== undefined
665
- ? `fract`
666
- : header_indices.cart_x !== undefined &&
667
- header_indices.cart_y !== undefined &&
668
- header_indices.cart_z !== undefined
669
- ? `cart`
670
- : null;
710
+ const coords_type = cif_coords_type(header_indices);
671
711
  if (!coords_type) {
672
- console.error(`CIF atom site loop missing coordinates (fract or Cartn)`);
712
+ diag_error(`CIF atom site loop missing coordinates (fract or Cartn)`);
673
713
  return null;
674
714
  }
675
715
  // Collect required coordinate indices
676
- const required_indices = coords_type === `fract`
677
- ? [header_indices.x, header_indices.y, header_indices.z]
678
- : [header_indices.cart_x, header_indices.cart_y, header_indices.cart_z];
716
+ const required_indices = cif_coord_indices(header_indices, coords_type);
679
717
  const atoms = atom_data_lines
680
- .map((line) => {
681
- // Handle quoted multi-word values by splitting only on whitespace
682
- // that is not inside quotes.
683
- const tokens = line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
684
- return tokens.map((token) => token.replaceAll(/['"]/g, ``));
685
- })
718
+ .map(split_cif_tokens)
686
719
  .filter((tokens) => {
687
720
  const { disorder } = header_indices;
688
721
  const max_required_idx = Math.max(...required_indices);
@@ -694,20 +727,20 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
694
727
  return parse_cif_atom_data(tokens, header_indices, coords_type);
695
728
  }
696
729
  catch (error) {
697
- console.warn(`Skipping invalid atom data: ${error}`);
730
+ diag_warn(`Skipping invalid atom data: ${error}`);
698
731
  return null;
699
732
  }
700
733
  })
701
734
  .filter((atom) => atom !== null);
702
735
  if (atoms.length === 0) {
703
- console.error(`No valid atoms found in CIF file`);
736
+ diag_error(`No valid atoms found in CIF file`);
704
737
  return null;
705
738
  }
706
739
  // Extract cell parameters and build lattice
707
740
  const lengths = extract_cif_cell_parameters(text, `cell_length`, strict);
708
741
  const angles = extract_cif_cell_parameters(text, `cell_angle`, strict);
709
742
  if (lengths.length < 3 || angles.length < 3) {
710
- console.error(`Insufficient cell parameters in CIF file`);
743
+ diag_error(`Insufficient cell parameters in CIF file`);
711
744
  return null;
712
745
  }
713
746
  // Build lattice and create sites
@@ -716,60 +749,42 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
716
749
  const lattice_matrix = math.cell_to_lattice_matrix(a, b, c, alpha, beta, gamma);
717
750
  const lattice_params = math.calc_lattice_params(lattice_matrix);
718
751
  const frac_to_cart = math.create_frac_to_cart(lattice_matrix);
719
- const cart_to_frac = try_create_cart_to_frac(lattice_matrix);
752
+ const cart_to_frac = cart_to_frac_with_fallback(lattice_matrix, [a, b, c]).convert;
720
753
  // Create sites with coordinate conversion and symmetry operations
721
- const wrap_vec3 = (v) => (wrap_fractional_coords ? wrap_to_unit_cell(v) : v);
722
- // Apply symmetry operations to generate all equivalent positions
723
- const all_sites = [];
724
- // Normalize symmetry operations (trim/strip quotes) but preserve duplicates; we deduplicate positions later
725
- const normalized_ops = symmetry_ops
726
- .map((op) => /['"]([^'"]+)['"]/.exec(op)?.[1] ?? op.trim())
727
- .map((op) => op.replaceAll(/\s+/g, ``));
728
- // Rely on symmetry operations list for all centering/translations to avoid double-counting
729
- // TODO: Support conventional cells with centering by discovering centering from space group metadata
730
- // when present (e.g. P, I, F, C, R centering types)
731
- const centering_vectors = [[0, 0, 0]];
754
+ const wrap_vec3 = (vec) => wrap_fractional_coords ? wrap_to_unit_cell(vec) : vec;
755
+ // Strip surrounding quotes and all whitespace (preserving duplicates; positions
756
+ // are deduplicated later). Leaves ops as bare `x,y,z`-style expressions.
757
+ const normalized_ops = symmetry_ops.map((op) => (/['"](?<expr>[^'"]+)['"]/.exec(op)?.groups?.expr ?? op).replaceAll(/\s+/g, ``));
732
758
  // Inspect optional _atom_type_number_in_cell loop to see if atom sites are already expanded
733
- const atom_type_counts = (() => {
734
- const map = {};
735
- const text_lines = text.split(`\n`);
736
- for (let li = 0; li < text_lines.length; li++) {
737
- if (text_lines[li].trim() !== `loop_`) {
759
+ const atom_type_counts = {};
760
+ for (const { headers, data_start } of iter_cif_loops(lines)) {
761
+ const hdrs = headers.map((hdr) => hdr.toLowerCase());
762
+ const sym_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_symbol`));
763
+ const num_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_number_in_cell`));
764
+ if (sym_idx === -1 || num_idx === -1)
765
+ continue;
766
+ for (let lj = data_start; lj < lines.length; lj++) {
767
+ const line = lines[lj].trim();
768
+ if (!line || line === `loop_` || line.startsWith(`data_`))
769
+ break;
770
+ if (line.startsWith(`#`))
738
771
  continue;
739
- }
740
- let lj = li + 1;
741
- const hdrs = [];
742
- while (lj < text_lines.length && text_lines[lj].trim().startsWith(`_`)) {
743
- hdrs.push(text_lines[lj].trim().toLowerCase());
744
- lj++;
745
- }
746
- const sym_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_symbol`));
747
- const num_idx = hdrs.findIndex((hdr) => hdr.endsWith(`_atom_type_number_in_cell`));
748
- if (sym_idx !== -1 && num_idx !== -1) {
749
- while (lj < text_lines.length) {
750
- const line = text_lines[lj].trim();
751
- if (!line || line === `loop_` || line.startsWith(`data_`))
752
- break;
753
- if (line.startsWith(`#`)) {
754
- lj++;
755
- continue;
756
- }
757
- const toks = (line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((tok) => tok.replaceAll(/['"]/g, ``));
758
- if (toks.length > Math.max(sym_idx, num_idx)) {
759
- // Normalize type symbol to bare element (e.g. 'Sn2+' -> 'Sn')
760
- const match = /^([A-Z][a-z]*)/.exec(toks[sym_idx]);
761
- const sym = match ? match[1] : toks[sym_idx];
762
- const num = parseInt(toks[num_idx], 10);
763
- if (sym && !Number.isNaN(num))
764
- map[sym] = num;
765
- }
766
- lj++;
772
+ const toks = split_cif_tokens(line);
773
+ if (toks.length > Math.max(sym_idx, num_idx)) {
774
+ // Normalize type symbol to bare element (e.g. 'Sn2+' -> 'Sn')
775
+ const match = /^(?<element>[A-Z][a-z]*)/.exec(toks[sym_idx]);
776
+ const sym = match ? match[1] : toks[sym_idx];
777
+ // Strip standard-uncertainty parentheses (`8(0)` -> `8`) like other CIF
778
+ // readers; empty prefixes like `(8)` parse as NaN and get skipped
779
+ const num = Math.trunc(parse_num_token(toks[num_idx].split(`(`)[0]));
780
+ // sum rows that normalize to the same element (e.g. Fe2+ and Fe3+ → Fe)
781
+ if (sym && !Number.isNaN(num)) {
782
+ atom_type_counts[sym] = (atom_type_counts[sym] ?? 0) + num;
767
783
  }
768
- break;
769
784
  }
770
785
  }
771
- return map;
772
- })();
786
+ break;
787
+ }
773
788
  const observed_counts = {};
774
789
  for (const atom of atoms) {
775
790
  observed_counts[atom.element] = (observed_counts[atom.element] || 0) + 1;
@@ -778,57 +793,65 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
778
793
  const already_enumerated = has_expected_counts &&
779
794
  Object.entries(atom_type_counts).every(([el, exp]) => (observed_counts[el] || 0) >= exp);
780
795
  const ops_to_use = already_enumerated ? [] : normalized_ops;
781
- // Global deduplication of final sites (per element + coordinates + label)
782
- // Use 6 decimal places to handle floating point imprecision from compound symmetry ops
783
- const seen_site_keys = new Set();
784
- for (const atom of atoms) {
785
- const element = validate_element_symbol(atom.element, all_sites.length);
786
- // Convert to fractional coordinates if needed
787
- let fractional_atom;
788
- if (atom.coords_type === `fract`) {
789
- fractional_atom = {
790
- ...atom,
791
- coords: wrap_vec3(atom.coords),
792
- coords_type: `fract`,
793
- };
794
- }
795
- else {
796
- const xyz_base = [atom.coords[0], atom.coords[1], atom.coords[2]];
797
- const atom_abc = wrap_vec3(cart_to_frac
798
- ? cart_to_frac(xyz_base)
799
- : approximate_cart_to_frac(xyz_base, [a, b, c]));
800
- fractional_atom = { ...atom, coords: atom_abc, coords_type: `fract` };
801
- }
802
- // First apply symmetry operations in fractional space
803
- const equiv_atoms = apply_symmetry_ops(fractional_atom, ops_to_use, wrap_fractional_coords);
804
- // Then apply lattice centering shifts to each equivalent position
805
- for (const equiv_atom of equiv_atoms) {
806
- for (const cv of centering_vectors) {
807
- const abc = wrap_vec3([
808
- equiv_atom.coords[0] + cv[0],
809
- equiv_atom.coords[1] + cv[1],
810
- equiv_atom.coords[2] + cv[2],
811
- ]);
796
+ // Candidate lattice-centering translations from the space-group symbol (R
797
+ // only valid in the hexagonal setting, α≈β≈90°, γ≈120°). Whether to actually
798
+ // apply them is decided below by reconciling against _atom_type_number_in_cell.
799
+ const centering_letter = extract_cif_centering(text);
800
+ const is_hexagonal_setting = Math.abs(alpha - 90) <= 1 && Math.abs(beta - 90) <= 1 && Math.abs(gamma - 120) <= 1;
801
+ const centering = centering_letter && (centering_letter !== `R` || is_hexagonal_setting)
802
+ ? CENTERING_VECTORS[centering_letter]
803
+ : [];
804
+ // Build all sites by expanding each atom via the symmetry ops (+ optional
805
+ // centering). Deduplicate globally on element + coordinates + label (6 dp to
806
+ // absorb floating point error from compound ops).
807
+ const build_sites = (extra_centering) => {
808
+ const sites = [];
809
+ const seen_site_keys = new Set();
810
+ for (const atom of atoms) {
811
+ const element = validate_element_symbol(atom.element, sites.length);
812
+ const coords = atom.coords_type === `fract`
813
+ ? wrap_vec3(atom.coords)
814
+ : wrap_vec3(cart_to_frac([atom.coords[0], atom.coords[1], atom.coords[2]]));
815
+ const fractional_atom = { ...atom, coords, coords_type: `fract` };
816
+ const equiv_atoms = apply_symmetry_ops(fractional_atom, ops_to_use, wrap_fractional_coords, extra_centering);
817
+ for (const equiv_atom of equiv_atoms) {
818
+ const abc = wrap_vec3(equiv_atom.coords);
812
819
  const key = cif_site_key(element, abc, equiv_atom.id);
813
820
  if (seen_site_keys.has(key))
814
821
  continue;
815
822
  seen_site_keys.add(key);
816
- const xyz = frac_to_cart(abc);
817
- all_sites.push({
818
- species: [{ element, occu: equiv_atom.occupancy, oxidation_state: 0 }],
819
- abc,
820
- xyz,
821
- label: equiv_atom.id,
822
- properties: {},
823
- });
823
+ sites.push(make_site(element, abc, frac_to_cart(abc), equiv_atom.id, {}, equiv_atom.occupancy));
824
824
  }
825
825
  }
826
+ return sites;
827
+ };
828
+ // Expand with point-group ops first. If the space group is centered and the
829
+ // result falls short of _atom_type_number_in_cell, retry with centering and
830
+ // adopt it only when it reconciles the expected total exactly — this fixes
831
+ // CIFs listing point-only ops for the asymmetric unit while avoiding
832
+ // double-counting CIFs whose atom list already embeds centering (e.g. C2/c
833
+ // COD 7008984, where listed ops + atoms already total the cell contents).
834
+ let sites = build_sites([]);
835
+ const expected_total = Object.values(atom_type_counts).reduce((sum, num) => sum + num, 0);
836
+ if (centering.length > 0 && expected_total > sites.length) {
837
+ const centered_sites = build_sites(centering);
838
+ // Adopt centering only when per-element counts reconcile exactly. Checking
839
+ // the total alone is insufficient: it can coincide while individual element
840
+ // counts are wrong (e.g. expected Fe 1 / O 3 but centering yields Fe 2 / O 2).
841
+ const counts = {};
842
+ for (const site of centered_sites) {
843
+ const element = site.species[0].element;
844
+ counts[element] = (counts[element] ?? 0) + 1;
845
+ }
846
+ const reconciles = centered_sites.length === expected_total &&
847
+ Object.entries(atom_type_counts).every(([element, exp]) => counts[element] === exp);
848
+ if (reconciles)
849
+ sites = centered_sites;
826
850
  }
827
- const sites = all_sites;
828
851
  return { sites, lattice: { matrix: lattice_matrix, ...lattice_params } };
829
852
  }
830
853
  catch (error) {
831
- console.error(`Error parsing CIF file:`, error);
854
+ diag_error(`Error parsing CIF file`, error);
832
855
  return null;
833
856
  }
834
857
  }
@@ -836,11 +859,7 @@ export function parse_cif(content, wrap_fractional_coords = true, strict = true)
836
859
  function convert_phonopy_cell(cell) {
837
860
  const sites = [];
838
861
  // Phonopy stores lattice vectors as rows, use them directly
839
- const lattice_matrix = [
840
- vec3_from_values(cell.lattice[0], `phonopy lattice vector 1`),
841
- vec3_from_values(cell.lattice[1], `phonopy lattice vector 2`),
842
- vec3_from_values(cell.lattice[2], `phonopy lattice vector 3`),
843
- ];
862
+ const lattice_matrix = matrix3x3_from_rows(cell.lattice, `phonopy lattice vector`);
844
863
  // Process each atomic site
845
864
  const phonopy_frac_to_cart = math.create_frac_to_cart(lattice_matrix);
846
865
  for (const point of cell.points) {
@@ -851,9 +870,7 @@ function convert_phonopy_cell(cell) {
851
870
  mass: point.mass,
852
871
  ...(point.reduced_to !== undefined && { reduced_to: point.reduced_to }),
853
872
  };
854
- const species = [{ element, occu: 1.0, oxidation_state: 0 }];
855
- const site = { species, abc, xyz, label: point.symbol, properties };
856
- sites.push(site);
873
+ sites.push(make_site(element, abc, xyz, point.symbol, properties));
857
874
  }
858
875
  // Calculate lattice parameters
859
876
  const calculated_lattice_params = math.calc_lattice_params(lattice_matrix);
@@ -872,7 +889,7 @@ const get_phonopy_cell = (data, cell_type) => {
872
889
  const cell = Reflect.get(data, cell_type);
873
890
  return is_phonopy_cell(cell) ? cell : undefined;
874
891
  };
875
- // Parse phonopy YAML file and return the requested cell type (or preferred single structure)
892
+ // @internal parser exported for tests; public entry points: parse_structure_file/parse_any_structure. Parse phonopy YAML, returns requested cell type (or preferred single structure).
876
893
  export function parse_phonopy_yaml(content, cell_type) {
877
894
  try {
878
895
  // Parse YAML content but exclude large phonon_displacements array for performance
@@ -887,7 +904,7 @@ export function parse_phonopy_yaml(content, cell_type) {
887
904
  }
888
905
  // Check if we're still in the phonon_displacements section
889
906
  if (skip_displacements) {
890
- if (/^[a-zA-Z_]/.exec(line)) {
907
+ if (/^[a-zA-Z_]/.test(line)) {
891
908
  // New top-level key, stop skipping
892
909
  skip_displacements = false;
893
910
  }
@@ -899,7 +916,7 @@ export function parse_phonopy_yaml(content, cell_type) {
899
916
  const filtered_content = filtered_lines.join(`\n`);
900
917
  const data = yaml_load(filtered_content);
901
918
  if (!data) {
902
- console.error(`Failed to parse phonopy YAML`);
919
+ diag_error(`Failed to parse phonopy YAML`);
903
920
  return null;
904
921
  }
905
922
  // If specific cell type requested, parse only that one
@@ -907,27 +924,26 @@ export function parse_phonopy_yaml(content, cell_type) {
907
924
  const cell = get_phonopy_cell(data, cell_type);
908
925
  if (cell)
909
926
  return convert_phonopy_cell(cell);
910
- console.error(`Requested cell type '${cell_type}' not found in phonopy YAML`);
927
+ diag_error(`Requested cell type '${cell_type}' not found in phonopy YAML`);
911
928
  return null;
912
929
  }
913
- // Auto mode: return preferred structure in order of preference
914
- // 1. supercell (most detailed)
915
- // 2. phonon_supercell
916
- // 3. unit_cell
917
- // 4. phonon_primitive_cell
918
- // 5. primitive_cell
919
- const auto_cell = get_phonopy_cell(data, `supercell`) ??
920
- get_phonopy_cell(data, `phonon_supercell`) ??
921
- get_phonopy_cell(data, `unit_cell`) ??
922
- get_phonopy_cell(data, `phonon_primitive_cell`) ??
923
- get_phonopy_cell(data, `primitive_cell`);
930
+ // Auto mode: return first available cell, most detailed first
931
+ const auto_cell = [
932
+ `supercell`,
933
+ `phonon_supercell`,
934
+ `unit_cell`,
935
+ `phonon_primitive_cell`,
936
+ `primitive_cell`,
937
+ ]
938
+ .map((kind) => get_phonopy_cell(data, kind))
939
+ .find(Boolean);
924
940
  if (auto_cell)
925
941
  return convert_phonopy_cell(auto_cell);
926
- console.error(`No valid cells found in phonopy YAML`);
942
+ diag_error(`No valid cells found in phonopy YAML`);
927
943
  return null;
928
944
  }
929
945
  catch (error) {
930
- console.error(`Error parsing phonopy YAML:`, error);
946
+ diag_error(`Error parsing phonopy YAML`, error);
931
947
  return null;
932
948
  }
933
949
  }
@@ -961,8 +977,8 @@ function find_structure_in_json(obj, visited = new WeakSet()) {
961
977
  }
962
978
  return null;
963
979
  }
964
- // Type guard to validate structure-like objects
965
- function is_parsed_structure(obj) {
980
+ // Type guard to validate structure-like objects (sites array with species + coordinates)
981
+ export function is_parsed_structure(obj) {
966
982
  if (!obj || typeof obj !== `object`)
967
983
  return false;
968
984
  const sites = `sites` in obj ? obj.sites : undefined;
@@ -978,6 +994,31 @@ function is_parsed_structure(obj) {
978
994
  const has_coords = Array.isArray(abc) || Array.isArray(xyz);
979
995
  return has_species && has_coords;
980
996
  }
997
+ // Structure JSON serialized by pymatgen (default verbosity) stores only the lattice
998
+ // matrix + pbc; derive the missing scalar params (a/b/c/angles/volume) from the matrix
999
+ // so downstream consumers (camera auto-fit, density, export) never see NaN.
1000
+ export function ensure_lattice_params(structure) {
1001
+ const lattice = structure.lattice;
1002
+ if (!lattice?.matrix)
1003
+ return structure;
1004
+ const params = [
1005
+ lattice.a,
1006
+ lattice.b,
1007
+ lattice.c,
1008
+ lattice.alpha,
1009
+ lattice.beta,
1010
+ lattice.gamma,
1011
+ lattice.volume,
1012
+ ];
1013
+ if (params.every(Number.isFinite))
1014
+ return structure;
1015
+ // The matrix is authoritative: recompute all params from it rather than
1016
+ // trusting a partially-populated (or non-numeric) set of values.
1017
+ return {
1018
+ ...structure,
1019
+ lattice: { ...lattice, ...math.calc_lattice_params(lattice.matrix) },
1020
+ };
1021
+ }
981
1022
  // Normalize structure coordinates: wrap fractional coords to [0,1) and recompute Cartesian
982
1023
  // Only normalizes when lattice matrix is available to ensure abc/xyz stay consistent
983
1024
  export function normalize_fractional_coords(structure) {
@@ -1011,17 +1052,14 @@ const detect_json_structure = (content) => {
1011
1052
  }
1012
1053
  // Otherwise try parsing as pymatgen/nested structure JSON
1013
1054
  const structure = find_structure_in_json(parsed);
1014
- return structure ? normalize_fractional_coords(structure) : null;
1055
+ return structure ? ensure_lattice_params(normalize_fractional_coords(structure)) : null;
1015
1056
  };
1016
- // Auto-detect file format and parse accordingly
1017
- export function parse_structure_file(content, filename) {
1057
+ // Internal: auto-detect file format, returns null on failure after recording reasons (see parse error contract at top)
1058
+ function parse_structure_file_impl(content, filename) {
1018
1059
  // If a filename is provided, try to detect format by file extension first
1019
1060
  if (filename) {
1020
1061
  // Handle compressed files by removing compression extensions
1021
- let base_filename = filename.toLowerCase();
1022
- while (COMPRESSION_EXTENSIONS_REGEX.test(base_filename)) {
1023
- base_filename = base_filename.replace(COMPRESSION_EXTENSIONS_REGEX, ``);
1024
- }
1062
+ const base_filename = strip_compression_extensions(filename);
1025
1063
  const ext = base_filename.split(`.`).pop();
1026
1064
  // Try to detect format by file extension
1027
1065
  if (ext === `xyz` || ext === `extxyz`)
@@ -1035,10 +1073,10 @@ export function parse_structure_file(content, filename) {
1035
1073
  const result = detect_json_structure(content);
1036
1074
  if (result)
1037
1075
  return result;
1038
- console.error(`JSON file does not contain a valid structure format`);
1076
+ diag_error(`JSON file does not contain a valid structure format`);
1039
1077
  }
1040
1078
  catch (error) {
1041
- console.error(`Error parsing JSON file:`, error);
1079
+ diag_error(`Error parsing JSON file`, error);
1042
1080
  }
1043
1081
  return null;
1044
1082
  }
@@ -1053,49 +1091,40 @@ export function parse_structure_file(content, filename) {
1053
1091
  // Try to auto-detect based on content.
1054
1092
  // JSON detection must come before the line-count guard: minified JSON
1055
1093
  // (e.g. fetched via extensionless blob: object URLs) is a single line.
1094
+ const content_start = content.trimStart();
1095
+ const looks_like_json = content_start.startsWith(`{`) || content_start.startsWith(`[`);
1056
1096
  try {
1057
1097
  const result = detect_json_structure(content);
1058
1098
  if (result)
1059
1099
  return result;
1100
+ if (looks_like_json)
1101
+ diag_error(`JSON content does not contain a valid structure format`);
1060
1102
  }
1061
- catch {
1062
- // Not JSON, continue with other format detection
1103
+ catch (error) {
1104
+ // Only swallow silently when content doesn't even look like JSON; otherwise the
1105
+ // syntax error is the most useful failure reason and must be surfaced
1106
+ if (looks_like_json)
1107
+ diag_error(`Invalid JSON`, error);
1063
1108
  }
1064
1109
  const lines = content.trim().split(/\r?\n/);
1065
1110
  if (lines.length < 2) {
1066
- console.error(`File too short to determine format`);
1111
+ diag_error(`File too short to determine format`);
1067
1112
  return null;
1068
1113
  }
1069
- // XYZ format detection: first line should be a number, second line is comment
1070
- const first_line_number = parseInt(lines[0].trim(), 10);
1071
- if (!isNaN(first_line_number) && first_line_number > 0) {
1072
- // Check if this looks like XYZ format
1073
- if (lines.length >= first_line_number + 2) {
1074
- // Try to parse a coordinate line to see if it looks like XYZ
1075
- const coord_line_idx = 2; // First coordinate line in XYZ
1076
- if (coord_line_idx < lines.length) {
1077
- const parts = lines[coord_line_idx].trim().split(/\s+/);
1078
- // XYZ format: element symbol followed by 3 coordinates
1079
- if (parts.length >= 4) {
1080
- const first_token = parts[0];
1081
- const coords = parts.slice(1, 4);
1082
- // Check if first token looks like an element symbol (not a number)
1083
- // and the next 3 tokens look like coordinates (numbers)
1084
- const is_element_symbol = isNaN(parseInt(first_token, 10)) && first_token.length <= 3;
1085
- const are_coordinates = coords.every((coord) => !isNaN(parseFloat(coord)));
1086
- if (is_element_symbol && are_coordinates) {
1087
- // First token is likely an element symbol, likely XYZ
1088
- return parse_xyz(content);
1089
- }
1090
- }
1091
- }
1092
- }
1093
- }
1114
+ // XYZ format detection: first line is a positive atom count, second line is a
1115
+ // comment, and the first coordinate line looks like "<element> <x> <y> <z>"
1116
+ const first_line_number = Math.trunc(parse_leading_num(lines[0]));
1117
+ if (!isNaN(first_line_number) &&
1118
+ first_line_number > 0 &&
1119
+ lines.length >= first_line_number + 2 &&
1120
+ is_xyz_atom_line(lines[2]?.trim().split(/\s+/)))
1121
+ return parse_xyz(content);
1094
1122
  // POSCAR format detection: look for typical structure
1095
1123
  if (lines.length >= 8) {
1096
- const second_line_number = parseFloat(lines[1].trim());
1097
- // Second line is a number (scale factor), likely POSCAR
1098
- if (!isNaN(second_line_number))
1124
+ // Second line starts with a number (scale factor), likely POSCAR. First
1125
+ // token only: POSCAR allows three per-axis scale factors (or trailing
1126
+ // comments) on line 2, and blank lines must not pass
1127
+ if (!isNaN(parse_leading_num(lines[1])))
1099
1128
  return parse_poscar(content);
1100
1129
  }
1101
1130
  // CIF format detection: look for CIF-specific keywords
@@ -1113,38 +1142,48 @@ export function parse_structure_file(content, filename) {
1113
1142
  line.includes(`phonon_supercell:`));
1114
1143
  if (has_phonopy_keywords)
1115
1144
  return parse_phonopy_yaml(content);
1116
- console.error(`Unable to determine file format`);
1145
+ diag_error(`Unable to determine file format`);
1117
1146
  return null;
1118
1147
  }
1119
- // Universal parser that handles JSON and structure files
1148
+ // Auto-detect file format and parse; throws an Error aggregating per-format failure reasons when nothing parses
1149
+ export function parse_structure_file(content, filename) {
1150
+ reset_parse_diagnostics();
1151
+ const structure = parse_structure_file_impl(content, filename);
1152
+ if (structure)
1153
+ return structure;
1154
+ throw aggregate_parse_error(filename);
1155
+ }
1156
+ // Universal parser for JSON and structure files; throws an Error aggregating per-format failure reasons when nothing parses
1120
1157
  export function parse_any_structure(content, filename) {
1121
- const finalize_structure = (structure) => ({
1122
- sites: structure.sites,
1123
- charge: 0,
1124
- ...(structure.properties && {
1125
- properties: clone_structure_properties(structure.properties),
1126
- }),
1127
- ...(structure.lattice && {
1128
- lattice: { ...structure.lattice, pbc: [true, true, true] },
1129
- }),
1130
- });
1131
- // Try JSON first, but handle nested structures properly
1158
+ reset_parse_diagnostics();
1159
+ const finalize_structure = (parsed_structure) => {
1160
+ const structure = ensure_lattice_params(parsed_structure);
1161
+ return {
1162
+ sites: structure.sites,
1163
+ charge: 0,
1164
+ ...(structure.properties && {
1165
+ properties: structuredClone(structure.properties),
1166
+ }),
1167
+ ...(structure.lattice && {
1168
+ lattice: { ...structure.lattice, pbc: [true, true, true] },
1169
+ }),
1170
+ };
1171
+ };
1172
+ // Fast path: content is already a serialized structure object
1132
1173
  try {
1133
1174
  const parsed = JSON.parse(content);
1134
- // Check if it's already a valid structure using proper type guard
1135
1175
  if (is_parsed_structure(parsed)) {
1136
1176
  // Normalize coordinates (wrap fractional to [0,1) and recompute Cartesian)
1137
1177
  return finalize_structure(normalize_fractional_coords(parsed));
1138
1178
  }
1139
- // If not, use parse_structure_file to find nested structures
1140
- const structure = parse_structure_file(content, filename);
1141
- return structure ? finalize_structure(structure) : null;
1142
1179
  }
1143
1180
  catch {
1144
- // Try structure file formats
1145
- const parsed = parse_structure_file(content, filename);
1146
- return parsed ? finalize_structure(parsed) : null;
1181
+ // Not plain JSON — fall through to format detection, which records failure reasons
1147
1182
  }
1183
+ const structure = parse_structure_file_impl(content, filename);
1184
+ if (structure)
1185
+ return finalize_structure(structure);
1186
+ throw aggregate_parse_error(filename);
1148
1187
  }
1149
1188
  // Parse OPTIMADE JSON format
1150
1189
  export function parse_optimade_json(content) {
@@ -1153,16 +1192,80 @@ export function parse_optimade_json(content) {
1153
1192
  return parse_optimade_from_raw(raw);
1154
1193
  }
1155
1194
  catch (error) {
1156
- console.error(`Error parsing OPTIMADE JSON:`, error);
1195
+ diag_error(`Error parsing OPTIMADE JSON`, error);
1157
1196
  return null;
1158
1197
  }
1159
1198
  }
1199
+ // Build sites + lattice shared by parse_optimade_from_raw and optimade_to_crystal.
1200
+ // on_invalid controls whether invalid positions are skipped with a warning or throw;
1201
+ // site_props extracts per-site mass/concentration from the species list.
1202
+ function build_optimade_sites(attrs, opts) {
1203
+ const positions = attrs.cartesian_site_positions ?? [];
1204
+ const species_at_sites = attrs.species_at_sites ?? [];
1205
+ const species_list = Array.isArray(attrs.species) ? attrs.species : undefined;
1206
+ // OPTIMADE stores lattice vectors as rows, so use as-is
1207
+ const lattice_matrix = attrs.lattice_vectors
1208
+ ? matrix3x3_from_rows(attrs.lattice_vectors, `OPTIMADE lattice vector`)
1209
+ : undefined;
1210
+ const lattice_params = lattice_matrix ? math.calc_lattice_params(lattice_matrix) : null;
1211
+ let cart_to_frac = null;
1212
+ if (lattice_matrix && lattice_params) {
1213
+ const converter = cart_to_frac_with_fallback(lattice_matrix, [
1214
+ lattice_params.a,
1215
+ lattice_params.b,
1216
+ lattice_params.c,
1217
+ ]);
1218
+ if (!converter.exact) {
1219
+ diag_warn(`Failed to create exact coordinate converter for OPTIMADE structure`);
1220
+ }
1221
+ cart_to_frac = converter.convert;
1222
+ }
1223
+ const sites = [];
1224
+ for (let idx = 0; idx < positions.length; idx++) {
1225
+ const species_name = species_at_sites[idx];
1226
+ if (!species_name) {
1227
+ if (opts.on_invalid === `throw`)
1228
+ throw new Error(`Missing species for site ${idx}`);
1229
+ diag_warn(`Missing species for site ${idx}, skipping`);
1230
+ continue;
1231
+ }
1232
+ let xyz;
1233
+ try {
1234
+ xyz = vec3_from_values(positions[idx], `OPTIMADE atom position ${idx + 1}`);
1235
+ }
1236
+ catch (error) {
1237
+ if (opts.on_invalid === `throw`)
1238
+ throw error;
1239
+ diag_warn(`Invalid position data at site ${idx}: ${error}`);
1240
+ continue;
1241
+ }
1242
+ const { symbol: element, sym_idx } = resolve_optimade_element(species_name, species_list, idx);
1243
+ // Calculate fractional coordinates if lattice is available
1244
+ const abc = cart_to_frac ? cart_to_frac(xyz) : [0, 0, 0];
1245
+ const site_props = {};
1246
+ if (opts.site_props) {
1247
+ // Extract mass/concentration for the chosen element. sym_idx indexes the (parallel)
1248
+ // chemical_symbols/mass/concentration arrays; -1 (name resolved directly, no
1249
+ // chemical_symbols) falls back to index 0 — the single-element entry.
1250
+ const spec = species_list?.find((entry) => entry.name === species_name);
1251
+ const spec_idx = Math.max(sym_idx, 0);
1252
+ if (spec?.mass?.[spec_idx] !== undefined)
1253
+ site_props.mass = spec.mass[spec_idx];
1254
+ if (spec?.concentration?.[spec_idx] !== undefined &&
1255
+ spec.concentration[spec_idx] !== 1) {
1256
+ site_props.concentration = spec.concentration[spec_idx];
1257
+ }
1258
+ }
1259
+ sites.push(make_site(element, abc, xyz, `${element}${idx + 1}`, site_props));
1260
+ }
1261
+ return { sites, lattice_matrix, lattice_params };
1262
+ }
1160
1263
  // Parse OPTIMADE from already-parsed JSON
1161
1264
  export function parse_optimade_from_raw(raw) {
1162
1265
  try {
1163
1266
  const structure = extract_optimade_structure_from_raw(raw);
1164
1267
  if (!structure) {
1165
- console.error(`No valid OPTIMADE structure found in JSON`);
1268
+ diag_error(`No valid OPTIMADE structure found in JSON`);
1166
1269
  return null;
1167
1270
  }
1168
1271
  const attrs = structure.attributes;
@@ -1170,83 +1273,28 @@ export function parse_optimade_from_raw(raw) {
1170
1273
  const positions_raw = attrs.cartesian_site_positions;
1171
1274
  const species_raw = attrs.species_at_sites;
1172
1275
  if (!(Array.isArray(positions_raw) && Array.isArray(species_raw))) {
1173
- console.error(`OPTIMADE JSON missing required position or species data`);
1276
+ diag_error(`OPTIMADE JSON missing required position or species data`);
1174
1277
  return null;
1175
1278
  }
1176
1279
  if (positions_raw.length !== species_raw.length) {
1177
- console.error(`OPTIMADE JSON position/species count mismatch`);
1280
+ diag_error(`OPTIMADE JSON position/species count mismatch`);
1178
1281
  return null;
1179
1282
  }
1180
- const positions = positions_raw;
1181
- const species = species_raw;
1182
- // Optimade stores lattice vectors as rows, so use as is
1183
- const lattice_matrix = attrs.lattice_vectors
1184
- ? [
1185
- vec3_from_values(attrs.lattice_vectors[0], `OPTIMADE lattice vector 1`),
1186
- vec3_from_values(attrs.lattice_vectors[1], `OPTIMADE lattice vector 2`),
1187
- vec3_from_values(attrs.lattice_vectors[2], `OPTIMADE lattice vector 3`),
1188
- ]
1189
- : undefined;
1190
- const optimade_lattice_params = lattice_matrix
1191
- ? math.calc_lattice_params(lattice_matrix)
1192
- : null;
1193
- // Parse atomic sites
1194
- const optimade_exact_cart_to_frac = lattice_matrix
1195
- ? try_create_cart_to_frac(lattice_matrix)
1196
- : null;
1197
- const optimade_cart_to_frac = lattice_matrix && optimade_lattice_params
1198
- ? (optimade_exact_cart_to_frac ??
1199
- ((xyz) => approximate_cart_to_frac(xyz, [
1200
- optimade_lattice_params.a,
1201
- optimade_lattice_params.b,
1202
- optimade_lattice_params.c,
1203
- ])))
1204
- : null;
1205
- if (lattice_matrix && !optimade_exact_cart_to_frac) {
1206
- console.warn(`Failed to create exact coordinate converter for OPTIMADE structure`);
1207
- }
1208
- const optimade_species = Array.isArray(attrs.species) ? attrs.species : undefined;
1209
- const sites = [];
1210
- for (let idx = 0; idx < positions.length; idx++) {
1211
- const pos = positions[idx];
1212
- const element_symbol = species[idx];
1213
- let xyz;
1214
- try {
1215
- xyz = vec3_from_values(pos, `OPTIMADE site ${idx} position`);
1216
- }
1217
- catch (error) {
1218
- console.warn(`Invalid position data at site ${idx}: ${error}`);
1219
- continue;
1220
- }
1221
- const { symbol: element } = resolve_optimade_element(element_symbol, optimade_species, idx);
1222
- // Calculate fractional coordinates if lattice is available
1223
- const abc = optimade_cart_to_frac ? optimade_cart_to_frac(xyz) : [0, 0, 0];
1224
- const site = {
1225
- species: [{ element, occu: 1, oxidation_state: 0 }],
1226
- abc,
1227
- xyz,
1228
- label: `${element}${idx + 1}`,
1229
- properties: {},
1230
- };
1231
- sites.push(site);
1232
- }
1283
+ const { sites, lattice_matrix, lattice_params } = build_optimade_sites(attrs, {
1284
+ on_invalid: `skip`,
1285
+ });
1233
1286
  if (sites.length === 0) {
1234
- console.error(`No valid sites found in OPTIMADE JSON`);
1287
+ diag_error(`No valid sites found in OPTIMADE JSON`);
1235
1288
  return null;
1236
1289
  }
1237
- // Create structure object
1238
- let lattice;
1239
- if (lattice_matrix && optimade_lattice_params) {
1240
- lattice = { matrix: lattice_matrix, ...optimade_lattice_params };
1241
- }
1242
- const structure_result = {
1290
+ return {
1243
1291
  sites,
1244
- ...(lattice && { lattice }),
1292
+ ...(lattice_matrix &&
1293
+ lattice_params && { lattice: { matrix: lattice_matrix, ...lattice_params } }),
1245
1294
  };
1246
- return structure_result;
1247
1295
  }
1248
1296
  catch (error) {
1249
- console.error(`Error parsing OPTIMADE JSON:`, error);
1297
+ diag_error(`Error parsing OPTIMADE JSON`, error);
1250
1298
  return null;
1251
1299
  }
1252
1300
  }
@@ -1268,9 +1316,7 @@ function extract_optimade_structure_from_raw(raw) {
1268
1316
  const candidate = Array.isArray(payload) ? payload[0] : payload;
1269
1317
  return is_optimade_structure_object(candidate) ? candidate : null;
1270
1318
  }
1271
- const unwrap_data = (value) => value && typeof value === `object` && `data` in value
1272
- ? value.data
1273
- : value;
1319
+ const unwrap_data = (value) => value && typeof value === `object` && `data` in value ? value.data : value;
1274
1320
  // Type guard: verify minimal OPTIMADE structure shape
1275
1321
  function is_optimade_structure_object(value) {
1276
1322
  if (!value || typeof value !== `object`)
@@ -1286,47 +1332,18 @@ function is_optimade_structure_object(value) {
1286
1332
  }
1287
1333
  // Convert OPTIMADE structure to Crystal format
1288
1334
  export function optimade_to_crystal(optimade_structure) {
1289
- const { lattice_vectors, cartesian_site_positions, species_at_sites, species, ...properties } = optimade_structure.attributes;
1335
+ const { lattice_vectors, cartesian_site_positions, species_at_sites, species: _species, // excluded from the properties rest
1336
+ ...properties } = optimade_structure.attributes;
1290
1337
  if (!lattice_vectors || !cartesian_site_positions || !species_at_sites) {
1291
- console.error(`Missing required OPTIMADE structure data`);
1338
+ diag_error(`Missing required OPTIMADE structure data`);
1292
1339
  return null;
1293
1340
  }
1294
1341
  try {
1295
- const lattice_matrix = [
1296
- vec3_from_values(lattice_vectors[0], `OPTIMADE lattice vector 1`),
1297
- vec3_from_values(lattice_vectors[1], `OPTIMADE lattice vector 2`),
1298
- vec3_from_values(lattice_vectors[2], `OPTIMADE lattice vector 3`),
1299
- ];
1300
- const lattice_params = math.calc_lattice_params(lattice_matrix);
1301
- const crystal_cart_to_frac = try_create_cart_to_frac(lattice_matrix) ??
1302
- ((xyz) => approximate_cart_to_frac(xyz, [lattice_params.a, lattice_params.b, lattice_params.c]));
1303
- const sites = cartesian_site_positions.map((pos, idx) => {
1304
- const element_symbol = species_at_sites[idx];
1305
- if (!element_symbol)
1306
- throw new Error(`Missing species for site ${idx}`);
1307
- const { symbol: element, sym_idx } = resolve_optimade_element(element_symbol, species, idx);
1308
- const xyz = vec3_from_values(pos, `OPTIMADE atom position ${idx + 1}`);
1309
- const abc = crystal_cart_to_frac ? crystal_cart_to_frac(xyz) : [0, 0, 0];
1310
- // Extract mass/concentration for the chosen element. sym_idx indexes the (parallel)
1311
- // chemical_symbols/mass/concentration arrays; -1 (name resolved directly, no
1312
- // chemical_symbols) falls back to index 0 — the single-element entry.
1313
- const spec = species?.find((entry) => entry.name === element_symbol);
1314
- const spec_idx = Math.max(sym_idx, 0);
1315
- const site_props = {};
1316
- if (spec?.mass?.[spec_idx] !== undefined)
1317
- site_props.mass = spec.mass[spec_idx];
1318
- if (spec?.concentration?.[spec_idx] !== undefined &&
1319
- spec.concentration[spec_idx] !== 1) {
1320
- site_props.concentration = spec.concentration[spec_idx];
1321
- }
1322
- return {
1323
- species: [{ element, occu: 1, oxidation_state: 0 }],
1324
- abc,
1325
- xyz,
1326
- label: `${element}${idx + 1}`,
1327
- properties: site_props,
1328
- };
1329
- });
1342
+ const { sites, lattice_matrix, lattice_params } = build_optimade_sites(optimade_structure.attributes, { on_invalid: `throw`, site_props: true });
1343
+ if (!lattice_matrix || !lattice_params) {
1344
+ diag_error(`Missing required OPTIMADE structure data`);
1345
+ return null;
1346
+ }
1330
1347
  return {
1331
1348
  sites,
1332
1349
  lattice: { matrix: lattice_matrix, ...lattice_params, pbc: [true, true, true] },
@@ -1335,46 +1352,13 @@ export function optimade_to_crystal(optimade_structure) {
1335
1352
  };
1336
1353
  }
1337
1354
  catch (err) {
1338
- console.error(`Error converting OPTIMADE to Crystal format:`, err);
1355
+ diag_error(`Error converting OPTIMADE to Crystal format`, err);
1339
1356
  return null;
1340
1357
  }
1341
1358
  }
1342
- // Check if filename indicates a structure file
1343
- export function is_structure_file(filename) {
1344
- const name = filename.toLowerCase();
1345
- // Trajectory-only formats (can't be structures)
1346
- if (/\.(traj|xtc|h5|hdf5)$/i.test(name) || /xdatcar/i.test(name))
1347
- return false;
1348
- // Always structure formats
1349
- if (STRUCTURE_EXTENSIONS_REGEX.test(name))
1350
- return true;
1351
- if (VASP_FILES_REGEX.test(name))
1352
- return true;
1353
- // .xyz/.extxyz files: structure unless they have trajectory keywords
1354
- if (/\.(xyz|extxyz)$/i.test(name))
1355
- return !TRAJ_KEYWORDS_REGEX.test(name);
1356
- // Keyword-based detection for YAML/XML
1357
- if (/\.(yaml|yml|xml)$/i.test(name) && STRUCT_KEYWORDS_REGEX.test(name))
1358
- return true;
1359
- // More restrictive keyword detection for JSON files
1360
- if (/\.json$/i.test(name) &&
1361
- STRUCT_KEYWORDS_STRICT_REGEX.test(name) &&
1362
- !TRAJ_KEYWORDS_REGEX.test(name) &&
1363
- !CONFIG_DIRS_REGEX.test(name))
1364
- return true;
1365
- // Compressed files - check base filename recursively
1366
- if (COMPRESSION_EXTENSIONS_REGEX.test(name)) {
1367
- return is_structure_file(name.replace(COMPRESSION_EXTENSIONS_REGEX, ``));
1368
- }
1369
- return false;
1370
- }
1371
1359
  export const detect_structure_type = (filename, content) => {
1372
- const lower_filename = filename.toLowerCase();
1373
1360
  // Normalize compressed suffixes (gz, gzip, zip, xz, bz2) for detection parity
1374
- let name_to_check = lower_filename;
1375
- while (COMPRESSION_EXTENSIONS_REGEX.test(name_to_check)) {
1376
- name_to_check = name_to_check.replace(COMPRESSION_EXTENSIONS_REGEX, ``);
1377
- }
1361
+ const name_to_check = strip_compression_extensions(filename);
1378
1362
  if (name_to_check.endsWith(`.json`)) {
1379
1363
  try {
1380
1364
  const parsed = JSON.parse(content);