matterviz 0.4.1 → 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 (395) 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 +75 -85
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +5 -7
  8. package/dist/brillouin/BrillouinZoneControls.svelte +3 -6
  9. package/dist/brillouin/BrillouinZoneExportPane.svelte +18 -14
  10. package/dist/brillouin/BrillouinZoneInfoPane.svelte +6 -10
  11. package/dist/brillouin/BrillouinZoneScene.svelte +26 -39
  12. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  13. package/dist/chempot-diagram/ChemPotDiagram.svelte +33 -38
  14. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +62 -74
  15. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +1 -1
  16. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +511 -547
  17. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +1 -1
  18. package/dist/chempot-diagram/async-compute.svelte.js +1 -1
  19. package/dist/chempot-diagram/controls-state.svelte.js +1 -1
  20. package/dist/colors/index.d.ts +0 -4
  21. package/dist/colors/index.js +19 -20
  22. package/dist/composition/BarChart.svelte +37 -31
  23. package/dist/composition/BubbleChart.svelte +17 -17
  24. package/dist/composition/Composition.svelte +7 -11
  25. package/dist/composition/Formula.svelte +18 -25
  26. package/dist/composition/FormulaFilter.svelte +92 -95
  27. package/dist/composition/PieChart.svelte +55 -41
  28. package/dist/composition/format.js +1 -1
  29. package/dist/composition/index.d.ts +2 -0
  30. package/dist/composition/index.js +14 -0
  31. package/dist/composition/parse.d.ts +2 -2
  32. package/dist/composition/parse.js +11 -10
  33. package/dist/constants.js +11 -9
  34. package/dist/convex-hull/ConvexHull.svelte +12 -15
  35. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  36. package/dist/convex-hull/ConvexHull2D.svelte +93 -92
  37. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  38. package/dist/convex-hull/ConvexHull3D.svelte +109 -140
  39. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  40. package/dist/convex-hull/ConvexHull4D.svelte +75 -94
  41. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  42. package/dist/convex-hull/ConvexHullChrome.svelte +36 -40
  43. package/dist/convex-hull/ConvexHullControls.svelte +46 -48
  44. package/dist/convex-hull/ConvexHullStats.svelte +193 -195
  45. package/dist/convex-hull/ConvexHullTooltip.svelte +5 -12
  46. package/dist/convex-hull/GasPressureControls.svelte +5 -12
  47. package/dist/convex-hull/StructurePopup.svelte +17 -21
  48. package/dist/convex-hull/StructurePopup.svelte.d.ts +2 -0
  49. package/dist/convex-hull/TemperatureSlider.svelte +4 -10
  50. package/dist/convex-hull/canvas-interactions.svelte.js +2 -1
  51. package/dist/convex-hull/helpers.d.ts +8 -10
  52. package/dist/convex-hull/helpers.js +36 -36
  53. package/dist/convex-hull/hull-state.svelte.d.ts +1 -1
  54. package/dist/convex-hull/thermodynamics.js +18 -17
  55. package/dist/coordination/CoordinationBarPlot.svelte +38 -33
  56. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +1 -1
  57. package/dist/coordination/calc-coordination.d.ts +1 -1
  58. package/dist/coordination/calc-coordination.js +28 -25
  59. package/dist/element/BohrAtom.svelte +5 -8
  60. package/dist/element/ElementHeading.svelte +4 -1
  61. package/dist/element/ElementPhoto.svelte +11 -9
  62. package/dist/element/ElementStats.svelte +12 -10
  63. package/dist/element/ElementTile.svelte +24 -29
  64. package/dist/element/Nucleus.svelte +4 -2
  65. package/dist/element/data.d.ts +1 -2
  66. package/dist/element/helpers.d.ts +4 -0
  67. package/dist/element/helpers.js +18 -0
  68. package/dist/element/index.d.ts +1 -4
  69. package/dist/element/index.js +3 -18
  70. package/dist/feedback/DragOverlay.svelte +5 -5
  71. package/dist/feedback/DragOverlay.svelte.d.ts +2 -2
  72. package/dist/feedback/StatusMessage.svelte +7 -4
  73. package/dist/fermi-surface/FermiSlice.svelte +21 -18
  74. package/dist/fermi-surface/FermiSurface.svelte +14 -22
  75. package/dist/fermi-surface/FermiSurface.svelte.d.ts +5 -7
  76. package/dist/fermi-surface/FermiSurfaceControls.svelte +6 -20
  77. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  78. package/dist/fermi-surface/FermiSurfaceScene.svelte +37 -64
  79. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  80. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +9 -6
  81. package/dist/fermi-surface/compute.js +3 -3
  82. package/dist/fermi-surface/parse.js +22 -24
  83. package/dist/heatmap-matrix/HeatmapMatrix.svelte +109 -148
  84. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +2 -2
  85. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +9 -7
  86. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +1 -1
  87. package/dist/heatmap-matrix/index.d.ts +5 -0
  88. package/dist/icons.d.ts +4 -0
  89. package/dist/icons.js +4 -0
  90. package/dist/io/ExportPane.svelte +10 -6
  91. package/dist/io/decompress.d.ts +2 -1
  92. package/dist/io/decompress.js +22 -35
  93. package/dist/io/export.d.ts +4 -0
  94. package/dist/io/export.js +5 -1
  95. package/dist/io/fetch.d.ts +0 -3
  96. package/dist/io/fetch.js +0 -11
  97. package/dist/io/index.d.ts +0 -1
  98. package/dist/io/index.js +2 -9
  99. package/dist/io/is-binary.d.ts +9 -0
  100. package/dist/io/is-binary.js +45 -1
  101. package/dist/io/url-drop.js +13 -25
  102. package/dist/isosurface/Isosurface.svelte +17 -23
  103. package/dist/isosurface/IsosurfaceControls.svelte +37 -42
  104. package/dist/isosurface/parse-vaspwave.d.ts +3 -0
  105. package/dist/isosurface/parse-vaspwave.js +138 -0
  106. package/dist/isosurface/parse.js +6 -6
  107. package/dist/labels.d.ts +6 -2
  108. package/dist/labels.js +43 -13
  109. package/dist/layout/FullscreenButton.svelte +2 -1
  110. package/dist/layout/FullscreenToggle.svelte +10 -4
  111. package/dist/layout/InfoCard.svelte +7 -18
  112. package/dist/layout/InfoTag.svelte +1 -3
  113. package/dist/layout/NumberRangeInput.svelte +46 -0
  114. package/dist/layout/NumberRangeInput.svelte.d.ts +12 -0
  115. package/dist/layout/PropertyFilter.svelte +2 -6
  116. package/dist/layout/SettingsSection.svelte +21 -17
  117. package/dist/layout/SubpageGrid.svelte +3 -1
  118. package/dist/layout/ViewerChrome.svelte +9 -10
  119. package/dist/layout/ViewerChrome.svelte.d.ts +2 -1
  120. package/dist/layout/fullscreen.js +3 -5
  121. package/dist/layout/fullscreen.svelte.js +1 -2
  122. package/dist/layout/index.d.ts +1 -0
  123. package/dist/layout/index.js +1 -0
  124. package/dist/layout/json-tree/JsonNode.svelte +8 -11
  125. package/dist/layout/json-tree/JsonTree.svelte +53 -77
  126. package/dist/layout/json-tree/JsonValue.svelte +7 -15
  127. package/dist/layout/json-tree/utils.js +14 -23
  128. package/dist/math.d.ts +2 -0
  129. package/dist/math.js +19 -0
  130. package/dist/overlays/ContextMenu.svelte +4 -2
  131. package/dist/overlays/CopyButton.svelte +11 -5
  132. package/dist/overlays/DragControlTab.svelte +6 -1
  133. package/dist/overlays/DraggablePane.svelte +81 -26
  134. package/dist/overlays/DraggablePane.svelte.d.ts +1 -0
  135. package/dist/overlays/GlassChip.svelte +30 -0
  136. package/dist/overlays/GlassChip.svelte.d.ts +8 -0
  137. package/dist/overlays/InfoPaneCards.svelte +11 -23
  138. package/dist/overlays/index.d.ts +7 -0
  139. package/dist/overlays/index.js +26 -0
  140. package/dist/overlays/portal.d.ts +2 -0
  141. package/dist/overlays/portal.js +14 -0
  142. package/dist/periodic-table/PeriodicTable.svelte +113 -145
  143. package/dist/periodic-table/PeriodicTable.svelte.d.ts +5 -3
  144. package/dist/periodic-table/PeriodicTableControls.svelte +11 -63
  145. package/dist/periodic-table/TableInset.svelte +9 -4
  146. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +50 -71
  147. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -8
  148. package/dist/phase-diagram/PhaseDiagramControls.svelte +70 -94
  149. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  150. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +2 -1
  151. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +12 -12
  152. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +54 -58
  153. package/dist/phase-diagram/TdbInfoPanel.svelte +21 -15
  154. package/dist/phase-diagram/build-diagram.js +3 -3
  155. package/dist/phase-diagram/diagram-input.d.ts +1 -2
  156. package/dist/phase-diagram/parse.js +15 -15
  157. package/dist/phase-diagram/svg-to-diagram.js +21 -15
  158. package/dist/phase-diagram/utils.js +2 -3
  159. package/dist/plot/bar/BarPlot.svelte +430 -391
  160. package/dist/plot/bar/BarPlot.svelte.d.ts +2 -0
  161. package/dist/plot/bar/BarPlotControls.svelte +1 -3
  162. package/dist/plot/bar/BarPlotControls.svelte.d.ts +4 -6
  163. package/dist/plot/bar/SpacegroupBarPlot.svelte +29 -36
  164. package/dist/plot/bar/data.d.ts +5 -4
  165. package/dist/plot/bar/data.js +2 -1
  166. package/dist/plot/box/BoxPlot.svelte +301 -209
  167. package/dist/plot/box/BoxPlot.svelte.d.ts +2 -0
  168. package/dist/plot/box/Violin.svelte.d.ts +1 -0
  169. package/dist/plot/box/box-plot.js +1 -1
  170. package/dist/plot/core/auto-place.js +1 -1
  171. package/dist/plot/core/axis-utils.d.ts +12 -7
  172. package/dist/plot/core/axis-utils.js +48 -48
  173. package/dist/plot/core/components/AxisLabel.svelte +82 -20
  174. package/dist/plot/core/components/AxisLabel.svelte.d.ts +1 -0
  175. package/dist/plot/core/components/ColorBar.svelte +101 -106
  176. package/dist/plot/core/components/ColorScaleSelect.svelte +1 -1
  177. package/dist/plot/core/components/FillArea.svelte +10 -17
  178. package/dist/plot/core/components/HierarchyControls.svelte +249 -0
  179. package/dist/plot/{sunburst/SunburstControls.svelte.d.ts → core/components/HierarchyControls.svelte.d.ts} +12 -8
  180. package/dist/plot/core/components/InteractiveAxisLabel.svelte +1 -1
  181. package/dist/plot/core/components/InteractiveAxisLabel.svelte.d.ts +1 -1
  182. package/dist/plot/core/components/Line.svelte +18 -9
  183. package/dist/plot/core/components/Line.svelte.d.ts +2 -0
  184. package/dist/plot/core/components/PlotAxis.svelte +40 -17
  185. package/dist/plot/core/components/PlotControls.svelte +65 -41
  186. package/dist/plot/core/components/PlotLegend.svelte +31 -35
  187. package/dist/plot/core/components/PlotMarginals.svelte +616 -0
  188. package/dist/plot/core/components/PlotMarginals.svelte.d.ts +15 -0
  189. package/dist/plot/core/components/PlotTooltip.svelte +1 -1
  190. package/dist/plot/core/components/PortalSelect.svelte +4 -8
  191. package/dist/plot/core/components/ReferenceLine.svelte +27 -24
  192. package/dist/plot/core/components/ReferenceLine3D.svelte +54 -57
  193. package/dist/plot/core/components/ReferencePlane.svelte +7 -8
  194. package/dist/plot/core/components/ZeroLines.svelte +4 -8
  195. package/dist/plot/core/components/ZoomRect.svelte +1 -2
  196. package/dist/plot/core/components/index.d.ts +1 -0
  197. package/dist/plot/core/components/index.js +1 -0
  198. package/dist/plot/core/data-cleaning-signal.d.ts +46 -0
  199. package/dist/plot/core/data-cleaning-signal.js +467 -0
  200. package/dist/plot/core/data-cleaning.d.ts +2 -42
  201. package/dist/plot/core/data-cleaning.js +27 -474
  202. package/dist/plot/core/data-transform.js +9 -5
  203. package/dist/plot/core/fill-utils.d.ts +3 -1
  204. package/dist/plot/core/fill-utils.js +28 -6
  205. package/dist/plot/core/index.d.ts +1 -0
  206. package/dist/plot/core/index.js +1 -0
  207. package/dist/plot/core/interactions.js +0 -2
  208. package/dist/plot/core/layout.d.ts +3 -1
  209. package/dist/plot/core/layout.js +22 -13
  210. package/dist/plot/core/marginals.d.ts +170 -0
  211. package/dist/plot/core/marginals.js +436 -0
  212. package/dist/plot/core/reference-line.js +1 -1
  213. package/dist/plot/core/scales.d.ts +23 -0
  214. package/dist/plot/core/scales.js +39 -13
  215. package/dist/plot/core/types/fills.d.ts +101 -0
  216. package/dist/plot/core/types/fills.js +15 -0
  217. package/dist/plot/core/types/plot-3d.d.ts +87 -0
  218. package/dist/plot/core/types/plot-3d.js +4 -0
  219. package/dist/plot/core/types/reference-lines.d.ts +140 -0
  220. package/dist/plot/core/types/reference-lines.js +9 -0
  221. package/dist/plot/core/types.d.ts +29 -324
  222. package/dist/plot/core/types.js +23 -20
  223. package/dist/plot/core/utils/hierarchy-chart.d.ts +51 -0
  224. package/dist/plot/core/utils/hierarchy-chart.js +179 -0
  225. package/dist/plot/core/utils/hierarchy-labels.d.ts +20 -0
  226. package/dist/plot/core/utils/hierarchy-labels.js +99 -0
  227. package/dist/plot/core/utils/label-placement.js +2 -2
  228. package/dist/plot/histogram/Histogram.svelte +304 -325
  229. package/dist/plot/histogram/Histogram.svelte.d.ts +7 -7
  230. package/dist/plot/histogram/HistogramControls.svelte +30 -28
  231. package/dist/plot/histogram/histogram.d.ts +31 -0
  232. package/dist/plot/histogram/histogram.js +70 -0
  233. package/dist/plot/histogram/index.d.ts +1 -0
  234. package/dist/plot/histogram/index.js +1 -0
  235. package/dist/plot/index.d.ts +1 -0
  236. package/dist/plot/index.js +1 -0
  237. package/dist/plot/sankey/Sankey.svelte +78 -69
  238. package/dist/plot/sankey/Sankey.svelte.d.ts +2 -2
  239. package/dist/plot/sankey/SankeyControls.svelte +22 -16
  240. package/dist/plot/scatter/BinnedScatterPlot.svelte +263 -109
  241. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +7 -0
  242. package/dist/plot/scatter/ElementScatter.svelte +4 -1
  243. package/dist/plot/scatter/ScatterPlot.svelte +477 -385
  244. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +2 -0
  245. package/dist/plot/scatter/ScatterPlotControls.svelte +18 -19
  246. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  247. package/dist/plot/scatter/ScatterPoint.svelte +16 -9
  248. package/dist/plot/scatter/binned-scatter-types.d.ts +3 -2
  249. package/dist/plot/scatter/binned-scatter-types.js +6 -1
  250. package/dist/plot/scatter/scatter-data.d.ts +1 -1
  251. package/dist/plot/scatter/scatter-data.js +8 -7
  252. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +25 -34
  253. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +2 -2
  254. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +8 -5
  255. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +113 -116
  256. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +2 -2
  257. package/dist/plot/scatter-3d/Surface3D.svelte +4 -13
  258. package/dist/plot/sunburst/Sunburst.svelte +251 -329
  259. package/dist/plot/sunburst/Sunburst.svelte.d.ts +2 -2
  260. package/dist/plot/sunburst/index.d.ts +1 -1
  261. package/dist/plot/sunburst/index.js +2 -1
  262. package/dist/plot/sunburst/render.d.ts +2 -2
  263. package/dist/plot/sunburst/render.js +47 -22
  264. package/dist/plot/sunburst/sunburst.d.ts +5 -1
  265. package/dist/plot/sunburst/sunburst.js +10 -3
  266. package/dist/plot/treemap/Treemap.svelte +907 -0
  267. package/dist/plot/treemap/Treemap.svelte.d.ts +84 -0
  268. package/dist/plot/treemap/index.d.ts +3 -0
  269. package/dist/plot/treemap/index.js +4 -0
  270. package/dist/plot/treemap/treemap.d.ts +26 -0
  271. package/dist/plot/treemap/treemap.js +99 -0
  272. package/dist/rdf/RdfPlot.svelte +22 -26
  273. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  274. package/dist/rdf/calc-rdf.js +4 -8
  275. package/dist/sanitize.js +1 -1
  276. package/dist/scene/bind-renderer.svelte.js +14 -6
  277. package/dist/settings.d.ts +19 -9
  278. package/dist/settings.js +77 -50
  279. package/dist/spectral/Bands.svelte +223 -230
  280. package/dist/spectral/BandsAndDos.svelte +11 -9
  281. package/dist/spectral/BrillouinBandsDos.svelte +19 -27
  282. package/dist/spectral/Dos.svelte +70 -73
  283. package/dist/spectral/helpers.d.ts +3 -3
  284. package/dist/spectral/helpers.js +25 -35
  285. package/dist/state.svelte.js +5 -18
  286. package/dist/structure/Arrow.svelte +5 -18
  287. package/dist/structure/ArrowInstances.svelte +160 -0
  288. package/dist/structure/ArrowInstances.svelte.d.ts +16 -0
  289. package/dist/structure/AtomLegend.svelte +115 -75
  290. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  291. package/dist/structure/Bond.svelte +31 -23
  292. package/dist/structure/CanvasTooltip.svelte +15 -3
  293. package/dist/structure/CellSelect.svelte +11 -26
  294. package/dist/structure/Cylinder.svelte +24 -40
  295. package/dist/structure/InstancedAtoms.svelte +114 -0
  296. package/dist/structure/InstancedAtoms.svelte.d.ts +15 -0
  297. package/dist/structure/Lattice.svelte +85 -149
  298. package/dist/structure/SiteLabels.svelte +122 -0
  299. package/dist/structure/SiteLabels.svelte.d.ts +18 -0
  300. package/dist/structure/Structure.svelte +473 -419
  301. package/dist/structure/Structure.svelte.d.ts +4 -2
  302. package/dist/structure/StructureCarousel.svelte +630 -0
  303. package/dist/structure/StructureCarousel.svelte.d.ts +19 -0
  304. package/dist/structure/StructureControls.svelte +157 -399
  305. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  306. package/dist/structure/StructureExportPane.svelte +17 -21
  307. package/dist/structure/StructureInfoPane.svelte +72 -66
  308. package/dist/structure/StructureInfoPane.svelte.d.ts +1 -1
  309. package/dist/structure/StructureScene.svelte +467 -476
  310. package/dist/structure/StructureScene.svelte.d.ts +3 -1
  311. package/dist/structure/StructureViewport.svelte +386 -0
  312. package/dist/structure/StructureViewport.svelte.d.ts +63 -0
  313. package/dist/structure/atom-label-placement.d.ts +16 -12
  314. package/dist/structure/atom-label-placement.js +70 -37
  315. package/dist/structure/atom-properties.d.ts +2 -0
  316. package/dist/structure/atom-properties.js +104 -38
  317. package/dist/structure/bonding.d.ts +10 -3
  318. package/dist/structure/bonding.js +84 -28
  319. package/dist/structure/edit-history.d.ts +6 -0
  320. package/dist/structure/edit-history.js +27 -0
  321. package/dist/structure/export.d.ts +1 -0
  322. package/dist/structure/export.js +39 -31
  323. package/dist/structure/format-detect.d.ts +1 -0
  324. package/dist/structure/format-detect.js +22 -0
  325. package/dist/structure/geometry.d.ts +9 -0
  326. package/dist/structure/geometry.js +27 -0
  327. package/dist/structure/index.d.ts +16 -1
  328. package/dist/structure/index.js +13 -2
  329. package/dist/structure/measure.d.ts +1 -1
  330. package/dist/structure/measure.js +6 -12
  331. package/dist/structure/parse.d.ts +2 -1
  332. package/dist/structure/parse.js +243 -222
  333. package/dist/structure/partial-occupancy.js +4 -2
  334. package/dist/structure/pbc.js +78 -98
  335. package/dist/structure/polyhedra.d.ts +0 -1
  336. package/dist/structure/polyhedra.js +3 -18
  337. package/dist/structure/supercell.d.ts +1 -2
  338. package/dist/structure/supercell.js +7 -13
  339. package/dist/symmetry/SymmetryElements.svelte +11 -15
  340. package/dist/symmetry/SymmetryStats.svelte +56 -68
  341. package/dist/symmetry/WyckoffTable.svelte +5 -24
  342. package/dist/symmetry/cell-transform.js +1 -0
  343. package/dist/symmetry/index.d.ts +1 -0
  344. package/dist/symmetry/index.js +8 -2
  345. package/dist/symmetry/spacegroups.js +3 -2
  346. package/dist/table/HeatmapTable.svelte +1102 -365
  347. package/dist/table/HeatmapTable.svelte.d.ts +12 -2
  348. package/dist/table/ToggleMenu.svelte +5 -12
  349. package/dist/table/index.d.ts +21 -6
  350. package/dist/table/index.js +34 -21
  351. package/dist/theme/ThemeControl.svelte +16 -8
  352. package/dist/theme/index.d.ts +1 -5
  353. package/dist/theme/index.js +4 -10
  354. package/dist/theme/themes.mjs +14 -7
  355. package/dist/tooltip/KCoords.svelte +4 -1
  356. package/dist/tooltip/TooltipContent.svelte +2 -5
  357. package/dist/trajectory/Trajectory.svelte +515 -308
  358. package/dist/trajectory/Trajectory.svelte.d.ts +1 -1
  359. package/dist/trajectory/TrajectoryError.svelte +11 -8
  360. package/dist/trajectory/TrajectoryExportPane.svelte +14 -31
  361. package/dist/trajectory/TrajectoryInfoPane.svelte +85 -97
  362. package/dist/trajectory/extract.js +56 -34
  363. package/dist/trajectory/format-detect.js +11 -9
  364. package/dist/trajectory/frame-reader.d.ts +2 -0
  365. package/dist/trajectory/frame-reader.js +33 -29
  366. package/dist/trajectory/helpers.d.ts +2 -0
  367. package/dist/trajectory/helpers.js +18 -7
  368. package/dist/trajectory/index.d.ts +1 -0
  369. package/dist/trajectory/index.js +3 -0
  370. package/dist/trajectory/parse/h5-utils.d.ts +13 -0
  371. package/dist/trajectory/parse/h5-utils.js +109 -0
  372. package/dist/trajectory/parse/hdf5.d.ts +1 -1
  373. package/dist/trajectory/parse/hdf5.js +116 -106
  374. package/dist/trajectory/parse/index.d.ts +2 -3
  375. package/dist/trajectory/parse/index.js +17 -20
  376. package/dist/trajectory/parse/lammps.js +8 -12
  377. package/dist/trajectory/parse/vasp.js +10 -7
  378. package/dist/trajectory/parse/vaspout-electronic.d.ts +13 -0
  379. package/dist/trajectory/parse/vaspout-electronic.js +207 -0
  380. package/dist/trajectory/parse/vaspout-h5.d.ts +5 -0
  381. package/dist/trajectory/parse/vaspout-h5.js +284 -0
  382. package/dist/trajectory/parse/xyz.js +11 -7
  383. package/dist/trajectory/plotting.d.ts +8 -9
  384. package/dist/trajectory/plotting.js +99 -27
  385. package/dist/utils.d.ts +3 -0
  386. package/dist/utils.js +7 -0
  387. package/dist/xrd/XrdPlot.svelte +47 -61
  388. package/dist/xrd/broadening.js +2 -1
  389. package/dist/xrd/calc-xrd.js +2 -4
  390. package/dist/xrd/parse.d.ts +0 -1
  391. package/dist/xrd/parse.js +56 -73
  392. package/package.json +61 -33
  393. package/dist/api/mp.d.ts +0 -6
  394. package/dist/api/mp.js +0 -22
  395. package/dist/plot/sunburst/SunburstControls.svelte +0 -200
