matterviz 0.4.1 → 0.4.3

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 (499) hide show
  1. package/dist/EmptyState.svelte +10 -4
  2. package/dist/FilePicker.svelte +48 -60
  3. package/dist/Icon.svelte +7 -4
  4. package/dist/MillerIndexInput.svelte +2 -2
  5. package/dist/api/optimade.js +10 -7
  6. package/dist/app.css +68 -0
  7. package/dist/brillouin/BrillouinZone.svelte +90 -91
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +5 -7
  9. package/dist/brillouin/BrillouinZoneControls.svelte +3 -6
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +18 -14
  11. package/dist/brillouin/BrillouinZoneInfoPane.svelte +6 -10
  12. package/dist/brillouin/BrillouinZoneScene.svelte +46 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  14. package/dist/brillouin/compute.js +10 -16
  15. package/dist/brillouin/geometry.js +2 -2
  16. package/dist/chempot-diagram/ChemPotDiagram.svelte +48 -50
  17. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +61 -169
  18. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +1 -1
  19. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +559 -755
  20. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +1 -1
  21. package/dist/chempot-diagram/async-compute.svelte.js +13 -14
  22. package/dist/chempot-diagram/color.d.ts +21 -1
  23. package/dist/chempot-diagram/color.js +70 -1
  24. package/dist/chempot-diagram/compute.d.ts +7 -0
  25. package/dist/chempot-diagram/compute.js +85 -26
  26. package/dist/chempot-diagram/controls-state.svelte.js +1 -1
  27. package/dist/chempot-diagram/export.js +1 -6
  28. package/dist/chempot-diagram/temperature.js +9 -10
  29. package/dist/colors/index.d.ts +0 -4
  30. package/dist/colors/index.js +19 -20
  31. package/dist/composition/BarChart.svelte +30 -26
  32. package/dist/composition/BubbleChart.svelte +17 -17
  33. package/dist/composition/Composition.svelte +10 -19
  34. package/dist/composition/Formula.svelte +20 -27
  35. package/dist/composition/FormulaFilter.svelte +115 -184
  36. package/dist/composition/PieChart.svelte +55 -41
  37. package/dist/composition/format.js +7 -9
  38. package/dist/composition/index.d.ts +2 -0
  39. package/dist/composition/index.js +14 -0
  40. package/dist/composition/parse.d.ts +2 -2
  41. package/dist/composition/parse.js +61 -115
  42. package/dist/constants.d.ts +0 -1
  43. package/dist/constants.js +17 -26
  44. package/dist/convex-hull/ConvexHull.svelte +12 -15
  45. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  46. package/dist/convex-hull/ConvexHull2D.svelte +93 -94
  47. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  48. package/dist/convex-hull/ConvexHull3D.svelte +127 -186
  49. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  50. package/dist/convex-hull/ConvexHull4D.svelte +131 -180
  51. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  52. package/dist/convex-hull/ConvexHullChrome.svelte +36 -40
  53. package/dist/convex-hull/ConvexHullControls.svelte +46 -48
  54. package/dist/convex-hull/ConvexHullStats.svelte +193 -195
  55. package/dist/convex-hull/ConvexHullTooltip.svelte +5 -12
  56. package/dist/convex-hull/GasPressureControls.svelte +5 -12
  57. package/dist/convex-hull/StructurePopup.svelte +18 -22
  58. package/dist/convex-hull/StructurePopup.svelte.d.ts +2 -0
  59. package/dist/convex-hull/TemperatureSlider.svelte +4 -10
  60. package/dist/convex-hull/canvas-interactions.svelte.js +2 -1
  61. package/dist/convex-hull/gas-thermodynamics.js +21 -53
  62. package/dist/convex-hull/helpers.d.ts +8 -10
  63. package/dist/convex-hull/helpers.js +37 -43
  64. package/dist/convex-hull/hull-state.svelte.d.ts +1 -1
  65. package/dist/convex-hull/thermodynamics.js +37 -44
  66. package/dist/coordination/CoordinationBarPlot.svelte +49 -53
  67. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +1 -1
  68. package/dist/coordination/calc-coordination.d.ts +1 -1
  69. package/dist/coordination/calc-coordination.js +28 -25
  70. package/dist/element/BohrAtom.svelte +5 -9
  71. package/dist/element/ElementHeading.svelte +4 -1
  72. package/dist/element/ElementPhoto.svelte +11 -9
  73. package/dist/element/ElementStats.svelte +12 -10
  74. package/dist/element/ElementTile.svelte +44 -67
  75. package/dist/element/Nucleus.svelte +4 -2
  76. package/dist/element/data.d.ts +3 -2
  77. package/dist/element/data.js +5 -1
  78. package/dist/element/helpers.d.ts +4 -0
  79. package/dist/element/helpers.js +18 -0
  80. package/dist/element/index.d.ts +2 -5
  81. package/dist/element/index.js +4 -19
  82. package/dist/feedback/DragOverlay.svelte +5 -5
  83. package/dist/feedback/DragOverlay.svelte.d.ts +2 -2
  84. package/dist/feedback/StatusMessage.svelte +7 -4
  85. package/dist/fermi-surface/FermiSlice.svelte +21 -18
  86. package/dist/fermi-surface/FermiSurface.svelte +35 -44
  87. package/dist/fermi-surface/FermiSurface.svelte.d.ts +7 -13
  88. package/dist/fermi-surface/FermiSurfaceControls.svelte +6 -20
  89. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  90. package/dist/fermi-surface/FermiSurfaceScene.svelte +64 -136
  91. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  92. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +9 -6
  93. package/dist/fermi-surface/compute.js +25 -58
  94. package/dist/fermi-surface/parse.js +54 -88
  95. package/dist/file-viewer/JsonBrowser.svelte +1043 -0
  96. package/dist/file-viewer/JsonBrowser.svelte.d.ts +9 -0
  97. package/dist/file-viewer/PlotPanel.svelte +350 -0
  98. package/dist/file-viewer/PlotPanel.svelte.d.ts +9 -0
  99. package/dist/file-viewer/TrajectoryWithDos.svelte +30 -0
  100. package/dist/file-viewer/TrajectoryWithDos.svelte.d.ts +10 -0
  101. package/dist/file-viewer/detect.d.ts +11 -0
  102. package/dist/file-viewer/detect.js +381 -0
  103. package/dist/file-viewer/eligibility.d.ts +4 -0
  104. package/dist/file-viewer/eligibility.js +53 -0
  105. package/dist/file-viewer/host-protocol.d.ts +50 -0
  106. package/dist/file-viewer/host-transfer.d.ts +25 -0
  107. package/dist/file-viewer/host-transfer.js +42 -0
  108. package/dist/file-viewer/index.d.ts +5 -0
  109. package/dist/file-viewer/index.js +5 -0
  110. package/dist/file-viewer/main.d.ts +44 -0
  111. package/dist/file-viewer/main.js +535 -0
  112. package/dist/file-viewer/parse.d.ts +9 -0
  113. package/dist/file-viewer/parse.js +170 -0
  114. package/dist/file-viewer/plot-utils.d.ts +24 -0
  115. package/dist/file-viewer/plot-utils.js +191 -0
  116. package/dist/file-viewer/types.d.ts +4 -0
  117. package/dist/file-viewer/types.js +5 -0
  118. package/dist/heatmap-matrix/HeatmapMatrix.svelte +153 -219
  119. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +2 -2
  120. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +9 -7
  121. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +1 -1
  122. package/dist/heatmap-matrix/index.d.ts +5 -0
  123. package/dist/icons.d.ts +4 -0
  124. package/dist/icons.js +4 -0
  125. package/dist/index.d.ts +2 -2
  126. package/dist/index.js +1 -1
  127. package/dist/io/ExportPane.svelte +11 -7
  128. package/dist/io/decompress.d.ts +4 -1
  129. package/dist/io/decompress.js +24 -36
  130. package/dist/io/export.d.ts +11 -4
  131. package/dist/io/export.js +75 -19
  132. package/dist/io/fetch.d.ts +0 -3
  133. package/dist/io/fetch.js +0 -11
  134. package/dist/io/file-drop.js +60 -35
  135. package/dist/io/index.d.ts +0 -1
  136. package/dist/io/index.js +2 -9
  137. package/dist/io/is-binary.d.ts +9 -0
  138. package/dist/io/is-binary.js +45 -1
  139. package/dist/io/url-drop.d.ts +2 -0
  140. package/dist/io/url-drop.js +40 -40
  141. package/dist/isosurface/Isosurface.svelte +700 -215
  142. package/dist/isosurface/Isosurface.svelte.d.ts +7 -1
  143. package/dist/isosurface/IsosurfaceControls.svelte +590 -116
  144. package/dist/isosurface/IsosurfaceControls.svelte.d.ts +1 -1
  145. package/dist/isosurface/VolumeSlice.svelte +224 -0
  146. package/dist/isosurface/VolumeSlice.svelte.d.ts +28 -0
  147. package/dist/isosurface/coloring.d.ts +20 -0
  148. package/dist/isosurface/coloring.js +118 -0
  149. package/dist/isosurface/geometry-worker-types.d.ts +38 -0
  150. package/dist/isosurface/geometry.worker.d.ts +1 -0
  151. package/dist/isosurface/geometry.worker.js +59 -0
  152. package/dist/isosurface/grid.d.ts +10 -0
  153. package/dist/isosurface/grid.js +31 -0
  154. package/dist/isosurface/index.d.ts +4 -0
  155. package/dist/isosurface/index.js +4 -0
  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 +6 -6
  159. package/dist/isosurface/profile.d.ts +12 -0
  160. package/dist/isosurface/profile.js +19 -0
  161. package/dist/isosurface/sampling.d.ts +34 -0
  162. package/dist/isosurface/sampling.js +408 -0
  163. package/dist/isosurface/slice-rendering.d.ts +13 -0
  164. package/dist/isosurface/slice-rendering.js +79 -0
  165. package/dist/isosurface/slice.d.ts +22 -2
  166. package/dist/isosurface/slice.js +200 -116
  167. package/dist/isosurface/types.d.ts +24 -1
  168. package/dist/isosurface/types.js +120 -8
  169. package/dist/json-path.d.ts +4 -0
  170. package/dist/json-path.js +89 -0
  171. package/dist/labels.d.ts +6 -2
  172. package/dist/labels.js +44 -14
  173. package/dist/layout/FullscreenButton.svelte +2 -1
  174. package/dist/layout/FullscreenToggle.svelte +10 -4
  175. package/dist/layout/InfoCard.svelte +8 -21
  176. package/dist/layout/InfoTag.svelte +1 -3
  177. package/dist/layout/NumberRangeInput.svelte +46 -0
  178. package/dist/layout/NumberRangeInput.svelte.d.ts +12 -0
  179. package/dist/layout/PropertyFilter.svelte +2 -6
  180. package/dist/layout/SettingsSection.svelte +57 -62
  181. package/dist/layout/SettingsSection.svelte.d.ts +6 -7
  182. package/dist/layout/SubpageGrid.svelte +3 -1
  183. package/dist/layout/ViewerChrome.svelte +16 -11
  184. package/dist/layout/ViewerChrome.svelte.d.ts +2 -1
  185. package/dist/layout/fullscreen.js +3 -5
  186. package/dist/layout/fullscreen.svelte.js +1 -2
  187. package/dist/layout/index.d.ts +1 -0
  188. package/dist/layout/index.js +1 -0
  189. package/dist/layout/json-tree/JsonNode.svelte +22 -59
  190. package/dist/layout/json-tree/JsonTree.svelte +101 -172
  191. package/dist/layout/json-tree/JsonValue.svelte +9 -32
  192. package/dist/layout/json-tree/utils.d.ts +0 -3
  193. package/dist/layout/json-tree/utils.js +50 -188
  194. package/dist/marching-cubes.d.ts +8 -1
  195. package/dist/marching-cubes.js +115 -48
  196. package/dist/math.d.ts +5 -2
  197. package/dist/math.js +48 -38
  198. package/dist/overlays/ContextMenu.svelte +19 -34
  199. package/dist/overlays/CopyButton.svelte +11 -5
  200. package/dist/overlays/DragControlTab.svelte +6 -1
  201. package/dist/overlays/DraggablePane.svelte +110 -74
  202. package/dist/overlays/DraggablePane.svelte.d.ts +2 -1
  203. package/dist/overlays/GlassChip.svelte +32 -0
  204. package/dist/overlays/GlassChip.svelte.d.ts +8 -0
  205. package/dist/overlays/InfoPaneCards.svelte +11 -23
  206. package/dist/overlays/index.d.ts +7 -0
  207. package/dist/overlays/index.js +26 -0
  208. package/dist/overlays/portal.d.ts +2 -0
  209. package/dist/overlays/portal.js +14 -0
  210. package/dist/periodic-table/PeriodicTable.svelte +113 -145
  211. package/dist/periodic-table/PeriodicTable.svelte.d.ts +5 -3
  212. package/dist/periodic-table/PeriodicTableControls.svelte +37 -115
  213. package/dist/periodic-table/TableInset.svelte +9 -4
  214. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +68 -105
  215. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -8
  216. package/dist/phase-diagram/PhaseDiagramControls.svelte +70 -94
  217. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  218. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +2 -1
  219. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +12 -12
  220. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +54 -58
  221. package/dist/phase-diagram/TdbInfoPanel.svelte +21 -15
  222. package/dist/phase-diagram/build-diagram.js +4 -9
  223. package/dist/phase-diagram/diagram-input.d.ts +1 -2
  224. package/dist/phase-diagram/parse.js +15 -15
  225. package/dist/phase-diagram/svg-to-diagram.js +28 -27
  226. package/dist/phase-diagram/utils.js +2 -3
  227. package/dist/plot/bar/BarPlot.svelte +461 -426
  228. package/dist/plot/bar/BarPlot.svelte.d.ts +2 -0
  229. package/dist/plot/bar/BarPlotControls.svelte +1 -3
  230. package/dist/plot/bar/BarPlotControls.svelte.d.ts +4 -6
  231. package/dist/plot/bar/SpacegroupBarPlot.svelte +46 -77
  232. package/dist/plot/bar/data.d.ts +5 -4
  233. package/dist/plot/bar/data.js +2 -1
  234. package/dist/plot/box/BoxPlot.svelte +322 -226
  235. package/dist/plot/box/BoxPlot.svelte.d.ts +2 -0
  236. package/dist/plot/box/Violin.svelte.d.ts +1 -0
  237. package/dist/plot/box/box-plot.js +1 -1
  238. package/dist/plot/core/auto-place.d.ts +1 -1
  239. package/dist/plot/core/auto-place.js +2 -2
  240. package/dist/plot/core/axis-utils.d.ts +12 -7
  241. package/dist/plot/core/axis-utils.js +48 -48
  242. package/dist/plot/core/components/AxisLabel.svelte +82 -20
  243. package/dist/plot/core/components/AxisLabel.svelte.d.ts +1 -0
  244. package/dist/plot/core/components/ColorBar.svelte +101 -106
  245. package/dist/plot/core/components/ColorScaleSelect.svelte +1 -1
  246. package/dist/plot/core/components/FillArea.svelte +10 -17
  247. package/dist/plot/core/components/HierarchyControls.svelte +249 -0
  248. package/dist/plot/{sunburst/SunburstControls.svelte.d.ts → core/components/HierarchyControls.svelte.d.ts} +12 -8
  249. package/dist/plot/core/components/InteractiveAxisLabel.svelte +1 -1
  250. package/dist/plot/core/components/InteractiveAxisLabel.svelte.d.ts +1 -1
  251. package/dist/plot/core/components/Line.svelte +18 -9
  252. package/dist/plot/core/components/Line.svelte.d.ts +2 -0
  253. package/dist/plot/core/components/PlotAxis.svelte +40 -17
  254. package/dist/plot/core/components/PlotControls.svelte +65 -41
  255. package/dist/plot/core/components/PlotLegend.svelte +31 -35
  256. package/dist/plot/core/components/PlotMarginals.svelte +616 -0
  257. package/dist/plot/core/components/PlotMarginals.svelte.d.ts +15 -0
  258. package/dist/plot/core/components/PlotTooltip.svelte +1 -1
  259. package/dist/plot/core/components/PortalSelect.svelte +4 -8
  260. package/dist/plot/core/components/ReferenceLine.svelte +27 -24
  261. package/dist/plot/core/components/ReferenceLine3D.svelte +54 -57
  262. package/dist/plot/core/components/ReferencePlane.svelte +7 -8
  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 +27 -474
  271. package/dist/plot/core/data-transform.d.ts +1 -2
  272. package/dist/plot/core/data-transform.js +9 -15
  273. package/dist/plot/core/fill-utils.d.ts +3 -1
  274. package/dist/plot/core/fill-utils.js +28 -6
  275. package/dist/plot/core/index.d.ts +1 -0
  276. package/dist/plot/core/index.js +1 -0
  277. package/dist/plot/core/interactions.js +0 -2
  278. package/dist/plot/core/layout.d.ts +15 -6
  279. package/dist/plot/core/layout.js +57 -38
  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 +2 -4
  283. package/dist/plot/core/pan-zoom.svelte.js +34 -17
  284. package/dist/plot/core/placed-tween.svelte.d.ts +3 -0
  285. package/dist/plot/core/placed-tween.svelte.js +68 -21
  286. package/dist/plot/core/reference-line.js +1 -1
  287. package/dist/plot/core/scales.d.ts +23 -0
  288. package/dist/plot/core/scales.js +39 -13
  289. package/dist/plot/core/svg.js +0 -1
  290. package/dist/plot/core/types/fills.d.ts +101 -0
  291. package/dist/plot/core/types/fills.js +15 -0
  292. package/dist/plot/core/types/plot-3d.d.ts +87 -0
  293. package/dist/plot/core/types/plot-3d.js +4 -0
  294. package/dist/plot/core/types/reference-lines.d.ts +140 -0
  295. package/dist/plot/core/types/reference-lines.js +9 -0
  296. package/dist/plot/core/types.d.ts +29 -324
  297. package/dist/plot/core/types.js +23 -20
  298. package/dist/plot/core/utils/hierarchy-chart.d.ts +52 -0
  299. package/dist/plot/core/utils/hierarchy-chart.js +191 -0
  300. package/dist/plot/core/utils/hierarchy-labels.d.ts +20 -0
  301. package/dist/plot/core/utils/hierarchy-labels.js +100 -0
  302. package/dist/plot/core/utils/label-placement.js +2 -2
  303. package/dist/plot/histogram/Histogram.svelte +314 -340
  304. package/dist/plot/histogram/Histogram.svelte.d.ts +7 -7
  305. package/dist/plot/histogram/HistogramControls.svelte +30 -28
  306. package/dist/plot/histogram/histogram.d.ts +31 -0
  307. package/dist/plot/histogram/histogram.js +70 -0
  308. package/dist/plot/histogram/index.d.ts +1 -0
  309. package/dist/plot/histogram/index.js +1 -0
  310. package/dist/plot/index.d.ts +1 -0
  311. package/dist/plot/index.js +1 -0
  312. package/dist/plot/sankey/Sankey.svelte +78 -69
  313. package/dist/plot/sankey/Sankey.svelte.d.ts +2 -2
  314. package/dist/plot/sankey/SankeyControls.svelte +22 -16
  315. package/dist/plot/sankey/sankey.js +13 -9
  316. package/dist/plot/scatter/BinnedScatterPlot.svelte +311 -141
  317. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +7 -0
  318. package/dist/plot/scatter/ElementScatter.svelte +4 -1
  319. package/dist/plot/scatter/ScatterPlot.svelte +517 -410
  320. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +2 -0
  321. package/dist/plot/scatter/ScatterPlotControls.svelte +18 -19
  322. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  323. package/dist/plot/scatter/ScatterPoint.svelte +16 -9
  324. package/dist/plot/scatter/adaptive-density.d.ts +1 -1
  325. package/dist/plot/scatter/adaptive-density.js +36 -20
  326. package/dist/plot/scatter/binned-scatter-types.d.ts +3 -2
  327. package/dist/plot/scatter/binned-scatter-types.js +6 -1
  328. package/dist/plot/scatter/scatter-data.d.ts +1 -1
  329. package/dist/plot/scatter/scatter-data.js +8 -7
  330. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +25 -34
  331. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +2 -2
  332. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +8 -5
  333. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +113 -116
  334. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +2 -2
  335. package/dist/plot/scatter-3d/Surface3D.svelte +4 -13
  336. package/dist/plot/sunburst/Sunburst.svelte +269 -347
  337. package/dist/plot/sunburst/Sunburst.svelte.d.ts +2 -2
  338. package/dist/plot/sunburst/index.d.ts +1 -1
  339. package/dist/plot/sunburst/index.js +2 -1
  340. package/dist/plot/sunburst/render.d.ts +2 -2
  341. package/dist/plot/sunburst/render.js +47 -22
  342. package/dist/plot/sunburst/sunburst.d.ts +5 -1
  343. package/dist/plot/sunburst/sunburst.js +10 -3
  344. package/dist/plot/treemap/Treemap.svelte +1022 -0
  345. package/dist/plot/treemap/Treemap.svelte.d.ts +90 -0
  346. package/dist/plot/treemap/index.d.ts +4 -0
  347. package/dist/plot/treemap/index.js +4 -0
  348. package/dist/plot/treemap/labels.d.ts +38 -0
  349. package/dist/plot/treemap/labels.js +80 -0
  350. package/dist/plot/treemap/treemap.d.ts +28 -0
  351. package/dist/plot/treemap/treemap.js +102 -0
  352. package/dist/rdf/RdfPlot.svelte +37 -59
  353. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  354. package/dist/rdf/calc-rdf.js +63 -52
  355. package/dist/rdf/index.d.ts +0 -1
  356. package/dist/sanitize.js +85 -27
  357. package/dist/scene/SceneCamera.svelte +1 -1
  358. package/dist/scene/SceneCamera.svelte.d.ts +1 -1
  359. package/dist/scene/bind-renderer.svelte.js +14 -6
  360. package/dist/scene/index.d.ts +1 -2
  361. package/dist/scene/index.js +1 -1
  362. package/dist/scene/{props.js → props.svelte.js} +15 -1
  363. package/dist/settings.d.ts +19 -9
  364. package/dist/settings.js +229 -525
  365. package/dist/spectral/Bands.svelte +207 -221
  366. package/dist/spectral/BandsAndDos.svelte +11 -9
  367. package/dist/spectral/BrillouinBandsDos.svelte +19 -42
  368. package/dist/spectral/Dos.svelte +92 -93
  369. package/dist/spectral/helpers.d.ts +3 -3
  370. package/dist/spectral/helpers.js +65 -140
  371. package/dist/state.svelte.js +5 -18
  372. package/dist/structure/Arrow.svelte +5 -18
  373. package/dist/structure/ArrowInstances.svelte +160 -0
  374. package/dist/structure/ArrowInstances.svelte.d.ts +16 -0
  375. package/dist/structure/AtomLegend.svelte +120 -85
  376. package/dist/structure/AtomLegend.svelte.d.ts +2 -3
  377. package/dist/structure/Bond.svelte +31 -23
  378. package/dist/structure/CanvasTooltip.svelte +15 -3
  379. package/dist/structure/CellSelect.svelte +45 -35
  380. package/dist/structure/Cylinder.svelte +24 -40
  381. package/dist/structure/InstancedAtoms.svelte +114 -0
  382. package/dist/structure/InstancedAtoms.svelte.d.ts +15 -0
  383. package/dist/structure/Lattice.svelte +85 -149
  384. package/dist/structure/SiteLabels.svelte +122 -0
  385. package/dist/structure/SiteLabels.svelte.d.ts +18 -0
  386. package/dist/structure/Structure.svelte +636 -504
  387. package/dist/structure/Structure.svelte.d.ts +4 -2
  388. package/dist/structure/StructureCarousel.svelte +637 -0
  389. package/dist/structure/StructureCarousel.svelte.d.ts +19 -0
  390. package/dist/structure/StructureControls.svelte +165 -425
  391. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  392. package/dist/structure/StructureExportPane.svelte +17 -21
  393. package/dist/structure/StructureInfoPane.svelte +128 -133
  394. package/dist/structure/StructureInfoPane.svelte.d.ts +1 -1
  395. package/dist/structure/StructureScene.svelte +535 -578
  396. package/dist/structure/StructureScene.svelte.d.ts +6 -2
  397. package/dist/structure/StructureViewport.svelte +451 -0
  398. package/dist/structure/StructureViewport.svelte.d.ts +65 -0
  399. package/dist/structure/atom-label-placement.d.ts +16 -12
  400. package/dist/structure/atom-label-placement.js +70 -37
  401. package/dist/structure/atom-properties.d.ts +2 -0
  402. package/dist/structure/atom-properties.js +105 -38
  403. package/dist/structure/bond-order-perception.js +3 -8
  404. package/dist/structure/bonding.d.ts +9 -3
  405. package/dist/structure/bonding.js +95 -46
  406. package/dist/structure/edit-history.d.ts +6 -0
  407. package/dist/structure/edit-history.js +27 -0
  408. package/dist/structure/export.d.ts +1 -0
  409. package/dist/structure/export.js +89 -105
  410. package/dist/structure/format-detect.d.ts +1 -0
  411. package/dist/structure/format-detect.js +20 -0
  412. package/dist/structure/geometry.d.ts +9 -0
  413. package/dist/structure/geometry.js +27 -0
  414. package/dist/structure/index.d.ts +16 -1
  415. package/dist/structure/index.js +15 -10
  416. package/dist/structure/measure.d.ts +1 -1
  417. package/dist/structure/measure.js +6 -12
  418. package/dist/structure/parse.d.ts +2 -1
  419. package/dist/structure/parse.js +272 -313
  420. package/dist/structure/partial-occupancy.js +10 -5
  421. package/dist/structure/pbc.js +85 -107
  422. package/dist/structure/polyhedra.d.ts +0 -1
  423. package/dist/structure/polyhedra.js +7 -21
  424. package/dist/structure/prop-groups.d.ts +85 -0
  425. package/dist/structure/prop-groups.js +18 -0
  426. package/dist/structure/supercell.d.ts +1 -2
  427. package/dist/structure/supercell.js +7 -13
  428. package/dist/symmetry/SymmetryElements.svelte +11 -15
  429. package/dist/symmetry/SymmetryStats.svelte +55 -71
  430. package/dist/symmetry/WyckoffTable.svelte +10 -28
  431. package/dist/symmetry/cell-transform.js +6 -14
  432. package/dist/symmetry/index.d.ts +1 -0
  433. package/dist/symmetry/index.js +13 -10
  434. package/dist/symmetry/spacegroups.js +3 -2
  435. package/dist/table/HeatmapTable.svelte +1142 -447
  436. package/dist/table/HeatmapTable.svelte.d.ts +12 -2
  437. package/dist/table/ToggleMenu.svelte +168 -53
  438. package/dist/table/ToggleMenu.svelte.d.ts +1 -1
  439. package/dist/table/index.d.ts +21 -6
  440. package/dist/table/index.js +34 -21
  441. package/dist/theme/ThemeControl.svelte +16 -8
  442. package/dist/theme/embedded.d.ts +18 -0
  443. package/dist/theme/embedded.js +206 -0
  444. package/dist/theme/index.d.ts +5 -5
  445. package/dist/theme/index.js +5 -12
  446. package/dist/theme/themes.mjs +44 -66
  447. package/dist/tooltip/KCoords.svelte +4 -1
  448. package/dist/tooltip/TooltipContent.svelte +3 -6
  449. package/dist/tooltip/TooltipContent.svelte.d.ts +1 -1
  450. package/dist/tooltip/index.d.ts +8 -1
  451. package/dist/trajectory/Trajectory.svelte +613 -386
  452. package/dist/trajectory/Trajectory.svelte.d.ts +1 -1
  453. package/dist/trajectory/TrajectoryError.svelte +12 -74
  454. package/dist/trajectory/TrajectoryExportPane.svelte +14 -32
  455. package/dist/trajectory/TrajectoryInfoPane.svelte +127 -164
  456. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +2 -1
  457. package/dist/trajectory/extract.js +61 -49
  458. package/dist/trajectory/format-detect.d.ts +2 -0
  459. package/dist/trajectory/format-detect.js +17 -13
  460. package/dist/trajectory/frame-reader.d.ts +2 -0
  461. package/dist/trajectory/frame-reader.js +35 -32
  462. package/dist/trajectory/helpers.d.ts +2 -0
  463. package/dist/trajectory/helpers.js +34 -56
  464. package/dist/trajectory/index.d.ts +1 -0
  465. package/dist/trajectory/index.js +5 -8
  466. package/dist/trajectory/parse/ase.js +1 -1
  467. package/dist/trajectory/parse/h5-utils.d.ts +13 -0
  468. package/dist/trajectory/parse/h5-utils.js +109 -0
  469. package/dist/trajectory/parse/hdf5.d.ts +1 -1
  470. package/dist/trajectory/parse/hdf5.js +116 -106
  471. package/dist/trajectory/parse/index.d.ts +6 -6
  472. package/dist/trajectory/parse/index.js +25 -33
  473. package/dist/trajectory/parse/lammps.js +8 -12
  474. package/dist/trajectory/parse/vasp.js +10 -7
  475. package/dist/trajectory/parse/vaspout-electronic.d.ts +13 -0
  476. package/dist/trajectory/parse/vaspout-electronic.js +207 -0
  477. package/dist/trajectory/parse/vaspout-h5.d.ts +4 -0
  478. package/dist/trajectory/parse/vaspout-h5.js +281 -0
  479. package/dist/trajectory/parse/xyz.js +59 -9
  480. package/dist/trajectory/plotting.d.ts +8 -9
  481. package/dist/trajectory/plotting.js +113 -64
  482. package/dist/utils.d.ts +3 -0
  483. package/dist/utils.js +7 -0
  484. package/dist/xrd/XrdPlot.svelte +79 -118
  485. package/dist/xrd/broadening.js +2 -1
  486. package/dist/xrd/calc-xrd.js +24 -62
  487. package/dist/xrd/parse.d.ts +0 -1
  488. package/dist/xrd/parse.js +69 -95
  489. package/package.json +94 -34
  490. package/readme.md +6 -5
  491. package/dist/api/mp.d.ts +0 -6
  492. package/dist/api/mp.js +0 -22
  493. package/dist/plot/sunburst/SunburstControls.svelte +0 -200
  494. package/dist/scene/types.d.ts +0 -26
  495. package/dist/tooltip/types.d.ts +0 -8
  496. package/dist/trajectory/constants.d.ts +0 -6
  497. package/dist/trajectory/constants.js +0 -11
  498. /package/dist/{scene/types.js → file-viewer/host-protocol.js} +0 -0
  499. /package/dist/{tooltip/types.js → isosurface/geometry-worker-types.js} +0 -0
