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
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { D3InterpolateName } from '../colors'
3
- import { get_d3_interpolator } from '../colors'
3
+ import { get_d3_interpolator, is_dark_mode, watch_dark_mode } from '../colors'
4
4
  import type { ElementSymbol } from '../element'
5
- import { element_data } from '../element'
5
+ import { element_by_symbol } from '../element'
6
6
  import Isosurface from '../isosurface/Isosurface.svelte'
7
7
  import type { IsosurfaceSettings, VolumetricData } from '../isosurface/types'
8
8
  import { DEFAULT_ISOSURFACE_SETTINGS } from '../isosurface/types'
@@ -25,7 +25,6 @@
25
25
  StructureBond,
26
26
  } from './'
27
27
  import {
28
- Arrow,
29
28
  atomic_radii,
30
29
  Cylinder,
31
30
  get_all_site_vectors,
@@ -34,11 +33,11 @@
34
33
  Lattice,
35
34
  VECTOR_PALETTE,
36
35
  } from './'
36
+ import ArrowInstances from './ArrowInstances.svelte'
37
+ import InstancedAtoms from './InstancedAtoms.svelte'
38
+ import SiteLabels from './SiteLabels.svelte'
37
39
  import type { AtomColorConfig } from './atom-properties'
38
- import {
39
- get_orig_site_idx,
40
- get_property_colors,
41
- } from './atom-properties'
40
+ import { get_orig_site_idx, get_property_colors } from './atom-properties'
42
41
  import type { SymmetryElement } from '../symmetry'
43
42
  import { has_visible_symmetry_overlay } from '../symmetry/symmetry-elements'
44
43
  import SymmetryElements from '../symmetry/SymmetryElements.svelte'
@@ -59,9 +58,9 @@
59
58
  import type { BondEditResult, BondingStrategy, BondKeyTarget } from './bonding'
60
59
  import {
61
60
  add_or_restore_bond,
62
- BONDING_STRATEGIES,
63
61
  BOND_ORDER_OPTIONS,
64
62
  canonicalize_bond_target,
63
+ compute_bonds,
65
64
  delete_bond as apply_delete_bond,
66
65
  get_bond_key,
67
66
  get_bond_render_matrices,
@@ -70,37 +69,11 @@
70
69
  set_bond_order as apply_set_bond_order,
71
70
  structure_bond_to_bond_pair,
72
71
  } from './bonding'
73
- import {
74
- CanvasTooltip,
75
- compose_perceived_bonds,
76
- perceive_bond_orders,
77
- } from './index'
78
- import {
79
- choose_site_label_offset,
80
- LABEL_OFFSET_EPS,
81
- make_label_position_calculator,
82
- } from './atom-label-placement'
72
+ import { CanvasTooltip, compose_perceived_bonds, perceive_bond_orders } from './index'
73
+ import { choose_site_label_offset, LABEL_OFFSET_EPS } from './atom-label-placement'
83
74
  import type { PolyhedraColorMode, Polyhedron } from './polyhedra'
84
75
  import { compute_polyhedra, merge_polyhedra_buffers } from './polyhedra'
85
76
 
86
- type InstancedAtomGroup = {
87
- element: string
88
- radius: number
89
- color: string
90
- is_image_atom: boolean
91
- atoms: (typeof atom_data)[number][]
92
- }
93
-
94
- function instanced_atom_group_key(
95
- { element, radius, color, is_image_atom, atoms }: InstancedAtomGroup,
96
- measure_mode: MeasureMode,
97
- ): string {
98
- const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom
99
- return `${element}-${format_num(radius, `.3~`)}-${color}-${
100
- is_image_atom ? `img` : `base`
101
- }-${edit_mode_image}-${atoms.length}`
102
- }
103
-
104
77
  type EditableAtomHitTarget = {
105
78
  site_idx: number
106
79
  position: Vec3
@@ -133,6 +106,7 @@
133
106
  same_size_atoms = false,
134
107
  camera_position = DEFAULTS.structure.camera_position,
135
108
  camera_target = undefined,
109
+ camera_direction = undefined,
136
110
  camera_projection = DEFAULTS.structure.camera_projection,
137
111
  rotation_damping = DEFAULTS.structure.rotation_damping,
138
112
  max_zoom = DEFAULTS.structure.max_zoom,
@@ -237,14 +211,19 @@
237
211
  dragging_atoms = $bindable(false),
238
212
  volumetric_data = undefined,
239
213
  isosurface_settings = DEFAULT_ISOSURFACE_SETTINGS,
214
+ active_volume_idx = 0,
215
+ volume_scaling = [1, 1, 1],
216
+ interactive = true,
240
217
  }: SceneControlProps & {
241
218
  structure?: AnyStructure
242
219
  base_structure?: AnyStructure // The original structure without image atoms, used for property color calculation
243
220
  atom_radius?: number // scale factor for atomic radii
244
- same_size_atoms?: boolean // whether to use the same radius for all atoms. if not, the radius will be
245
- // determined by the atomic radius of the element
221
+ same_size_atoms?: boolean // uniform radius for all atoms (else per-element atomic radii)
246
222
  camera_position?: [x: number, y: number, z: number] // initial camera position from which to render the scene
247
223
  camera_target?: Vec3 // external orbit-controls target for pan synchronization
224
+ // When set (and camera_position is unset/zero), auto-place the camera along this
225
+ // direction from the structure center (used by the multi-side view for fixed angles)
226
+ camera_direction?: Vec3
248
227
  show_atoms?: boolean
249
228
  show_bonds?: ShowBonds
250
229
  show_site_labels?: boolean
@@ -287,7 +266,10 @@
287
266
  // Fractional coords must refer to the SAME cell as the rendered lattice (moyo
288
267
  // operations are in the input-cell frame, i.e. the original untransformed cell).
289
268
  symmetry_elements?: SymmetryElement[]
290
- symmetry_elements_props?: Omit<ComponentProps<typeof SymmetryElements>, `elements` | `lattice`>
269
+ symmetry_elements_props?: Omit<
270
+ ComponentProps<typeof SymmetryElements>,
271
+ `elements` | `lattice`
272
+ >
291
273
  // Auto-reduce visual clutter while a symmetry-element overlay is visible: hides
292
274
  // coordination polyhedra and calculated bonds, and shrinks atoms so axes/planes/
293
275
  // centers stay readable. Purely derived — toggling the overlay off restores the
@@ -335,8 +317,15 @@
335
317
  add_element?: ElementSymbol // element to add when clicking in add-atom mode
336
318
  cursor?: string // cursor style for the 3D canvas
337
319
  dragging_atoms?: boolean // true while TransformControls drag is active (skips expensive recalculations)
338
- volumetric_data?: VolumetricData // Active volumetric data for isosurface rendering
320
+ // Loaded volumetric datasets for isosurface rendering (single volume accepted
321
+ // for backwards compatibility)
322
+ volumetric_data?: VolumetricData | VolumetricData[]
339
323
  isosurface_settings?: IsosurfaceSettings // Isosurface rendering settings
324
+ active_volume_idx?: number // Volume implicit single-isovalue settings apply to
325
+ volume_scaling?: Vec3 // Supercell tiling applied to isosurface geometry
326
+ // When false, render the scene without hover/edit raycast helpers. Used by multi-side
327
+ // view so inactive panes skip interaction-only work while the active pane stays editable.
328
+ interactive?: boolean
340
329
  } = $props()
341
330
 
342
331
  const pulse = create_pulse_animation(
@@ -344,6 +333,8 @@
344
333
  { step: 0.015, frequency: 5 },
345
334
  )
346
335
  let pulse_opacity = $derived(0.15 + 0.25 * pulse.unit)
336
+ let dark_mode = $state(is_dark_mode())
337
+ $effect(() => watch_dark_mode((dark) => (dark_mode = dark)))
347
338
 
348
339
  bind_renderer((threlte_scene, threlte_camera) => {
349
340
  scene = threlte_scene
@@ -364,7 +355,6 @@
364
355
  initial_computed_zoom = stored_initial_zoom
365
356
  })
366
357
 
367
- let bond_pairs: BondPair[] = $state([])
368
358
  let atom_tooltip_active = $state(false)
369
359
  let hovered_bond_key = $state<string | null>(null)
370
360
  const ATOM_HOVER_CLEAR_DELAY_MS = 200
@@ -392,20 +382,18 @@
392
382
  }, ATOM_HOVER_CLEAR_DELAY_MS)
393
383
  }
394
384
 
395
- const atom_hover_props = (site_idx: number | null, disabled = false) => ({
396
- onpointerenter: () => {
397
- if (!disabled && site_idx != null) set_atom_hover(site_idx)
398
- },
399
- onpointermove: () => {
400
- if (!disabled && site_idx != null) set_atom_hover(site_idx)
401
- },
402
- onpointerleave: () => {
403
- if (!disabled && site_idx != null) schedule_atom_hover_clear(site_idx)
404
- },
405
- })
385
+ const atom_hover_props = (site_idx: number | null) =>
386
+ !interactive || site_idx == null
387
+ ? {}
388
+ : {
389
+ onpointerenter: () => set_atom_hover(site_idx),
390
+ onpointermove: () => set_atom_hover(site_idx),
391
+ onpointerleave: () => schedule_atom_hover_clear(site_idx),
392
+ }
406
393
 
407
394
  // Cursor style for the canvas, derived from mode and hover state
408
395
  let canvas_cursor = $derived.by(() => {
396
+ if (!interactive) return `default`
409
397
  if (measure_mode === `edit-atoms` && add_atom_mode) return `crosshair`
410
398
  if (measure_mode === `edit-bonds` && hovered_bond_key != null) {
411
399
  return bond_edits_enabled ? `pointer` : `not-allowed`
@@ -419,7 +407,6 @@
419
407
  if (measure_mode === `edit-atoms`) {
420
408
  const site = structure?.sites?.[hovered_idx]
421
409
  if (site?.properties?.orig_site_idx != null) return `not-allowed`
422
- return `pointer`
423
410
  }
424
411
  return `pointer`
425
412
  }
@@ -451,6 +438,22 @@
451
438
  bond_context_target = null
452
439
  }
453
440
 
441
+ // Shared handlers for bond context-menu buttons: act on pointerdown (a click would
442
+ // arrive after orbit-controls' start handler already closed the menu) or Enter/Space
443
+ const menu_action_props = (action: () => void) => {
444
+ const run = (event: Event) => {
445
+ event.preventDefault()
446
+ event.stopPropagation()
447
+ action()
448
+ }
449
+ return {
450
+ onpointerdown: run,
451
+ onkeydown: (event: KeyboardEvent) => {
452
+ if (event.key === `Enter` || event.key === ` `) run(event)
453
+ },
454
+ }
455
+ }
456
+
454
457
  const canonical_bond_target = (bond: BondKeyTarget): BondKeyTarget =>
455
458
  canonicalize_bond_target(bond, structure?.sites)
456
459
 
@@ -474,8 +477,9 @@
474
477
  ): BondKeyTarget {
475
478
  const rendered_target = { site_idx_1, site_idx_2, cell_shift }
476
479
  const rendered_key = rendered_bond_key_for(rendered_target)
477
- return find_added_bond_by_rendered_key(rendered_key) ??
478
- canonical_bond_target(rendered_target)
480
+ return (
481
+ find_added_bond_by_rendered_key(rendered_key) ?? canonical_bond_target(rendered_target)
482
+ )
479
483
  }