@@ -21,6 +21,7 @@
21
21
  import { create_cart_to_frac, create_frac_to_cart } from '../math'
22
22
  import { DEFAULTS } from '../settings'
23
23
  import { colors } from '../state.svelte'
24
+ import StructureViewport from './StructureViewport.svelte'
24
25
  import type {
25
26
  AnyStructure,
26
27
  BondEditMode,
@@ -28,13 +29,16 @@
28
29
  Crystal,
29
30
  MeasureMode,
30
31
  StructureBond,
32
+ StructureView,
31
33
  } from './'
32
34
  import {
35
+ DEFAULT_STRUCTURE_VIEWS,
33
36
  default_vector_configs,
34
37
  get_element_counts,
35
38
  get_pbc_image_sites,
36
39
  get_structure_vector_keys,
37
40
  } from './'
41
+ import { push_edit, step_history } from './edit-history'
38
42
  import { wrap_to_unit_cell } from './pbc'
39
43
  import {
40
44
  is_valid_supercell_input,
@@ -45,13 +49,12 @@
45
49
  import * as symmetry from '../symmetry'
46
50
  import { transform_cell } from '../symmetry'
47
51
  import type { MoyoDataset } from '@spglib/moyo-wasm'
48
- import { Canvas } from '@threlte/core'
49
52
  import type { ComponentProps, Snippet } from 'svelte'
50
53
  import { untrack } from 'svelte'
51
54
  import { click_outside, tooltip } from 'svelte-multiselect/attachments'
52
55
  import type { HTMLAttributes } from 'svelte/elements'
53
56
  import { SvelteMap, SvelteSet } from 'svelte/reactivity'
54
- import type { Camera, OrthographicCamera, Scene } from 'three'
57
+ import type { Camera, Scene } from 'three'
55
58
  import type { AtomColorConfig } from './atom-properties'
56
59
  import { get_property_colors } from './atom-properties'
57
60
  import AtomLegend from './AtomLegend.svelte'
@@ -59,7 +62,11 @@
59
62
  import { BOND_ORDER_OPTIONS, merge_bond_edits, remap_bonds_after_deletion } from './bonding'
60
63
  import type { StructureHandlerData } from './index'
61
64
  import { MAX_SELECTED_SITES } from './measure'
62
- import { normalize_fractional_coords, parse_any_structure } from './parse'
65
+ import {
66
+ ensure_lattice_params,
67
+ normalize_fractional_coords,
68
+ parse_any_structure,
69
+ } from './parse'
63
70
  import StructureControls from './StructureControls.svelte'
64
71
  import StructureExportPane from './StructureExportPane.svelte'
65
72
  import StructureInfoPane from './StructureInfoPane.svelte'
@@ -107,6 +114,8 @@
107
114
  lattice_props: lattice_props_in = $bindable(),
108
115
  controls_open = $bindable(false),
109
116
  info_pane_open = $bindable(false),
117
+ multi_view = $bindable(false),
118
+ views = DEFAULT_STRUCTURE_VIEWS,
110
119
  enable_measure_mode = $bindable(true),
111
120
  measure_mode = $bindable<MeasureMode>(`distance`),
112
121
  bond_edit_mode = $bindable<BondEditMode>(`add`),
@@ -183,95 +192,101 @@
183
192
  on_camera_reset,
184
193
  on_bonds_change,
185
194
  ...rest
186
- }:
187
- & {
188
- structure?: AnyStructure
189
- bonds?: StructureBond[]
190
- scene_props?: ComponentProps<typeof StructureScene>
191
- // Controls visibility configuration.
192
- // - 'always': controls always visible
193
- // - 'hover': controls visible on component hover (default)
194
- // - 'never': controls never visible
195
- // - object: { mode, hidden, style } for fine-grained control
196
- //
197
- // Control names: 'reset-camera', 'fullscreen', 'measure-mode', 'info-pane', 'export-pane', 'controls'
198
- show_controls?: ShowControlsProp
199
- fullscreen?: boolean
200
- // bindable width of the canvas
201
- width?: number
202
- // bindable height of the canvas
203
- height?: number
204
- // Canvas wrapper element (for export pane)
205
- wrapper?: HTMLDivElement
206
- // PNG export DPI setting
207
- png_dpi?: number
208
- reset_text?: string
209
- hovered?: boolean
210
- dragover?: boolean
211
- allow_file_drop?: boolean
212
- enable_info_pane?: boolean
213
- enable_measure_mode?: boolean
214
- measure_mode?: MeasureMode
215
- bond_edit_mode?: BondEditMode
216
- bond_edit_order?: BondOrder
217
- info_pane_open?: boolean
218
- fullscreen_toggle?: FullscreenToggleProp
219
- bottom_left?: Snippet<[{ structure?: AnyStructure }]>
220
- top_right_controls?: Snippet // Additional controls to render at the end of the control buttons row
221
- data_url?: string // URL to load structure from (alternative to providing structure directly)
222
- // Generic callback for when files are dropped - receives raw content and filename
223
- on_file_drop?: (content: string | ArrayBuffer, filename: string) => void
224
- // spinner props (passed to Spinner component)
225
- spinner_props?: ComponentProps<typeof Spinner>
226
- loading?: boolean
227
- error_msg?: string
228
- // Performance mode: 'quality' (default) or 'speed' for large structures
229
- performance_mode?: `quality` | `speed`
230
- // allow parent components to control highlighted/selected site indices
231
- selected_sites?: number[]
232
- highlighted_sites?: number[]
233
- hovered_site_idx?: number | null
234
- // explicit measured sites for distance/angle overlays
235
- measured_sites?: number[]
236
- // expose the displayed structure (with image atoms and/or supercell) for external use
237
- displayed_structure?: AnyStructure
238
- // Track which elements are hidden (bindable across frames in trajectories)
239
- hidden_elements?: Set<ElementSymbol>
240
- // Track which property values are hidden (e.g. Wyckoff positions, coordination numbers)
241
- hidden_prop_vals?: Set<number | string>
242
- // Per-element radius overrides (absolute values in Angstroms)
243
- element_radius_overrides?: Partial<Record<ElementSymbol, number>>
244
- // Per-site radius overrides (absolute values in Angstroms)
245
- // Accepts Map or SvelteMap for flexibility with external callers
246
- site_radius_overrides?: Map<number, number> | SvelteMap<number, number>
247
- // Symmetry analysis data (bindable for external access)
248
- sym_data?: MoyoDataset | null
249
- // Symmetry analysis settings (bindable for external control)
250
- symmetry_settings?: Partial<SymmetrySettings>
251
- // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
252
- element_mapping?: Partial<Record<ElementSymbol, ElementSymbol>>
253
- // Cell type: original, conventional, or primitive (requires symmetry analysis)
254
- cell_type?: CellType
255
- // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
256
- volumetric_data?: VolumetricData[]
257
- // Isosurface rendering settings
258
- isosurface_settings?: IsosurfaceSettings
259
- // Active volume index when multiple volumes are present
260
- active_volume_idx?: number
261
- // structure content as string (alternative to providing structure directly or via data_url)
262
- structure_string?: string
263
- // Atom coloring configuration
264
- atom_color_config?: Partial<AtomColorConfig>
265
- children?: Snippet<[{ structure?: AnyStructure; fullscreen: boolean }]>
266
- on_file_load?: EventHandler
267
- on_error?: EventHandler
268
- on_fullscreen_change?: EventHandler
269
- on_camera_move?: EventHandler
270
- on_camera_reset?: EventHandler
271
- on_bonds_change?: (bonds: StructureBond[] | undefined) => void
272
- }
273
- & Omit<ComponentProps<typeof StructureControls>, `children` | `onclose`>
274
- & Omit<HTMLAttributes<HTMLDivElement>, `children`> = $props()
195
+ }: {
196
+ structure?: AnyStructure
197
+ bonds?: StructureBond[]
198
+ scene_props?: ComponentProps<typeof StructureScene>
199
+ // Controls visibility configuration.
200
+ // - 'always': controls always visible
201
+ // - 'hover': controls visible on component hover (default)
202
+ // - 'never': controls never visible
203
+ // - object: { mode, hidden, style } for fine-grained control
204
+ //
205
+ // Control names: 'reset-camera', 'fullscreen', 'measure-mode', 'info-pane', 'export-pane', 'controls'
206
+ show_controls?: ShowControlsProp
207
+ fullscreen?: boolean
208
+ // bindable width of the canvas
209
+ width?: number
210
+ // bindable height of the canvas
211
+ height?: number
212
+ // Canvas wrapper element (for export pane)
213
+ wrapper?: HTMLDivElement
214
+ // PNG export DPI setting
215
+ png_dpi?: number
216
+ reset_text?: string
217
+ hovered?: boolean
218
+ dragover?: boolean
219
+ allow_file_drop?: boolean
220
+ enable_info_pane?: boolean
221
+ enable_measure_mode?: boolean
222
+ measure_mode?: MeasureMode
223
+ bond_edit_mode?: BondEditMode
224
+ bond_edit_order?: BondOrder
225
+ info_pane_open?: boolean
226
+ // When true, split the canvas into a 2x2 grid showing the structure from
227
+ // different angles (Ovito-style). Each pane has independent orbit controls.
228
+ multi_view?: boolean
229
+ // The 4 (or more) view definitions used by multi_view. Defaults to an
230
+ // Ovito-like set: one perspective + three orthographic axis views.
231
+ views?: StructureView[]
232
+ fullscreen_toggle?: FullscreenToggleProp
233
+ bottom_left?: Snippet<[{ structure?: AnyStructure }]>
234
+ top_right_controls?: Snippet // Additional controls to render at the end of the control buttons row
235
+ data_url?: string // URL to load structure from (alternative to providing structure directly)
236
+ // Generic callback for when files are dropped - receives raw content and filename
237
+ on_file_drop?: (content: string | ArrayBuffer, filename: string) => void
238
+ // spinner props (passed to Spinner component)
239
+ spinner_props?: ComponentProps<typeof Spinner>
240
+ loading?: boolean
241
+ error_msg?: string
242
+ // Performance mode: 'quality' (default) or 'speed' for large structures
243
+ performance_mode?: `quality` | `speed`
244
+ // allow parent components to control highlighted/selected site indices
245
+ selected_sites?: number[]
246
+ highlighted_sites?: number[]
247
+ hovered_site_idx?: number | null
248
+ // explicit measured sites for distance/angle overlays
249
+ measured_sites?: number[]
250
+ // expose the displayed structure (with image atoms and/or supercell) for external
251
+ // use. Output-only: the scene renders from an internal raw copy for performance,
252
+ // so external writes to this prop are ignored (overwritten on the next pipeline run)
253
+ displayed_structure?: AnyStructure
254
+ // Track which elements are hidden (bindable across frames in trajectories)
255
+ hidden_elements?: Set<ElementSymbol>
256
+ // Track which property values are hidden (e.g. Wyckoff positions, coordination numbers)
257
+ hidden_prop_vals?: Set<number | string>
258
+ // Per-element radius overrides (absolute values in Angstroms)
259
+ element_radius_overrides?: Partial<Record<ElementSymbol, number>>
260
+ // Per-site radius overrides (absolute values in Angstroms)
261
+ // Accepts Map or SvelteMap for flexibility with external callers
262
+ site_radius_overrides?: Map<number, number> | SvelteMap<number, number>
263
+ // Symmetry analysis data (bindable for external access)
264
+ sym_data?: MoyoDataset | null
265
+ // Symmetry analysis settings (bindable for external control)
266
+ symmetry_settings?: Partial<SymmetrySettings>
267
+ // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
268
+ element_mapping?: Partial<Record<ElementSymbol, ElementSymbol>>
269
+ // Cell type: original, conventional, or primitive (requires symmetry analysis)
270
+ cell_type?: CellType
271
+ // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
272
+ volumetric_data?: VolumetricData[]
273
+ // Isosurface rendering settings
274
+ isosurface_settings?: IsosurfaceSettings
275
+ // Active volume index when multiple volumes are present
276
+ active_volume_idx?: number
277
+ // structure content as string (alternative to providing structure directly or via data_url)
278
+ structure_string?: string
279
+ // Atom coloring configuration
280
+ atom_color_config?: Partial<AtomColorConfig>
281
+ children?: Snippet<[{ structure?: AnyStructure; fullscreen: boolean }]>
282
+ on_file_load?: EventHandler
283
+ on_error?: EventHandler
284
+ on_fullscreen_change?: EventHandler
285
+ on_camera_move?: EventHandler
286
+ on_camera_reset?: EventHandler
287
+ on_bonds_change?: (bonds: StructureBond[] | undefined) => void
288
+ } & Omit<ComponentProps<typeof StructureControls>, `children` | `onclose`> &
289
+ Omit<HTMLAttributes<HTMLDivElement>, `children`> = $props()
275
290
 