@@ -6,14 +6,17 @@
6
6
  import { StatusMessage } from '../feedback'
7
7
  import Spinner from '../feedback/Spinner.svelte'
8
8
  import Icon from '../Icon.svelte'
9
- import { create_file_drop_handler, drag_over_handlers, load_from_url } from '../io'
9
+ import * as io from '../io'
10
10
  import { forward_window_keydown, handle_and_prevent } from '../keyboard'
11
11
  import { parse_volumetric_file } from '../isosurface/parse'
12
12
  import type { IsosurfaceSettings, VolumetricData } from '../isosurface/types'
13
13
  import {
14
14
  auto_isosurface_settings,
15
15
  DEFAULT_ISOSURFACE_SETTINGS,
16
- tile_volumetric_data,
16
+ label_file_volumes,
17
+ lattices_match,
18
+ materialize_layers,
19
+ merge_imported_volumes,
17
20
  } from '../isosurface/types'
18
21
  import { type FullscreenToggleProp, toggle_fullscreen, ViewerChrome } from '../layout'
19
22
  import { sync_fullscreen } from '../layout/fullscreen.svelte'
@@ -21,6 +24,7 @@
21
24
  import { create_cart_to_frac, create_frac_to_cart } from '../math'
22
25
  import { DEFAULTS } from '../settings'