480
484
 
481
485
  const is_image_bond_site = (site_idx: number): boolean =>
@@ -486,9 +490,11 @@
486
490
 
487
491
  const can_edit_bond = (bond: BondKeyTarget): boolean => {
488
492
  const target = canonical_bond_target(bond)
489
- return bond_edits_enabled &&
493
+ return (
494
+ bond_edits_enabled &&
490
495
  !is_image_bond_site(target.site_idx_1) &&
491
496
  !is_image_bond_site(target.site_idx_2)
497
+ )
492
498
  }
493
499
 
494
500
  const format_bond_order = (order: BondOrder | undefined): string =>
@@ -500,10 +506,12 @@
500
506
  cell_shift?: Vec3,
501
507
  ): BondOrder | undefined {
502
508
  const key = get_bond_key(site_idx_1, site_idx_2, cell_shift)
503
- return find_added_bond_by_rendered_key(key)?.order ??
509
+ return (
510
+ find_added_bond_by_rendered_key(key)?.order ??
504
511
  bond_order_overrides.find((bond) => matches_bond_key(bond, key))?.order ??
505
512
  added_bonds.find((bond) => matches_bond_key(bond, key))?.order ??
506
513
  filtered_bond_pairs.find((bond) => matches_bond_key(bond, key))?.bond_order
514
+ )
507
515
  }
508
516
 