276
291
  // Initialize models from incoming props; mutations come from UI controls; we mirror into local dicts (NOTE only doing shallow merge)
277
292
  $effect.pre(() => {
@@ -283,41 +298,54 @@
283
298
  }
284
299
  })
285
300
 
286
- // Load structure from URL when data_url is provided
301
+ // Load structure from URL when data_url is provided. A monotonic load_id ignores stale
302
+ // completions so a newer data_url (or an externally-supplied structure, via the cleanup)
303
+ // can't be clobbered by a slow earlier fetch.
304
+ let data_url_load_id = 0
287
305
  $effect(() => {
288
- if (data_url && !structure) {
289
- loading = true
290
- error_msg = undefined
291
-
292
- load_from_url(data_url, (content, filename) => {
293
- if (on_file_drop) on_file_drop(content, filename)
294
- else {
295
- // Parse structure internally when no handler provided
296
- try {
297
- const text_content = content instanceof ArrayBuffer
298
- ? new TextDecoder().decode(content)
299
- : content
300
- const parsed = parse_file_content(text_content, filename)
301
- emit_file_load_event(parsed, filename, content)
302
- } catch (error) {
303
- error_msg = `Failed to parse structure: ${
304
- to_error(error).message
305
- }`
306
- on_error?.({ error_msg, filename })
307
- }
306
+ if (!data_url || structure) return
307
+ const load_id = ++data_url_load_id
308
+ const is_current = () => load_id === data_url_load_id
309
+ loading = true
310
+ error_msg = undefined
311
+
312
+ load_from_url(data_url, (content, filename) => {
313
+ if (!is_current()) return // stale response
314
+ if (on_file_drop) on_file_drop(content, filename)
315
+ else {
316
+ // Parse structure internally when no handler provided
317
+ try {
318
+ const text_content =
319
+ content instanceof ArrayBuffer ? new TextDecoder().decode(content) : content
320
+ const parsed = parse_file_content(text_content, filename)
321
+ emit_file_load_event(parsed, filename, content)
322
+ } catch (error) {
323
+ error_msg = `Failed to parse structure: ${to_error(error).message}`
324
+ on_error?.({ error_msg, filename })
308
325
  }
326
+ }
327
+ })
328
+ .catch((error: Error) => {
329
+ if (!is_current()) return
330
+ console.error(`Failed to load structure from URL:`, error)
331
+ error_msg = `Failed to load structure: ${error.message}`
332
+ on_error?.({ error_msg, filename: data_url })
333
+ })
334
+ .finally(() => {
335
+ if (is_current()) loading = false
309
336
  })
310
- .then(() => loading = false)
311
- .catch((error: Error) => {
312
- console.error(`Failed to load structure from URL:`, error)
313
- error_msg = `Failed to load structure: ${error.message}`
314
- loading = false
315
- on_error?.({ error_msg, filename: data_url })
316
- })
337
+
338
+ return () => {
339
+ // invalidate in-flight load on data_url change / structure arrival / unmount
340
+ if (is_current()) {
341
+ data_url_load_id += 1
342
+ loading = false
343
+ }
317
344
  }
318
345
  })
319
346
 
320
- $effect(() => { // Parse structure from string when structure_string is provided
347
+ $effect(() => {
348
+ // Parse structure from string when structure_string is provided
321
349
  if (!structure_string || data_url) return
322
350
  loading = true
323
351
  error_msg = undefined
@@ -331,9 +359,7 @@
331
359
  throw new Error(`Failed to parse structure from string`)
332
360
  }
333
361
  } catch (err) {
334
- error_msg = `Failed to parse structure from string: ${
335
- to_error(err).message
336
- }`
362
+ error_msg = `Failed to parse structure from string: ${to_error(err).message}`
337
363
  untrack(() => on_error?.({ error_msg, filename: `string` }))
338
364
  } finally {
339
365
  loading = false
@@ -389,12 +415,7 @@
389
415
 
390
416
  // Compute property-based colors for legend display
391
417
  let property_colors = $derived(
392
- get_property_colors(
393
- structure,
394
- atom_color_config,
395
- scene_props.bonding_strategy,
396
- sym_data,
397
- ),
418
+ get_property_colors(structure, atom_color_config, scene_props.bonding_strategy, sym_data),
398
419
  )
399
420
 
400
421
  let symmetry_run_id = 0
@@ -426,7 +447,7 @@
426
447
  } else {
427
448
  // Keep previous symmetry data while recomputing so bound consumers
428
449
  // (e.g. SymmetryStats inputs) do not unmount and lose focus.
429
- untrack(() => symmetry_error = undefined)
450
+ untrack(() => (symmetry_error = undefined))
430
451
  }
431
452
  const run_id = ++symmetry_run_id
432
453
  // Destructure symmetry_settings to ensure Svelte tracks changes to symprec and algo
@@ -436,20 +457,21 @@
436
457
  // Skip symmetry auto-analysis in unit tests; happy-dom can't fetch WASM assets
437
458
  if (typeof process !== `undefined` && process.env?.VITEST) return
438
459
 
439
- symmetry.ensure_moyo_wasm_ready()
460
+ symmetry
461
+ .ensure_moyo_wasm_ready()
440
462
  .then(() =>
441
463
  run_id === symmetry_run_id
442
464
  ? symmetry.analyze_structure_symmetry(current_structure, current_settings)
443
- : null
465
+ : null,
444
466
  )
445
467
  .then((data) => {
446
468
  if (data && run_id === symmetry_run_id) {
447
- untrack(() => sym_data = data)
469
+ untrack(() => (sym_data = data))
448
470
  }
449
471
  })
450
472
  .catch((err) => {
451
473
  if (run_id === symmetry_run_id) {
452
- untrack(() => sym_data = null)
474
+ untrack(() => (sym_data = null))
453
475
  symmetry_error = `Symmetry analysis failed: ${err?.message || err}`
454
476
  console.error(`Symmetry analysis failed:`, err)
455
477
  }
@@ -474,8 +496,7 @@
474
496
  let last_emitted_bond_signature = $state<string>()
475
497
  let bond_edit_snapshot = $state.raw<BondEditSnapshot>()
476
498
  let has_bond_edits = $derived(
477
- added_bonds.length > 0 || removed_bonds.length > 0 ||
478
- bond_order_overrides.length > 0,
499
+ added_bonds.length > 0 || removed_bonds.length > 0 || bond_order_overrides.length > 0,
479
500
  )
480
501
 
481
502
  const clone_bonds = (edit_bonds: StructureBond[]): StructureBond[] =>
@@ -556,10 +577,10 @@
556
577
  const current_source_bond_signature = (): string => {
557
578
  const raw_signature = bond_signature(current_source_bonds())
558
579
  if (raw_signature !== last_emitted_bond_signature) return raw_signature
559
- return bond_history_context?.source_bond_signature ??
560
- (bond_edit_snapshot
561
- ? bond_signature(bond_edit_snapshot.bonds)
562
- : raw_signature)
580
+ return (
581
+ bond_history_context?.source_bond_signature ??
582
+ (bond_edit_snapshot ? bond_signature(bond_edit_snapshot.bonds) : raw_signature)
583
+ )
563
584
  }
564
585
 
565
586
  const current_bond_edit_context = (): BondEditContext => ({
@@ -646,8 +667,15 @@
646
667
 
647
668
  // === Edit-atoms mode state ===
648
669
  let dragging_atoms = $state(false)
649
- let undo_stack = $state<AnyStructure[]>([])
650
- let redo_stack = $state<AnyStructure[]>([])
670
+ // History entries snapshot the bindable `bonds` prop alongside the structure:
671
+ // atom deletion remaps `bonds` to the shifted site indices, so restoring the
672
+ // structure alone would leave explicit bonds pointing at wrong sites.
673
+ type EditSnapshot = { structure: AnyStructure; bonds: StructureBond[] | undefined }
674
+ // $state.raw: stacks hold full structure snapshots — deep-proxying them would make
675
+ // every restored structure (and all downstream site reads) go through proxy traps.
676
+ // Mutations happen via reassignment so .length stays reactive in the template.
677
+ let undo_stack = $state.raw<EditSnapshot[]>([])
678
+ let redo_stack = $state.raw<EditSnapshot[]>([])
651
679
  const MAX_HISTORY = 20
652
680
  // Flag set before internal edits (undo/redo/delete/add/move) to distinguish
653
681
  // them from external structure changes (file load, trajectory step, etc.)
@@ -655,7 +683,6 @@
655
683
  // Add-atom sub-mode state (bound to StructureScene)
656
684
  let add_atom_mode = $state(false)
657
685
  let add_element = $state<ElementSymbol>(`C` as ElementSymbol)
658
- let canvas_cursor = $state(`default`)
659
686
  let is_measure_selection_mode = $derived(
660
687
  measure_mode === `distance` || measure_mode === `angle`,
661
688
  )
@@ -693,28 +720,40 @@
693
720
  dragging_atoms = false
694
721
  }
695
722
 
723
+ const snapshot_edit_state = (current: AnyStructure): EditSnapshot => ({
724
+ structure: $state.snapshot(current) as AnyStructure,
725
+ bonds: bonds && ($state.snapshot(bonds) as StructureBond[]),
726
+ })
727
+
696
728
  function push_undo() {
697
729
  if (!structure) return
698
- if (undo_stack.length >= MAX_HISTORY) {
699
- undo_stack.splice(0, undo_stack.length - MAX_HISTORY + 1)
700
- }
701
- undo_stack.push($state.snapshot(structure) as AnyStructure)
702
- redo_stack.length = 0
730
+ ;[undo_stack, redo_stack] = push_edit(
731
+ [undo_stack, redo_stack],
732
+ snapshot_edit_state(structure),
733
+ MAX_HISTORY,
734
+ )
703
735
  }
704
736
 
705
- // Shared undo/redo: pop from `source`, push current state onto `target`
706
- function apply_history(source: AnyStructure[], target: AnyStructure[]) {
707
- if (source.length === 0 || !structure) return
708
- const restored = source.pop()
709
- if (!restored) return
737
+ // Shared undo/redo: restore the top of that direction's stack, pushing the
738
+ // current state onto the opposite one ($state.raw stacks update by reassignment)
739
+ function apply_history(direction: `undo` | `redo`) {
740
+ // Check the stack before snapshotting: $state.snapshot deep-clones the structure
741
+ if (!structure || (direction === `undo` ? undo_stack : redo_stack).length === 0) return
742
+ const result = step_history(
743
+ [undo_stack, redo_stack],
744
+ direction,
745
+ snapshot_edit_state(structure),
746
+ )
747
+ if (!result) return
710
748
  is_internal_edit = true
711
- target.push($state.snapshot(structure) as AnyStructure)
712
- structure = restored
749
+ ;[undo_stack, redo_stack] = result.stacks
750
+ structure = result.restored.structure
751
+ bonds = result.restored.bonds
713
752
  clear_selection()
714
753
  }
715
754
 
716
- const undo = () => apply_history(undo_stack, redo_stack)
717
- const redo = () => apply_history(redo_stack, undo_stack)
755
+ const undo = () => apply_history(`undo`)
756
+ const redo = () => apply_history(`redo`)
718
757
 
719
758
  // Clear undo/redo stacks when structure changes externally (file load, etc.)
720
759
  // Internal edits set is_internal_edit=true before modifying structure.
@@ -757,7 +796,10 @@
757
796
  $effect(() => {
758
797
  void bond_edit_mode
759
798
  untrack(() => {
760
- if (measure_mode === `edit-bonds` && (selected_sites.length > 0 || measured_sites.length > 0)) {
799
+ if (
800
+ measure_mode === `edit-bonds` &&
801
+ (selected_sites.length > 0 || measured_sites.length > 0)
802
+ ) {
761
803
  clear_selection()
762
804
  }
763
805
  })
@@ -794,10 +836,13 @@
794
836
  ])
795
837
 
796
838
  // Normalize structure coordinates: wrap fractional coords to [0,1) and recompute Cartesian
797
- // This ensures atoms are rendered inside the unit cell regardless of data source
839
+ // This ensures atoms are rendered inside the unit cell regardless of data source.
840
+ // ensure_lattice_params covers structures that bypass parse_any_structure (direct
841
+ // props, trajectory JSON frames) with matrix-only pymatgen lattices, which would
842
+ // otherwise NaN the camera auto-fit and render blank.
798
843
  let normalized_structure = $derived.by(() => {
799
844
  if (!structure || !(`lattice` in structure)) return structure
800
- return normalize_fractional_coords(structure) as AnyStructure
845
+ return ensure_lattice_params(normalize_fractional_coords(structure)) as AnyStructure
801
846
  })
802
847
 
803
848
  let structure_with_bonds = $derived.by(() => {
@@ -812,7 +857,8 @@
812
857
  // This must happen BEFORE supercell transformation
813
858
  let cell_transformed_structure = $derived.by(() => {
814
859
  if (
815
- !structure_with_bonds || !(`lattice` in structure_with_bonds) ||
860
+ !structure_with_bonds ||
861
+ !(`lattice` in structure_with_bonds) ||
816
862
  cell_type === `original`
817
863
  ) {
818
864
  return structure_with_bonds
@@ -829,8 +875,11 @@
829
875
  }
830
876
  })
831
877
 
832
- // Create supercell if needed (uses cell_transformed_structure as base)
833
- let supercell_structure = $state(structure)
878
+ // Create supercell if needed (uses cell_transformed_structure as base).
879
+ // $state.raw: supercells can hold thousands of sites — deep-proxying them makes
880
+ // every downstream read (bonding, PBC images, instancing) traverse proxy traps,
881
+ // measured ~15x slower for compute_bonds on a 704-site supercell.
882
+ let supercell_structure = $state.raw(structure)
834
883
  let supercell_loading = $state(false)
835
884
  let has_supercell = $derived(
836
885
  Boolean(supercell_scaling) && ![``, `1x1x1`, `1`].includes(supercell_scaling),
@@ -863,21 +912,34 @@
863
912
  })
864
913
 
865
914
  let supercell_timeout: ReturnType<typeof setTimeout> | undefined
915
+ // Compute the supercell, falling back to the unscaled structure on error
916
+ const make_supercell_safe = (base: Crystal): Crystal => {
917
+ try {
918
+ return make_supercell(base, supercell_scaling)
919
+ } catch (error) {
920
+ console.error(`Failed to create supercell:`, error)
921
+ show_toast(`Failed to create supercell: ${to_error(error).message}`)
922
+ return base
923
+ }
924
+ }
866
925
  $effect(() => {
867
926
  const base_structure = cell_transformed_structure
868
927
  clearTimeout(supercell_timeout)
869
- if (!base_structure || !(`lattice` in base_structure) || !has_supercell) {
870
- supercell_structure = base_structure
871
- supercell_loading = false
872
- } else if (!is_valid_supercell_input(supercell_scaling)) {
928
+ if (
929
+ !base_structure ||
930
+ !(`lattice` in base_structure) ||
931
+ !has_supercell ||
932
+ !is_valid_supercell_input(supercell_scaling)
933
+ ) {
873
934
  supercell_structure = base_structure
874
935
  supercell_loading = false
875
936
  } else {
876
937
  // For large supercells, show loading state and use async generation
877
938
  const sites_count = base_structure.sites?.length || 0
878
- const [nx_str, ny_str, nz_str] = supercell_scaling.split(/[x×]/)
879
- const scaling_mult = (parseInt(nx_str, 10) || 1) * (parseInt(ny_str, 10) || 1) *
880
- (parseInt(nz_str, 10) || 1)
939
+ // lenient parse just for a size estimate (invalid factors count as 1)
940
+ const scaling_mult = supercell_scaling
941
+ .split(/[x×]/)
942
+ .reduce((product, factor) => product * (Number(factor) || 1), 1)
881
943
  const estimated_sites = sites_count * scaling_mult
882
944
 
883
945
  // Show spinner for supercells with >1000 estimated sites or scaling >8
@@ -887,27 +949,11 @@
887
949
  supercell_loading = true
888
950
  // Use setTimeout to allow UI to update before heavy computation
889
951
  supercell_timeout = setTimeout(() => {
890
- try {
891
- if (base_structure && `lattice` in base_structure) {
892
- supercell_structure = make_supercell(
893
- base_structure as Crystal,
894
- supercell_scaling,
895
- )
896
- }
897
- } catch (error) {
898
- console.error(`Failed to create supercell:`, error)
899
- supercell_structure = base_structure
900
- } finally {
901
- supercell_loading = false
902
- }
952
+ supercell_structure = make_supercell_safe(base_structure as Crystal)
953
+ supercell_loading = false
903
954
  }, 10)
904
955
  } else {
905
- if (base_structure && `lattice` in base_structure) {
906
- supercell_structure = make_supercell(
907
- base_structure as Crystal,
908
- supercell_scaling,
909
- )
910
- }
956
+ supercell_structure = make_supercell_safe(base_structure as Crystal)
911
957
  supercell_loading = false
912
958
  }
913
959
  }
@@ -934,9 +980,12 @@
934
980
  })
935
981
  })
936
982
 
937
- // Apply element mapping then image atoms to the supercell structure.
938
- // Skip get_pbc_image_sites during atom drags — the vector math + doubled site
939
- // count causes frame drops. Image atoms reappear instantly on drag release.
983
+ // Element-map + PBC image atoms. Skipped during drags (doubled site count drops frames);
984
+ // images return on release.
985
+ // The render path reads the internal $state.raw copy, NOT the bindable prop:
986
+ // if a consumer binds displayed_structure to a deep $state, reading the prop
987
+ // back would re-proxy every site and slow all scene computations ~10x.
988
+ let internal_displayed_structure = $state.raw<AnyStructure | undefined>(undefined)
940
989
  $effect(() => {
941
990
  let struct = supercell_structure
942
991
  if (struct && element_mapping && Object.keys(element_mapping).length > 0) {
@@ -953,124 +1002,90 @@
953
1002
  })),
954
1003
  }
955
1004
  }
956
- displayed_structure =
957
- !dragging_atoms && show_image_atoms && struct && `lattice` in struct &&
958
- struct.lattice
1005
+ // Local var, NOT a read-back of the states written below: reading our own
1006
+ // fresh-object write inside this effect would self-retrigger it forever.
1007
+ const next =
1008
+ !dragging_atoms && show_image_atoms && struct && `lattice` in struct && struct.lattice
959
1009
  ? get_pbc_image_sites(struct)
960
1010
  : struct
1011
+ internal_displayed_structure = next
1012
+ displayed_structure = next
961
1013
  })
962
1014
 
963
- // Track if camera has ever been moved from initial position
964
- let camera_has_moved = $state(false)
965
- let camera_is_moving = $state(false)
1015
+ // scene + camera of the primary pane, bound out for the export pane. All other camera
1016
+ // handling (move tracking, reset, re-framing) lives in StructureViewport.
966
1017
  let scene = $state<Scene | undefined>(undefined)
967
1018
  let camera = $state<Camera | undefined>(undefined)
968
- let orbit_controls = $state<
969
- ComponentProps<typeof StructureScene>[`orbit_controls`]
970
- >(undefined)
971
- let rotation_target_ref = $state<Vec3 | undefined>(undefined)
972
- let initial_computed_zoom = $state<number | undefined>(undefined)
1019
+
1020
+ // Multi-side view state: index of the pane the pointer is over (gets edit interactions),
1021
+ // a token bumped to reset every pane, and the set of panes whose camera has moved (so
1022
+ // the reset button stays visible until every moved pane is reset). Pane 0 = primary.
1023
+ let active_pane_idx = $state(0)
1024
+ let reset_token = $state(0)
1025
+ // SvelteSet is already reactive; do NOT wrap in $state (double-proxying breaks it)
1026
+ const moved_panes = new SvelteSet<number>()
1027
+ let any_camera_moved = $derived(moved_panes.size > 0)
1028
+
1029
+ // Inputs shared by every StructureViewport (single + all multi-view panes). Camera,
1030
+ // selection bindings, and per-pane chrome differ and stay on each snippet below.
1031
+ let shared_viewport_props = $derived({
1032
+ structure: internal_displayed_structure,
1033
+ base_structure: cell_transformed_structure,
1034
+ scene_props,
1035
+ gizmo: scene_gizmo,
1036
+ lattice_props,
1037
+ volumetric_data: supercell_volume,
1038
+ isosurface_settings,
1039
+ bond_edits_enabled,
1040
+ bond_edit_order,
1041
+ measure_mode,
1042
+ atom_color_config,
1043
+ sym_data,
1044
+ active_sites: active_scene_sites,
1045
+ on_sites_moved: handle_sites_moved,
1046
+ on_operation_start: push_undo,
1047
+ on_bond_edit_start: push_bond_undo,
1048
+ on_add_atom: handle_add_atom,
1049
+ })
973
1050
 
974
1051
  // Mutual exclusion: opening one pane closes others
975
1052
  $effect(() => {
976
1053
  if (info_pane_open) {
977
- untrack(() => [controls_open, export_pane_open] = [false, false])
1054
+ untrack(() => ([controls_open, export_pane_open] = [false, false]))
978
1055
  }
979
1056
  })
980
1057
  $effect(() => {
981
1058
  if (controls_open) {
982
- untrack(() => [info_pane_open, export_pane_open] = [false, false])
1059
+ untrack(() => ([info_pane_open, export_pane_open] = [false, false]))
983
1060
  }
984
1061
  })
985
1062
  $effect(() => {
986
1063
  if (export_pane_open) {
987
- untrack(() => [info_pane_open, controls_open] = [false, false])
1064
+ untrack(() => ([info_pane_open, controls_open] = [false, false]))
988
1065
  }
989
1066
  })
990
1067
 
991
- // Reset tracking when structure changes
1068
+ // Reset moved-pane tracking when structure changes
992
1069
  $effect(() => {
993
- if (structure) camera_has_moved = false
1070
+ // untrack: clearing must not add moved_panes as a dependency, else a pane move
1071
+ // (which adds to moved_panes) would immediately re-run this and clear it again.
1072
+ if (structure) untrack(() => moved_panes.clear())
994
1073
  })
995
1074
 
996
1075
  // Clear stale camera target and position so StructureScene uses the new
997
1076
  // structure's rotation_target (unit cell center) and auto-positions the camera.
998
1077
  function clear_camera_state() {
1078
+ // Reset to a fresh [0,0,0] so the primary viewport re-frames the new structure.
1079
+ // Side panes reset their local camera state in StructureViewport's structure effect.
999
1080
  scene_props.camera_target = undefined
1000
1081
  scene_props.camera_position = [0, 0, 0]
1001
1082
  }
1002
1083
 
1003
- const read_orbit_target = (): Vec3 | undefined => {
1004
- if (!orbit_controls?.target) return
1005
- const { x, y, z } = orbit_controls.target
1006
- return [x, y, z]
1007
- }
1008
-
1009
- const read_camera_position = (): Vec3 | undefined =>
1010
- camera
1011
- ? [camera.position.x, camera.position.y, camera.position.z]
1012
- : scene_props.camera_position
1013
-
1014
- // Emit debounced camera updates while controls are active.
1015
- $effect(() => {
1016
- if (!camera_is_moving) return
1017
- camera_has_moved = true
1018
-
1019
- const emit_camera_move = () => {
1020
- const camera_position = read_camera_position()
1021
- if (camera_position === undefined) return
1022
- const camera_target = read_orbit_target()
1023
- scene_props.camera_position = camera_position
1024
- scene_props.camera_target = camera_target
1025
- on_camera_move?.({
1026
- structure,
1027
- camera_has_moved,
1028
- camera_position,
1029
- camera_target,
1030
- })
1031
- }
1032
-
1033
- emit_camera_move()
1034
- const emit_interval = setInterval(emit_camera_move, 200)
1035
- return () => clearInterval(emit_interval)
1036
- })
1037
-
1038
- function reset_camera() {
1039
- // Reset camera position to trigger automatic positioning.
1040
- scene_props.camera_position = [0, 0, 0]
1041
- scene_props.camera_target = rotation_target_ref
1042
- camera_has_moved = false
1043
-
1044
- let camera_position: Vec3 = [0, 0, 0]
1045
- let camera_target: Vec3 | undefined = rotation_target_ref
1046
-
1047
- // Reset pan/zoom and ensure controls target returns to structure center.
1048
- if (orbit_controls && camera) {
1049
- if (
1050
- `reset` in orbit_controls &&
1051
- typeof orbit_controls.reset === `function`
1052
- ) orbit_controls.reset()
1053
- if (orbit_controls.target && rotation_target_ref) {
1054
- const [target_x, target_y, target_z] = rotation_target_ref
1055
- orbit_controls.target.set(target_x, target_y, target_z)
1056
- }
1057
-
1058
- // Reset zoom for orthographic camera
1059
- if (`zoom` in camera && initial_computed_zoom !== undefined) {
1060
- const ortho_camera = camera as OrthographicCamera
1061
- ortho_camera.zoom = initial_computed_zoom
1062
- ortho_camera.updateProjectionMatrix()
1063
- }
1064
-
1065
- // Call update to apply changes immediately
1066
- if (typeof orbit_controls.update === `function`) orbit_controls.update()
1067
- camera_position = read_camera_position() ?? camera_position
1068
- camera_target = read_orbit_target()
1069
- }
1070
-
1071
- scene_props.camera_position = camera_position
1072
- scene_props.camera_target = camera_target
1073
- on_camera_reset?.({ structure, camera_has_moved, camera_position, camera_target })
1084
+ // Reset every pane's camera (each StructureViewport resets on a reset_token bump and,
1085
+ // for the primary, emits on_camera_reset).
1086
+ function reset_all_cameras() {
1087
+ reset_token += 1
1088
+ moved_panes.clear()
1074
1089
  }
1075
1090
 
1076
1091
  const emit_file_load_event = (
@@ -1081,19 +1096,14 @@
1081
1096
  on_file_load?.({
1082
1097
  structure: loaded_structure,
1083
1098
  filename,
1084
- file_size: typeof content === `string`
1085
- ? new Blob([content]).size
1086
- : content.byteLength,
1099
+ file_size: typeof content === `string` ? new Blob([content]).size : content.byteLength,
1087
1100
  total_atoms: loaded_structure.sites?.length || 0,
1088
1101
  })
1089
1102
 
1090
1103
  // Try to parse content as a volumetric file, setting both structure and volumetric data.
1091
1104
  // Delegates format detection entirely to parse_volumetric_file (filename + content sniffing).
1092
1105
  // Returns the parsed structure on success, or null if the file isn't a volumetric format.
1093
- function try_parse_volumetric(
1094
- text_content: string,
1095
- filename: string,
1096
- ): AnyStructure | null {
1106
+ function try_parse_volumetric(text_content: string, filename: string): AnyStructure | null {
1097
1107
  const vol_result = parse_volumetric_file(text_content, filename)
1098
1108
  if (!vol_result) return null
1099
1109
  // parse_volumetric_file extracts structure from file header;
@@ -1128,15 +1138,12 @@
1128
1138
  on_drop: (content, filename) => {
1129
1139
  if (on_file_drop) return on_file_drop(content, filename)
1130
1140
  try {
1131
- const text_content = content instanceof ArrayBuffer
1132
- ? new TextDecoder().decode(content)
1133
- : content
1141
+ const text_content =
1142
+ content instanceof ArrayBuffer ? new TextDecoder().decode(content) : content
1134
1143
  const parsed = parse_file_content(text_content, filename)
1135
1144
  emit_file_load_event(parsed, filename, content)
1136
1145
  } catch (err) {
1137
- error_msg = `Failed to parse structure: ${
1138
- to_error(err).message
1139
- }`
1146
+ error_msg = `Failed to parse structure: ${to_error(err).message}`
1140
1147
  on_error?.({ error_msg, filename })
1141
1148
  }
1142
1149
  },
@@ -1263,8 +1270,10 @@
1263
1270
  }
1264
1271
  // Duplicate selected atoms at a small offset
1265
1272
  if (
1266
- key === `d` && (event.ctrlKey || event.metaKey) &&
1267
- selected_sites.length > 0 && structure?.sites
1273
+ key === `d` &&
1274
+ (event.ctrlKey || event.metaKey) &&
1275
+ selected_sites.length > 0 &&
1276
+ structure?.sites
1268
1277
  ) {
1269
1278
  is_internal_edit = true
1270
1279
  push_undo()
@@ -1273,11 +1282,7 @@
1273
1282
  const new_sites = structure.sites
1274
1283
  .filter((_, idx) => orig_indices.has(idx))
1275
1284
  .map((site) => {
1276
- const new_xyz: Vec3 = [
1277
- site.xyz[0] + 0.5,
1278
- site.xyz[1] + 0.5,
1279
- site.xyz[2] + 0.5,
1280
- ]
1285
+ const new_xyz: Vec3 = [site.xyz[0] + 0.5, site.xyz[1] + 0.5, site.xyz[2] + 0.5]
1281
1286
  return {
1282
1287
  ...site,
1283
1288
  xyz: new_xyz,
@@ -1293,9 +1298,7 @@
1293
1298
  // Select the newly duplicated atoms
1294
1299
  selected_sites = new_sites.map((_, idx) => base_idx + idx)
1295
1300
  measured_sites = [...selected_sites]
1296
- show_toast(
1297
- `Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`,
1298
- )
1301
+ show_toast(`Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`)
1299
1302
  return true
1300
1303
  }
1301
1304
 
@@ -1320,6 +1323,9 @@
1320
1323
  } else if (event.key === `i` && has_modifier && enable_info_pane) {
1321
1324
  info_pane_open = !info_pane_open
1322
1325
  return true
1326
+ } else if (event.key === `g` && has_modifier && controls_config.visible(`multi-view`)) {
1327
+ multi_view = !multi_view
1328
+ return true
1323
1329
  } else if (event.key === `Escape`) {
1324
1330
  // Prioritize closing panes, then exit edit modes, then exit fullscreen
1325
1331
  if (info_pane_open) info_pane_open = false
@@ -1351,7 +1357,7 @@
1351
1357
  ): SvelteSet<number> {
1352
1358
  const result = new SvelteSet<number>()
1353
1359
  for (const scene_idx of scene_indices) {
1354
- const displayed_site = displayed_structure?.sites?.[scene_idx]
1360
+ const displayed_site = internal_displayed_structure?.sites?.[scene_idx]
1355
1361
  if (!displayed_site) continue
1356
1362
  if (skip_image_atoms && displayed_site.properties?.orig_site_idx != null) {
1357
1363
  continue
@@ -1389,10 +1395,9 @@
1389
1395
  const orig_indices = scene_to_structure_indices(scene_indices)
1390
1396
  // For crystals, wrap to [0,1) inline so normalize_fractional_coords fast-paths.
1391
1397
  // For molecules (no lattice), just apply the Cartesian delta directly.
1392
- const lattice = `lattice` in structure
1393
- ? (structure as Crystal).lattice.matrix
1394
- : null
1395
- const cart_to_frac = lattice ? create_cart_to_frac(lattice) : null
1398
+ const lattice = `lattice` in structure ? (structure as Crystal).lattice.matrix : null
1399
+ // get_cart_to_frac guards matrix_inverse_3x3, which throws on singular lattices
1400
+ const cart_to_frac = get_cart_to_frac()
1396
1401
  const frac_to_cart = lattice ? create_frac_to_cart(lattice) : null
1397
1402
  structure = {
1398
1403
  ...structure,
@@ -1434,9 +1439,7 @@
1434
1439
  change_element_mode = false
1435
1440
  change_element_value = ``
1436
1441
  show_toast(
1437
- `Changed ${orig_indices.size} site${
1438
- orig_indices.size > 1 ? `s` : ``
1439
- } to ${elem}`,
1442
+ `Changed ${orig_indices.size} site${orig_indices.size > 1 ? `s` : ``} to ${elem}`,
1440
1443
  )
1441
1444
  }
1442
1445
 
@@ -1451,13 +1454,16 @@
1451
1454
  push_undo()
1452
1455
  structure = {
1453
1456
  ...structure,
1454
- sites: [...structure.sites, {
1455
- species: [{ element: elem, occu: 1, oxidation_state: 0 }],
1456
- xyz,
1457
- abc: get_cart_to_frac()?.(xyz) ?? xyz,
1458
- label: elem,
1459
- properties: {},
1460
- }],
1457
+ sites: [
1458
+ ...structure.sites,
1459
+ {
1460
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
1461
+ xyz,
1462
+ abc: get_cart_to_frac()?.(xyz) ?? xyz,
1463
+ label: elem,
1464
+ properties: {},
1465
+ },
1466
+ ],
1461
1467
  }
1462
1468
  show_toast(`Added ${elem} at (${xyz.map((coord) => coord.toFixed(2)).join(`, `)})`)
1463
1469
  }
@@ -1469,10 +1475,7 @@
1469
1475
  const alpha_hex = Math.round(background_opacity * 255)
1470
1476
  .toString(16)
1471
1477
  .padStart(2, `0`)
1472
- wrapper.style.setProperty(
1473
- `--struct-bg-override`,
1474
- `${background_color}${alpha_hex}`,
1475
- )
1478
+ wrapper.style.setProperty(`--struct-bg-override`, `${background_color}${alpha_hex}`)
1476
1479
  } else if (typeof window !== `undefined` && wrapper) {
1477
1480
  // Remove override to use theme system
1478
1481
  wrapper.style.removeProperty(`--struct-bg-override`)
@@ -1498,9 +1501,9 @@
1498
1501
  class:dragover
1499
1502
  class:active={info_pane_open || controls_open || export_pane_open}
1500
1503
  class:gizmo-visible={Boolean(scene_gizmo)}
1504
+ class:multi-view={multi_view}
1501
1505
  role="application"
1502
1506
  tabindex="0"
1503
- style:--canvas-cursor={canvas_cursor}
1504
1507
  aria-label="Structure viewer"
1505
1508
  bind:this={wrapper}
1506
1509
  bind:clientWidth={width}
@@ -1513,22 +1516,14 @@
1513
1516
  focused = false
1514
1517
  }
1515
1518
  }}
1516
- ondblclick={(event) => {
1517
- const target = event.target
1518
- if (!(target instanceof HTMLElement)) return
1519
- // Don't reset if double-click was on UI controls/panes/legend
1520
- if (
1521
- [`.control-buttons`, `.structure-legend`, `.atom-legend`, `.info-pane`, `.export-pane`, `.controls-pane`].some((selector) => target.closest(selector))
1522
- || target.tagName === `BUTTON` || target.tagName === `INPUT` || target.tagName === `SELECT`
1523
- ) return
1524
- // Reset camera for double-clicks on the 3D scene
1525
- reset_camera()
1526
- }}
1527
1519
  ondrop={handle_file_drop}
1528
- {...drag_over_handlers({ allow: () => allow_file_drop, set_dragover: (over) => dragover = over })}
1520
+ {...drag_over_handlers({
1521
+ allow: () => allow_file_drop,
1522
+ set_dragover: (over) => (dragover = over),
1523
+ })}
1529
1524
  onkeydown={handle_and_prevent(handle_keydown)}
1530
1525
  {...rest}
1531
- class="structure {rest.class ?? ``}"
1526
+ class={[`structure`, rest.class]}
1532
1527
  >
1533
1528
  {@render children?.({ structure, fullscreen })}
1534
1529
  {#if loading}
@@ -1541,8 +1536,13 @@
1541
1536
  <StatusMessage bind:message={error_msg} type="error" dismissible />
1542
1537
  {:else if (structure?.sites?.length ?? 0) > 0}
1543
1538
  {#snippet reset_camera_btn()}
1544
- {#if camera_has_moved && controls_config.visible(`reset-camera`)}
1545
- <button class="reset-camera" onclick={reset_camera} title={reset_text}>
1539
+ {#if any_camera_moved && controls_config.visible(`reset-camera`)}
1540
+ <button
1541
+ class="reset-camera"
1542
+ onclick={reset_all_cameras}
1543
+ title={reset_text}
1544
+ aria-label={reset_text}
1545
+ >
1546
1546
  <!-- Target/Focus icon for reset camera -->
1547
1547
  <Icon icon="Reset" />
1548
1548
  </button>
@@ -1557,18 +1557,34 @@
1557
1557
  before={reset_camera_btn}
1558
1558
  style="--viewer-buttons-gap: 4pt; --viewer-buttons-btn-padding: 1px 6px; --viewer-buttons-align: stretch"
1559
1559
  >
1560
+ {#if controls_config.visible(`multi-view`)}
1561
+ <button
1562
+ class="multi-view-toggle"
1563
+ class:active={multi_view}
1564
+ onclick={() => (multi_view = !multi_view)}
1565
+ title="Toggle multi-side view 2×2 grid (Cmd/Ctrl+G)"
1566
+ aria-label="Toggle multi-side view"
1567
+ aria-pressed={multi_view}
1568
+ {@attach tooltip()}
1569
+ >
1570
+ <Icon icon="Grid2x2" />
1571
+ </button>
1572
+ {/if}
1573
+
1560
1574
  {#if enable_measure_mode && controls_config.visible(`measure-mode`)}
1561
1575
  <div
1562
1576
  class="measure-mode-dropdown"
1563
- {@attach click_outside({ callback: () => measure_menu_open = false })}
1577
+ {@attach click_outside({ callback: () => (measure_menu_open = false) })}
1564
1578
  >
1565
1579
  <button
1566
1580
  onclick={() => (measure_menu_open = !measure_menu_open)}
1567
1581
  title="Measure / Edit"
1582
+ aria-label="Measure / Edit"
1568
1583
  class="view-mode-button"
1569
1584
  class:active={measure_menu_open}
1570
1585
  aria-expanded={measure_menu_open}
1571
1586
  style="transform: scale(1.2)"
1587
+ {@attach tooltip()}
1572
1588
  >
1573
1589
  {#if show_measure_selection_limit}
1574
1590
  <span class="selection-limit-text">
@@ -1576,18 +1592,17 @@
1576
1592
  </span>
1577
1593
  {:else}
1578
1594
  <Icon
1579
- icon={({
1580
- distance: `Ruler`,
1581
- angle: `Angle`,
1582
- 'edit-bonds': `Link`,
1583
- 'edit-atoms': `Edit`,
1584
- } as const)[measure_mode]}
1595
+ icon={(
1596
+ {
1597
+ distance: `Ruler`,
1598
+ angle: `Angle`,
1599
+ 'edit-bonds': `Link`,
1600
+ 'edit-atoms': `Edit`,
1601
+ } as const
1602
+ )[measure_mode]}
1585
1603
  />
1586
1604
  {/if}
1587
- <Icon
1588
- icon="Arrow{measure_menu_open ? `Up` : `Down`}"
1589
- style="margin-left: -2px"
1590
- />
1605
+ <Icon icon="Arrow{measure_menu_open ? `Up` : `Down`}" style="margin-left: -2px" />
1591
1606
  </button>
1592
1607
  {#if show_selection_reset}
1593
1608
  <button
@@ -1603,12 +1618,7 @@
1603
1618
  {/if}
1604
1619
  {#if measure_menu_open}
1605
1620
  <div class="view-mode-dropdown">
1606
- {#each [
1607
- { mode: `distance`, icon: `Ruler`, label: `Distance`, scale: 1.1 },
1608
- { mode: `angle`, icon: `Angle`, label: `Angle`, scale: 1.3 },
1609
- { mode: `edit-atoms`, icon: `Edit`, label: `Edit Atoms`, scale: 1.0 },
1610
- { mode: `edit-bonds`, icon: `Link`, label: `Edit Bonds`, scale: 1.0 },
1611
- ] as const as { mode, icon, label, scale } (mode)}
1621
+ {#each [{ mode: `distance`, icon: `Ruler`, label: `Distance`, scale: 1.1 }, { mode: `angle`, icon: `Angle`, label: `Angle`, scale: 1.3 }, { mode: `edit-atoms`, icon: `Edit`, label: `Edit Atoms`, scale: 1.0 }, { mode: `edit-bonds`, icon: `Link`, label: `Edit Bonds`, scale: 1.0 }] as const as { mode, icon, label, scale } (mode)}
1612
1622
  <button
1613
1623
  class="view-mode-option"
1614
1624
  class:selected={measure_mode === mode}
@@ -1674,10 +1684,7 @@
1674
1684
  </label>
1675
1685
  {/if}
1676
1686
  <div class="bond-edit-mode-toggle">
1677
- {#each [
1678
- { mode: `add`, label: `Add`, title: `Add: click two atoms` },
1679
- { mode: `delete`, label: `Delete`, title: `Delete: click a bond` },
1680
- ] as const as { mode, label, title } (mode)}
1687
+ {#each [{ mode: `add`, label: `Add`, title: `Add: click two atoms` }, { mode: `delete`, label: `Delete`, title: `Delete: click a bond` }] as const as { mode, label, title } (mode)}
1681
1688
  <button
1682
1689
  type="button"
1683
1690
  class:selected={bond_edit_mode === mode}
@@ -1738,8 +1745,7 @@
1738
1745
  {/if}
1739
1746
 
1740
1747
  <!-- Change-element input (shown when 'e' pressed with selection) -->
1741
- {#if measure_mode === `edit-atoms` && change_element_mode &&
1742
- selected_sites.length > 0}
1748
+ {#if measure_mode === `edit-atoms` && change_element_mode && selected_sites.length > 0}
1743
1749
  <div class="add-atom-input">
1744
1750
  <label>
1745
1751
  <span>New element:</span>
@@ -1767,8 +1773,7 @@
1767
1773
  {/if}
1768
1774
  {/if}
1769
1775
 
1770
- {#if enable_info_pane && normalized_structure &&
1771
- controls_config.visible(`info-pane`)}
1776
+ {#if enable_info_pane && normalized_structure && controls_config.visible(`info-pane`)}
1772
1777
  <StructureInfoPane
1773
1778
  structure={normalized_structure}
1774
1779
  bind:pane_open={info_pane_open}
@@ -1827,7 +1832,7 @@
1827
1832
  bind:element_radius_overrides
1828
1833
  bind:site_radius_overrides
1829
1834
  selected_sites={atom_legend_selected_sites}
1830
- structure={displayed_structure}
1835
+ structure={internal_displayed_structure}
1831
1836
  show_mode_toggle={viewer_active}
1832
1837
  {sym_data}
1833
1838
  >
@@ -1845,60 +1850,90 @@
1845
1850
  {/snippet}
1846
1851
  </AtomLegend>
1847
1852
 
1853
+ <!-- One StructureViewport renders the single view; four render the 2x2 multi-view.
1854
+ The primary pane (index 0) carries the external camera API: scene/camera are bound
1855
+ out for export and camera_position/target persist into scene_props, and it emits
1856
+ on_camera_move/on_camera_reset. All camera handling itself lives in StructureViewport. -->
1857
+ {#snippet primary_viewport(view: StructureView)}
1858
+ <StructureViewport
1859
+ in_grid={multi_view}
1860
+ label={multi_view ? view.label : undefined}
1861
+ active={multi_view && active_pane_idx === 0}
1862
+ interactive={!multi_view || active_pane_idx === 0}
1863
+ onactivate={() => (active_pane_idx = 0)}
1864
+ {reset_token}
1865
+ report_moved={(moved) => (moved ? moved_panes.add(0) : moved_panes.delete(0))}
1866
+ {on_camera_move}
1867
+ {on_camera_reset}
1868
+ {...shared_viewport_props}
1869
+ camera_direction={view.direction}
1870
+ camera_projection={view.projection ?? scene_props.camera_projection}
1871
+ bind:camera_position={scene_props.camera_position}
1872
+ bind:camera_target={scene_props.camera_target}
1873
+ bind:scene
1874
+ bind:camera
1875
+ bind:selected_sites
1876
+ bind:measured_sites
1877
+ bind:hovered_site_idx
1878
+ bind:hidden_elements
1879
+ bind:hidden_prop_vals
1880
+ bind:element_radius_overrides
1881
+ bind:site_radius_overrides
1882
+ bind:added_bonds
1883
+ bind:removed_bonds
1884
+ bind:bond_order_overrides
1885
+ bind:bond_edit_mode
1886
+ bind:add_atom_mode
1887
+ bind:add_element
1888
+ bind:dragging_atoms
1889
+ bind:polyhedra_rendered_elements
1890
+ />
1891
+ {/snippet}
1892
+
1893
+ {#snippet extra_viewport(view: StructureView, pane_idx: number)}
1894
+ <StructureViewport
1895
+ in_grid
1896
+ label={view.label}
1897
+ active={active_pane_idx === pane_idx}
1898
+ interactive={active_pane_idx === pane_idx}
1899
+ onactivate={() => (active_pane_idx = pane_idx)}
1900
+ {reset_token}
1901
+ report_moved={(moved) =>
1902
+ moved ? moved_panes.add(pane_idx) : moved_panes.delete(pane_idx)}
1903
+ {...shared_viewport_props}
1904
+ camera_direction={view.direction}
1905
+ camera_projection={view.projection ?? scene_props.camera_projection}
1906
+ bind:selected_sites
1907
+ bind:measured_sites
1908
+ bind:hovered_site_idx
1909
+ bind:hidden_elements
1910
+ bind:hidden_prop_vals
1911
+ bind:element_radius_overrides
1912
+ bind:site_radius_overrides
1913
+ bind:added_bonds
1914
+ bind:removed_bonds
1915
+ bind:bond_order_overrides
1916
+ bind:bond_edit_mode
1917
+ bind:add_atom_mode
1918
+ bind:add_element
1919
+ bind:dragging_atoms
1920
+ />
1921
+ {/snippet}
1922
+
1848
1923
  <!-- prevent from rendering in vitest runner since WebGLRenderingContext not available -->
1849
1924
  {#if typeof WebGLRenderingContext !== `undefined`}
1850
- <!-- prevent HTML labels from rendering outside of the canvas -->
1851
- <div style="overflow: hidden; height: 100%; width: 100%">
1852
- <Canvas>
1853
- <StructureScene
1854
- structure={displayed_structure}
1855
- base_structure={cell_transformed_structure}
1856
- {...scene_props}
1857
- gizmo={scene_gizmo}
1858
- {lattice_props}
1859
- volumetric_data={supercell_volume}
1860
- {isosurface_settings}
1861
- bind:camera_is_moving
1862
- bind:selected_sites
1863
- active_sites={active_scene_sites}
1864
- bind:hovered_idx={hovered_site_idx}
1865
- bind:measured_sites
1866
- bind:scene
1867
- bind:camera
1868
- bind:orbit_controls
1869
- bind:rotation_target_ref
1870
- bind:initial_computed_zoom
1871
- bind:hidden_elements
1872
- bind:hidden_prop_vals
1873
- bind:element_radius_overrides
1874
- bind:site_radius_overrides
1875
- bind:added_bonds
1876
- bind:removed_bonds
1877
- bind:bond_order_overrides
1878
- {bond_edits_enabled}
1879
- bind:bond_edit_mode
1880
- {bond_edit_order}
1881
- {measure_mode}
1882
- {width}
1883
- {height}
1884
- {atom_color_config}
1885
- {sym_data}
1886
- on_sites_moved={handle_sites_moved}
1887
- on_operation_start={push_undo}
1888
- on_bond_edit_start={push_bond_undo}
1889
- on_add_atom={handle_add_atom}
1890
- bind:add_atom_mode
1891
- bind:add_element
1892
- bind:cursor={canvas_cursor}
1893
- bind:dragging_atoms
1894
- bind:polyhedra_rendered_elements
1895
- />
1896
- </Canvas>
1925
+ <div class:multi={multi_view} class="viewport-stage">
1926
+ {@render primary_viewport(multi_view ? (views[0] ?? {}) : {})}
1927
+ {#if multi_view}
1928
+ {#each views.slice(1) as view, idx (idx)}
1929
+ {@render extra_viewport(view, idx + 1)}
1930
+ {/each}
1931
+ {/if}
1897
1932
  </div>
1898
1933
  {/if}
1899
1934
 
1900
1935
  <div class="bottom-left">
1901
- {@render bottom_left?.({ structure: displayed_structure })}
1936
+ {@render bottom_left?.({ structure: internal_displayed_structure })}
1902
1937
  </div>
1903
1938
 
1904
1939
  {#if toast_msg}
@@ -1908,9 +1943,7 @@
1908
1943
  {#if symmetry_error}
1909
1944
  <div class="symmetry-error">
1910
1945
  <span>{symmetry_error}</span>
1911
- <button onclick={() => (symmetry_error = undefined)} aria-label="Dismiss">
1912
- ×
1913
- </button>
1946
+ <button onclick={() => (symmetry_error = undefined)} aria-label="Dismiss"> × </button>
1914
1947
  </div>
1915
1948
  {/if}
1916
1949
  {:else if structure}
@@ -1940,7 +1973,9 @@
1940
1973
  background: var(--struct-bg-fullscreen, var(--struct-bg));
1941
1974
  overflow: hidden;
1942
1975
  }
1943
- .structure:fullscreen :global(canvas) {
1976
+ /* Single view: stretch the lone canvas to the full screen in fullscreen mode.
1977
+ In multi-view the grid fills the screen and each canvas fills its 1fr cell. */
1978
+ .structure:fullscreen:not(.multi-view) :global(canvas) {
1944
1979
  height: 100vh !important;
1945
1980
  width: 100vw !important;
1946
1981
  }
@@ -1948,6 +1983,22 @@
1948
1983
  background: var(--struct-dragover-bg, var(--dragover-bg));
1949
1984
  border: var(--struct-dragover-border, var(--dragover-border));
1950
1985
  }
1986
+ .viewport-stage {
1987
+ height: 100%;
1988
+ width: 100%;
1989
+ }
1990
+ /* 2x2 multi-side view grid: four equal subcanvases. grid-auto-rows keeps rows
1991
+ equal-height if a custom `views` array supplies more than four entries. */
1992
+ .viewport-stage.multi {
1993
+ display: grid;
1994
+ grid-template-columns: 1fr 1fr;
1995
+ grid-template-rows: 1fr 1fr;
1996
+ grid-auto-rows: 1fr;
1997
+ gap: var(--struct-viewport-gap, 2px);
1998
+ }
1999
+ .multi-view-toggle.active {
2000
+ color: var(--accent-color, #4a9eff);
2001
+ }
1951
2002
  /* Ensure canvas is transparent so the themed --struct-bg shows through */
1952
2003
  .structure :global(canvas) {
1953
2004
  background: transparent;
@@ -1978,7 +2029,9 @@
1978
2029
  right: 0;
1979
2030
  background: var(--surface-bg);
1980
2031
  border-radius: var(--border-radius, 3pt);
1981
- box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -2px rgba(0, 0, 0, 0.1);
2032
+ box-shadow:
2033
+ 0 8px 16px -4px rgba(0, 0, 0, 0.3),
2034
+ 0 4px 8px -2px rgba(0, 0, 0, 0.1);
1982
2035
  display: flex;
1983
2036
  flex-direction: column;
1984
2037
  }
@@ -2017,7 +2070,7 @@
2017
2070
  .measure-mode-dropdown > button {
2018
2071
  background: transparent;
2019
2072
  padding: 1px 6px;
2020
- font-size: clamp(0.85em, 2cqmin, 1.3em);
2073
+ font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem));
2021
2074
  }
2022
2075
  .selection-limit-text {
2023
2076
  font-weight: bold;
@@ -2079,7 +2132,8 @@
2079
2132
  opacity: 0;
2080
2133
  }
2081
2134
  @keyframes toast-fade {
2082
- 0%, 70% {
2135
+ 0%,
2136
+ 70% {
2083
2137
  opacity: 1;
2084
2138
  }
2085
2139
  100% {