23
26
  import { colors } from '../state.svelte'
27
+ import StructureViewport from './StructureViewport.svelte'
24
28
  import type {
25
29
  AnyStructure,
26
30
  BondEditMode,
@@ -28,13 +32,16 @@
28
32
  Crystal,
29
33
  MeasureMode,
30
34
  StructureBond,
35
+ StructureView,
31
36
  } from './'
32
37
  import {
38
+ DEFAULT_STRUCTURE_VIEWS,
33
39
  default_vector_configs,
34
40
  get_element_counts,
35
41
  get_pbc_image_sites,
36
42
  get_structure_vector_keys,
37
43
  } from './'
44
+ import { push_edit, step_history } from './edit-history'
38
45
  import { wrap_to_unit_cell } from './pbc'
39
46
  import {
40
47
  is_valid_supercell_input,
@@ -45,13 +52,12 @@
45
52
  import * as symmetry from '../symmetry'
46
53
  import { transform_cell } from '../symmetry'
47
54
  import type { MoyoDataset } from '@spglib/moyo-wasm'
48
- import { Canvas } from '@threlte/core'
49
55
  import type { ComponentProps, Snippet } from 'svelte'
50
56
  import { untrack } from 'svelte'
51
57
  import { click_outside, tooltip } from 'svelte-multiselect/attachments'
52
58
  import type { HTMLAttributes } from 'svelte/elements'
53
59
  import { SvelteMap, SvelteSet } from 'svelte/reactivity'
54
- import type { Camera, OrthographicCamera, Scene } from 'three'
60
+ import type { Camera, Scene } from 'three'
55
61
  import type { AtomColorConfig } from './atom-properties'
56
62
  import { get_property_colors } from './atom-properties'
57
63
  import AtomLegend from './AtomLegend.svelte'
@@ -59,7 +65,11 @@
59
65
  import { BOND_ORDER_OPTIONS, merge_bond_edits, remap_bonds_after_deletion } from './bonding'
60
66
  import type { StructureHandlerData } from './index'
61
67
  import { MAX_SELECTED_SITES } from './measure'
62
- import { normalize_fractional_coords, parse_any_structure } from './parse'
68
+ import {
69
+ ensure_lattice_params,
70
+ normalize_fractional_coords,
71
+ parse_any_structure,
72
+ } from './parse'
63
73
  import StructureControls from './StructureControls.svelte'
64
74
  import StructureExportPane from './StructureExportPane.svelte'
65
75
  import StructureInfoPane from './StructureInfoPane.svelte'
@@ -107,6 +117,8 @@
107
117
  lattice_props: lattice_props_in = $bindable(),
108
118
  controls_open = $bindable(false),
109
119
  info_pane_open = $bindable(false),
120
+ multi_view = $bindable(false),
121
+ views = DEFAULT_STRUCTURE_VIEWS,
110
122
  enable_measure_mode = $bindable(true),
111
123
  measure_mode = $bindable<MeasureMode>(`distance`),
112
124
  bond_edit_mode = $bindable<BondEditMode>(`add`),
@@ -183,95 +195,101 @@
183
195
  on_camera_reset,
184
196
  on_bonds_change,
185
197
  ...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()
198
+ }: {
199
+ structure?: AnyStructure
200
+ bonds?: StructureBond[]
201
+ scene_props?: ComponentProps<typeof StructureScene>
202
+ // Controls visibility configuration.
203
+ // - 'always': controls always visible
204
+ // - 'hover': controls visible on component hover (default)
205
+ // - 'never': controls never visible
206
+ // - object: { mode, hidden, style } for fine-grained control
207
+ //
208
+ // Control names: 'reset-camera', 'fullscreen', 'measure-mode', 'info-pane', 'export-pane', 'controls'
209
+ show_controls?: ShowControlsProp
210
+ fullscreen?: boolean
211
+ // bindable width of the canvas
212
+ width?: number
213
+ // bindable height of the canvas
214
+ height?: number
215
+ // Canvas wrapper element (for export pane)
216
+ wrapper?: HTMLDivElement
217
+ // PNG export DPI setting
218
+ png_dpi?: number
219
+ reset_text?: string
220
+ hovered?: boolean
221
+ dragover?: boolean
222
+ allow_file_drop?: boolean
223
+ enable_info_pane?: boolean
224
+ enable_measure_mode?: boolean
225
+ measure_mode?: MeasureMode
226
+ bond_edit_mode?: BondEditMode
227
+ bond_edit_order?: BondOrder
228
+ info_pane_open?: boolean
229
+ // When true, split the canvas into a 2x2 grid showing the structure from
230
+ // different angles (Ovito-style). Each pane has independent orbit controls.
231
+ multi_view?: boolean
232
+ // The 4 (or more) view definitions used by multi_view. Defaults to an
233
+ // Ovito-like set: one perspective + three orthographic axis views.
234
+ views?: StructureView[]
235
+ fullscreen_toggle?: FullscreenToggleProp
236
+ bottom_left?: Snippet<[{ structure?: AnyStructure }]>
237
+ top_right_controls?: Snippet // Additional controls to render at the end of the control buttons row
238
+ data_url?: string // URL to load structure from (alternative to providing structure directly)
239
+ // Generic callback for when files are dropped - receives raw content and filename
240
+ on_file_drop?: (content: string | ArrayBuffer, filename: string) => void
241
+ // spinner props (passed to Spinner component)
242
+ spinner_props?: ComponentProps<typeof Spinner>
243
+ loading?: boolean
244
+ error_msg?: string
245
+ // Performance mode: 'quality' (default) or 'speed' for large structures
246
+ performance_mode?: `quality` | `speed`
247
+ // allow parent components to control highlighted/selected site indices
248
+ selected_sites?: number[]
249
+ highlighted_sites?: number[]
250
+ hovered_site_idx?: number | null
251
+ // explicit measured sites for distance/angle overlays
252
+ measured_sites?: number[]
253
+ // expose the displayed structure (with image atoms and/or supercell) for external
254
+ // use. Output-only: the scene renders from an internal raw copy for performance,
255
+ // so external writes to this prop are ignored (overwritten on the next pipeline run)
256
+ displayed_structure?: AnyStructure
257
+ // Track which elements are hidden (bindable across frames in trajectories)
258
+ hidden_elements?: Set<ElementSymbol>
259
+ // Track which property values are hidden (e.g. Wyckoff positions, coordination numbers)
260
+ hidden_prop_vals?: Set<number | string>
261
+ // Per-element radius overrides (absolute values in Angstroms)
262
+ element_radius_overrides?: Partial<Record<ElementSymbol, number>>
263
+ // Per-site radius overrides (absolute values in Angstroms)
264
+ // Accepts Map or SvelteMap for flexibility with external callers
265
+ site_radius_overrides?: Map<number, number> | SvelteMap<number, number>
266
+ // Symmetry analysis data (bindable for external access)
267
+ sym_data?: MoyoDataset | null
268
+ // Symmetry analysis settings (bindable for external control)
269
+ symmetry_settings?: Partial<SymmetrySettings>
270
+ // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
271
+ element_mapping?: Partial<Record<ElementSymbol, ElementSymbol>>
272
+ // Cell type: original, conventional, or primitive (requires symmetry analysis)
273
+ cell_type?: CellType
274
+ // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
275
+ volumetric_data?: VolumetricData[]
276
+ // Isosurface rendering settings
277
+ isosurface_settings?: IsosurfaceSettings
278
+ // Active volume index when multiple volumes are present
279
+ active_volume_idx?: number
280
+ // structure content as string (alternative to providing structure directly or via data_url)
281
+ structure_string?: string
282
+ // Atom coloring configuration
283
+ atom_color_config?: Partial<AtomColorConfig>
284
+ children?: Snippet<[{ structure?: AnyStructure; fullscreen: boolean }]>
285
+ on_file_load?: EventHandler
286
+ on_error?: EventHandler
287
+ on_fullscreen_change?: EventHandler
288
+ on_camera_move?: EventHandler
289
+ on_camera_reset?: EventHandler
290
+ on_bonds_change?: (bonds: StructureBond[] | undefined) => void
291
+ } & Omit<ComponentProps<typeof StructureControls>, `children` | `onclose`> &
292
+ Omit<HTMLAttributes<HTMLDivElement>, `children`> = $props()
275
293
 