509
517
  const midpoint = (pos_1: Vec3, pos_2: Vec3): Vec3 => [
@@ -515,20 +523,14 @@
515
523
  const BOND_ENDPOINT_HIT_FRACTION = 0.3
516
524
  const BOND_ENDPOINT_SITE_MATCH_TOLERANCE = 1e-6
517
525
  const EDITABLE_ATOM_HIT_RADIUS_SCALE = 1.15
518
- const skip_raycast = (): void => undefined
519
526
 
520
527
  function apply_bond_transform(mesh: Mesh, bond: BondPair): void {
521
528
  mesh.matrix.fromArray(bond.transform_matrix)
522
529
  mesh.matrixWorldNeedsUpdate = true
523
530
  }
524
531
 
525
- function apply_non_raycastable_bond_hit_transform(mesh: Mesh, bond: BondPair): void {
526
- apply_bond_transform(mesh, bond)
527
- disable_raycast(mesh)
528
- }
529
-
530
532
  function disable_raycast(mesh: Mesh): void {
531
- mesh.raycast = skip_raycast
533
+ mesh.raycast = () => undefined
532
534
  }
533
535
 
534
536
  function site_world_position(parent: Object3D, site: Site): Vector3 {
@@ -553,9 +555,10 @@
553
555
  return site_idx
554
556
  }
555
557
 
556
- const image_site_idx = structure.sites.findIndex((candidate_site) =>
557
- candidate_site.properties?.orig_site_idx === site_idx &&
558
- matches_world_position(candidate_site)
558
+ const image_site_idx = structure.sites.findIndex(
559
+ (candidate_site) =>
560
+ candidate_site.properties?.orig_site_idx === site_idx &&
561
+ matches_world_position(candidate_site),
559
562
  )
560
563
  return image_site_idx === -1 ? site_idx : image_site_idx
561
564
  }
@@ -590,10 +593,7 @@
590
593
 
591
594
  let label_screen_margin = $derived(site_label_size * 10 + site_label_padding)
592
595
 
593
- function get_bond_context_menu_position(
594
- bond: BondPair,
595
- event?: BondContextMenuEvent,
596
- ): Vec3 {
596
+ function get_bond_context_menu_position(bond: BondPair, event?: BondContextMenuEvent): Vec3 {
597
597
  const parent = event?.object?.parent
598
598
  if (!event?.point || !parent) return midpoint(bond.pos_1, bond.pos_2)
599
599
 
@@ -634,8 +634,10 @@
634
634
  ): BondPair | undefined => {
635
635
  const rendered_key = rendered_bond_key_for(target)
636
636
  const canonical_key = bond_key_for(canonical_target)
637
- return filtered_bond_pairs.find((bond) => rendered_bond_key_for(bond) === rendered_key) ??
637
+ return (
638
+ filtered_bond_pairs.find((bond) => rendered_bond_key_for(bond) === rendered_key) ??
638
639
  filtered_bond_pairs.find((bond) => bond_key_for(bond) === canonical_key)
640
+ )
639
641
  }
640
642
 
641
643
  function open_bond_order_menu_for_target(
@@ -647,6 +649,7 @@
647
649
  }
648
650
 
649
651
  function add_or_restore_pair(site_idx_1: number, site_idx_2: number) {
652
+ if (!interactive) return // inactive panes must not mutate shared bond state
650
653
  const rendered_target = { site_idx_1, site_idx_2 }
651
654
  if (!can_edit_bond(rendered_target)) return
652
655
  const edit_state = current_bond_edit_state()
@@ -704,11 +707,7 @@
704
707
  const target = resolve_bond_edit_target(site_idx_1, site_idx_2, cell_shift)
705
708
  if (!can_edit_bond(target)) return
706
709
  apply_bond_edit_result(
707
- apply_delete_bond(
708
- current_bond_edit_state(),
709
- target,
710
- editable_perceived_bond_pairs,
711
- ),
710
+ apply_delete_bond(current_bond_edit_state(), target, editable_perceived_bond_pairs),
712
711
  )
713
712
  }
714
713
 
@@ -722,12 +721,10 @@
722
721
  // intercept the same native click, only the first intersection should fire.
723
722
  // All threlte intersection events from one click share the same nativeEvent ref.
724
723
  let last_native_event: Event | null = null
725
- // extras.Instance does not always emit pointerdown, so edit-bonds also falls
726
- // back to click. When pointerdown did fire, skip the matching click once.
724
+ // Instanced-atom raycasts do not always emit pointerdown, so edit-bonds also
725
+ // falls back to click. When pointerdown did fire, skip the matching click once.
727
726
  let last_edit_bonds_pointerdown_site_idx: number | null = null
728
- let clear_edit_bonds_pointerdown_site_timeout:
729
- | ReturnType<typeof setTimeout>
730
- | null = null
727
+ let clear_edit_bonds_pointerdown_site_timeout: ReturnType<typeof setTimeout> | null = null
731
728
 
732
729
  function remember_edit_bonds_pointerdown_site(site_idx: number) {
733
730
  last_edit_bonds_pointerdown_site_idx = site_idx
@@ -756,30 +753,62 @@
756
753
  return true
757
754
  }
758
755
 
759
- // Pointer props (hover + select) shared by instanced atoms and partial-occupancy
760
- // hit targets. is_edit_image disables interaction for ghosted PBC image atoms.
761
- const atom_pointer_props = (site_idx: number, is_edit_image: boolean) => ({
762
- ...atom_hover_props(site_idx, is_edit_image),
763
- onpointerdown(event: PointerEvent) {
764
- if (is_edit_image || measure_mode !== `edit-bonds` || bond_edit_mode !== `add`) return
765
- select_edit_bonds_site(site_idx, event)
766
- },
767
- onclick(event: MouseEvent) {
768
- if (is_edit_image) return
769
- if (measure_mode === `edit-bonds`) {
770
- if (bond_edit_mode !== `add`) return
771
- if (skip_duplicate_edit_bonds_click(site_idx)) {
772
- event.stopPropagation()
773
- return
774
- }
756
+ // Selection handlers shared by instanced atom meshes and per-site hit targets
757
+ // so the edit-bonds click semantics can't drift between the two paths
758
+ const handle_atom_pointerdown = (site_idx: number, event: PointerEvent) => {
759
+ if (measure_mode !== `edit-bonds` || bond_edit_mode !== `add`) return
760
+ select_edit_bonds_site(site_idx, event)
761
+ }
762
+ const handle_atom_click = (site_idx: number, event: MouseEvent) => {
763
+ if (measure_mode === `edit-bonds`) {
764
+ if (bond_edit_mode !== `add`) return
765
+ if (skip_duplicate_edit_bonds_click(site_idx)) {
766
+ event.stopPropagation()
767
+ return
775
768
  }
776
- toggle_selection(site_idx, event)
777
- },
778
- })
769
+ }
770
+ toggle_selection(site_idx, event)
771
+ }
772
+
773
+ // Pointer handlers for an instanced atom mesh: intersection events carry the
774
+ // hit `instanceId`, which indexes into the mesh's `atoms` array. One handler
775
+ // set per mesh instead of one per atom. Inactive grid panes render without
776
+ // raycast handlers; ghosted edit-mode image atoms are non-interactive.
777
+ type InstanceEvent = { instanceId?: number }
778
+ const atom_instance_events = (
779
+ instance_atoms: { site_idx: number }[],
780
+ is_edit_image: boolean,
781
+ ) => {
782
+ if (!interactive || is_edit_image) return {}
783
+ const wrap =
784
+ <Event_ extends InstanceEvent>(handler: (site_idx: number, event: Event_) => void) =>
785
+ (event: Event_) => {
786
+ const site_idx = instance_atoms[event.instanceId ?? -1]?.site_idx
787
+ if (site_idx != null) handler(site_idx, event)
788
+ }
789
+ return {
790
+ onpointerenter: wrap(set_atom_hover),
791
+ onpointermove: wrap(set_atom_hover),
792
+ onpointerleave: wrap(schedule_atom_hover_clear),
793
+ onpointerdown: wrap<PointerEvent & InstanceEvent>(handle_atom_pointerdown),
794
+ onclick: wrap<MouseEvent & InstanceEvent>(handle_atom_click),
795
+ }
796
+ }
797
+
798
+ // Pointer props (hover + select) for per-site hit-target meshes (partial-occupancy
799
+ // sites), with the same interactivity gating as atom_instance_events
800
+ const atom_pointer_props = (site_idx: number, is_edit_image: boolean) =>
801
+ !interactive || is_edit_image
802
+ ? {}
803
+ : {
804
+ ...atom_hover_props(site_idx),
805
+ onpointerdown: (event: PointerEvent) => handle_atom_pointerdown(site_idx, event),
806
+ onclick: (event: MouseEvent) => handle_atom_click(site_idx, event),
807
+ }
779
808
 
780
809
  function toggle_selection(site_index: number, evt?: Event) {
781
810
  evt?.stopPropagation?.()
782
- const event_with_native = evt as Event & { nativeEvent?: unknown } | undefined
811
+ const event_with_native = evt as (Event & { nativeEvent?: unknown }) | undefined
783
812
  const native_event = event_with_native?.nativeEvent ?? evt
784
813
  if (native_event instanceof Event) {
785
814
  if (native_event === last_native_event) return
@@ -787,6 +816,9 @@
787
816
  }
788
817
 
789
818
  if (measure_mode === `edit-bonds`) {
819
+ // Only the active pane edits: inactive panes keep selection/hover overlays visible
820
+ // but must not change selection or add/restore bonds in shared state.
821
+ if (!interactive) return
790
822
  if (bond_edit_mode === `delete`) {
791
823
  measured_sites = []
792
824
  selected_sites = []
@@ -811,12 +843,16 @@
811
843
  }
812
844
 
813
845
  if (measure_mode === `edit-atoms`) {
846
+ // Inactive panes don't drive edit-atoms selection (gizmo/add-plane are interactive-gated)
847
+ if (!interactive) return
814
848
  // Block image atoms (detected by orig_site_idx property from PBC)
815
849
  const site = structure?.sites?.[site_index]
816
850
  if (site?.properties?.orig_site_idx != null) return
817
851
 
818
852
  const is_selected = selected_sites.includes(site_index)
819
- const is_shift = evt instanceof MouseEvent && evt.shiftKey
853
+ // threlte dispatches plain objects wrapping the DOM event, so read shift
854
+ // from the extracted native event (evt itself is never a MouseEvent for 3D hits)
855
+ const is_shift = native_event instanceof MouseEvent && native_event.shiftKey
820
856
 
821
857
  // In edit-atoms mode, selected_sites and measured_sites always stay in sync
822
858
  let new_sites: number[]
@@ -866,11 +902,15 @@
866
902
  $effect(() => {
867
903
  const count = structure?.sites?.length ?? 0
868
904
  if (count <= 0) {
869
- measured_sites = []
905
+ if (untrack(() => measured_sites.length) > 0) measured_sites = []
870
906
  return
871
907
  }
872
908
  untrack(() => {
873
- measured_sites = measured_sites.filter((idx) => idx >= 0 && idx < count)
909
+ // Only reassign when out-of-range indices were dropped: a fresh (equal)
910
+ // array identity on every structure change would ripple through all
911
+ // measured_sites bindings and their dependents each frame of a trajectory.
912
+ const filtered = measured_sites.filter((idx) => idx >= 0 && idx < count)
913
+ if (filtered.length !== measured_sites.length) measured_sites = filtered
874
914
  })
875
915
  })
876
916
 
@@ -882,9 +922,7 @@
882
922
  $effect.pre(() => {
883
923
  hovered_site = structure?.sites?.[hovered_idx ?? -1] ?? null
884
924
  })
885
- let lattice = $derived(
886
- structure && `lattice` in structure ? structure.lattice : null,
887
- )
925
+ let lattice = $derived(structure && `lattice` in structure ? structure.lattice : null)
888
926
 
889
927
  let visual_lattice = $derived(
890
928
  base_structure && `lattice` in base_structure ? base_structure.lattice : lattice,
@@ -894,9 +932,11 @@
894
932
  lattice
895
933
  ? math.scale(math.add(...lattice.matrix), 0.5)
896
934
  : structure
897
- ? get_center_of_mass(structure)
898
- : [0, 0, 0] as Vec3,
935
+ ? get_center_of_mass(structure)
936
+ : ([0, 0, 0] as Vec3),
899
937
  )
938
+ // Negated target for the inner un-translate group (recomputed only on target change)
939
+ let neg_rotation_target = $derived(math.scale(rotation_target, -1) as Vec3)
900
940
 
901
941
  let structure_size = $derived.by(() => {
902
942
  if (lattice) return (lattice.a + lattice.b + lattice.c) / 2
@@ -913,8 +953,8 @@
913
953
  // Excludes PBC image atoms (orig_site_idx) so toggling image atoms doesn't affect arrow sizing.
914
954
  let char_atom_spacing = $derived.by(() => {
915
955
  if (!lattice || !structure?.sites?.length) return structure_size
916
- const n_real = structure.sites.filter((site) =>
917
- site.properties?.orig_site_idx == null
956
+ const n_real = structure.sites.filter(
957
+ (site) => site.properties?.orig_site_idx == null,
918
958
  ).length
919
959
  return n_real > 0 ? Math.cbrt(lattice.volume / n_real) : structure_size
920
960
  })
@@ -945,9 +985,19 @@
945
985
 
946
986
  // Using $state because this is mutated in an effect based on viewport/structure size
947
987
  let computed_zoom = $state(untrack(() => initial_zoom))
988
+ // structure_size is read untracked so structure changes don't re-zoom the user's view;
989
+ // zoom only re-frames on a genuine viewport resize. Skip same-value width/height
990
+ // re-fires (a wrapping component can transiently re-emit clientWidth/Height during a
991
+ // structure swap) so they don't sneak in a re-zoom with the new structure_size.
992
+ let last_zoom_dims: [number, number] = [0, 0]
948
993
  $effect(() => {
949
994
  if (!(width > 0) || !(height > 0)) return
950
- const structure_max_dim = Math.max(1, untrack(() => structure_size))
995
+ if (width === last_zoom_dims[0] && height === last_zoom_dims[1]) return
996
+ last_zoom_dims = [width, height]
997
+ const structure_max_dim = Math.max(
998
+ 1,
999
+ untrack(() => structure_size),
1000
+ )
951
1001
  const viewer_min_dim = Math.min(width, height)
952
1002
  const scale_factor = viewer_min_dim / (structure_max_dim * 50) // 50px per unit
953
1003
  let new_zoom = initial_zoom * scale_factor
@@ -956,11 +1006,23 @@
956
1006
  computed_zoom = new_zoom
957
1007
  })
958
1008
 
959
- $effect.pre(() => { // Simple initial camera auto-position: proportional to structure size and fov
1009
+ $effect.pre(() => {
1010
+ // Simple initial camera auto-position: proportional to structure size and fov
960
1011
  if (camera_position.every((val) => val === 0) && structure) {
961
1012
  stored_initial_zoom = undefined
962
1013
  const distance = Math.max(1, structure_size) * (60 / fov)
963
- camera_position = [distance, distance * 0.3, distance * 0.8]
1014
+ // When a view direction is given (multi-side view), place the camera
1015
+ // target-relative along it; otherwise use the default angled position.
1016
+ // normalize_vec returns [0,0,0] for an absent or zero-length direction (arrays are
1017
+ // truthy, so a plain `camera_direction ?` check would miss [0,0,0]); treat that as
1018
+ // "no direction" and fall back to the default, since placing the camera on the
1019
+ // rotation target would be a degenerate zero-length view.
1020
+ const view_dir: Vec3 = camera_direction
1021
+ ? math.normalize_vec(camera_direction, [0, 0, 0])
1022
+ : [0, 0, 0]
1023
+ camera_position = view_dir.some((val) => val !== 0)
1024
+ ? math.add(rotation_target, math.scale(view_dir, distance))
1025
+ : [distance, distance * 0.3, distance * 0.8]
964
1026
  }
965
1027
  })
966
1028
  // Whether a never|always|crystals|molecules setting applies to the current structure
@@ -984,23 +1046,29 @@
984
1046
  )
985
1047
  // Calculated bonds are hidden in declutter mode (only their cylinders — bond_pairs
986
1048
  // stay computed so tooltips and manually added bonds keep working)
987
- const effective_show_bonds: ShowBonds = $derived(
988
- declutter_active ? `never` : show_bonds,
989
- )
1049
+ const effective_show_bonds: ShowBonds = $derived(declutter_active ? `never` : show_bonds)
990
1050
  const effective_atom_radius = $derived(declutter_active ? atom_radius * 0.6 : atom_radius)
991
1051
 
992
- $effect(() => {
993
- // Bonds are computed when either bond rendering or polyhedra need them. The
994
- // raw/effective mix is deliberate: RAW show_bonds keeps bond_pairs available
995
- // during symmetry declutter (cylinders hide via effective_show_bonds in
996
- // bonds_to_render, but tooltips + manually added bonds still need the data),
997
- // while EFFECTIVE show_polyhedra skips computing bonds whose only consumer —
998
- // the polyhedra $derived below, gated on the same effective value — won't run.
1052
+ // Derived (not effect + state) so downstream consumers (filtering, polyhedra,
1053
+ // instanced bond buffers) recompute exactly once per structure change instead
1054
+ // of once with stale bonds and again after an effect flush. The mutable cache
1055
+ // lets atom drags freeze the last computed value (recompute happens on release).
1056
+ // Bonds are computed when either bond rendering or polyhedra need them. The
1057
+ // raw/effective mix is deliberate: RAW show_bonds keeps bond_pairs available
1058
+ // during symmetry declutter (cylinders hide via effective_show_bonds in
1059
+ // bonds_to_render, but tooltips + manually added bonds still need the data),
1060
+ // while EFFECTIVE show_polyhedra skips computing bonds whose only consumer —
1061
+ // the polyhedra $derived below, gated on the same effective value — won't run.
1062
+ let last_bond_pairs: BondPair[] = []
1063
+ let bond_pairs: BondPair[] = $derived.by(() => {
1064
+ if (dragging_atoms) return last_bond_pairs
999
1065
  const want_bonds = applies_to_structure(show_bonds)
1000
1066
  const want_polyhedra = applies_to_structure(effective_show_polyhedra)
1001
- if (structure && (want_bonds || want_polyhedra)) {
1002
- bond_pairs = BONDING_STRATEGIES[bonding_strategy](structure, bonding_options)
1003
- } else bond_pairs = []
1067
+ last_bond_pairs =
1068
+ structure && (want_bonds || want_polyhedra)
1069
+ ? compute_bonds(structure, bonding_strategy, bonding_options)
1070
+ : []
1071
+ return last_bond_pairs
1004
1072
  })
1005
1073
 
1006
1074
  // Compute property-based colors when not using element coloring
@@ -1053,21 +1121,22 @@
1053
1121
  site.properties?.completion_image &&
1054
1122
  !applies_to_structure(effective_show_bonds) &&
1055
1123
  !applies_to_structure(effective_show_polyhedra)
1056
- ) return []
1124
+ )
1125
+ return []
1057
1126
 
1058
1127
  // Calculate radius: same_size > site override > element override > default
1059
1128
  // All radii scale uniformly with atom_radius for consistent slider behavior
1060
1129
  const base_radius = same_size_atoms
1061
1130
  ? 1
1062
- : site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site)
1131
+ : (site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site))
1063
1132
  const radius = base_radius * effective_atom_radius
1064
1133
 
1065
1134
  // Use property color if available (e.g. coordination number, Wyckoff position)
1066
1135
  // Otherwise, each species gets its own element color (important for disordered sites)
1067
1136
  const site_property_color = property_colors?.colors[orig_idx]
1068
1137
 
1069
- const visible_species = site.species.filter(({ element }) =>
1070
- !hidden_elements.has(element)
1138
+ const visible_species = site.species.filter(
1139
+ ({ element }) => !hidden_elements.has(element),
1071
1140
  )
1072
1141
  const slice_geometry = compute_slice_geometry(visible_species)
1073
1142
  return slice_geometry.map((slice_data) => {
@@ -1095,13 +1164,12 @@
1095
1164
  const is_site_visible = (site_idx: number): boolean => {
1096
1165
  if (!structure?.sites) return false
1097
1166
  const site = structure.sites[site_idx]
1098
- const has_visible_element = site?.species.some(({ element }) =>
1099
- !hidden_elements.has(element)
1167
+ const has_visible_element = site?.species.some(
1168
+ ({ element }) => !hidden_elements.has(element),
1100
1169
  )
1101
1170
  const orig_idx = get_orig_site_idx(site, site_idx)
1102
1171
  const prop_val = property_colors?.values[orig_idx]
1103
- const prop_visible = prop_val === undefined ||
1104
- !hidden_prop_vals.has(prop_val)
1172
+ const prop_visible = prop_val === undefined || !hidden_prop_vals.has(prop_val)
1105
1173
  return has_visible_element && prop_visible
1106
1174
  }
1107
1175
 
@@ -1127,19 +1195,17 @@
1127
1195
  })
1128
1196
 
1129
1197
  let editable_perceived_bond_pairs = $derived(
1130
- perceived_bond_pairs.map((bond) => ({ ...bond, ...canonical_bond_target(bond) })),
1198
+ interactive && bond_edits_enabled
1199
+ ? perceived_bond_pairs.map((bond) => ({ ...bond, ...canonical_bond_target(bond) }))
1200
+ : [],
1131
1201
  )
1132
1202
 
1133
1203
  let filtered_bond_pairs = $derived.by(() => {
1134
1204
  if (!structure?.sites) return perceived_bond_pairs
1135
1205
 
1136
1206
  // Build set of removed bond keys for efficient lookup
1137
- const removed_keys = new Set(
1138
- removed_bonds.map(bond_key_for),
1139
- )
1140
- const added_keys = new Set(
1141
- added_bonds.map(bond_key_for),
1142
- )
1207
+ const removed_keys = new Set(removed_bonds.map(bond_key_for))
1208
+ const added_keys = new Set(added_bonds.map(bond_key_for))
1143
1209
  const order_overrides = new Map(
1144
1210
  bond_order_overrides.map((bond) => [bond_key_for(bond), bond.order]),
1145
1211
  )
@@ -1177,7 +1243,7 @@
1177
1243
  })
1178
1244
 
1179
1245
  let editable_bond_pairs = $derived(
1180
- bond_edits_enabled ? bonds_to_render.filter(can_edit_bond) : [],
1246
+ interactive && bond_edits_enabled ? bonds_to_render.filter(can_edit_bond) : [],
1181
1247
  )
1182
1248
 
1183
1249
  // Coordination polyhedra around cation-like centers, derived from the same
@@ -1186,10 +1252,12 @@
1186
1252
  // never recompute the hull geometry.
1187
1253
  let polyhedra: Polyhedron[] = $derived.by(() => {
1188
1254
  if (
1189
- !structure?.sites || dragging_atoms ||
1255
+ !structure?.sites ||
1256
+ dragging_atoms ||
1190
1257
  !applies_to_structure(effective_show_polyhedra) ||
1191
1258
  filtered_bond_pairs.length === 0
1192
- ) return []
1259
+ )
1260
+ return []
1193
1261
  return compute_polyhedra(structure, filtered_bond_pairs, {
1194
1262
  min_neighbors: polyhedra_min_neighbors,
1195
1263
  max_neighbors: polyhedra_max_neighbors,
@@ -1203,8 +1271,9 @@
1203
1271
  const site = structure?.sites[site_idx]
1204
1272
  const orig_idx = get_orig_site_idx(site, site_idx)
1205
1273
  const element = get_majority_element(site)
1206
- return property_colors?.colors[orig_idx] ??
1207
- (element && colors.element?.[element]) ?? `#808080`
1274
+ return (
1275
+ property_colors?.colors[orig_idx] ?? (element && colors.element?.[element]) ?? `#808080`
1276
+ )
1208
1277
  }
1209
1278
 
1210
1279
  // Separate derived so material-only changes (opacity, edge color) don't rebuild
@@ -1229,6 +1298,7 @@
1229
1298
  // Publish which elements currently anchor polyhedra (consumed by controls so
1230
1299
  // per-element toggles reflect the actual render state incl. spectator hiding)
1231
1300
  $effect(() => {
1301
+ if (!interactive) return
1232
1302
  const elems = [...new Set(polyhedra.map((poly) => poly.center_element))].sort()
1233
1303
  if (elems.join(`,`) !== polyhedra_rendered_elements.join(`,`)) {
1234
1304
  polyhedra_rendered_elements = elems
@@ -1257,23 +1327,23 @@
1257
1327
 
1258
1328
  let polyhedra_edge_geometry: BufferGeometry | null = $state(null)
1259
1329
  $effect(() => {
1260
- const geo = polyhedra_show_edges && polyhedra_buffers && polyhedra_buffers.edge_count > 0
1261
- ? buffer_geometry({ position: polyhedra_buffers.edge_positions })
1262
- : null
1330
+ const geo =
1331
+ polyhedra_show_edges && polyhedra_buffers && polyhedra_buffers.edge_count > 0
1332
+ ? buffer_geometry({ position: polyhedra_buffers.edge_positions })
1333
+ : null
1263
1334
  polyhedra_edge_geometry = geo
1264
1335
  return () => geo?.dispose()
1265
1336
  })
1266
1337
 
1267
1338
  let smart_site_label_offsets = $derived.by(() => {
1268
- const offsets = new SvelteMap<number, Vec3>()
1339
+ // Plain Maps: built and consumed inside deriveds, so per-entry reactivity
1340
+ // (SvelteMap) would only add signal overhead for potentially thousands of sites
1341
+ const offsets = new Map<number, Vec3>()
1269
1342
  if (bonds_to_render.length === 0) return offsets
1270
1343
 
1271
- const bond_directions_by_site = new SvelteMap<number, Vec3[]>()
1344
+ const bond_directions_by_site = new Map<number, Vec3[]>()
1272
1345
  const add_bond_direction = (site_idx: number, pos_1: Vec3, pos_2: Vec3) => {
1273
- const direction = math.normalize_vec(
1274
- math.subtract(pos_2, pos_1),
1275
- [0, 0, 0],
1276
- )
1346
+ const direction = math.normalize_vec(math.subtract(pos_2, pos_1), [0, 0, 0])
1277
1347
  if (Math.hypot(...direction) < LABEL_OFFSET_EPS) return
1278
1348
  bond_directions_by_site.set(site_idx, [
1279
1349
  ...(bond_directions_by_site.get(site_idx) ?? []),
@@ -1312,7 +1382,7 @@
1312
1382
  const get_majority_color = (site: typeof site_a) => {
1313
1383
  if (!site?.species || site.species.length === 0) return bond_color
1314
1384
  const majority_species = site.species.reduce((max, spec) =>
1315
- spec.occu > max.occu ? spec : max
1385
+ spec.occu > max.occu ? spec : max,
1316
1386
  )
1317
1387
  return colors.element?.[majority_species.element] || bond_color
1318
1388
  }
@@ -1328,7 +1398,7 @@
1328
1398
  })
1329
1399
 
1330
1400
  let radius_by_site_idx = $derived.by(() => {
1331
- const map = new SvelteMap<number, number>()
1401
+ const map = new Map<number, number>()
1332
1402
  for (const atom of atom_data) {
1333
1403
  if (!map.has(atom.site_idx)) map.set(atom.site_idx, atom.radius)
1334
1404
  }
@@ -1340,7 +1410,8 @@
1340
1410
  // angles. Give each such site one invisible full-sphere hit target so it's as
1341
1411
  // reliably hoverable as an ordered atom (single solid sphere). One per site.
1342
1412
  let partial_hit_targets = $derived.by(() => {
1343
- const targets = new SvelteMap<number, EditableAtomHitTarget & { is_image_atom: boolean }>()
1413
+ if (!interactive) return []
1414
+ const targets = new Map<number, EditableAtomHitTarget & { is_image_atom: boolean }>()
1344
1415
  for (const atom of atom_data) {
1345
1416
  if (!atom.has_partial_occupancy || targets.has(atom.site_idx)) continue
1346
1417
  targets.set(atom.site_idx, {
@@ -1355,6 +1426,7 @@
1355
1426
 
1356
1427
  let editable_atom_hit_targets = $derived.by(() => {
1357
1428
  if (
1429
+ !interactive ||
1358
1430
  measure_mode !== `edit-bonds` ||
1359
1431
  bond_edit_mode !== `add` ||
1360
1432
  !bond_edits_enabled
@@ -1362,7 +1434,7 @@
1362
1434
  return []
1363
1435
  }
1364
1436
 
1365
- const targets = new SvelteMap<number, EditableAtomHitTarget>()
1437
+ const targets = new Map<number, EditableAtomHitTarget>()
1366
1438
  for (const atom of atom_data) {
1367
1439
  if (!can_select_bond_site(atom.site_idx)) continue
1368
1440
  if (targets.has(atom.site_idx)) continue
@@ -1378,13 +1450,47 @@
1378
1450
  // Get radius for a site (for highlight fallback when site is hidden/filtered)
1379
1451
  // Checks site_radius_overrides first for consistency with visible atoms
1380
1452
  const get_site_radius = (site: Site, site_idx: number | null): number => {
1381
- const override = site_idx !== null
1382
- ? site_radius_overrides?.get(site_idx)
1383
- : undefined
1384
- const base_radius = same_size_atoms ? 1 : override ?? calc_weighted_radius(site)
1453
+ const override = site_idx !== null ? site_radius_overrides?.get(site_idx) : undefined
1454
+ const base_radius = same_size_atoms ? 1 : (override ?? calc_weighted_radius(site))
1385
1455
  return base_radius * effective_atom_radius
1386
1456
  }
1387
1457
 
1458
+ // Sites to outline with a translucent sphere: hovered + all selected/active sites. Kept
1459
+ // independent of the pulse animation so this list (with its per-site radius lookups) only
1460
+ // rebuilds when highlighted sites change; pulsing opacity is applied per-frame in the
1461
+ // template instead, avoiding a rebuild every frame in every multi-view pane.
1462
+ type HighlightTarget = {
1463
+ kind: `hover` | `selected` | `active`
1464
+ site: Site
1465
+ site_idx: number | null
1466
+ color: string
1467
+ radius: number
1468
+ }
1469
+ let highlight_targets: HighlightTarget[] = $derived.by(() => {
1470
+ const targets: HighlightTarget[] = []
1471
+ const add = (
1472
+ kind: HighlightTarget[`kind`],
1473
+ site: Site | null,
1474
+ site_idx: number | null,
1475
+ color: string,
1476
+ ) => {
1477
+ if (!site) return
1478
+ const radius =
1479
+ site_idx !== null
1480
+ ? (radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx))
1481
+ : get_site_radius(site, site_idx)
1482
+ targets.push({ kind, site, site_idx, color, radius })
1483
+ }
1484
+ add(`hover`, hovered_site, hovered_idx, dark_mode ? `white` : `#333`)
1485
+ for (const idx of selected_sites ?? []) {
1486
+ add(`selected`, structure?.sites?.[idx] ?? null, idx, selection_highlight_color)
1487
+ }
1488
+ for (const idx of active_sites ?? []) {
1489
+ add(`active`, structure?.sites?.[idx] ?? null, idx, active_highlight_color)
1490
+ }
1491
+ return targets
1492
+ })
1493
+
1388
1494
  // Interpolate between spin-down (#3498db blue) and spin-up (#e74c3c red)
1389
1495
  // based on the z-component direction of a magnetic vector
1390
1496
  function spin_direction_color(vec: Vec3): string {
@@ -1393,9 +1499,9 @@
1393
1499
  const red = Math.round(52 + (231 - 52) * z_frac)
1394
1500
  const grn = Math.round(152 + (76 - 152) * z_frac)
1395
1501
  const blu = Math.round(219 + (60 - 219) * z_frac)
1396
- return `#${red.toString(16).padStart(2, `0`)}${
1397
- grn.toString(16).padStart(2, `0`)
1398
- }${blu.toString(16).padStart(2, `0`)}`
1502
+ return `#${red.toString(16).padStart(2, `0`)}${grn
1503
+ .toString(16)
1504
+ .padStart(2, `0`)}${blu.toString(16).padStart(2, `0`)}`
1399
1505
  }
1400
1506
 
1401
1507
  // Build one arrow layer per visible vector key. Auto-scales the longest
@@ -1415,7 +1521,7 @@
1415
1521
  let max_mag = 0
1416
1522
  const site_vec_maps = structure.sites.map((site, site_idx) => {
1417
1523
  if (!is_site_visible(site_idx)) return null
1418
- const map = new SvelteMap<string, Vec3>()
1524
+ const map = new Map<string, Vec3>()
1419
1525
  for (const { key, vec } of get_all_site_vectors(site)) {
1420
1526
  map.set(key, vec)
1421
1527
  if (active_set.has(key)) {
@@ -1427,9 +1533,7 @@
1427
1533
 
1428
1534
  // When normalize is on, treat all magnitudes as 1 so arrows have equal length
1429
1535
  const effective_max = vector_normalize ? 1 : max_mag
1430
- const auto_scale = effective_max > 1e-10
1431
- ? (char_atom_spacing * 1.8) / effective_max
1432
- : 1
1536
+ const auto_scale = effective_max > 1e-10 ? (char_atom_spacing * 1.8) / effective_max : 1
1433
1537
  const is_single = active_keys.length === 1
1434
1538
  const effective_global_scale = auto_scale * vector_scale
1435
1539
 
@@ -1439,40 +1543,40 @@
1439
1543
  // of the visual atom radius (0 = center, 0.5 = halfway to surface).
1440
1544
  // get_site_radius() returns the uniform scale applied to SphereGeometry(0.5),
1441
1545
  // so visual_radius = get_site_radius() * 0.5.
1442
- const site_offsets = (vector_origin_gap > 0 && !is_single)
1443
- ? structure.sites.map((site, site_idx) => {
1444
- const vec_map = site_vec_maps[site_idx]
1445
- if (!vec_map) return null
1446
- const site_keys = active_keys.filter((key) => vec_map.has(key))
1447
- const n_keys = site_keys.length
1448
- if (n_keys <= 1) return null
1449
- const visual_radius = get_site_radius(site, site_idx) * 0.5
1450
- const gap_abs = vector_origin_gap * visual_radius
1451
- let mean: Vec3 = [0, 0, 0]
1452
- for (const key of site_keys) {
1453
- const vec = vec_map.get(key)
1454
- if (vec) mean = math.add(mean, math.normalize_vec(vec)) as Vec3
1455
- }
1456
- const mean_dir = math.normalize_vec(mean, [0, 1, 0] as Vec3)
1457
- const [u_vec, v_vec] = math.compute_in_plane_basis(mean_dir)
1458
- const offsets = new SvelteMap<string, Vec3>()
1459
- for (const [idx, key] of site_keys.entries()) {
1460
- const angle = (2 * Math.PI * idx) / n_keys
1461
- const dx = math.scale(u_vec, gap_abs * Math.cos(angle))
1462
- const dy = math.scale(v_vec, gap_abs * Math.sin(angle))
1463
- offsets.set(key, math.add(dx, dy))
1464
- }
1465
- return offsets
1466
- })
1467
- : null
1546
+ const site_offsets =
1547
+ vector_origin_gap > 0 && !is_single
1548
+ ? structure.sites.map((site, site_idx) => {
1549
+ const vec_map = site_vec_maps[site_idx]
1550
+ if (!vec_map) return null
1551
+ const site_keys = active_keys.filter((key) => vec_map.has(key))
1552
+ const n_keys = site_keys.length
1553
+ if (n_keys <= 1) return null
1554
+ const visual_radius = get_site_radius(site, site_idx) * 0.5
1555
+ const gap_abs = vector_origin_gap * visual_radius
1556
+ let mean: Vec3 = [0, 0, 0]
1557
+ for (const key of site_keys) {
1558
+ const vec = vec_map.get(key)
1559
+ if (vec) mean = math.add(mean, math.normalize_vec(vec)) as Vec3
1560
+ }
1561
+ const mean_dir = math.normalize_vec(mean, [0, 1, 0] as Vec3)
1562
+ const [u_vec, v_vec] = math.compute_in_plane_basis(mean_dir)
1563
+ const offsets = new Map<string, Vec3>()
1564
+ for (const [idx, key] of site_keys.entries()) {
1565
+ const angle = (2 * Math.PI * idx) / n_keys
1566
+ const dx = math.scale(u_vec, gap_abs * Math.cos(angle))
1567
+ const dy = math.scale(v_vec, gap_abs * Math.sin(angle))
1568
+ offsets.set(key, math.add(dx, dy))
1569
+ }
1570
+ return offsets
1571
+ })
1572
+ : null
1468
1573
 
1469
1574
  const mag_interpolator = get_d3_interpolator(vector_color_scale)
1470
1575
 
1471
1576
  return active_keys.map((key, layer_idx) => {
1472
1577
  const layer_cfg = vector_configs[key]
1473
1578
  const layer_scale = effective_global_scale * (layer_cfg?.scale ?? 1.0)
1474
- const layer_color = layer_cfg?.color ??
1475
- VECTOR_PALETTE[layer_idx % VECTOR_PALETTE.length]
1579
+ const layer_color = layer_cfg?.color ?? VECTOR_PALETTE[layer_idx % VECTOR_PALETTE.length]
1476
1580
 
1477
1581
  const arrows = structure.sites
1478
1582
  .map((site, site_idx) => {
@@ -1488,11 +1592,12 @@
1488
1592
  arrow_color = layer_cfg.color
1489
1593
  } else if (!is_single) arrow_color = layer_color
1490
1594
  else {
1491
- const effective_mode = vector_color_mode === `auto`
1492
- ? (key.startsWith(`magmom`) || key.startsWith(`spin`)
1493
- ? `spin_direction`
1494
- : `element`)
1495
- : vector_color_mode
1595
+ const effective_mode =
1596
+ vector_color_mode === `auto`
1597
+ ? key.startsWith(`magmom`) || key.startsWith(`spin`)
1598
+ ? `spin_direction`
1599
+ : `element`
1600
+ : vector_color_mode
1496
1601
  if (effective_mode === `magnitude`) {
1497
1602
  const mag = Math.hypot(...vec)
1498
1603
  const norm = max_mag > 1e-10 ? mag / max_mag : 0
@@ -1502,14 +1607,13 @@
1502
1607
  } else if (effective_mode === `uniform`) {
1503
1608
  arrow_color = vector_color
1504
1609
  } else {
1505
- const majority_element = site.species.length > 0
1506
- ? site.species.reduce((max, spec) =>
1507
- spec.occu > max.occu ? spec : max
1508
- ).element
1509
- : undefined
1610
+ const majority_element =
1611
+ site.species.length > 0
1612
+ ? site.species.reduce((max, spec) => (spec.occu > max.occu ? spec : max))
1613
+ .element
1614
+ : undefined
1510
1615
  arrow_color =
1511
- (majority_element && colors.element?.[majority_element]) ||
1512
- vector_color
1616
+ (majority_element && colors.element?.[majority_element]) || vector_color
1513
1617
  }
1514
1618
  }
1515
1619
 
@@ -1531,61 +1635,68 @@
1531
1635
  })
1532
1636
  })
1533
1637
 
1534
- let instanced_atom_groups = $derived(
1535
- Object.values(
1536
- atom_data
1537
- .filter((atom) => !atom.has_partial_occupancy)
1538
- .reduce(
1539
- (groups, atom) => {
1540
- const { element, radius, color, is_image_atom } = atom
1541
- // Separate image atoms into their own groups for distinct styling in edit-atoms mode
1542
- const key = `${element}-${format_num(radius, `.3~`)}-${color}-${
1543
- is_image_atom ? `img` : `base`
1544
- }`
1545
- const bucket = groups[key] ||
1546
- (groups[key] = { element, radius, color, is_image_atom, atoms: [] })
1547
- bucket.atoms.push(atom)
1548
- return groups
1549
- },
1550
- {} as Record<string, InstancedAtomGroup>,
1551
- ),
1552
- ),
1553
- )
1638
+ // Full-occupancy atoms split into base and PBC-image sets. Each set renders
1639
+ // as ONE InstancedMesh (per-atom color/radius live in instance buffers), so
1640
+ // no per-element grouping is needed. Image atoms get their own mesh because
1641
+ // they ghost (desaturate + translucent) and lose interactivity in edit-atoms mode.
1642
+ let instanced_atom_sets = $derived.by(() => {
1643
+ const base: typeof atom_data = []
1644
+ const image: typeof atom_data = []
1645
+ for (const atom of atom_data) {
1646
+ if (!atom.has_partial_occupancy) (atom.is_image_atom ? image : base).push(atom)
1647
+ }
1648
+ return { base, image }
1649
+ })
1554
1650
 
1555
- let unique_instanced_atoms = $derived(
1556
- Object.values(
1557
- instanced_atom_groups
1558
- .flatMap((group) => group.atoms)
1559
- .reduce((acc, atom) => {
1560
- acc[atom.site_idx] = atom
1561
- return acc
1562
- }, {} as Record<number, (typeof atom_data)[number]>),
1563
- ),
1651
+ // One label anchor per visible site (sites can contribute several atom_data
1652
+ // entries, e.g. partial-occupancy wedges — label each site once)
1653
+ let label_entries = $derived.by(() => {
1654
+ if (!show_site_labels && !show_site_indices) return []
1655
+ const seen = new Set<number>()
1656
+ const entries: { site_idx: number; position: Vec3; radius: number }[] = []
1657
+ for (const atom of atom_data) {
1658
+ if (seen.has(atom.site_idx)) continue
1659
+ seen.add(atom.site_idx)
1660
+ entries.push({
1661
+ site_idx: atom.site_idx,
1662
+ position: atom.position,
1663
+ radius: atom.radius,
1664
+ })
1665
+ }
1666
+ return entries
1667
+ })
1668
+
1669
+ // Partial-occupancy atoms render as separate wedge meshes (see template below).
1670
+ // Derived so the filter isn't re-run inline on every render of the atoms group.
1671
+ let partial_occupancy_atoms = $derived(
1672
+ atom_data.filter((atom) => atom.has_partial_occupancy),
1564
1673
  )
1565
1674
 
1566
- let orbit_controls_props = $derived(build_orbit_props({
1567
- camera_projection,
1568
- target: camera_target ?? rotation_target,
1569
- rotate_speed,
1570
- zoom_speed,
1571
- zoom_to_cursor,
1572
- pan_speed,
1573
- max_zoom,
1574
- min_zoom,
1575
- auto_rotate,
1576
- rotation_damping,
1577
- set_camera_is_moving: (moving) => (camera_is_moving = moving),
1578
- // Close hover tooltips + bond context menu while the camera moves. Only hide the
1579
- // VISIBLE menu (not bond_context_target): clicking a menu button fires this
1580
- // orbit-controls start handler before the button's own handler runs, which still
1581
- // needs the target bond to apply the edit (see bond_context_target comment).
1582
- onstart_extra: () => {
1583
- cancel_atom_hover_clear()
1584
- hovered_idx = null
1585
- hovered_bond_key = null
1586
- bond_context_menu = null
1587
- },
1588
- }))
1675
+ let orbit_controls_props = $derived(
1676
+ build_orbit_props({
1677
+ camera_projection,
1678
+ target: camera_target ?? rotation_target,
1679
+ rotate_speed,
1680
+ zoom_speed,
1681
+ zoom_to_cursor,
1682
+ pan_speed,
1683
+ max_zoom,
1684
+ min_zoom,
1685
+ auto_rotate,
1686
+ rotation_damping,
1687
+ set_camera_is_moving: (moving) => (camera_is_moving = moving),
1688
+ // Close hover tooltips + bond context menu while the camera moves. Only hide the
1689
+ // VISIBLE menu (not bond_context_target): clicking a menu button fires this
1690
+ // orbit-controls start handler before the button's own handler runs, which still
1691
+ // needs the target bond to apply the edit (see bond_context_target comment).
1692
+ onstart_extra: () => {
1693
+ cancel_atom_hover_clear()
1694
+ hovered_idx = null
1695
+ hovered_bond_key = null
1696
+ bond_context_menu = null
1697
+ },
1698
+ }),
1699
+ )
1589
1700
 
1590
1701
  let measure_line_color = $derived.by(() => {
1591
1702
  if (typeof window === `undefined`) return
@@ -1595,27 +1706,9 @@
1595
1706
  })
1596
1707
  </script>
1597
1708
 
1598
- {#snippet bond_instanced_mesh_snippet(
1599
- group: ComponentProps<typeof Bond>[`group`],
1600
- )}
1601
- {#key group.instances.length}
1602
- <Bond {group} />
1603
- {/key}
1604
- {/snippet}
1605
-
1606
- {#snippet site_label_snippet(position: Vec3, site_idx: number)}
1709
+ {#snippet site_label_snippet(site_idx: number)}
1607
1710
  {@const site = structure!.sites[site_idx]}
1608
- {@const visual_radius = (radius_by_site_idx.get(site_idx) ?? 1) * 0.5}
1609
- <extras.HTML
1610
- center
1611
- position={position}
1612
- calculatePosition={make_label_position_calculator(
1613
- position,
1614
- () => smart_site_label_offsets.get(site_idx) ?? site_label_offset,
1615
- visual_radius,
1616
- label_screen_margin,
1617
- )}
1618
- >
1711
+ {#if site}
1619
1712
  {#if atom_label}
1620
1713
  {@render atom_label({ site, site_idx })}
1621
1714
  {:else}
@@ -1646,10 +1739,7 @@
1646
1739
  {#if site.species.length === 1}
1647
1740
  {site.species[0].element}{#if show_site_indices}-{site_idx + 1}{/if}
1648
1741
  {:else}
1649
- {#each site.species as
1650
- { element, occu, oxidation_state }
1651
- (`${element}-${occu}-${oxidation_state}`)
1652
- }
1742
+ {#each site.species as { element, occu, oxidation_state } (`${element}-${occu}-${oxidation_state}`)}
1653
1743
  {element}<sub>{format_num(occu, `.3~`).replace(`0.`, `.`)}</sub>
1654
1744
  {/each}
1655
1745
  {#if show_site_indices}-{site_idx + 1}{/if}
@@ -1659,7 +1749,7 @@
1659
1749
  {/if}
1660
1750
  </button>
1661
1751
  {/if}
1662
- </extras.HTML>
1752
+ {/if}
1663
1753
  {/snippet}
1664
1754
 
1665
1755
  <SceneCamera
@@ -1680,56 +1770,39 @@
1680
1770
  <!-- Apply manual rotation around center: translate to origin, rotate, translate back -->
1681
1771
  <T.Group position={rotation_target}>
1682
1772
  <T.Group {rotation}>
1683
- <T.Group position={math.scale(rotation_target, -1)}>
1773
+ <T.Group position={neg_rotation_target}>
1684
1774
  {#if show_atoms}
1685
- <!-- Instanced rendering for full occupancy atoms -->
1686
- {#each instanced_atom_groups as atom_group (instanced_atom_group_key(atom_group, measure_mode))}
1687
- {@const { element, radius, color, is_image_atom, atoms } = atom_group}
1688
- {@const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom}
1689
- <extras.InstancedMesh
1690
- key={instanced_atom_group_key(atom_group, measure_mode)}
1691
- limit={atoms.length}
1692
- range={atoms.length}
1693
- frustumCulled={false}
1694
- >
1695
- <T.SphereGeometry args={[0.5, sphere_segments, sphere_segments]} />
1696
- <T.MeshStandardMaterial
1697
- color={edit_mode_image ? desaturate(color) : color}
1698
- opacity={edit_mode_image ? 0.5 : 1}
1699
- transparent={edit_mode_image}
1700
- />
1701
- {#each atoms as atom (atom.site_idx)}
1702
- <extras.Instance
1703
- position={atom.position}
1704
- scale={atom.radius}
1705
- {...atom_pointer_props(atom.site_idx, edit_mode_image)}
1706
- />
1707
- {/each}
1708
- </extras.InstancedMesh>
1709
- {/each}
1775
+ <!-- Instanced rendering for full-occupancy atoms: one InstancedMesh for
1776
+ base atoms and one for PBC image atoms (which ghost + lose interaction
1777
+ in edit-atoms mode). Pointer events are resolved via raycast instanceId. -->
1778
+ {#if instanced_atom_sets.base.length > 0}
1779
+ <InstancedAtoms
1780
+ atoms={instanced_atom_sets.base}
1781
+ {sphere_segments}
1782
+ {...atom_instance_events(instanced_atom_sets.base, false)}
1783
+ />
1784
+ {/if}
1785
+ {#if instanced_atom_sets.image.length > 0}
1786
+ {@const edit_mode_image = measure_mode === `edit-atoms`}
1787
+ <InstancedAtoms
1788
+ atoms={instanced_atom_sets.image}
1789
+ {sphere_segments}
1790
+ ghost={edit_mode_image}
1791
+ {...atom_instance_events(instanced_atom_sets.image, edit_mode_image)}
1792
+ />
1793
+ {/if}
1710
1794
 
1711
1795
  <!-- Regular rendering for partial occupancy atoms -->
1712
- {#each atom_data.filter((atom) => atom.has_partial_occupancy) as
1713
- atom
1714
- (atom.site_idx + atom.element + atom.occupancy)
1715
- }
1796
+ {#each partial_occupancy_atoms as atom (atom.site_idx + atom.element + atom.occupancy)}
1716
1797
  {@const partial_edit_image = measure_mode === `edit-atoms` && atom.is_image_atom}
1717
1798
  {@const ghost_opacity = partial_edit_image ? 0.5 : 1}
1718
1799
  <!-- Visual only: pointer interaction handled by the invisible full-sphere
1719
1800
  hit targets below (wedge meshes leave gaps at the poles). -->
1720
1801
  <T.Group position={atom.position} scale={atom.radius}>
1721
- {@const partial_color = partial_edit_image
1722
- ? desaturate(atom.color)
1723
- : atom.color}
1802
+ {@const partial_color = partial_edit_image ? desaturate(atom.color) : atom.color}
1724
1803
  <T.Mesh>
1725
1804
  <T.SphereGeometry
1726
- args={[
1727
- 0.5,
1728
- sphere_segments,
1729
- sphere_segments,
1730
- atom.start_phi,
1731
- atom.phi_length,
1732
- ]}
1805
+ args={[0.5, sphere_segments, sphere_segments, atom.start_phi, atom.phi_length]}
1733
1806
  />
1734
1807
  <T.MeshStandardMaterial
1735
1808
  color={partial_color}
@@ -1738,49 +1811,28 @@
1738
1811
  />
1739
1812
  </T.Mesh>
1740
1813
 
1741
- {#if atom.render_start_cap}
1742
- <T.Mesh rotation={[0, atom.start_phi, 0]}>
1743
- <T.CircleGeometry
1744
- args={[
1745
- 0.5,
1746
- sphere_segments,
1747
- PARTIAL_OCCUPANCY_CAP_ARC.start_cap_arc_start,
1748
- PARTIAL_OCCUPANCY_CAP_ARC.arc_length,
1749
- ]}
1750
- />
1751
- <T.MeshStandardMaterial
1752
- color={partial_color}
1753
- side={2}
1754
- opacity={ghost_opacity}
1755
- transparent={partial_edit_image}
1756
- />
1757
- </T.Mesh>
1758
- {/if}
1759
- {#if atom.render_end_cap}
1760
- <T.Mesh rotation={[0, atom.end_phi, 0]}>
1761
- <T.CircleGeometry
1762
- args={[
1763
- 0.5,
1764
- sphere_segments,
1765
- PARTIAL_OCCUPANCY_CAP_ARC.end_cap_arc_start,
1766
- PARTIAL_OCCUPANCY_CAP_ARC.arc_length,
1767
- ]}
1768
- />
1769
- <T.MeshStandardMaterial
1770
- color={partial_color}
1771
- side={2}
1772
- opacity={ghost_opacity}
1773
- transparent={partial_edit_image}
1774
- />
1775
- </T.Mesh>
1776
- {/if}
1814
+ <!-- Flat caps closing the wedge at its start/end azimuthal angles -->
1815
+ {#each [[atom.render_start_cap, atom.start_phi, PARTIAL_OCCUPANCY_CAP_ARC.start_cap_arc_start], [atom.render_end_cap, atom.end_phi, PARTIAL_OCCUPANCY_CAP_ARC.end_cap_arc_start]] as const as [render_cap, phi, arc_start], cap_idx (cap_idx)}
1816
+ {#if render_cap}
1817
+ <T.Mesh rotation={[0, phi, 0]}>
1818
+ <T.CircleGeometry
1819
+ args={[
1820
+ 0.5,
1821
+ sphere_segments,
1822
+ arc_start,
1823
+ PARTIAL_OCCUPANCY_CAP_ARC.arc_length,
1824
+ ]}
1825
+ />
1826
+ <T.MeshStandardMaterial
1827
+ color={partial_color}
1828
+ side={2}
1829
+ opacity={ghost_opacity}
1830
+ transparent={partial_edit_image}
1831
+ />
1832
+ </T.Mesh>
1833
+ {/if}
1834
+ {/each}
1777
1835
  </T.Group>
1778
-
1779
- <!-- Render label only for the first species of this site to avoid duplicates -->
1780
- {#if (show_site_labels || show_site_indices) &&
1781
- atom.element === structure!.sites[atom.site_idx].species[0].element}
1782
- {@render site_label_snippet(atom.position, atom.site_idx)}
1783
- {/if}
1784
1836
  {/each}
1785
1837
 
1786
1838
  <!-- Invisible full-sphere hit targets for partial-occupancy sites so the
@@ -1797,31 +1849,39 @@
1797
1849
  </T.Mesh>
1798
1850
  {/each}
1799
1851
 
1800
- <!-- Site labels/indices for instanced atoms -->
1801
- {#if show_site_labels || show_site_indices}
1802
- {#each unique_instanced_atoms as atom (atom.site_idx)}
1803
- {@render site_label_snippet(atom.position, atom.site_idx)}
1804
- {/each}
1852
+ <!-- Site labels/indices: single overlay for all labels (one DOM container
1853
+ + one per-frame position pass instead of one threlte <HTML> per label) -->
1854
+ {#if label_entries.length > 0}
1855
+ <SiteLabels
1856
+ entries={label_entries}
1857
+ get_offset={(site_idx) =>
1858
+ smart_site_label_offsets.get(site_idx) ?? site_label_offset}
1859
+ screen_margin={label_screen_margin}
1860
+ >
1861
+ {#snippet label({ site_idx })}
1862
+ {@render site_label_snippet(site_idx)}
1863
+ {/snippet}
1864
+ </SiteLabels>
1805
1865
  {/if}
1806
1866
  {/if}
1807
1867
 
1868
+ <!-- Per-site vector arrows (forces, magmoms, ...) as instanced meshes:
1869
+ 2 draw calls per layer instead of 2 meshes per site -->
1808
1870
  {#each vector_layers as layer (layer.key)}
1809
- {#each layer.arrows as arrow (`${layer.key}-${arrow.site_idx}`)}
1810
- <Arrow
1811
- {...arrow}
1812
- shaft_radius={eff_shaft_radius}
1813
- arrow_head_radius={eff_head_radius}
1814
- arrow_head_length={eff_head_length}
1815
- />
1816
- {/each}
1871
+ <ArrowInstances
1872
+ arrows={layer.arrows}
1873
+ shaft_radius={eff_shaft_radius}
1874
+ arrow_head_radius={eff_head_radius}
1875
+ arrow_head_length={eff_head_length}
1876
+ />
1817
1877
  {/each}
1818
1878
 
1819
1879
  <!-- Instanced bond rendering with gradient colors -->
1820
- {#if instanced_bond_groups.length > 0}
1821
- {#each instanced_bond_groups as group (group.thickness + group.instances.length)}
1822
- {@render bond_instanced_mesh_snippet(group)}
1823
- {/each}
1824
- {/if}
1880
+ {#each instanced_bond_groups as group (group.thickness + group.instances.length)}
1881
+ {#key group.instances.length}
1882
+ <Bond {group} />
1883
+ {/key}
1884
+ {/each}
1825
1885
 
1826
1886
  <!-- Coordination polyhedra: all faces in one merged mesh, edges in one
1827
1887
  LineSegments (1-2 draw calls regardless of supercell size) -->
@@ -1850,16 +1910,12 @@
1850
1910
  {/if}
1851
1911
 
1852
1912
  <!-- Clickable bond hit-test cylinders in edit-bonds mode -->
1853
- {#if measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1854
- {#each editable_bond_pairs as
1855
- bond
1856
- (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`)
1857
- }
1913
+ {#if interactive && measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1914
+ {#each editable_bond_pairs as bond (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`)}
1858
1915
  {@const bond_key = rendered_bond_key_for(bond)}
1859
1916
  {@const is_hovered = hovered_bond_key === bond_key}
1860
1917
  {@const is_delete_mode = bond_edit_mode === `delete`}
1861
- {@const bond_hit_radius =
1862
- bond_thickness * (is_delete_mode ? 5 : 1.25)}
1918
+ {@const bond_hit_radius = bond_thickness * (is_delete_mode ? 5 : 1.25)}
1863
1919
  {@const bond_hover_radius = bond_thickness * 1.1}
1864
1920
  <T.Mesh
1865
1921
  matrixAutoUpdate={false}
@@ -1888,24 +1944,16 @@
1888
1944
  onpointermove={() => (hovered_bond_key = bond_key)}
1889
1945
  onpointerleave={() => (hovered_bond_key = null)}
1890
1946
  >
1891
- <T.CylinderGeometry
1892
- args={[
1893
- bond_hit_radius,
1894
- bond_hit_radius,
1895
- 1,
1896
- 6,
1897
- ]}
1898
- />
1899
- <T.MeshBasicMaterial
1900
- transparent
1901
- opacity={0}
1902
- depthWrite={false}
1903
- />
1947
+ <T.CylinderGeometry args={[bond_hit_radius, bond_hit_radius, 1, 6]} />
1948
+ <T.MeshBasicMaterial transparent opacity={0} depthWrite={false} />
1904
1949
  </T.Mesh>
1905
1950
  {#if is_hovered}
1906
1951
  <T.Mesh
1907
1952
  matrixAutoUpdate={false}
1908
- oncreate={(ref) => apply_non_raycastable_bond_hit_transform(ref, bond)}
1953
+ oncreate={(ref) => {
1954
+ apply_bond_transform(ref, bond)
1955
+ disable_raycast(ref)
1956
+ }}
1909
1957
  >
1910
1958
  <T.CylinderGeometry args={[bond_hover_radius, bond_hover_radius, 1, 6]} />
1911
1959
  <T.MeshBasicMaterial
@@ -1919,7 +1967,7 @@
1919
1967
  {/each}
1920
1968
  {/if}
1921
1969
 
1922
- {#if editable_atom_hit_targets.length > 0}
1970
+ {#if interactive && editable_atom_hit_targets.length > 0}
1923
1971
  {#each editable_atom_hit_targets as atom_hit (atom_hit.site_idx)}
1924
1972
  <T.Mesh
1925
1973
  position={atom_hit.position}
@@ -1930,16 +1978,12 @@
1930
1978
  }}
1931
1979
  >
1932
1980
  <T.SphereGeometry args={[0.5, 12, 12]} />
1933
- <T.MeshBasicMaterial
1934
- transparent
1935
- opacity={0}
1936
- depthWrite={false}
1937
- />
1981
+ <T.MeshBasicMaterial transparent opacity={0} depthWrite={false} />
1938
1982
  </T.Mesh>
1939
1983
  {/each}
1940
1984
  {/if}
1941
1985
 
1942
- {#if measure_mode === `edit-bonds` && bond_context_menu}
1986
+ {#if interactive && measure_mode === `edit-bonds` && bond_context_menu}
1943
1987
  {@const current_order = get_current_bond_order(
1944
1988
  bond_context_menu.site_idx_1,
1945
1989
  bond_context_menu.site_idx_2,
@@ -1951,114 +1995,46 @@
1951
1995
  {#each BOND_ORDER_OPTIONS as { order, label } (label)}
1952
1996
  <button
1953
1997
  type="button"
1954
- onpointerdown={(event: PointerEvent) => {
1955
- event.preventDefault()
1956
- event.stopPropagation()
1957
- set_context_bond_order(order)
1958
- }}
1959
- onkeydown={(event: KeyboardEvent) => {
1960
- if (event.key !== `Enter` && event.key !== ` `) return
1961
- event.preventDefault()
1962
- event.stopPropagation()
1963
- set_context_bond_order(order)
1964
- }}
1998
+ {...menu_action_props(() => set_context_bond_order(order))}
1965
1999
  >
1966
2000
  {label}
1967
2001
  </button>
1968
2002
  {/each}
1969
- <button
1970
- type="button"
1971
- class="remove"
1972
- onpointerdown={(event: PointerEvent) => {
1973
- event.preventDefault()
1974
- event.stopPropagation()
1975
- remove_context_bond()
1976
- }}
1977
- onkeydown={(event: KeyboardEvent) => {
1978
- if (event.key !== `Enter` && event.key !== ` `) return
1979
- event.preventDefault()
1980
- event.stopPropagation()
1981
- remove_context_bond()
1982
- }}
1983
- >
2003
+ <button type="button" class="remove" {...menu_action_props(remove_context_bond)}>
1984
2004
  Remove
1985
2005
  </button>
1986
- <button
1987
- type="button"
1988
- onpointerdown={(event: PointerEvent) => {
1989
- event.preventDefault()
1990
- event.stopPropagation()
1991
- close_bond_context_menu()
1992
- }}
1993
- onkeydown={(event: KeyboardEvent) => {
1994
- if (event.key !== `Enter` && event.key !== ` `) return
1995
- event.preventDefault()
1996
- event.stopPropagation()
1997
- close_bond_context_menu()
1998
- }}
1999
- >
2006
+ <button type="button" {...menu_action_props(close_bond_context_menu)}>
2000
2007
  Close
2001
2008
  </button>
2002
2009
  </div>
2003
2010
  </extras.HTML>
2004
2011
  {/if}
2005
2012
 
2006
- <!-- highlight hovered, active and selected sites -->
2007
- {#each [
2008
- {
2009
- kind: `hover`,
2010
- site: hovered_site,
2011
- opacity: 0.28,
2012
- color: `white`,
2013
- site_idx: hovered_idx,
2014
- },
2015
- ...((selected_sites ?? []).map((idx) => ({
2016
- kind: `selected`,
2017
- site: structure?.sites?.[idx] ?? null,
2018
- site_idx: idx,
2019
- opacity: pulse_opacity,
2020
- color: selection_highlight_color,
2021
- }))),
2022
- ...((active_sites ?? []).map((idx) => ({
2023
- kind: `active`,
2024
- site: structure?.sites?.[idx] ?? null,
2025
- site_idx: idx,
2026
- opacity: pulse_opacity, // Let it pulse freely
2027
- color: active_highlight_color,
2028
- }))),
2029
- ] as
2030
- entry
2031
- (`${entry.kind}-${entry.site_idx}`)
2032
- }
2033
- {@const { site, opacity, color, kind, site_idx } = entry}
2034
- {#if site}
2035
- {@const xyz = site.xyz}
2036
- {@const highlight_radius = site_idx !== null
2037
- ? radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx)
2038
- : get_site_radius(site, site_idx)}
2039
- <T.Mesh
2040
- position={xyz}
2041
- scale={1.2 * highlight_radius}
2042
- oncreate={disable_raycast}
2043
- >
2044
- <T.SphereGeometry args={[0.5, 22, 22]} />
2045
- <T.MeshStandardMaterial
2046
- {color}
2047
- transparent
2048
- {opacity}
2049
- emissive={color}
2050
- emissiveIntensity={kind === `selected` || kind === `active` ? 0.7 : 0.2}
2051
- depthTest={false}
2052
- depthWrite={false}
2053
- />
2054
- </T.Mesh>
2055
- {/if}
2013
+ <!-- highlight hovered, active and selected sites. The target list is a pulse-
2014
+ independent $derived; only the opacity reads the per-frame pulse value so the
2015
+ array isn't rebuilt every animation frame. -->
2016
+ {#each highlight_targets as entry (`${entry.kind}-${entry.site_idx}`)}
2017
+ {@const is_pulsing = entry.kind !== `hover`}
2018
+ <T.Mesh
2019
+ position={entry.site.xyz}
2020
+ scale={1.2 * entry.radius}
2021
+ oncreate={disable_raycast}
2022
+ >
2023
+ <T.SphereGeometry args={[0.5, 22, 22]} />
2024
+ <T.MeshStandardMaterial
2025
+ color={entry.color}
2026
+ transparent
2027
+ opacity={is_pulsing ? pulse_opacity : 0.28}
2028
+ emissive={entry.color}
2029
+ emissiveIntensity={is_pulsing ? 0.7 : 0.2}
2030
+ depthTest={false}
2031
+ depthWrite={false}
2032
+ />
2033
+ </T.Mesh>
2056
2034
  {/each}
2057
2035
 
2058
2036
  <!-- selection order labels (1, 2, 3, ...) for measurements and bond editing -->
2059
- {#if structure?.sites && (measured_sites?.length ?? 0) > 0 &&
2060
- (measure_mode === `distance` || measure_mode === `angle` ||
2061
- measure_mode === `edit-bonds`)}
2037
+ {#if structure?.sites && (measured_sites?.length ?? 0) > 0 && (measure_mode === `distance` || measure_mode === `angle` || measure_mode === `edit-bonds`)}
2062
2038
  {#each measured_sites as site_index, loop_idx (site_index)}
2063
2039
  {@const site = structure.sites[site_index]}
2064
2040
  {#if site}
@@ -2073,24 +2049,18 @@
2073
2049
  {/if}
2074
2050
 
2075
2051
  <!-- hovered site tooltip -->
2076
- {#if hovered_site && !camera_is_moving &&
2077
- (atom_tooltip_active || active_sites.includes(hovered_idx ?? -1))}
2078
- {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(
2079
- `, `,
2080
- )}
2081
- {@const xyz = hovered_site.xyz.map((val) => format_num(val, float_fmt)).join(
2082
- `, `,
2083
- )}
2052
+ {#if hovered_site && !camera_is_moving && (atom_tooltip_active || active_sites.includes(hovered_idx ?? -1))}
2053
+ {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(`, `)}
2054
+ {@const xyz = hovered_site.xyz.map((val) => format_num(val, float_fmt)).join(`, `)}
2084
2055
  {@const bond_neighbors = (() => {
2085
2056
  if (hovered_idx == null || !structure?.sites) return []
2086
2057
  return filtered_bond_pairs
2087
- .filter((bond) =>
2088
- bond.site_idx_1 === hovered_idx || bond.site_idx_2 === hovered_idx
2058
+ .filter(
2059
+ (bond) => bond.site_idx_1 === hovered_idx || bond.site_idx_2 === hovered_idx,
2089
2060
  )
2090
2061
  .map((bond) => {
2091
- const neighbor_idx = bond.site_idx_1 === hovered_idx
2092
- ? bond.site_idx_2
2093
- : bond.site_idx_1
2062
+ const neighbor_idx =
2063
+ bond.site_idx_1 === hovered_idx ? bond.site_idx_2 : bond.site_idx_1
2094
2064
  return structure.sites[neighbor_idx]?.species[0]?.element ?? `?`
2095
2065
  })
2096
2066
  })()}
@@ -2107,27 +2077,20 @@
2107
2077
  })()}
2108
2078
  {@const tooltip_species =
2109
2079
  render_sites.find((rs) => rs.site_idx === hovered_idx)?.site.species ??
2110
- hovered_site.species ?? []}
2080
+ hovered_site.species ??
2081
+ []}
2111
2082
  <CanvasTooltip position={hovered_site.xyz}>
2112
2083
  <!-- Element symbols with occupancies for disordered sites -->
2113
2084
  <div class="elements">
2114
- {#each tooltip_species as
2115
- { element, occu, oxidation_state: oxi_state },
2116
- idx
2117
- (`${element ?? ``}-${occu ?? ``}-${oxi_state ?? ``}-${idx}`)
2118
- }
2119
- {@const element_name = element_data.find((elem) =>
2120
- elem.symbol === element
2121
- )?.name ??
2122
- ``}
2085
+ {#each tooltip_species as { element, occu, oxidation_state: oxi_state }, idx (`${element ?? ``}-${occu ?? ``}-${oxi_state ?? ``}-${idx}`)}
2086
+ {@const element_name =
2087
+ element_by_symbol.get(element as ElementSymbol)?.name ?? ``}
2123
2088
  <span class="species">
2124
- {#if occu !== 1}<span class="occupancy">{
2125
- format_num(occu, `.3~f`)
2126
- }</span>{/if}
2089
+ {#if occu !== 1}<span class="occupancy">{format_num(occu, `.3~f`)}</span>{/if}
2127
2090
  <strong>
2128
- {element}{#if oxi_state != null && oxi_state !== 0}<sup>{Math.abs(
2129
- oxi_state,
2130
- )}{oxi_state > 0 ? `+` : `−`}</sup>{/if}
2091
+ {element}{#if oxi_state != null && oxi_state !== 0}<sup
2092
+ >{Math.abs(oxi_state)}{oxi_state > 0 ? `+` : `−`}</sup
2093
+ >{/if}
2131
2094
  </strong>
2132
2095
  {#if element_name}<span class="elem-name">{element_name}</span>{/if}
2133
2096
  </span>
@@ -2153,23 +2116,19 @@
2153
2116
  {/if}
2154
2117
 
2155
2118
  <!-- TransformControls for editing atoms in edit-atoms mode -->
2156
- {#if measure_mode === `edit-atoms` && selected_sites.length > 0 &&
2157
- structure?.sites}
2119
+ {#if interactive && measure_mode === `edit-atoms` && selected_sites.length > 0 && structure?.sites}
2158
2120
  {@const selected_atoms = selected_sites
2159
2121
  .map((idx) => structure?.sites?.[idx])
2160
2122
  .filter((site): site is Site => site != null)}
2161
2123
  {#if selected_atoms.length > 0}
2162
2124
  {@const avg = (dim: number) =>
2163
- selected_atoms.reduce((sum, atom) => sum + atom.xyz[dim], 0) /
2164
- selected_atoms.length}
2125
+ selected_atoms.reduce((sum, atom) => sum + atom.xyz[dim], 0) /
2126
+ selected_atoms.length}
2165
2127
  {@const centroid = [avg(0), avg(1), avg(2)] as Vec3}
2166
2128
  <!-- Invisible mesh at centroid for TransformControls to manipulate.
2167
2129
  During drag, use frozen_centroid so Svelte doesn't override TransformControls
2168
2130
  with the wrapped centroid (which jumps on PBC boundary crossings). -->
2169
- <T.Mesh
2170
- position={frozen_centroid ?? centroid}
2171
- bind:ref={transform_object}
2172
- >
2131
+ <T.Mesh position={frozen_centroid ?? centroid} bind:ref={transform_object}>
2173
2132
  <T.SphereGeometry args={[0.01, 4, 4]} />
2174
2133
  <T.MeshBasicMaterial transparent opacity={0} />
2175
2134
  </T.Mesh>
@@ -2208,7 +2167,7 @@
2208
2167
 
2209
2168
  <!-- Invisible plane for click-to-place atom in add-atom mode -->
2210
2169
  <!-- Uses onBeforeRender to orient normal toward camera so raycasts always hit -->
2211
- {#if measure_mode === `edit-atoms` && add_atom_mode}
2170
+ {#if interactive && measure_mode === `edit-atoms` && add_atom_mode}
2212
2171
  {@const center = rotation_target ?? [0, 0, 0]}
2213
2172
  <T.Mesh
2214
2173
  position={center}
@@ -2223,10 +2182,7 @@
2223
2182
  }}
2224
2183
  >
2225
2184
  <T.PlaneGeometry
2226
- args={[
2227
- Math.max(200, structure_size * 4),
2228
- Math.max(200, structure_size * 4),
2229
- ]}
2185
+ args={[Math.max(200, structure_size * 4), Math.max(200, structure_size * 4)]}
2230
2186
  />
2231
2187
  <T.MeshBasicMaterial transparent opacity={0} side={2} depthWrite={false} />
2232
2188
  </T.Mesh>
@@ -2234,7 +2190,15 @@
2234
2190
 
2235
2191
  <!-- Isosurface rendering from volumetric data (CHGCAR, .cube files) -->
2236
2192
  {#if volumetric_data && isosurface_settings}
2237
- <Isosurface volume={volumetric_data} settings={isosurface_settings} />
2193
+ {@const volume_list = Array.isArray(volumetric_data)
2194
+ ? volumetric_data
2195
+ : [volumetric_data]}
2196
+ <Isosurface
2197
+ volumes={volume_list}
2198
+ settings={isosurface_settings}
2199
+ {active_volume_idx}
2200
+ tiling={volume_scaling}
2201
+ />
2238
2202
  {/if}
2239
2203
 
2240
2204
  <!-- Measurement overlays for measured sites -->
@@ -2246,27 +2210,19 @@
2246
2210
  {@const site_j = structure.sites[idx_j]}
2247
2211
  {@const pos_i = site_i.xyz}
2248
2212
  {@const pos_j = site_j.xyz}
2249
- <Cylinder
2250
- from={pos_i}
2251
- to={pos_j}
2252
- thickness={0.12}
2253
- color={measure_line_color}
2254
- />
2255
- {@const midpoint = [
2256
- (pos_i[0] + pos_j[0]) / 2,
2257
- (pos_i[1] + pos_j[1]) / 2,
2258
- (pos_i[2] + pos_j[2]) / 2,
2259
- ] as Vec3}
2213
+ <Cylinder from={pos_i} to={pos_j} thickness={0.12} color={measure_line_color} />
2214
+ {@const mid_pos = midpoint(pos_i, pos_j)}
2260
2215
  {@const direct = math.euclidean_dist(pos_i, pos_j)}
2261
2216
  {@const pbc = lattice
2262
- ? measure.distance_pbc(pos_i, pos_j, lattice.matrix, undefined, lattice.pbc)
2263
- : direct}
2217
+ ? measure.distance_pbc(pos_i, pos_j, lattice.matrix, undefined, lattice.pbc)
2218
+ : direct}
2264
2219
  {@const differ = lattice ? Math.abs(pbc - direct) > 1e-6 : false}
2265
- <extras.HTML center position={midpoint}>
2220
+ <extras.HTML center position={mid_pos}>
2266
2221
  <span class="measure-label">
2267
2222
  {#if differ}
2268
2223
  PBC: {format_num(pbc, float_fmt)} Å<br /><small>
2269
- Direct: {format_num(direct, float_fmt)} Å</small>
2224
+ Direct: {format_num(direct, float_fmt)} Å</small
2225
+ >
2270
2226
  {:else}
2271
2227
  {format_num(pbc, float_fmt)} Å
2272
2228
  {/if}
@@ -2277,19 +2233,20 @@
2277
2233
  {:else if measure_mode === `angle` && measured_sites.length >= 3}
2278
2234
  {#each measured_sites as idx_center (idx_center)}
2279
2235
  {@const center = structure.sites[idx_center]}
2280
- {#each measured_sites.filter((idx) => idx !== idx_center) as
2281
- idx_a,
2282
- loop_idx
2283
- (idx_center + `-` + idx_a)
2284
- }
2285
- {#each measured_sites.filter((idx) => idx !== idx_center).slice(loop_idx + 1) as
2286
- idx_b
2287
- (idx_center + `-` + idx_a + `-` + idx_b)
2288
- }
2236
+ {#each measured_sites.filter((idx) => idx !== idx_center) as idx_a, loop_idx (idx_center + `-` + idx_a)}
2237
+ {#each measured_sites
2238
+ .filter((idx) => idx !== idx_center)
2239
+ .slice(loop_idx + 1) as idx_b (idx_center + `-` + idx_a + `-` + idx_b)}
2289
2240
  {@const site_a = structure.sites[idx_a]}
2290
2241
  {@const site_b = structure.sites[idx_b]}
2291
2242
  {@const disp = (to: Vec3) =>
2292
- measure.displacement_pbc(center.xyz, to, lattice?.matrix, undefined, lattice?.pbc)}
2243
+ measure.displacement_pbc(
2244
+ center.xyz,
2245
+ to,
2246
+ lattice?.matrix,
2247
+ undefined,
2248
+ lattice?.pbc,
2249
+ )}
2293
2250
  {@const v1 = disp(site_a.xyz)}
2294
2251
  {@const v2 = disp(site_b.xyz)}
2295
2252
  {@const n1 = Math.hypot(v1[0], v1[1], v1[2])}