276
294
  // Initialize models from incoming props; mutations come from UI controls; we mirror into local dicts (NOTE only doing shallow merge)
277
295
  $effect.pre(() => {
@@ -283,41 +301,70 @@
283
301
  }
284
302
  })
285
303
 
286
- // Load structure from URL when data_url is provided
304
+ // Load structure from URL when data_url is provided. A monotonic load_id ignores stale
305
+ // completions so a newer data_url (or an externally-supplied structure, via the cleanup)
306
+ // can't be clobbered by a slow earlier fetch.
307
+ let data_url_load_id = 0
308
+ let loaded_data_url: string | undefined
309
+ let url_owned_structure: AnyStructure | undefined
310
+
287
311
  $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
- }
312
+ const requested_url = data_url
313
+ const current_structure = structure
314
+ // Host on_file_drop owns the structure; don't treat it as caller-owned cancel.
315
+ const caller_owns_structure = Boolean(
316
+ !on_file_drop && current_structure && current_structure !== url_owned_structure,
317
+ )
318
+ if (!requested_url || caller_owns_structure) {
319
+ loaded_data_url = undefined
320
+ url_owned_structure = undefined
321
+ return
322
+ }
323
+ if (loaded_data_url === requested_url) return
324
+
325
+ const load_id = ++data_url_load_id
326
+ const is_current = () => load_id === data_url_load_id
327
+ loading = true
328
+ error_msg = undefined
329
+
330
+ io.load_from_url(requested_url, (content, filename) => {
331
+ if (!is_current()) return // stale response
332
+ if (on_file_drop) {
333
+ on_file_drop(content, filename)
334
+ loaded_data_url = requested_url
335
+ } else {
336
+ // Parse structure internally when no handler provided
337
+ try {
338
+ parse_and_emit_file(content, filename)
339
+ url_owned_structure = structure
340
+ loaded_data_url = requested_url
341
+ } catch (error) {
342
+ error_msg = `Failed to parse structure: ${to_error(error).message}`
343
+ on_error?.({ error_msg, filename })
308
344
  }
345
+ }
346
+ })
347
+ .catch((error: Error) => {
348
+ if (!is_current()) return
349
+ console.error(`Failed to load structure from URL:`, error)
350
+ error_msg = `Failed to load structure: ${error.message}`
351
+ on_error?.({ error_msg, filename: io.basename_from_url(requested_url) })
352
+ })
353
+ .finally(() => {
354
+ if (is_current()) loading = false
309
355
  })
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
- })
356
+
357
+ return () => {
358
+ // invalidate in-flight load on data_url change / structure arrival / unmount
359
+ if (is_current()) {
360
+ data_url_load_id += 1
361
+ loading = false
362
+ }
317
363
  }
318
364
  })
319
365
 
320
- $effect(() => { // Parse structure from string when structure_string is provided
366
+ $effect(() => {
367
+ // Parse structure from string when structure_string is provided
321
368
  if (!structure_string || data_url) return
322
369
  loading = true
323
370
  error_msg = undefined
@@ -331,9 +378,7 @@
331
378
  throw new Error(`Failed to parse structure from string`)
332
379
  }
333
380
  } catch (err) {
334
- error_msg = `Failed to parse structure from string: ${
335
- to_error(err).message
336
- }`
381
+ error_msg = `Failed to parse structure from string: ${to_error(err).message}`
337
382
  untrack(() => on_error?.({ error_msg, filename: `string` }))
338
383
  } finally {
339
384
  loading = false
@@ -389,12 +434,7 @@
389
434
 
390
435
  // Compute property-based colors for legend display
391
436
  let property_colors = $derived(
392
- get_property_colors(
393
- structure,
394
- atom_color_config,
395
- scene_props.bonding_strategy,
396
- sym_data,
397
- ),
437
+ get_property_colors(structure, atom_color_config, scene_props.bonding_strategy, sym_data),
398
438
  )
399
439
 
400
440
  let symmetry_run_id = 0
@@ -426,7 +466,7 @@
426
466
  } else {
427
467
  // Keep previous symmetry data while recomputing so bound consumers
428
468
  // (e.g. SymmetryStats inputs) do not unmount and lose focus.
429
- untrack(() => symmetry_error = undefined)
469
+ untrack(() => (symmetry_error = undefined))
430
470
  }
431
471
  const run_id = ++symmetry_run_id
432
472
  // Destructure symmetry_settings to ensure Svelte tracks changes to symprec and algo
@@ -436,20 +476,21 @@
436
476
  // Skip symmetry auto-analysis in unit tests; happy-dom can't fetch WASM assets
437
477
  if (typeof process !== `undefined` && process.env?.VITEST) return
438
478
 
439
- symmetry.ensure_moyo_wasm_ready()
479
+ symmetry
480
+ .ensure_moyo_wasm_ready()
440
481
  .then(() =>
441
482
  run_id === symmetry_run_id
442
483
  ? symmetry.analyze_structure_symmetry(current_structure, current_settings)
443
- : null
484
+ : null,
444
485
  )
445
486
  .then((data) => {
446
487
  if (data && run_id === symmetry_run_id) {
447
- untrack(() => sym_data = data)
488
+ untrack(() => (sym_data = data))
448
489
  }
449
490
  })
450
491
  .catch((err) => {
451
492
  if (run_id === symmetry_run_id) {
452
- untrack(() => sym_data = null)
493
+ untrack(() => (sym_data = null))
453
494
  symmetry_error = `Symmetry analysis failed: ${err?.message || err}`
454
495
  console.error(`Symmetry analysis failed:`, err)
455
496
  }
@@ -474,8 +515,7 @@
474
515
  let last_emitted_bond_signature = $state<string>()
475
516
  let bond_edit_snapshot = $state.raw<BondEditSnapshot>()
476
517
  let has_bond_edits = $derived(
477
- added_bonds.length > 0 || removed_bonds.length > 0 ||
478
- bond_order_overrides.length > 0,
518
+ added_bonds.length > 0 || removed_bonds.length > 0 || bond_order_overrides.length > 0,
479
519
  )
480
520
 
481
521
  const clone_bonds = (edit_bonds: StructureBond[]): StructureBond[] =>
@@ -556,10 +596,10 @@
556
596
  const current_source_bond_signature = (): string => {
557
597
  const raw_signature = bond_signature(current_source_bonds())
558
598
  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)
599
+ return (
600
+ bond_history_context?.source_bond_signature ??
601
+ (bond_edit_snapshot ? bond_signature(bond_edit_snapshot.bonds) : raw_signature)
602
+ )
563
603
  }
564
604
 
565
605
  const current_bond_edit_context = (): BondEditContext => ({
@@ -646,16 +686,30 @@
646
686
 
647
687
  // === Edit-atoms mode state ===
648
688
  let dragging_atoms = $state(false)
649
- let undo_stack = $state<AnyStructure[]>([])
650
- let redo_stack = $state<AnyStructure[]>([])
689
+ // History entries snapshot the bindable `bonds` prop alongside the structure:
690
+ // atom deletion remaps `bonds` to the shifted site indices, so restoring the
691
+ // structure alone would leave explicit bonds pointing at wrong sites.
692
+ type EditSnapshot = { structure: AnyStructure; bonds: StructureBond[] | undefined }
693
+ // $state.raw: stacks hold full structure snapshots — deep-proxying them would make
694
+ // every restored structure (and all downstream site reads) go through proxy traps.
695
+ // Mutations happen via reassignment so .length stays reactive in the template.
696
+ let undo_stack = $state.raw<EditSnapshot[]>([])
697
+ let redo_stack = $state.raw<EditSnapshot[]>([])
651
698
  const MAX_HISTORY = 20
652
699
  // Flag set before internal edits (undo/redo/delete/add/move) to distinguish
653
700
  // them from external structure changes (file load, trajectory step, etc.)
654
701
  let is_internal_edit = false
702
+ // Claim URL ownership before regular effects so internal edits aren't treated as caller-owned.
703
+ $effect.pre(() => {
704
+ void structure
705
+ if (is_internal_edit && loaded_data_url && loaded_data_url === data_url && structure) {
706
+ url_owned_structure = structure
707
+ }
708
+ })
709
+
655
710
  // Add-atom sub-mode state (bound to StructureScene)
656
711
  let add_atom_mode = $state(false)
657
712
  let add_element = $state<ElementSymbol>(`C` as ElementSymbol)
658
- let canvas_cursor = $state(`default`)
659
713
  let is_measure_selection_mode = $derived(
660
714
  measure_mode === `distance` || measure_mode === `angle`,
661
715
  )
@@ -693,28 +747,40 @@
693
747
  dragging_atoms = false
694
748
  }
695
749
 
750
+ const snapshot_edit_state = (current: AnyStructure): EditSnapshot => ({
751
+ structure: $state.snapshot(current) as AnyStructure,
752
+ bonds: bonds && ($state.snapshot(bonds) as StructureBond[]),
753
+ })
754
+
696
755
  function push_undo() {
697
756
  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
757
+ ;[undo_stack, redo_stack] = push_edit(
758
+ [undo_stack, redo_stack],
759
+ snapshot_edit_state(structure),
760
+ MAX_HISTORY,
761
+ )
703
762
  }
704
763
 
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
764
+ // Shared undo/redo: restore the top of that direction's stack, pushing the
765
+ // current state onto the opposite one ($state.raw stacks update by reassignment)
766
+ function apply_history(direction: `undo` | `redo`) {
767
+ // Check the stack before snapshotting: $state.snapshot deep-clones the structure
768
+ if (!structure || (direction === `undo` ? undo_stack : redo_stack).length === 0) return
769
+ const result = step_history(
770
+ [undo_stack, redo_stack],
771
+ direction,
772
+ snapshot_edit_state(structure),
773
+ )
774
+ if (!result) return
710
775
  is_internal_edit = true
711
- target.push($state.snapshot(structure) as AnyStructure)
712
- structure = restored
776
+ ;[undo_stack, redo_stack] = result.stacks
777
+ structure = result.restored.structure
778
+ bonds = result.restored.bonds
713
779
  clear_selection()
714
780
  }
715
781
 
716
- const undo = () => apply_history(undo_stack, redo_stack)
717
- const redo = () => apply_history(redo_stack, undo_stack)
782
+ const undo = () => apply_history(`undo`)
783
+ const redo = () => apply_history(`redo`)
718
784
 
719
785
  // Clear undo/redo stacks when structure changes externally (file load, etc.)
720
786
  // Internal edits set is_internal_edit=true before modifying structure.
@@ -757,7 +823,10 @@
757
823
  $effect(() => {
758
824
  void bond_edit_mode
759
825
  untrack(() => {
760
- if (measure_mode === `edit-bonds` && (selected_sites.length > 0 || measured_sites.length > 0)) {
826
+ if (
827
+ measure_mode === `edit-bonds` &&
828
+ (selected_sites.length > 0 || measured_sites.length > 0)
829
+ ) {
761
830
  clear_selection()
762
831
  }
763
832
  })
@@ -788,16 +857,21 @@
788
857
  $effect(() => {
789
858
  viewer_active = hovered || focused
790
859
  })
791
- let scene_gizmo = $derived(viewer_active && (scene_props.gizmo ?? scene_props.show_gizmo))
860
+ // Keep the gizmo mounted whenever enabled — toggling via `{#if gizmo}` on hover remounts
861
+ // OrbitControls/Gizmo and resets camera rotation. Visibility is CSS-gated by `gizmo-visible`.
862
+ let scene_gizmo = $derived(scene_props.gizmo ?? scene_props.show_gizmo)
792
863
  let active_scene_sites = $derived([
793
864
  ...new SvelteSet([...(scene_props.active_sites ?? []), ...highlighted_sites]),
794
865
  ])
795
866
 
796
867
  // 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
868
+ // This ensures atoms are rendered inside the unit cell regardless of data source.
869
+ // ensure_lattice_params covers structures that bypass parse_any_structure (direct
870
+ // props, trajectory JSON frames) with matrix-only pymatgen lattices, which would
871
+ // otherwise NaN the camera auto-fit and render blank.
798
872
  let normalized_structure = $derived.by(() => {
799
873
  if (!structure || !(`lattice` in structure)) return structure
800
- return normalize_fractional_coords(structure) as AnyStructure
874
+ return ensure_lattice_params(normalize_fractional_coords(structure)) as AnyStructure
801
875
  })
802
876
 
803
877
  let structure_with_bonds = $derived.by(() => {
@@ -812,7 +886,8 @@
812
886
  // This must happen BEFORE supercell transformation
813
887
  let cell_transformed_structure = $derived.by(() => {
814
888
  if (
815
- !structure_with_bonds || !(`lattice` in structure_with_bonds) ||
889
+ !structure_with_bonds ||
890
+ !(`lattice` in structure_with_bonds) ||
816
891
  cell_type === `original`
817
892
  ) {
818
893
  return structure_with_bonds
@@ -829,8 +904,11 @@
829
904
  }
830
905
  })
831
906
 
832
- // Create supercell if needed (uses cell_transformed_structure as base)
833
- let supercell_structure = $state(structure)
907
+ // Create supercell if needed (uses cell_transformed_structure as base).
908
+ // $state.raw: supercells can hold thousands of sites — deep-proxying them makes
909
+ // every downstream read (bonding, PBC images, instancing) traverse proxy traps,
910
+ // measured ~15x slower for compute_bonds on a 704-site supercell.
911
+ let supercell_structure = $state.raw(structure)
834
912
  let supercell_loading = $state(false)
835
913
  let has_supercell = $derived(
836
914
  Boolean(supercell_scaling) && ![``, `1x1x1`, `1`].includes(supercell_scaling),
@@ -849,35 +927,48 @@
849
927
  })
850
928
  })
851
929
 
852
- // Tile volumetric data to match supercell when active.
853
- // Gate on !supercell_loading so the tiled volume and supercell structure update
854
- // in the same frame (large supercells defer structure via setTimeout).
855
- let supercell_volume = $derived.by(() => {
856
- const vol = volumetric_data?.[active_volume_idx]
857
- if (!vol || !has_supercell || supercell_loading) return vol
930
+ // Supercell tiling factors for isosurface geometry (tiling itself happens in
931
+ // the Isosurface component so color sampling can use the untiled full-res
932
+ // volumes). Gate on !supercell_loading so tiled surfaces and the supercell
933
+ // structure update in the same frame (large supercells defer via setTimeout).
934
+ let volume_scaling = $derived.by((): Vec3 => {
935
+ if (!has_supercell || supercell_loading) return [1, 1, 1]
858
936
  try {
859
- return tile_volumetric_data(vol, parse_supercell_scaling(supercell_scaling))
937
+ return parse_supercell_scaling(supercell_scaling)
860
938
  } catch {
861
- return vol
939
+ return [1, 1, 1]
862
940
  }
863
941
  })
864
942
 
865
943
  let supercell_timeout: ReturnType<typeof setTimeout> | undefined
944
+ // Compute the supercell, falling back to the unscaled structure on error
945
+ const make_supercell_safe = (base: Crystal): Crystal => {
946
+ try {
947
+ return make_supercell(base, supercell_scaling)
948
+ } catch (error) {
949
+ console.error(`Failed to create supercell:`, error)
950
+ show_toast(`Failed to create supercell: ${to_error(error).message}`)
951
+ return base
952
+ }
953
+ }
866
954
  $effect(() => {
867
955
  const base_structure = cell_transformed_structure
868
956
  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)) {
957
+ if (
958
+ !base_structure ||
959
+ !(`lattice` in base_structure) ||
960
+ !has_supercell ||
961
+ !is_valid_supercell_input(supercell_scaling)
962
+ ) {
873
963
  supercell_structure = base_structure
874
964
  supercell_loading = false
875
965
  } else {
876
966
  // For large supercells, show loading state and use async generation
877
967
  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)
968
+ // lenient parse just for a size estimate (invalid factors count as 1)
969
+ const scaling_mult = supercell_scaling
970
+ .split(/[x×]/)
971
+ .reduce((product, factor) => product * (Number(factor) || 1), 1)
881
972
  const estimated_sites = sites_count * scaling_mult
882
973
 
883
974
  // Show spinner for supercells with >1000 estimated sites or scaling >8
@@ -887,27 +978,11 @@
887
978
  supercell_loading = true
888
979
  // Use setTimeout to allow UI to update before heavy computation
889
980
  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
- }
981
+ supercell_structure = make_supercell_safe(base_structure as Crystal)
982
+ supercell_loading = false
903
983
  }, 10)
904
984
  } else {
905
- if (base_structure && `lattice` in base_structure) {
906
- supercell_structure = make_supercell(
907
- base_structure as Crystal,
908
- supercell_scaling,
909
- )
910
- }
985
+ supercell_structure = make_supercell_safe(base_structure as Crystal)
911
986
  supercell_loading = false
912
987
  }
913
988
  }
@@ -934,9 +1009,12 @@
934
1009
  })
935
1010
  })
936
1011
 
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.
1012
+ // Element-map + PBC image atoms. Skipped during drags (doubled site count drops frames);
1013
+ // images return on release.
1014
+ // The render path reads the internal $state.raw copy, NOT the bindable prop:
1015
+ // if a consumer binds displayed_structure to a deep $state, reading the prop
1016
+ // back would re-proxy every site and slow all scene computations ~10x.
1017
+ let internal_displayed_structure = $state.raw<AnyStructure | undefined>(undefined)
940
1018
  $effect(() => {
941
1019
  let struct = supercell_structure
942
1020
  if (struct && element_mapping && Object.keys(element_mapping).length > 0) {
@@ -953,124 +1031,92 @@
953
1031
  })),
954
1032
  }
955
1033
  }
956
- displayed_structure =
957
- !dragging_atoms && show_image_atoms && struct && `lattice` in struct &&
958
- struct.lattice
1034
+ // Local var, NOT a read-back of the states written below: reading our own
1035
+ // fresh-object write inside this effect would self-retrigger it forever.
1036
+ const next =
1037
+ !dragging_atoms && show_image_atoms && struct && `lattice` in struct && struct.lattice
959
1038
  ? get_pbc_image_sites(struct)
960
1039
  : struct
1040
+ internal_displayed_structure = next
1041
+ displayed_structure = next
961
1042
  })
962
1043
 
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)
1044
+ // scene + camera of the primary pane, bound out for the export pane. All other camera
1045
+ // handling (move tracking, reset, re-framing) lives in StructureViewport.
966
1046
  let scene = $state<Scene | undefined>(undefined)
967
1047
  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)
1048
+
1049
+ // Multi-side view state: index of the pane the pointer is over (gets edit interactions),
1050
+ // a token bumped to reset every pane, and the set of panes whose camera has moved (so
1051
+ // the reset button stays visible until every moved pane is reset). Pane 0 = primary.
1052
+ let active_pane_idx = $state(0)
1053
+ let reset_token = $state(0)
1054
+ // SvelteSet is already reactive; do NOT wrap in $state (double-proxying breaks it)
1055
+ const moved_panes = new SvelteSet<number>()
1056
+ let any_camera_moved = $derived(moved_panes.size > 0)
1057
+
1058
+ // Inputs shared by every StructureViewport (single + all multi-view panes). Camera,
1059
+ // selection bindings, and per-pane chrome differ and stay on each snippet below.
1060
+ let shared_viewport_props = $derived({
1061
+ structure: internal_displayed_structure,
1062
+ base_structure: cell_transformed_structure,
1063
+ scene_props,
1064
+ gizmo: scene_gizmo,
1065
+ lattice_props,
1066
+ volumetric_data,
1067
+ volume_scaling,
1068
+ active_volume_idx,
1069
+ isosurface_settings,
1070
+ bond_edits_enabled,
1071
+ bond_edit_order,
1072
+ measure_mode,
1073
+ atom_color_config,
1074
+ sym_data,
1075
+ active_sites: active_scene_sites,
1076
+ on_sites_moved: handle_sites_moved,
1077
+ on_operation_start: push_undo,
1078
+ on_bond_edit_start: push_bond_undo,
1079
+ on_add_atom: handle_add_atom,
1080
+ })
973
1081
 
974
1082
  // Mutual exclusion: opening one pane closes others
975
1083
  $effect(() => {
976
1084
  if (info_pane_open) {
977
- untrack(() => [controls_open, export_pane_open] = [false, false])
1085
+ untrack(() => ([controls_open, export_pane_open] = [false, false]))
978
1086
  }
979
1087
  })
980
1088
  $effect(() => {
981
1089
  if (controls_open) {
982
- untrack(() => [info_pane_open, export_pane_open] = [false, false])
1090
+ untrack(() => ([info_pane_open, export_pane_open] = [false, false]))
983
1091
  }
984
1092
  })
985
1093
  $effect(() => {
986
1094
  if (export_pane_open) {
987
- untrack(() => [info_pane_open, controls_open] = [false, false])
1095
+ untrack(() => ([info_pane_open, controls_open] = [false, false]))
988
1096
  }
989
1097
  })
990
1098
 
991
- // Reset tracking when structure changes
1099
+ // Reset moved-pane tracking when structure changes
992
1100
  $effect(() => {
993
- if (structure) camera_has_moved = false
1101
+ // untrack: clearing must not add moved_panes as a dependency, else a pane move
1102
+ // (which adds to moved_panes) would immediately re-run this and clear it again.
1103
+ if (structure) untrack(() => moved_panes.clear())
994
1104
  })
995
1105
 
996
1106
  // Clear stale camera target and position so StructureScene uses the new
997
1107
  // structure's rotation_target (unit cell center) and auto-positions the camera.
998
1108
  function clear_camera_state() {
1109
+ // Reset to a fresh [0,0,0] so the primary viewport re-frames the new structure.
1110
+ // Side panes reset their local camera state in StructureViewport's structure effect.
999
1111
  scene_props.camera_target = undefined
1000
1112
  scene_props.camera_position = [0, 0, 0]
1001
1113
  }
1002
1114
 
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 })
1115
+ // Reset every pane's camera (each StructureViewport resets on a reset_token bump and,
1116
+ // for the primary, emits on_camera_reset).
1117
+ function reset_all_cameras() {
1118
+ reset_token += 1
1119
+ moved_panes.clear()
1074
1120
  }
1075
1121
 
1076
1122
  const emit_file_load_event = (
@@ -1081,27 +1127,69 @@
1081
1127
  on_file_load?.({
1082
1128
  structure: loaded_structure,
1083
1129
  filename,
1084
- file_size: typeof content === `string`
1085
- ? new Blob([content]).size
1086
- : content.byteLength,
1130
+ file_size: typeof content === `string` ? new Blob([content]).size : content.byteLength,
1087
1131
  total_atoms: loaded_structure.sites?.length || 0,
1088
1132
  })
1089
1133
 
1134
+ // The currently loaded structure's lattice matrix (undefined for molecules/none)
1135
+ const current_lattice_matrix = () =>
1136
+ structure && `lattice` in structure ? structure.lattice.matrix : undefined
1137
+ const shares_current_lattice = (matrix: Parameters<typeof lattices_match>[1]): boolean =>
1138
+ lattices_match(current_lattice_matrix(), matrix)
1139
+
1090
1140
  // Try to parse content as a volumetric file, setting both structure and volumetric data.
1091
1141
  // Delegates format detection entirely to parse_volumetric_file (filename + content sniffing).
1142
+ // When the file describes the same cell as already-loaded volumes, its volumes are
1143
+ // APPENDED (or replaced in place on re-import of the same source file) so multiple
1144
+ // fields — e.g. density + ESP — coexist and can cross-color each other's isosurfaces.
1145
+ // A file with a different lattice replaces the current structure and volumes.
1092
1146
  // 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 {
1147
+ function try_parse_volumetric(text_content: string, filename: string): AnyStructure | null {
1097
1148
  const vol_result = parse_volumetric_file(text_content, filename)
1098
1149
  if (!vol_result) return null
1150
+
1151
+ const incoming = label_file_volumes(vol_result.volumes, filename)
1152
+ const same_cell = shares_current_lattice(vol_result.structure.lattice?.matrix)
1153
+ const added_toast = (count: number) =>
1154
+ `Added ${count} volume${count > 1 ? `s` : ``} from ${filename}`
1155
+
1156
+ if (same_cell && structure) {
1157
+ // Same cell: keep the structure and camera, only update volumes
1158
+ if (volumetric_data?.length) {
1159
+ // Materialize the implicit single surface into explicit layers so existing
1160
+ // surfaces survive the transition to multi-volume mode
1161
+ const merged = merge_imported_volumes(
1162
+ volumetric_data,
1163
+ materialize_layers(isosurface_settings, active_volume_idx),
1164
+ incoming,
1165
+ active_volume_idx,
1166
+ )
1167
+ volumetric_data = merged.volumes
1168
+ isosurface_settings = { ...isosurface_settings, layers: merged.layers }
1169
+ active_volume_idx = merged.first_touched_idx
1170
+ show_toast(
1171
+ merged.n_added > 0
1172
+ ? added_toast(merged.n_added)
1173
+ : `Reloaded volumes from ${filename}`,
1174
+ )
1175
+ } else if (incoming[0]) {
1176
+ // First volumetric file for this structure
1177
+ volumetric_data = incoming
1178
+ isosurface_settings = auto_isosurface_settings(incoming[0].data_range)
1179
+ active_volume_idx = 0
1180
+ show_toast(added_toast(incoming.length))
1181
+ }
1182
+ return structure
1183
+ }
1184
+
1185
+ // Replace: new system (or nothing loaded yet)
1186
+ clear_camera_state()
1099
1187
  // parse_volumetric_file extracts structure from file header;
1100
1188
  // parsers set pbc so the lattice conforms to Crystal's LatticeType
1101
1189
  structure = vol_result.structure as AnyStructure
1102
- volumetric_data = vol_result.volumes
1190
+ volumetric_data = incoming
1103
1191
  // Auto-compute reasonable isosurface settings from data range
1104
- const vol = vol_result.volumes[0]
1192
+ const vol = incoming[0]
1105
1193
  if (vol) {
1106
1194
  isosurface_settings = auto_isosurface_settings(vol.data_range)
1107
1195
  active_volume_idx = 0
@@ -1112,33 +1200,36 @@
1112
1200
  // Parse file content, trying volumetric format first then falling back to plain structure.
1113
1201
  // Returns the parsed structure on success, throws on failure.
1114
1202
  function parse_file_content(text_content: string, filename: string): AnyStructure {
1115
- clear_camera_state()
1116
1203
  const vol_struct = try_parse_volumetric(text_content, filename)
1117
1204
  if (vol_struct) return vol_struct
1118
- // Clear stale volumetric data when loading a non-volumetric file
1119
- volumetric_data = []
1120
1205
  const parsed = parse_any_structure(text_content, filename)
1121
1206
  if (!parsed) throw new Error(`Failed to parse structure from ${filename}`)
1207
+ // Keep loaded volumes and camera when the new structure describes the same
1208
+ // cell (e.g. a mixed batch drop of CHGCAR + POSCAR, in either order);
1209
+ // clear both for a genuinely new system
1210
+ const same_cell = shares_current_lattice(
1211
+ `lattice` in parsed ? parsed.lattice?.matrix : undefined,
1212
+ )
1213
+ if (!same_cell) {
1214
+ clear_camera_state()
1215
+ volumetric_data = []
1216
+ }
1122
1217
  structure = parsed
1123
1218
  return parsed
1124
1219
  }
1125
1220
 
1126
- const handle_file_drop = create_file_drop_handler({
1221
+ function parse_and_emit_file(content: string | ArrayBuffer, filename: string): void {
1222
+ const text = content instanceof ArrayBuffer ? new TextDecoder().decode(content) : content
1223
+ emit_file_load_event(parse_file_content(text, filename), filename, content)
1224
+ }
1225
+
1226
+ const handle_file_drop = io.create_file_drop_handler({
1127
1227
  allow: () => allow_file_drop,
1228
+ // Parse errors propagate so multi-file batches aggregate all failures into
1229
+ // one message instead of the last error overwriting earlier ones
1128
1230
  on_drop: (content, filename) => {
1129
1231
  if (on_file_drop) return on_file_drop(content, filename)
1130
- try {
1131
- const text_content = content instanceof ArrayBuffer
1132
- ? new TextDecoder().decode(content)
1133
- : content
1134
- const parsed = parse_file_content(text_content, filename)
1135
- emit_file_load_event(parsed, filename, content)
1136
- } catch (err) {
1137
- error_msg = `Failed to parse structure: ${
1138
- to_error(err).message
1139
- }`
1140
- on_error?.({ error_msg, filename })
1141
- }
1232
+ parse_and_emit_file(content, filename)
1142
1233
  },
1143
1234
  on_error: (msg) => {
1144
1235
  error_msg = msg
@@ -1263,8 +1354,10 @@
1263
1354
  }
1264
1355
  // Duplicate selected atoms at a small offset
1265
1356
  if (
1266
- key === `d` && (event.ctrlKey || event.metaKey) &&
1267
- selected_sites.length > 0 && structure?.sites
1357
+ key === `d` &&
1358
+ (event.ctrlKey || event.metaKey) &&
1359
+ selected_sites.length > 0 &&
1360
+ structure?.sites
1268
1361
  ) {
1269
1362
  is_internal_edit = true
1270
1363
  push_undo()
@@ -1273,11 +1366,7 @@
1273
1366
  const new_sites = structure.sites
1274
1367
  .filter((_, idx) => orig_indices.has(idx))
1275
1368
  .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
- ]
1369
+ const new_xyz: Vec3 = [site.xyz[0] + 0.5, site.xyz[1] + 0.5, site.xyz[2] + 0.5]
1281
1370
  return {
1282
1371
  ...site,
1283
1372
  xyz: new_xyz,
@@ -1293,9 +1382,7 @@
1293
1382
  // Select the newly duplicated atoms
1294
1383
  selected_sites = new_sites.map((_, idx) => base_idx + idx)
1295
1384
  measured_sites = [...selected_sites]
1296
- show_toast(
1297
- `Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`,
1298
- )
1385
+ show_toast(`Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`)
1299
1386
  return true
1300
1387
  }
1301
1388
 
@@ -1320,6 +1407,9 @@
1320
1407
  } else if (event.key === `i` && has_modifier && enable_info_pane) {
1321
1408
  info_pane_open = !info_pane_open
1322
1409
  return true
1410
+ } else if (event.key === `g` && has_modifier && controls_config.visible(`multi-view`)) {
1411
+ multi_view = !multi_view
1412
+ return true
1323
1413
  } else if (event.key === `Escape`) {
1324
1414
  // Prioritize closing panes, then exit edit modes, then exit fullscreen
1325
1415
  if (info_pane_open) info_pane_open = false
@@ -1351,7 +1441,7 @@
1351
1441
  ): SvelteSet<number> {
1352
1442
  const result = new SvelteSet<number>()
1353
1443
  for (const scene_idx of scene_indices) {
1354
- const displayed_site = displayed_structure?.sites?.[scene_idx]
1444
+ const displayed_site = internal_displayed_structure?.sites?.[scene_idx]
1355
1445
  if (!displayed_site) continue
1356
1446
  if (skip_image_atoms && displayed_site.properties?.orig_site_idx != null) {
1357
1447
  continue
@@ -1389,10 +1479,9 @@
1389
1479
  const orig_indices = scene_to_structure_indices(scene_indices)
1390
1480
  // For crystals, wrap to [0,1) inline so normalize_fractional_coords fast-paths.
1391
1481
  // 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
1482
+ const lattice = `lattice` in structure ? (structure as Crystal).lattice.matrix : null
1483
+ // get_cart_to_frac guards matrix_inverse_3x3, which throws on singular lattices
1484
+ const cart_to_frac = get_cart_to_frac()
1396
1485
  const frac_to_cart = lattice ? create_frac_to_cart(lattice) : null
1397
1486
  structure = {
1398
1487
  ...structure,
@@ -1434,9 +1523,7 @@
1434
1523
  change_element_mode = false
1435
1524
  change_element_value = ``
1436
1525
  show_toast(
1437
- `Changed ${orig_indices.size} site${
1438
- orig_indices.size > 1 ? `s` : ``
1439
- } to ${elem}`,
1526
+ `Changed ${orig_indices.size} site${orig_indices.size > 1 ? `s` : ``} to ${elem}`,
1440
1527
  )
1441
1528
  }
1442
1529
 
@@ -1451,29 +1538,30 @@
1451
1538
  push_undo()
1452
1539
  structure = {
1453
1540
  ...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
- }],
1541
+ sites: [
1542
+ ...structure.sites,
1543
+ {
1544
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
1545
+ xyz,
1546
+ abc: get_cart_to_frac()?.(xyz) ?? xyz,
1547
+ label: elem,
1548
+ properties: {},
1549
+ },
1550
+ ],
1461
1551
  }
1462
1552
  show_toast(`Added ${elem} at (${xyz.map((coord) => coord.toFixed(2)).join(`, `)})`)
1463
1553
  }
1464
1554
 
1465
1555
  // Only set background override when background_color is explicitly provided
1466
1556
  $effect(() => {
1467
- if (typeof window !== `undefined` && wrapper && background_color) {
1557
+ if (!wrapper) return
1558
+ if (background_color) {
1468
1559
  // Convert opacity (0-1) to hex alpha value (00-FF)
1469
1560
  const alpha_hex = Math.round(background_opacity * 255)
1470
1561
  .toString(16)
1471
1562
  .padStart(2, `0`)
1472
- wrapper.style.setProperty(
1473
- `--struct-bg-override`,
1474
- `${background_color}${alpha_hex}`,
1475
- )
1476
- } else if (typeof window !== `undefined` && wrapper) {
1563
+ wrapper.style.setProperty(`--struct-bg-override`, `${background_color}${alpha_hex}`)
1564
+ } else {
1477
1565
  // Remove override to use theme system
1478
1566
  wrapper.style.removeProperty(`--struct-bg-override`)
1479
1567
  }
@@ -1497,10 +1585,10 @@
1497
1585
  <div
1498
1586
  class:dragover
1499
1587
  class:active={info_pane_open || controls_open || export_pane_open}
1500
- class:gizmo-visible={Boolean(scene_gizmo)}
1588
+ class:gizmo-visible={viewer_active && Boolean(scene_gizmo)}
1589
+ class:multi-view={multi_view}
1501
1590
  role="application"
1502
1591
  tabindex="0"
1503
- style:--canvas-cursor={canvas_cursor}
1504
1592
  aria-label="Structure viewer"
1505
1593
  bind:this={wrapper}
1506
1594
  bind:clientWidth={width}
@@ -1513,22 +1601,14 @@
1513
1601
  focused = false
1514
1602
  }
1515
1603
  }}
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
1604
  ondrop={handle_file_drop}
1528
- {...drag_over_handlers({ allow: () => allow_file_drop, set_dragover: (over) => dragover = over })}
1605
+ {...io.drag_over_handlers({
1606
+ allow: () => allow_file_drop,
1607
+ set_dragover: (over) => (dragover = over),
1608
+ })}
1529
1609
  onkeydown={handle_and_prevent(handle_keydown)}
1530
1610
  {...rest}
1531
- class="structure {rest.class ?? ``}"
1611
+ class={[`structure`, rest.class]}
1532
1612
  >
1533
1613
  {@render children?.({ structure, fullscreen })}
1534
1614
  {#if loading}
@@ -1541,8 +1621,13 @@
1541
1621
  <StatusMessage bind:message={error_msg} type="error" dismissible />
1542
1622
  {:else if (structure?.sites?.length ?? 0) > 0}
1543
1623
  {#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}>
1624
+ {#if any_camera_moved && controls_config.visible(`reset-camera`)}
1625
+ <button
1626
+ class="reset-camera"
1627
+ onclick={reset_all_cameras}
1628
+ title={reset_text}
1629
+ aria-label={reset_text}
1630
+ >
1546
1631
  <!-- Target/Focus icon for reset camera -->
1547
1632
  <Icon icon="Reset" />
1548
1633
  </button>
@@ -1555,20 +1640,36 @@
1555
1640
  fullscreen_btn_style="padding: 0 3px"
1556
1641
  {wrapper}
1557
1642
  before={reset_camera_btn}
1558
- style="--viewer-buttons-gap: 4pt; --viewer-buttons-btn-padding: 1px 6px; --viewer-buttons-align: stretch"
1643
+ style="--viewer-buttons-gap: 4pt; --viewer-buttons-btn-padding: 1px 6px; --viewer-buttons-align: stretch; --viewer-buttons-hover-bg: transparent; --viewer-buttons-hover-color: light-dark(#000, #fff)"
1559
1644
  >
1645
+ {#if controls_config.visible(`multi-view`)}
1646
+ <button
1647
+ class="multi-view-toggle"
1648
+ class:active={multi_view}
1649
+ onclick={() => (multi_view = !multi_view)}
1650
+ title="Toggle multi-side view 2×2 grid (Cmd/Ctrl+G)"
1651
+ aria-label="Toggle multi-side view"
1652
+ aria-pressed={multi_view}
1653
+ {@attach tooltip()}
1654
+ >
1655
+ <Icon icon="Grid2x2" />
1656
+ </button>
1657
+ {/if}
1658
+
1560
1659
  {#if enable_measure_mode && controls_config.visible(`measure-mode`)}
1561
1660
  <div
1562
1661
  class="measure-mode-dropdown"
1563
- {@attach click_outside({ callback: () => measure_menu_open = false })}
1662
+ {@attach click_outside({ callback: () => (measure_menu_open = false) })}
1564
1663
  >
1565
1664
  <button
1566
1665
  onclick={() => (measure_menu_open = !measure_menu_open)}
1567
1666
  title="Measure / Edit"
1667
+ aria-label="Measure / Edit"
1568
1668
  class="view-mode-button"
1569
1669
  class:active={measure_menu_open}
1570
1670
  aria-expanded={measure_menu_open}
1571
1671
  style="transform: scale(1.2)"
1672
+ {@attach tooltip()}
1572
1673
  >
1573
1674
  {#if show_measure_selection_limit}
1574
1675
  <span class="selection-limit-text">
@@ -1576,18 +1677,17 @@
1576
1677
  </span>
1577
1678
  {:else}
1578
1679
  <Icon
1579
- icon={({
1580
- distance: `Ruler`,
1581
- angle: `Angle`,
1582
- 'edit-bonds': `Link`,
1583
- 'edit-atoms': `Edit`,
1584
- } as const)[measure_mode]}
1680
+ icon={(
1681
+ {
1682
+ distance: `Ruler`,
1683
+ angle: `Angle`,
1684
+ 'edit-bonds': `Link`,
1685
+ 'edit-atoms': `Edit`,
1686
+ } as const
1687
+ )[measure_mode]}
1585
1688
  />
1586
1689
  {/if}
1587
- <Icon
1588
- icon="Arrow{measure_menu_open ? `Up` : `Down`}"
1589
- style="margin-left: -2px"
1590
- />
1690
+ <Icon icon="Arrow{measure_menu_open ? `Up` : `Down`}" style="margin-left: -2px" />
1591
1691
  </button>
1592
1692
  {#if show_selection_reset}
1593
1693
  <button
@@ -1603,12 +1703,7 @@
1603
1703
  {/if}
1604
1704
  {#if measure_menu_open}
1605
1705
  <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)}
1706
+ {#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
1707
  <button
1613
1708
  class="view-mode-option"
1614
1709
  class:selected={measure_mode === mode}
@@ -1629,36 +1724,43 @@
1629
1724
  {/if}
1630
1725
  </div>
1631
1726
 
1632
- <!-- Undo/redo buttons (only in edit-atoms mode) -->
1633
- {#if measure_mode === `edit-atoms`}
1727
+ {#snippet undo_redo_snippet(
1728
+ buttons: {
1729
+ icon: `Undo` | `Redo`
1730
+ title: string
1731
+ stack: unknown[]
1732
+ action: () => void
1733
+ }[],
1734
+ )}
1634
1735
  <div class="undo-redo-container">
1635
- <button
1636
- type="button"
1637
- aria-label="Undo (Cmd/Ctrl+Z)"
1638
- disabled={undo_stack.length === 0}
1639
- onclick={undo}
1640
- title="Undo (Cmd/Ctrl+Z)"
1641
- class="undo-redo-btn"
1642
- >
1643
- <Icon icon="Undo" />
1644
- {#if undo_stack.length > 0}
1645
- <span class="history-count">{undo_stack.length}</span>
1646
- {/if}
1647
- </button>
1648
- <button
1649
- type="button"
1650
- aria-label="Redo (Cmd/Ctrl+Y or Cmd+Shift+Z)"
1651
- disabled={redo_stack.length === 0}
1652
- onclick={redo}
1653
- title="Redo (Cmd/Ctrl+Y or Cmd+Shift+Z)"
1654
- class="undo-redo-btn"
1655
- >
1656
- <Icon icon="Redo" />
1657
- {#if redo_stack.length > 0}
1658
- <span class="history-count">{redo_stack.length}</span>
1659
- {/if}
1660
- </button>
1736
+ {#each buttons as { icon, title, stack, action } (icon)}
1737
+ <button
1738
+ type="button"
1739
+ aria-label={title}
1740
+ disabled={stack.length === 0}
1741
+ onclick={action}
1742
+ {title}
1743
+ class="undo-redo-btn"
1744
+ >
1745
+ <Icon {icon} />
1746
+ {#if stack.length > 0}
1747
+ <span class="history-count">{stack.length}</span>
1748
+ {/if}
1749
+ </button>
1750
+ {/each}
1661
1751
  </div>
1752
+ {/snippet}
1753
+
1754
+ {#if measure_mode === `edit-atoms`}
1755
+ {@render undo_redo_snippet([
1756
+ { icon: `Undo`, title: `Undo (Cmd/Ctrl+Z)`, stack: undo_stack, action: undo },
1757
+ {
1758
+ icon: `Redo`,
1759
+ title: `Redo (Cmd/Ctrl+Y or Cmd+Shift+Z)`,
1760
+ stack: redo_stack,
1761
+ action: redo,
1762
+ },
1763
+ ])}
1662
1764
  {/if}
1663
1765
 
1664
1766
  {#if measure_mode === `edit-bonds`}
@@ -1674,10 +1776,7 @@
1674
1776
  </label>
1675
1777
  {/if}
1676
1778
  <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)}
1779
+ {#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
1780
  <button
1682
1781
  type="button"
1683
1782
  class:selected={bond_edit_mode === mode}
@@ -1690,34 +1789,20 @@
1690
1789
  {/each}
1691
1790
  </div>
1692
1791
  </div>
1693
- <div class="undo-redo-container">
1694
- <button
1695
- type="button"
1696
- aria-label="Undo bond edit (Cmd/Ctrl+Z)"
1697
- disabled={bond_undo_stack.length === 0}
1698
- onclick={undo_bond_edit}
1699
- title="Undo bond edit (Cmd/Ctrl+Z)"
1700
- class="undo-redo-btn"
1701
- >
1702
- <Icon icon="Undo" />
1703
- {#if bond_undo_stack.length > 0}
1704
- <span class="history-count">{bond_undo_stack.length}</span>
1705
- {/if}
1706
- </button>
1707
- <button
1708
- type="button"
1709
- aria-label="Redo bond edit (Cmd/Ctrl+Y or Cmd+Shift+Z)"
1710
- disabled={bond_redo_stack.length === 0}
1711
- onclick={redo_bond_edit}
1712
- title="Redo bond edit (Cmd/Ctrl+Y or Cmd+Shift+Z)"
1713
- class="undo-redo-btn"
1714
- >
1715
- <Icon icon="Redo" />
1716
- {#if bond_redo_stack.length > 0}
1717
- <span class="history-count">{bond_redo_stack.length}</span>
1718
- {/if}
1719
- </button>
1720
- </div>
1792
+ {@render undo_redo_snippet([
1793
+ {
1794
+ icon: `Undo`,
1795
+ title: `Undo bond edit (Cmd/Ctrl+Z)`,
1796
+ stack: bond_undo_stack,
1797
+ action: undo_bond_edit,
1798
+ },
1799
+ {
1800
+ icon: `Redo`,
1801
+ title: `Redo bond edit (Cmd/Ctrl+Y or Cmd+Shift+Z)`,
1802
+ stack: bond_redo_stack,
1803
+ action: redo_bond_edit,
1804
+ },
1805
+ ])}
1721
1806
  {/if}
1722
1807
 
1723
1808
  <!-- Add-atom element input (shown when add_atom_mode is active) -->
@@ -1738,8 +1823,7 @@
1738
1823
  {/if}
1739
1824
 
1740
1825
  <!-- Change-element input (shown when 'e' pressed with selection) -->
1741
- {#if measure_mode === `edit-atoms` && change_element_mode &&
1742
- selected_sites.length > 0}
1826
+ {#if measure_mode === `edit-atoms` && change_element_mode && selected_sites.length > 0}
1743
1827
  <div class="add-atom-input">
1744
1828
  <label>
1745
1829
  <span>New element:</span>
@@ -1767,8 +1851,7 @@
1767
1851
  {/if}
1768
1852
  {/if}
1769
1853
 
1770
- {#if enable_info_pane && normalized_structure &&
1771
- controls_config.visible(`info-pane`)}
1854
+ {#if enable_info_pane && normalized_structure && controls_config.visible(`info-pane`)}
1772
1855
  <StructureInfoPane
1773
1856
  structure={normalized_structure}
1774
1857
  bind:pane_open={info_pane_open}
@@ -1827,7 +1910,7 @@
1827
1910
  bind:element_radius_overrides
1828
1911
  bind:site_radius_overrides
1829
1912
  selected_sites={atom_legend_selected_sites}
1830
- structure={displayed_structure}
1913
+ structure={internal_displayed_structure}
1831
1914
  show_mode_toggle={viewer_active}
1832
1915
  {sym_data}
1833
1916
  >
@@ -1845,60 +1928,90 @@
1845
1928
  {/snippet}
1846
1929
  </AtomLegend>
1847
1930
 
1931
+ <!-- One StructureViewport renders the single view; four render the 2x2 multi-view.
1932
+ The primary pane (index 0) carries the external camera API: scene/camera are bound
1933
+ out for export and camera_position/target persist into scene_props, and it emits
1934
+ on_camera_move/on_camera_reset. All camera handling itself lives in StructureViewport. -->
1935
+ {#snippet primary_viewport(view: StructureView)}
1936
+ <StructureViewport
1937
+ in_grid={multi_view}
1938
+ label={multi_view ? view.label : undefined}
1939
+ active={multi_view && active_pane_idx === 0}
1940
+ interactive={!multi_view || active_pane_idx === 0}
1941
+ onactivate={() => (active_pane_idx = 0)}
1942
+ {reset_token}
1943
+ report_moved={(moved) => (moved ? moved_panes.add(0) : moved_panes.delete(0))}
1944
+ {on_camera_move}
1945
+ {on_camera_reset}
1946
+ {...shared_viewport_props}
1947
+ camera_direction={view.direction}
1948
+ camera_projection={view.projection ?? scene_props.camera_projection}
1949
+ bind:camera_position={scene_props.camera_position}
1950
+ bind:camera_target={scene_props.camera_target}
1951
+ bind:scene
1952
+ bind:camera
1953
+ bind:selected_sites
1954
+ bind:measured_sites
1955
+ bind:hovered_site_idx
1956
+ bind:hidden_elements
1957
+ bind:hidden_prop_vals
1958
+ bind:element_radius_overrides
1959
+ bind:site_radius_overrides
1960
+ bind:added_bonds
1961
+ bind:removed_bonds
1962
+ bind:bond_order_overrides
1963
+ bind:bond_edit_mode
1964
+ bind:add_atom_mode
1965
+ bind:add_element
1966
+ bind:dragging_atoms
1967
+ bind:polyhedra_rendered_elements
1968
+ />
1969
+ {/snippet}
1970
+
1971
+ {#snippet extra_viewport(view: StructureView, pane_idx: number)}
1972
+ <StructureViewport
1973
+ in_grid
1974
+ label={view.label}
1975
+ active={active_pane_idx === pane_idx}
1976
+ interactive={active_pane_idx === pane_idx}
1977
+ onactivate={() => (active_pane_idx = pane_idx)}
1978
+ {reset_token}
1979
+ report_moved={(moved) =>
1980
+ moved ? moved_panes.add(pane_idx) : moved_panes.delete(pane_idx)}
1981
+ {...shared_viewport_props}
1982
+ camera_direction={view.direction}
1983
+ camera_projection={view.projection ?? scene_props.camera_projection}
1984
+ bind:selected_sites
1985
+ bind:measured_sites
1986
+ bind:hovered_site_idx
1987
+ bind:hidden_elements
1988
+ bind:hidden_prop_vals
1989
+ bind:element_radius_overrides
1990
+ bind:site_radius_overrides
1991
+ bind:added_bonds
1992
+ bind:removed_bonds
1993
+ bind:bond_order_overrides
1994
+ bind:bond_edit_mode
1995
+ bind:add_atom_mode
1996
+ bind:add_element
1997
+ bind:dragging_atoms
1998
+ />
1999
+ {/snippet}
2000
+
1848
2001
  <!-- prevent from rendering in vitest runner since WebGLRenderingContext not available -->
1849
2002
  {#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>
2003
+ <div class:multi={multi_view} class="viewport-stage">
2004
+ {@render primary_viewport(multi_view ? (views[0] ?? {}) : {})}
2005
+ {#if multi_view}
2006
+ {#each views.slice(1) as view, idx (idx)}
2007
+ {@render extra_viewport(view, idx + 1)}
2008
+ {/each}
2009
+ {/if}
1897
2010
  </div>
1898
2011
  {/if}
1899
2012
 
1900
2013
  <div class="bottom-left">
1901
- {@render bottom_left?.({ structure: displayed_structure })}
2014
+ {@render bottom_left?.({ structure: internal_displayed_structure })}
1902
2015
  </div>
1903
2016
 
1904
2017
  {#if toast_msg}
@@ -1908,9 +2021,7 @@
1908
2021
  {#if symmetry_error}
1909
2022
  <div class="symmetry-error">
1910
2023
  <span>{symmetry_error}</span>
1911
- <button onclick={() => (symmetry_error = undefined)} aria-label="Dismiss">
1912
- ×
1913
- </button>
2024
+ <button onclick={() => (symmetry_error = undefined)} aria-label="Dismiss"> × </button>
1914
2025
  </div>
1915
2026
  {/if}
1916
2027
  {:else if structure}
@@ -1940,7 +2051,9 @@
1940
2051
  background: var(--struct-bg-fullscreen, var(--struct-bg));
1941
2052
  overflow: hidden;
1942
2053
  }
1943
- .structure:fullscreen :global(canvas) {
2054
+ /* Single view: stretch the lone canvas to the full screen in fullscreen mode.
2055
+ In multi-view the grid fills the screen and each canvas fills its 1fr cell. */
2056
+ .structure:fullscreen:not(.multi-view) :global(canvas) {
1944
2057
  height: 100vh !important;
1945
2058
  width: 100vw !important;
1946
2059
  }
@@ -1948,6 +2061,22 @@
1948
2061
  background: var(--struct-dragover-bg, var(--dragover-bg));
1949
2062
  border: var(--struct-dragover-border, var(--dragover-border));
1950
2063
  }
2064
+ .viewport-stage {
2065
+ height: 100%;
2066
+ width: 100%;
2067
+ }
2068
+ /* 2x2 multi-side view grid: four equal subcanvases. grid-auto-rows keeps rows
2069
+ equal-height if a custom `views` array supplies more than four entries. */
2070
+ .viewport-stage.multi {
2071
+ display: grid;
2072
+ grid-template-columns: 1fr 1fr;
2073
+ grid-template-rows: 1fr 1fr;
2074
+ grid-auto-rows: 1fr;
2075
+ gap: var(--struct-viewport-gap, 2px);
2076
+ }
2077
+ .multi-view-toggle.active {
2078
+ color: var(--accent-color, #4a9eff);
2079
+ }
1951
2080
  /* Ensure canvas is transparent so the themed --struct-bg shows through */
1952
2081
  .structure :global(canvas) {
1953
2082
  background: transparent;
@@ -1978,7 +2107,9 @@
1978
2107
  right: 0;
1979
2108
  background: var(--surface-bg);
1980
2109
  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);
2110
+ box-shadow:
2111
+ 0 8px 16px -4px rgba(0, 0, 0, 0.3),
2112
+ 0 4px 8px -2px rgba(0, 0, 0, 0.1);
1982
2113
  display: flex;
1983
2114
  flex-direction: column;
1984
2115
  }
@@ -2017,7 +2148,7 @@
2017
2148
  .measure-mode-dropdown > button {
2018
2149
  background: transparent;
2019
2150
  padding: 1px 6px;
2020
- font-size: clamp(0.85em, 2cqmin, 1.3em);
2151
+ font-size: var(--ctrl-btn-icon-size, clamp(0.7rem, 2cqmin, 0.85rem));
2021
2152
  }
2022
2153
  .selection-limit-text {
2023
2154
  font-weight: bold;
@@ -2045,7 +2176,7 @@
2045
2176
  gap: 1rem;
2046
2177
  max-width: min(90%, 400px);
2047
2178
  font-size: 0.9rem;
2048
- z-index: 1000;
2179
+ z-index: var(--z-index-viewer-tooltip, 1000);
2049
2180
  }
2050
2181
  .symmetry-error span {
2051
2182
  flex: 1;
@@ -2072,14 +2203,15 @@
2072
2203
  padding: 0.4rem 0.8rem;
2073
2204
  border-radius: var(--border-radius, 3pt);
2074
2205
  font-size: 0.8rem;
2075
- z-index: 100;
2206
+ z-index: var(--z-index-viewer-dropdown, 100);
2076
2207
  pointer-events: none;
2077
2208
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
2078
2209
  animation: toast-fade 2s ease-in-out;
2079
2210
  opacity: 0;
2080
2211
  }
2081
2212
  @keyframes toast-fade {
2082
- 0%, 70% {
2213
+ 0%,
2214
+ 70% {
2083
2215
  opacity: 1;
2084
2216
  }
2085
2217
  100% {