matterviz 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (527) hide show
  1. package/dist/EmptyState.svelte +10 -4
  2. package/dist/FilePicker.svelte +12 -14
  3. package/dist/Icon.svelte +7 -4
  4. package/dist/MillerIndexInput.svelte +2 -2
  5. package/dist/app.css +53 -0
  6. package/dist/brillouin/BrillouinZone.svelte +132 -219
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +9 -20
  8. package/dist/brillouin/BrillouinZoneControls.svelte +3 -6
  9. package/dist/brillouin/BrillouinZoneExportPane.svelte +47 -96
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  11. package/dist/brillouin/BrillouinZoneInfoPane.svelte +15 -42
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte.d.ts +2 -3
  13. package/dist/brillouin/BrillouinZoneScene.svelte +63 -230
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +3 -23
  15. package/dist/brillouin/ReciprocalVectors.svelte +39 -0
  16. package/dist/brillouin/ReciprocalVectors.svelte.d.ts +9 -0
  17. package/dist/brillouin/compute.d.ts +2 -0
  18. package/dist/brillouin/compute.js +80 -77
  19. package/dist/brillouin/geometry.d.ts +8 -0
  20. package/dist/brillouin/geometry.js +57 -0
  21. package/dist/brillouin/index.d.ts +2 -0
  22. package/dist/brillouin/index.js +2 -0
  23. package/dist/brillouin/types.d.ts +2 -2
  24. package/dist/chempot-diagram/ChemPotDiagram.svelte +33 -38
  25. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +1 -1
  26. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +143 -246
  27. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +4 -1
  28. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +652 -976
  29. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +7 -1
  30. package/dist/chempot-diagram/async-compute.svelte.js +1 -1
  31. package/dist/chempot-diagram/color.d.ts +3 -6
  32. package/dist/chempot-diagram/color.js +5 -5
  33. package/dist/chempot-diagram/compute.d.ts +3 -3
  34. package/dist/chempot-diagram/compute.js +3 -1
  35. package/dist/chempot-diagram/controls-state.svelte.d.ts +10 -0
  36. package/dist/chempot-diagram/controls-state.svelte.js +42 -0
  37. package/dist/chempot-diagram/export.d.ts +47 -0
  38. package/dist/chempot-diagram/export.js +133 -0
  39. package/dist/chempot-diagram/index.d.ts +1 -0
  40. package/dist/chempot-diagram/index.js +1 -0
  41. package/dist/chempot-diagram/pointer.d.ts +0 -10
  42. package/dist/chempot-diagram/pointer.js +4 -4
  43. package/dist/chempot-diagram/types.d.ts +3 -3
  44. package/dist/colors/index.d.ts +0 -4
  45. package/dist/colors/index.js +21 -22
  46. package/dist/composition/BarChart.svelte +37 -31
  47. package/dist/composition/BubbleChart.svelte +17 -17
  48. package/dist/composition/Composition.svelte +7 -11
  49. package/dist/composition/Formula.svelte +18 -25
  50. package/dist/composition/FormulaFilter.svelte +96 -98
  51. package/dist/composition/PieChart.svelte +60 -46
  52. package/dist/composition/chem-sys.js +3 -2
  53. package/dist/composition/format.js +4 -3
  54. package/dist/composition/index.d.ts +2 -0
  55. package/dist/composition/index.js +14 -0
  56. package/dist/composition/parse.d.ts +2 -3
  57. package/dist/composition/parse.js +27 -28
  58. package/dist/constants.js +11 -9
  59. package/dist/controls.d.ts +1 -0
  60. package/dist/controls.js +0 -1
  61. package/dist/convex-hull/ConvexHull.svelte +20 -25
  62. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -4
  63. package/dist/convex-hull/ConvexHull2D.svelte +176 -256
  64. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  65. package/dist/convex-hull/ConvexHull3D.svelte +269 -804
  66. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  67. package/dist/convex-hull/ConvexHull4D.svelte +236 -755
  68. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  69. package/dist/convex-hull/ConvexHullChrome.svelte +264 -0
  70. package/dist/convex-hull/ConvexHullChrome.svelte.d.ts +30 -0
  71. package/dist/convex-hull/ConvexHullControls.svelte +128 -49
  72. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +7 -6
  73. package/dist/convex-hull/ConvexHullInfoPane.svelte +18 -5
  74. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +6 -5
  75. package/dist/convex-hull/ConvexHullStats.svelte +210 -351
  76. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +3 -1
  77. package/dist/convex-hull/ConvexHullTooltip.svelte +16 -14
  78. package/dist/convex-hull/ConvexHullTooltip.svelte.d.ts +2 -1
  79. package/dist/convex-hull/GasPressureControls.svelte +5 -12
  80. package/dist/convex-hull/StructurePopup.svelte +17 -21
  81. package/dist/convex-hull/StructurePopup.svelte.d.ts +2 -0
  82. package/dist/convex-hull/TemperatureSlider.svelte +4 -10
  83. package/dist/convex-hull/barycentric-coords.d.ts +2 -4
  84. package/dist/convex-hull/barycentric-coords.js +6 -33
  85. package/dist/convex-hull/canvas-interactions.svelte.d.ts +79 -0
  86. package/dist/convex-hull/canvas-interactions.svelte.js +279 -0
  87. package/dist/convex-hull/helpers.d.ts +47 -17
  88. package/dist/convex-hull/helpers.js +186 -101
  89. package/dist/convex-hull/hull-state.svelte.d.ts +44 -0
  90. package/dist/convex-hull/hull-state.svelte.js +124 -0
  91. package/dist/convex-hull/index.d.ts +9 -7
  92. package/dist/convex-hull/index.js +7 -2
  93. package/dist/convex-hull/thermodynamics.js +109 -937
  94. package/dist/convex-hull/types.d.ts +12 -4
  95. package/dist/convex-hull/types.js +12 -0
  96. package/dist/coordination/CoordinationBarPlot.svelte +41 -43
  97. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +1 -1
  98. package/dist/coordination/calc-coordination.d.ts +1 -1
  99. package/dist/coordination/calc-coordination.js +28 -25
  100. package/dist/element/BohrAtom.svelte +6 -8
  101. package/dist/element/ElementHeading.svelte +4 -1
  102. package/dist/element/ElementPhoto.svelte +11 -9
  103. package/dist/element/ElementStats.svelte +12 -10
  104. package/dist/element/ElementTile.svelte +24 -29
  105. package/dist/element/ElementTile.svelte.d.ts +1 -1
  106. package/dist/element/Nucleus.svelte +4 -2
  107. package/dist/element/data.d.ts +1 -2
  108. package/dist/element/helpers.d.ts +4 -0
  109. package/dist/element/helpers.js +18 -0
  110. package/dist/element/index.d.ts +1 -0
  111. package/dist/element/index.js +3 -0
  112. package/dist/feedback/DragOverlay.svelte +5 -3
  113. package/dist/feedback/DragOverlay.svelte.d.ts +2 -1
  114. package/dist/feedback/StatusMessage.svelte +20 -7
  115. package/dist/fermi-surface/FermiSlice.svelte +21 -18
  116. package/dist/fermi-surface/FermiSurface.svelte +78 -165
  117. package/dist/fermi-surface/FermiSurface.svelte.d.ts +9 -20
  118. package/dist/fermi-surface/FermiSurfaceControls.svelte +6 -20
  119. package/dist/fermi-surface/FermiSurfaceScene.svelte +93 -272
  120. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +3 -23
  121. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +9 -6
  122. package/dist/fermi-surface/compute.js +14 -13
  123. package/dist/fermi-surface/export.js +4 -15
  124. package/dist/fermi-surface/index.d.ts +0 -1
  125. package/dist/fermi-surface/index.js +0 -1
  126. package/dist/fermi-surface/parse.d.ts +1 -1
  127. package/dist/fermi-surface/parse.js +84 -97
  128. package/dist/fermi-surface/types.d.ts +2 -2
  129. package/dist/heatmap-matrix/HeatmapMatrix.svelte +161 -185
  130. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +6 -5
  131. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +12 -9
  132. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +5 -5
  133. package/dist/heatmap-matrix/index.d.ts +8 -2
  134. package/dist/icons.d.ts +4 -0
  135. package/dist/icons.js +4 -0
  136. package/dist/index.d.ts +1 -0
  137. package/dist/index.js +1 -0
  138. package/dist/io/ExportPane.svelte +170 -0
  139. package/dist/io/ExportPane.svelte.d.ts +17 -0
  140. package/dist/io/decompress.d.ts +2 -1
  141. package/dist/io/decompress.js +22 -36
  142. package/dist/io/export.d.ts +9 -1
  143. package/dist/io/export.js +37 -29
  144. package/dist/io/fetch.d.ts +2 -4
  145. package/dist/io/fetch.js +0 -11
  146. package/dist/io/file-drop.d.ts +7 -0
  147. package/dist/io/file-drop.js +13 -0
  148. package/dist/io/index.d.ts +1 -0
  149. package/dist/io/index.js +3 -0
  150. package/dist/io/is-binary.d.ts +9 -0
  151. package/dist/io/is-binary.js +45 -1
  152. package/dist/io/types.d.ts +13 -0
  153. package/dist/io/url-drop.js +13 -25
  154. package/dist/isosurface/Isosurface.svelte +17 -23
  155. package/dist/isosurface/IsosurfaceControls.svelte +37 -42
  156. package/dist/isosurface/parse-vaspwave.d.ts +3 -0
  157. package/dist/isosurface/parse-vaspwave.js +138 -0
  158. package/dist/isosurface/parse.js +50 -48
  159. package/dist/labels.d.ts +6 -2
  160. package/dist/labels.js +44 -14
  161. package/dist/layout/FullscreenButton.svelte +34 -0
  162. package/dist/layout/FullscreenButton.svelte.d.ts +10 -0
  163. package/dist/layout/FullscreenToggle.svelte +12 -12
  164. package/dist/layout/InfoCard.svelte +7 -18
  165. package/dist/layout/InfoTag.svelte +1 -3
  166. package/dist/layout/NumberRangeInput.svelte +46 -0
  167. package/dist/layout/NumberRangeInput.svelte.d.ts +12 -0
  168. package/dist/layout/PropertyFilter.svelte +2 -6
  169. package/dist/layout/SettingsSection.svelte +21 -17
  170. package/dist/layout/SubpageGrid.svelte +3 -1
  171. package/dist/layout/ViewerChrome.svelte +115 -0
  172. package/dist/layout/ViewerChrome.svelte.d.ts +18 -0
  173. package/dist/layout/fullscreen.d.ts +4 -0
  174. package/dist/layout/fullscreen.js +3 -5
  175. package/dist/layout/fullscreen.svelte.d.ts +8 -0
  176. package/dist/layout/fullscreen.svelte.js +36 -0
  177. package/dist/layout/index.d.ts +4 -0
  178. package/dist/layout/index.js +4 -0
  179. package/dist/layout/json-tree/JsonNode.svelte +8 -11
  180. package/dist/layout/json-tree/JsonTree.svelte +53 -77
  181. package/dist/layout/json-tree/JsonValue.svelte +7 -15
  182. package/dist/layout/json-tree/utils.js +14 -23
  183. package/dist/math.d.ts +9 -3
  184. package/dist/math.js +37 -21
  185. package/dist/overlays/ContextMenu.svelte +4 -2
  186. package/dist/overlays/CopyButton.svelte +11 -5
  187. package/dist/overlays/DragControlTab.svelte +6 -1
  188. package/dist/overlays/DraggablePane.svelte +81 -26
  189. package/dist/overlays/DraggablePane.svelte.d.ts +1 -0
  190. package/dist/overlays/GlassChip.svelte +30 -0
  191. package/dist/overlays/GlassChip.svelte.d.ts +8 -0
  192. package/dist/overlays/InfoPaneCards.svelte +11 -23
  193. package/dist/overlays/index.d.ts +11 -0
  194. package/dist/overlays/index.js +26 -0
  195. package/dist/overlays/portal.d.ts +2 -0
  196. package/dist/overlays/portal.js +14 -0
  197. package/dist/periodic-table/PeriodicTable.svelte +115 -146
  198. package/dist/periodic-table/PeriodicTable.svelte.d.ts +5 -3
  199. package/dist/periodic-table/PeriodicTableControls.svelte +11 -63
  200. package/dist/periodic-table/TableInset.svelte +9 -4
  201. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +50 -71
  202. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -8
  203. package/dist/phase-diagram/PhaseDiagramControls.svelte +73 -96
  204. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +4 -3
  205. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +4 -2
  206. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +2 -3
  207. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +49 -134
  208. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +3 -4
  209. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +54 -58
  210. package/dist/phase-diagram/TdbInfoPanel.svelte +21 -15
  211. package/dist/phase-diagram/build-diagram.js +3 -3
  212. package/dist/phase-diagram/colors.js +1 -1
  213. package/dist/phase-diagram/diagram-input.d.ts +1 -2
  214. package/dist/phase-diagram/parse.d.ts +2 -1
  215. package/dist/phase-diagram/parse.js +15 -15
  216. package/dist/phase-diagram/svg-to-diagram.js +21 -15
  217. package/dist/phase-diagram/utils.js +2 -3
  218. package/dist/plot/bar/BarPlot.svelte +494 -692
  219. package/dist/plot/bar/BarPlot.svelte.d.ts +9 -15
  220. package/dist/plot/bar/BarPlotControls.svelte +1 -3
  221. package/dist/plot/bar/BarPlotControls.svelte.d.ts +5 -7
  222. package/dist/plot/bar/SpacegroupBarPlot.svelte +31 -37
  223. package/dist/plot/bar/data.d.ts +5 -4
  224. package/dist/plot/bar/data.js +2 -1
  225. package/dist/plot/box/BoxPlot.svelte +361 -439
  226. package/dist/plot/box/BoxPlot.svelte.d.ts +6 -3
  227. package/dist/plot/box/BoxPlotControls.svelte.d.ts +1 -1
  228. package/dist/plot/box/Violin.svelte.d.ts +2 -1
  229. package/dist/plot/box/box-plot.d.ts +3 -2
  230. package/dist/plot/box/box-plot.js +6 -3
  231. package/dist/plot/box/kde.d.ts +2 -1
  232. package/dist/plot/box/kde.js +4 -4
  233. package/dist/plot/core/auto-place.d.ts +1 -1
  234. package/dist/plot/core/auto-place.js +5 -2
  235. package/dist/plot/core/axis-utils.d.ts +12 -7
  236. package/dist/plot/core/axis-utils.js +48 -48
  237. package/dist/plot/core/components/AxisLabel.svelte +82 -20
  238. package/dist/plot/core/components/AxisLabel.svelte.d.ts +1 -0
  239. package/dist/plot/core/components/ColorBar.svelte +106 -111
  240. package/dist/plot/core/components/ColorBar.svelte.d.ts +5 -4
  241. package/dist/plot/core/components/ColorScaleSelect.svelte +1 -1
  242. package/dist/plot/core/components/FillArea.svelte +10 -17
  243. package/dist/plot/core/components/HierarchyControls.svelte +249 -0
  244. package/dist/plot/{sunburst/SunburstControls.svelte.d.ts → core/components/HierarchyControls.svelte.d.ts} +12 -8
  245. package/dist/plot/core/components/InteractiveAxisLabel.svelte +1 -1
  246. package/dist/plot/core/components/InteractiveAxisLabel.svelte.d.ts +1 -1
  247. package/dist/plot/core/components/Line.svelte +21 -11
  248. package/dist/plot/core/components/Line.svelte.d.ts +5 -2
  249. package/dist/plot/core/components/PlotAxis.svelte +42 -18
  250. package/dist/plot/core/components/PlotAxis.svelte.d.ts +2 -1
  251. package/dist/plot/core/components/PlotControls.svelte +65 -41
  252. package/dist/plot/core/components/PlotControls.svelte.d.ts +1 -1
  253. package/dist/plot/core/components/PlotLegend.svelte +31 -35
  254. package/dist/plot/core/components/PlotMarginals.svelte +616 -0
  255. package/dist/plot/core/components/PlotMarginals.svelte.d.ts +15 -0
  256. package/dist/plot/core/components/PlotTooltip.svelte +1 -1
  257. package/dist/plot/core/components/PortalSelect.svelte +4 -8
  258. package/dist/plot/core/components/ReferenceLine.svelte +27 -24
  259. package/dist/plot/core/components/ReferenceLine3D.svelte +56 -59
  260. package/dist/plot/core/components/ReferenceLine3D.svelte.d.ts +4 -4
  261. package/dist/plot/core/components/ReferencePlane.svelte +9 -10
  262. package/dist/plot/core/components/ReferencePlane.svelte.d.ts +4 -4
  263. package/dist/plot/core/components/ZeroLines.svelte +4 -8
  264. package/dist/plot/core/components/ZoomRect.svelte +1 -2
  265. package/dist/plot/core/components/index.d.ts +1 -0
  266. package/dist/plot/core/components/index.js +1 -0
  267. package/dist/plot/core/data-cleaning-signal.d.ts +46 -0
  268. package/dist/plot/core/data-cleaning-signal.js +467 -0
  269. package/dist/plot/core/data-cleaning.d.ts +2 -42
  270. package/dist/plot/core/data-cleaning.js +29 -476
  271. package/dist/plot/core/data-transform.js +9 -5
  272. package/dist/plot/core/fill-utils.d.ts +7 -4
  273. package/dist/plot/core/fill-utils.js +34 -9
  274. package/dist/plot/core/index.d.ts +1 -0
  275. package/dist/plot/core/index.js +1 -0
  276. package/dist/plot/core/interactions.d.ts +5 -1
  277. package/dist/plot/core/interactions.js +14 -2
  278. package/dist/plot/core/layout.d.ts +3 -1
  279. package/dist/plot/core/layout.js +22 -13
  280. package/dist/plot/core/marginals.d.ts +170 -0
  281. package/dist/plot/core/marginals.js +436 -0
  282. package/dist/plot/core/pan-zoom.svelte.d.ts +35 -0
  283. package/dist/plot/core/pan-zoom.svelte.js +221 -0
  284. package/dist/plot/core/placed-tween.svelte.d.ts +21 -0
  285. package/dist/plot/core/placed-tween.svelte.js +68 -0
  286. package/dist/plot/core/reference-line.d.ts +10 -10
  287. package/dist/plot/core/reference-line.js +7 -7
  288. package/dist/plot/core/scales.d.ts +40 -25
  289. package/dist/plot/core/scales.js +49 -21
  290. package/dist/plot/core/svg.d.ts +2 -1
  291. package/dist/plot/core/types/fills.d.ts +101 -0
  292. package/dist/plot/core/types/fills.js +15 -0
  293. package/dist/plot/core/types/plot-3d.d.ts +87 -0
  294. package/dist/plot/core/types/plot-3d.js +4 -0
  295. package/dist/plot/core/types/reference-lines.d.ts +140 -0
  296. package/dist/plot/core/types/reference-lines.js +9 -0
  297. package/dist/plot/core/types.d.ts +47 -331
  298. package/dist/plot/core/types.js +23 -20
  299. package/dist/plot/core/utils/hierarchy-chart.d.ts +51 -0
  300. package/dist/plot/core/utils/hierarchy-chart.js +179 -0
  301. package/dist/plot/core/utils/hierarchy-labels.d.ts +20 -0
  302. package/dist/plot/core/utils/hierarchy-labels.js +99 -0
  303. package/dist/plot/core/utils/label-placement.d.ts +1 -1
  304. package/dist/plot/core/utils/label-placement.js +5 -5
  305. package/dist/plot/core/utils.d.ts +2 -1
  306. package/dist/plot/histogram/Histogram.svelte +373 -631
  307. package/dist/plot/histogram/Histogram.svelte.d.ts +7 -7
  308. package/dist/plot/histogram/HistogramControls.svelte +30 -28
  309. package/dist/plot/histogram/HistogramControls.svelte.d.ts +1 -1
  310. package/dist/plot/histogram/histogram.d.ts +31 -0
  311. package/dist/plot/histogram/histogram.js +70 -0
  312. package/dist/plot/histogram/index.d.ts +1 -0
  313. package/dist/plot/histogram/index.js +1 -0
  314. package/dist/plot/index.d.ts +1 -0
  315. package/dist/plot/index.js +1 -0
  316. package/dist/plot/sankey/Sankey.svelte +79 -73
  317. package/dist/plot/sankey/Sankey.svelte.d.ts +3 -3
  318. package/dist/plot/sankey/SankeyControls.svelte +22 -16
  319. package/dist/plot/sankey/sankey.js +3 -1
  320. package/dist/plot/scatter/BinnedScatterPlot.svelte +264 -112
  321. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +11 -4
  322. package/dist/plot/scatter/ElementScatter.svelte +4 -1
  323. package/dist/plot/scatter/ScatterPlot.svelte +623 -821
  324. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +9 -15
  325. package/dist/plot/scatter/ScatterPlotControls.svelte +18 -19
  326. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  327. package/dist/plot/scatter/ScatterPoint.svelte +16 -9
  328. package/dist/plot/scatter/binned-scatter-types.d.ts +6 -12
  329. package/dist/plot/scatter/binned-scatter-types.js +6 -1
  330. package/dist/plot/scatter/index.d.ts +1 -1
  331. package/dist/plot/scatter/scatter-data.d.ts +1 -1
  332. package/dist/plot/scatter/scatter-data.js +8 -7
  333. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +40 -60
  334. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +6 -14
  335. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +17 -15
  336. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte.d.ts +5 -5
  337. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +232 -234
  338. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +5 -14
  339. package/dist/plot/scatter-3d/Surface3D.svelte +9 -17
  340. package/dist/plot/scatter-3d/Surface3D.svelte.d.ts +4 -3
  341. package/dist/plot/sunburst/Sunburst.svelte +256 -338
  342. package/dist/plot/sunburst/Sunburst.svelte.d.ts +4 -3
  343. package/dist/plot/sunburst/index.d.ts +1 -1
  344. package/dist/plot/sunburst/index.js +2 -1
  345. package/dist/plot/sunburst/render.d.ts +2 -2
  346. package/dist/plot/sunburst/render.js +47 -22
  347. package/dist/plot/sunburst/sunburst.d.ts +5 -1
  348. package/dist/plot/sunburst/sunburst.js +14 -4
  349. package/dist/plot/treemap/Treemap.svelte +907 -0
  350. package/dist/plot/treemap/Treemap.svelte.d.ts +84 -0
  351. package/dist/plot/treemap/index.d.ts +3 -0
  352. package/dist/plot/treemap/index.js +4 -0
  353. package/dist/plot/treemap/treemap.d.ts +26 -0
  354. package/dist/plot/treemap/treemap.js +99 -0
  355. package/dist/rdf/RdfPlot.svelte +22 -26
  356. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  357. package/dist/rdf/calc-rdf.js +4 -8
  358. package/dist/sanitize.js +14 -3
  359. package/dist/scene/SceneCamera.svelte +62 -0
  360. package/dist/scene/SceneCamera.svelte.d.ts +19 -0
  361. package/dist/scene/bind-renderer.svelte.d.ts +2 -0
  362. package/dist/scene/bind-renderer.svelte.js +22 -0
  363. package/dist/scene/index.d.ts +4 -0
  364. package/dist/scene/index.js +5 -0
  365. package/dist/scene/props.js +52 -0
  366. package/dist/scene/types.d.ts +26 -0
  367. package/dist/scene/types.js +1 -0
  368. package/dist/settings.d.ts +33 -11
  369. package/dist/settings.js +136 -51
  370. package/dist/spectral/Bands.svelte +231 -237
  371. package/dist/spectral/Bands.svelte.d.ts +3 -2
  372. package/dist/spectral/BandsAndDos.svelte +29 -29
  373. package/dist/spectral/BrillouinBandsDos.svelte +21 -29
  374. package/dist/spectral/Dos.svelte +75 -77
  375. package/dist/spectral/Dos.svelte.d.ts +2 -1
  376. package/dist/spectral/helpers.d.ts +9 -9
  377. package/dist/spectral/helpers.js +66 -70
  378. package/dist/state.svelte.d.ts +0 -7
  379. package/dist/state.svelte.js +5 -24
  380. package/dist/structure/Arrow.svelte +6 -21
  381. package/dist/structure/ArrowInstances.svelte +160 -0
  382. package/dist/structure/ArrowInstances.svelte.d.ts +16 -0
  383. package/dist/structure/AtomLegend.svelte +115 -75
  384. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  385. package/dist/structure/Bond.svelte +31 -23
  386. package/dist/structure/CanvasTooltip.svelte +16 -3
  387. package/dist/structure/CellSelect.svelte +22 -29
  388. package/dist/structure/CellSelect.svelte.d.ts +2 -1
  389. package/dist/structure/Cylinder.svelte +24 -40
  390. package/dist/structure/InstancedAtoms.svelte +114 -0
  391. package/dist/structure/InstancedAtoms.svelte.d.ts +15 -0
  392. package/dist/structure/Lattice.svelte +85 -149
  393. package/dist/structure/SiteLabels.svelte +122 -0
  394. package/dist/structure/SiteLabels.svelte.d.ts +18 -0
  395. package/dist/structure/Structure.svelte +725 -735
  396. package/dist/structure/Structure.svelte.d.ts +8 -16
  397. package/dist/structure/StructureCarousel.svelte +630 -0
  398. package/dist/structure/StructureCarousel.svelte.d.ts +19 -0
  399. package/dist/structure/StructureControls.svelte +298 -325
  400. package/dist/structure/StructureControls.svelte.d.ts +5 -3
  401. package/dist/structure/StructureExportPane.svelte +62 -168
  402. package/dist/structure/StructureExportPane.svelte.d.ts +4 -5
  403. package/dist/structure/StructureInfoPane.svelte +77 -69
  404. package/dist/structure/StructureInfoPane.svelte.d.ts +6 -6
  405. package/dist/structure/StructureScene.svelte +758 -600
  406. package/dist/structure/StructureScene.svelte.d.ts +24 -20
  407. package/dist/structure/StructureViewport.svelte +386 -0
  408. package/dist/structure/StructureViewport.svelte.d.ts +63 -0
  409. package/dist/structure/atom-label-placement.d.ts +18 -0
  410. package/dist/structure/atom-label-placement.js +115 -0
  411. package/dist/structure/atom-properties.d.ts +3 -1
  412. package/dist/structure/atom-properties.js +115 -54
  413. package/dist/structure/bond-order-perception.js +2 -4
  414. package/dist/structure/bonding.d.ts +12 -2
  415. package/dist/structure/bonding.js +172 -73
  416. package/dist/structure/edit-history.d.ts +6 -0
  417. package/dist/structure/edit-history.js +27 -0
  418. package/dist/structure/export.d.ts +25 -4
  419. package/dist/structure/export.js +103 -153
  420. package/dist/structure/format-detect.d.ts +1 -0
  421. package/dist/structure/format-detect.js +22 -0
  422. package/dist/structure/geometry.d.ts +9 -0
  423. package/dist/structure/geometry.js +27 -0
  424. package/dist/structure/index.d.ts +18 -1
  425. package/dist/structure/index.js +15 -2
  426. package/dist/structure/measure.d.ts +1 -1
  427. package/dist/structure/measure.js +6 -12
  428. package/dist/structure/parse.d.ts +5 -3
  429. package/dist/structure/parse.js +551 -567
  430. package/dist/structure/partial-occupancy.d.ts +0 -1
  431. package/dist/structure/partial-occupancy.js +5 -3
  432. package/dist/structure/pbc.d.ts +1 -1
  433. package/dist/structure/pbc.js +204 -51
  434. package/dist/structure/polyhedra.d.ts +40 -0
  435. package/dist/structure/polyhedra.js +587 -0
  436. package/dist/structure/site.d.ts +4 -0
  437. package/dist/structure/site.js +1 -0
  438. package/dist/structure/supercell.d.ts +1 -2
  439. package/dist/structure/supercell.js +10 -15
  440. package/dist/structure/validation.js +5 -6
  441. package/dist/symmetry/SymmetryElementControls.svelte +69 -0
  442. package/dist/symmetry/SymmetryElementControls.svelte.d.ts +9 -0
  443. package/dist/symmetry/SymmetryElements.svelte +350 -0
  444. package/dist/symmetry/SymmetryElements.svelte.d.ts +24 -0
  445. package/dist/symmetry/SymmetryStats.svelte +142 -49
  446. package/dist/symmetry/WyckoffTable.svelte +58 -16
  447. package/dist/symmetry/WyckoffTable.svelte.d.ts +3 -0
  448. package/dist/symmetry/cell-transform.js +8 -14
  449. package/dist/symmetry/index.d.ts +15 -4
  450. package/dist/symmetry/index.js +308 -81
  451. package/dist/symmetry/spacegroups.d.ts +5 -1
  452. package/dist/symmetry/spacegroups.js +18 -3
  453. package/dist/symmetry/symmetry-elements.d.ts +33 -0
  454. package/dist/symmetry/symmetry-elements.js +521 -0
  455. package/dist/symmetry/wyckoff-db.d.ts +9 -0
  456. package/dist/symmetry/wyckoff-db.js +87 -0
  457. package/dist/table/HeatmapTable.svelte +1104 -378
  458. package/dist/table/HeatmapTable.svelte.d.ts +12 -2
  459. package/dist/table/ToggleMenu.svelte +5 -12
  460. package/dist/table/index.d.ts +21 -6
  461. package/dist/table/index.js +34 -21
  462. package/dist/theme/ThemeControl.svelte +16 -8
  463. package/dist/theme/index.d.ts +1 -5
  464. package/dist/theme/index.js +4 -10
  465. package/dist/theme/themes.mjs +14 -7
  466. package/dist/tooltip/KCoords.svelte +4 -1
  467. package/dist/tooltip/TooltipContent.svelte +2 -5
  468. package/dist/trajectory/Trajectory.svelte +556 -352
  469. package/dist/trajectory/Trajectory.svelte.d.ts +10 -22
  470. package/dist/trajectory/TrajectoryError.svelte +11 -8
  471. package/dist/trajectory/TrajectoryExportPane.svelte +28 -54
  472. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +4 -5
  473. package/dist/trajectory/TrajectoryInfoPane.svelte +88 -99
  474. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +3 -2
  475. package/dist/trajectory/constants.js +6 -2
  476. package/dist/trajectory/extract.js +73 -71
  477. package/dist/trajectory/format-detect.d.ts +0 -1
  478. package/dist/trajectory/format-detect.js +13 -17
  479. package/dist/trajectory/frame-reader.d.ts +2 -1
  480. package/dist/trajectory/frame-reader.js +74 -136
  481. package/dist/trajectory/helpers.d.ts +12 -2
  482. package/dist/trajectory/helpers.js +68 -37
  483. package/dist/trajectory/index.d.ts +1 -0
  484. package/dist/trajectory/index.js +3 -0
  485. package/dist/trajectory/parse/ase.d.ts +9 -1
  486. package/dist/trajectory/parse/ase.js +47 -32
  487. package/dist/trajectory/parse/diagnostics.d.ts +3 -0
  488. package/dist/trajectory/parse/diagnostics.js +14 -0
  489. package/dist/trajectory/parse/h5-utils.d.ts +13 -0
  490. package/dist/trajectory/parse/h5-utils.js +109 -0
  491. package/dist/trajectory/parse/hdf5.d.ts +1 -1
  492. package/dist/trajectory/parse/hdf5.js +116 -106
  493. package/dist/trajectory/parse/index.d.ts +3 -4
  494. package/dist/trajectory/parse/index.js +68 -119
  495. package/dist/trajectory/parse/lammps.d.ts +0 -2
  496. package/dist/trajectory/parse/lammps.js +14 -16
  497. package/dist/trajectory/parse/pymatgen.d.ts +2 -0
  498. package/dist/trajectory/parse/pymatgen.js +74 -0
  499. package/dist/trajectory/parse/vasp.js +13 -9
  500. package/dist/trajectory/parse/vaspout-electronic.d.ts +13 -0
  501. package/dist/trajectory/parse/vaspout-electronic.js +207 -0
  502. package/dist/trajectory/parse/vaspout-h5.d.ts +5 -0
  503. package/dist/trajectory/parse/vaspout-h5.js +284 -0
  504. package/dist/trajectory/parse/xyz.d.ts +9 -21
  505. package/dist/trajectory/parse/xyz.js +38 -39
  506. package/dist/trajectory/plotting.d.ts +8 -10
  507. package/dist/trajectory/plotting.js +102 -127
  508. package/dist/utils.d.ts +4 -0
  509. package/dist/utils.js +8 -1
  510. package/dist/xrd/XrdPlot.svelte +56 -85
  511. package/dist/xrd/broadening.d.ts +2 -1
  512. package/dist/xrd/broadening.js +2 -1
  513. package/dist/xrd/calc-xrd.js +8 -15
  514. package/dist/xrd/index.d.ts +2 -2
  515. package/dist/xrd/parse.d.ts +0 -1
  516. package/dist/xrd/parse.js +56 -73
  517. package/package.json +77 -36
  518. package/dist/api/mp.d.ts +0 -6
  519. package/dist/api/mp.js +0 -22
  520. package/dist/element/data.json +0 -11864
  521. package/dist/fermi-surface/marching-cubes.d.ts +0 -2
  522. package/dist/fermi-surface/marching-cubes.js +0 -2
  523. package/dist/plot/core/hover-lock.svelte.d.ts +0 -14
  524. package/dist/plot/core/hover-lock.svelte.js +0 -45
  525. package/dist/plot/sunburst/SunburstControls.svelte +0 -200
  526. package/dist/structure/label-placement.d.ts +0 -14
  527. package/dist/structure/label-placement.js +0 -72
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { D3InterpolateName } from '../colors'
3
- import { AXIS_COLORS, get_d3_interpolator, NEG_AXIS_COLORS } from '../colors'
3
+ import { get_d3_interpolator } from '../colors'
4
4
  import type { ElementSymbol } from '../element'
5
5
  import { element_data } from '../element'
6
6
  import Isosurface from '../isosurface/Isosurface.svelte'
@@ -9,12 +9,9 @@
9
9
  import { format_num } from '../labels'
10
10
  import type { Vec3 } from '../math'
11
11
  import * as math from '../math'
12
- import type {
13
- CameraProjection,
14
- ShowBonds,
15
- VectorColorMode,
16
- VectorLayerConfig,
17
- } from '../settings'
12
+ import { bind_renderer, build_orbit_props, SceneCamera } from '../scene'
13
+ import type { SceneControlProps } from '../scene'
14
+ import type { ShowBonds, VectorColorMode, VectorLayerConfig } from '../settings'
18
15
  import { DEFAULTS } from '../settings'
19
16
  import { create_pulse_animation } from '../effects.svelte'
20
17
  import { colors } from '../state.svelte'
@@ -28,7 +25,6 @@
28
25
  StructureBond,
29
26
  } from './'
30
27
  import {
31
- Arrow,
32
28
  atomic_radii,
33
29
  Cylinder,
34
30
  get_all_site_vectors,
@@ -37,11 +33,14 @@
37
33
  Lattice,
38
34
  VECTOR_PALETTE,
39
35
  } from './'
36
+ import ArrowInstances from './ArrowInstances.svelte'
37
+ import InstancedAtoms from './InstancedAtoms.svelte'
38
+ import SiteLabels from './SiteLabels.svelte'
40
39
  import type { AtomColorConfig } from './atom-properties'
41
- import {
42
- get_orig_site_idx,
43
- get_property_colors,
44
- } from './atom-properties'
40
+ import { get_orig_site_idx, get_property_colors } from './atom-properties'
41
+ import type { SymmetryElement } from '../symmetry'
42
+ import { has_visible_symmetry_overlay } from '../symmetry/symmetry-elements'
43
+ import SymmetryElements from '../symmetry/SymmetryElements.svelte'
45
44
  import * as measure from './measure'
46
45
  import {
47
46
  compute_slice_geometry,
@@ -49,53 +48,31 @@
49
48
  PARTIAL_OCCUPANCY_CAP_ARC,
50
49
  } from './partial-occupancy'
51
50
  import type { MoyoDataset } from '@spglib/moyo-wasm'
52
- import { T, useThrelte } from '@threlte/core'
51
+ import { T } from '@threlte/core'
53
52
  import * as extras from '@threlte/extras'
54
53
  import { type ComponentProps, type Snippet, untrack } from 'svelte'
55
54
  import { SvelteMap, SvelteSet } from 'svelte/reactivity'
56
- import { type Camera, Color, type Mesh, type Object3D, type Scene, Vector3 } from 'three'
55
+ import { BufferAttribute, BufferGeometry, Color, DoubleSide, Vector3 } from 'three'
56
+ import type { Mesh, Object3D } from 'three'
57
57
  import Bond from './Bond.svelte'
58
58
  import type { BondEditResult, BondingStrategy, BondKeyTarget } from './bonding'
59
59
  import {
60
60
  add_or_restore_bond,
61
- BONDING_STRATEGIES,
62
61
  BOND_ORDER_OPTIONS,
63
62
  canonicalize_bond_target,
63
+ compute_bonds,
64
64
  delete_bond as apply_delete_bond,
65
65
  get_bond_key,
66
66
  get_bond_render_matrices,
67
67
  get_explicit_bond_metadata,
68
+ get_majority_element,
68
69
  set_bond_order as apply_set_bond_order,
69
70
  structure_bond_to_bond_pair,
70
71
  } from './bonding'
71
- import {
72
- CanvasTooltip,
73
- compose_perceived_bonds,
74
- perceive_bond_orders,
75
- } from './index'
76
- import {
77
- choose_site_label_offset,
78
- LABEL_OFFSET_EPS,
79
- make_label_position_calculator,
80
- } from './label-placement'
81
-
82
- type InstancedAtomGroup = {
83
- element: string
84
- radius: number
85
- color: string
86
- is_image_atom: boolean
87
- atoms: (typeof atom_data)[number][]
88
- }
89
-
90
- function instanced_atom_group_key(
91
- { element, radius, color, is_image_atom, atoms }: InstancedAtomGroup,
92
- measure_mode: MeasureMode,
93
- ): string {
94
- const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom
95
- return `${element}-${format_num(radius, `.3~`)}-${color}-${
96
- is_image_atom ? `img` : `base`
97
- }-${edit_mode_image}-${atoms.length}`
98
- }
72
+ import { CanvasTooltip, compose_perceived_bonds, perceive_bond_orders } from './index'
73
+ import { choose_site_label_offset, LABEL_OFFSET_EPS } from './atom-label-placement'
74
+ import type { PolyhedraColorMode, Polyhedron } from './polyhedra'
75
+ import { compute_polyhedra, merge_polyhedra_buffers } from './polyhedra'
99
76
 
100
77
  type EditableAtomHitTarget = {
101
78
  site_idx: number
@@ -129,6 +106,7 @@
129
106
  same_size_atoms = false,
130
107
  camera_position = DEFAULTS.structure.camera_position,
131
108
  camera_target = undefined,
109
+ camera_direction = undefined,
132
110
  camera_projection = DEFAULTS.structure.camera_projection,
133
111
  rotation_damping = DEFAULTS.structure.rotation_damping,
134
112
  max_zoom = DEFAULTS.structure.max_zoom,
@@ -168,12 +146,27 @@
168
146
  auto_bond_order = DEFAULTS.structure.auto_bond_order,
169
147
  aromatic_display = DEFAULTS.structure.aromatic_display,
170
148
  bonding_options = {},
149
+ show_polyhedra = DEFAULTS.structure.show_polyhedra,
150
+ polyhedra_opacity = DEFAULTS.structure.polyhedra_opacity,
151
+ polyhedra_show_edges = DEFAULTS.structure.polyhedra_show_edges,
152
+ polyhedra_edge_color = DEFAULTS.structure.polyhedra_edge_color,
153
+ polyhedra_color_mode = DEFAULTS.structure.polyhedra_color_mode,
154
+ polyhedra_color = DEFAULTS.structure.polyhedra_color,
155
+ polyhedra_hide_center_atoms = DEFAULTS.structure.polyhedra_hide_center_atoms,
156
+ polyhedra_min_neighbors = DEFAULTS.structure.polyhedra_min_neighbors,
157
+ polyhedra_max_neighbors = DEFAULTS.structure.polyhedra_max_neighbors,
158
+ polyhedra_excluded_elements = DEFAULTS.structure.polyhedra_excluded_elements,
159
+ polyhedra_included_elements = DEFAULTS.structure.polyhedra_included_elements,
160
+ polyhedra_rendered_elements = $bindable<string[]>([]),
171
161
  fov = DEFAULTS.structure.fov,
172
162
  initial_zoom = DEFAULTS.structure.initial_zoom,
173
163
  ambient_light = DEFAULTS.structure.ambient_light,
174
164
  directional_light = DEFAULTS.structure.directional_light,
175
165
  sphere_segments = DEFAULTS.structure.sphere_segments,
176
166
  lattice_props = {},
167
+ symmetry_elements = [],
168
+ symmetry_elements_props = {},
169
+ symmetry_declutter = true,
177
170
  atom_label,
178
171
  camera_is_moving = $bindable(false),
179
172
  width = 0,
@@ -218,7 +211,8 @@
218
211
  dragging_atoms = $bindable(false),
219
212
  volumetric_data = undefined,
220
213
  isosurface_settings = DEFAULT_ISOSURFACE_SETTINGS,
221
- }: {
214
+ interactive = true,
215
+ }: SceneControlProps & {
222
216
  structure?: AnyStructure
223
217
  base_structure?: AnyStructure // The original structure without image atoms, used for property color calculation
224
218
  atom_radius?: number // scale factor for atomic radii
@@ -226,15 +220,9 @@
226
220
  // determined by the atomic radius of the element
227
221
  camera_position?: [x: number, y: number, z: number] // initial camera position from which to render the scene
228
222
  camera_target?: Vec3 // external orbit-controls target for pan synchronization
229
- camera_projection?: CameraProjection // camera projection type
230
- rotation_damping?: number // rotation damping factor (how quickly the rotation comes to rest after mouse release)
231
- // zoom level of the camera
232
- max_zoom?: number
233
- min_zoom?: number
234
- rotate_speed?: number // rotation speed. set to 0 to disable rotation.
235
- zoom_speed?: number // zoom speed. set to 0 to disable zooming.
236
- pan_speed?: number // pan speed. set to 0 to disable panning.
237
- zoom_to_cursor?: boolean // zoom toward cursor position instead of scene center
223
+ // When set (and camera_position is unset/zero), auto-place the camera along this
224
+ // direction from the structure center (used by the multi-side view for fixed angles)
225
+ camera_direction?: Vec3
238
226
  show_atoms?: boolean
239
227
  show_bonds?: ShowBonds
240
228
  show_site_labels?: boolean
@@ -250,30 +238,49 @@
250
238
  vector_shaft_radius?: number
251
239
  vector_arrow_head_radius?: number
252
240
  vector_arrow_head_length?: number
253
- gizmo?: boolean | ComponentProps<typeof extras.Gizmo>
254
241
  hovered_idx?: number | null
255
242
  hovered_site?: Site | null
256
243
  float_fmt?: string
257
- auto_rotate?: number
258
- initial_zoom?: number
259
244
  bond_thickness?: number
260
245
  bond_color?: string
261
246
  bonding_strategy?: BondingStrategy
262
247
  auto_bond_order?: boolean
263
248
  aromatic_display?: `aromatic` | `kekule`
264
249
  bonding_options?: Record<string, unknown>
265
- fov?: number
266
- ambient_light?: number
267
- directional_light?: number
250
+ show_polyhedra?: ShowBonds // when to render coordination polyhedra
251
+ polyhedra_opacity?: number
252
+ polyhedra_show_edges?: boolean
253
+ polyhedra_edge_color?: string
254
+ polyhedra_color_mode?: PolyhedraColorMode
255
+ polyhedra_color?: string // custom color used when polyhedra_color_mode is 'uniform'
256
+ polyhedra_hide_center_atoms?: boolean
257
+ polyhedra_min_neighbors?: number // min coordination number to form a polyhedron
258
+ polyhedra_max_neighbors?: number // max CN - skips e.g. CN-12 cuboctahedra
259
+ polyhedra_excluded_elements?: readonly string[] // elements never used as polyhedra centers
260
+ polyhedra_included_elements?: readonly string[] // force-include (bypasses spectator hiding)
261
+ polyhedra_rendered_elements?: string[] // (output) elements that currently have polyhedra
268
262
  sphere_segments?: number
269
263
  lattice_props?: ComponentProps<typeof Lattice>
264
+ // Symmetry elements (from symmetry_elements_from_ops) to overlay on the structure.
265
+ // Fractional coords must refer to the SAME cell as the rendered lattice (moyo
266
+ // operations are in the input-cell frame, i.e. the original untransformed cell).
267
+ symmetry_elements?: SymmetryElement[]
268
+ symmetry_elements_props?: Omit<
269
+ ComponentProps<typeof SymmetryElements>,
270
+ `elements` | `lattice`
271
+ >
272
+ // Auto-reduce visual clutter while a symmetry-element overlay is visible: hides
273
+ // coordination polyhedra and calculated bonds, and shrinks atoms so axes/planes/
274
+ // centers stay readable. Purely derived — toggling the overlay off restores the
275
+ // configured appearance.
276
+ symmetry_declutter?: boolean
270
277
  atom_label?: Snippet<[{ site: Site; site_idx: number }]>
271
278
  site_label_size?: number
272
279
  site_label_offset?: Vec3
273
280
  site_label_bg_color?: string
274
281
  site_label_color?: string
275
282
  site_label_padding?: number
276
- camera_is_moving?: boolean // used to prevent tooltip from showing while camera is moving
283
+ camera_is_moving?: boolean // bindable: true while orbit controls are active
277
284
  width?: number // Viewer dimensions for responsive zoom
278
285
  height?: number
279
286
  // measurement props
@@ -291,9 +298,6 @@
291
298
  active_sites?: number[]
292
299
  active_highlight_color?: string
293
300
  rotation?: Vec3 // rotation control prop
294
- // Expose scene and camera for external use (e.g. export pane)
295
- scene?: Scene
296
- camera?: Camera
297
301
  orbit_controls?: ComponentProps<typeof extras.OrbitControls>[`ref`] // OrbitControls instance
298
302
  rotation_target_ref?: Vec3 // Expose rotation target for reset
299
303
  initial_computed_zoom?: number // Expose initial zoom for reset
@@ -314,6 +318,9 @@
314
318
  dragging_atoms?: boolean // true while TransformControls drag is active (skips expensive recalculations)
315
319
  volumetric_data?: VolumetricData // Active volumetric data for isosurface rendering
316
320
  isosurface_settings?: IsosurfaceSettings // Isosurface rendering settings
321
+ // When false, render the scene without hover/edit raycast helpers. Used by multi-side
322
+ // view so inactive panes skip interaction-only work while the active pane stays editable.
323
+ interactive?: boolean
317
324
  } = $props()
318
325
 
319
326
  const pulse = create_pulse_animation(
@@ -322,13 +329,9 @@
322
329
  )
323
330
  let pulse_opacity = $derived(0.15 + 0.25 * pulse.unit)
324
331
 
325
- const threlte = useThrelte()
326
- $effect(() => {
327
- scene = threlte.scene
328
- camera = threlte.camera.current
329
- if (threlte.renderer) {
330
- Object.assign(threlte.renderer.domElement, { __renderer: threlte.renderer })
331
- }
332
+ bind_renderer((threlte_scene, threlte_camera) => {
333
+ scene = threlte_scene
334
+ camera = threlte_camera
332
335
  })
333
336
 
334
337
  // Expose rotation target for external reset
@@ -345,7 +348,6 @@
345
348
  initial_computed_zoom = stored_initial_zoom
346
349
  })
347
350
 
348
- let bond_pairs: BondPair[] = $state([])
349
351
  let atom_tooltip_active = $state(false)
350
352
  let hovered_bond_key = $state<string | null>(null)
351
353
  const ATOM_HOVER_CLEAR_DELAY_MS = 200
@@ -373,20 +375,18 @@
373
375
  }, ATOM_HOVER_CLEAR_DELAY_MS)
374
376
  }
375
377
 
376
- const atom_hover_props = (site_idx: number | null, disabled = false) => ({
377
- onpointerenter: () => {
378
- if (!disabled && site_idx != null) set_atom_hover(site_idx)
379
- },
380
- onpointermove: () => {
381
- if (!disabled && site_idx != null) set_atom_hover(site_idx)
382
- },
383
- onpointerleave: () => {
384
- if (!disabled && site_idx != null) schedule_atom_hover_clear(site_idx)
385
- },
386
- })
378
+ const atom_hover_props = (site_idx: number | null) =>
379
+ !interactive || site_idx == null
380
+ ? {}
381
+ : {
382
+ onpointerenter: () => set_atom_hover(site_idx),
383
+ onpointermove: () => set_atom_hover(site_idx),
384
+ onpointerleave: () => schedule_atom_hover_clear(site_idx),
385
+ }
387
386
 
388
387
  // Cursor style for the canvas, derived from mode and hover state
389
388
  let canvas_cursor = $derived.by(() => {
389
+ if (!interactive) return `default`
390
390
  if (measure_mode === `edit-atoms` && add_atom_mode) return `crosshair`
391
391
  if (measure_mode === `edit-bonds` && hovered_bond_key != null) {
392
392
  return bond_edits_enabled ? `pointer` : `not-allowed`
@@ -400,7 +400,6 @@
400
400
  if (measure_mode === `edit-atoms`) {
401
401
  const site = structure?.sites?.[hovered_idx]
402
402
  if (site?.properties?.orig_site_idx != null) return `not-allowed`
403
- return `pointer`
404
403
  }
405
404
  return `pointer`
406
405
  }
@@ -455,8 +454,9 @@
455
454
  ): BondKeyTarget {
456
455
  const rendered_target = { site_idx_1, site_idx_2, cell_shift }
457
456
  const rendered_key = rendered_bond_key_for(rendered_target)
458
- return find_added_bond_by_rendered_key(rendered_key) ??
459
- canonical_bond_target(rendered_target)
457
+ return (
458
+ find_added_bond_by_rendered_key(rendered_key) ?? canonical_bond_target(rendered_target)
459
+ )
460
460
  }
461
461
 
462
462
  const is_image_bond_site = (site_idx: number): boolean =>
@@ -467,9 +467,11 @@
467
467
 
468
468
  const can_edit_bond = (bond: BondKeyTarget): boolean => {
469
469
  const target = canonical_bond_target(bond)
470
- return bond_edits_enabled &&
470
+ return (
471
+ bond_edits_enabled &&
471
472
  !is_image_bond_site(target.site_idx_1) &&
472
473
  !is_image_bond_site(target.site_idx_2)
474
+ )
473
475
  }
474
476
 
475
477
  const format_bond_order = (order: BondOrder | undefined): string =>
@@ -481,10 +483,12 @@
481
483
  cell_shift?: Vec3,
482
484
  ): BondOrder | undefined {
483
485
  const key = get_bond_key(site_idx_1, site_idx_2, cell_shift)
484
- return find_added_bond_by_rendered_key(key)?.order ??
486
+ return (
487
+ find_added_bond_by_rendered_key(key)?.order ??
485
488
  bond_order_overrides.find((bond) => matches_bond_key(bond, key))?.order ??
486
489
  added_bonds.find((bond) => matches_bond_key(bond, key))?.order ??
487
490
  filtered_bond_pairs.find((bond) => matches_bond_key(bond, key))?.bond_order
491
+ )
488
492
  }
489
493
 
490
494
  const midpoint = (pos_1: Vec3, pos_2: Vec3): Vec3 => [
@@ -534,9 +538,10 @@
534
538
  return site_idx
535
539
  }
536
540
 
537
- const image_site_idx = structure.sites.findIndex((candidate_site) =>
538
- candidate_site.properties?.orig_site_idx === site_idx &&
539
- matches_world_position(candidate_site)
541
+ const image_site_idx = structure.sites.findIndex(
542
+ (candidate_site) =>
543
+ candidate_site.properties?.orig_site_idx === site_idx &&
544
+ matches_world_position(candidate_site),
540
545
  )
541
546
  return image_site_idx === -1 ? site_idx : image_site_idx
542
547
  }
@@ -571,10 +576,7 @@
571
576
 
572
577
  let label_screen_margin = $derived(site_label_size * 10 + site_label_padding)
573
578
 
574
- function get_bond_context_menu_position(
575
- bond: BondPair,
576
- event?: BondContextMenuEvent,
577
- ): Vec3 {
579
+ function get_bond_context_menu_position(bond: BondPair, event?: BondContextMenuEvent): Vec3 {
578
580
  const parent = event?.object?.parent
579
581
  if (!event?.point || !parent) return midpoint(bond.pos_1, bond.pos_2)
580
582
 
@@ -615,8 +617,10 @@
615
617
  ): BondPair | undefined => {
616
618
  const rendered_key = rendered_bond_key_for(target)
617
619
  const canonical_key = bond_key_for(canonical_target)
618
- return filtered_bond_pairs.find((bond) => rendered_bond_key_for(bond) === rendered_key) ??
620
+ return (
621
+ filtered_bond_pairs.find((bond) => rendered_bond_key_for(bond) === rendered_key) ??
619
622
  filtered_bond_pairs.find((bond) => bond_key_for(bond) === canonical_key)
623
+ )
620
624
  }
621
625
 
622
626
  function open_bond_order_menu_for_target(
@@ -628,6 +632,7 @@
628
632
  }
629
633
 
630
634
  function add_or_restore_pair(site_idx_1: number, site_idx_2: number) {
635
+ if (!interactive) return // inactive panes must not mutate shared bond state
631
636
  const rendered_target = { site_idx_1, site_idx_2 }
632
637
  if (!can_edit_bond(rendered_target)) return
633
638
  const edit_state = current_bond_edit_state()
@@ -685,11 +690,7 @@
685
690
  const target = resolve_bond_edit_target(site_idx_1, site_idx_2, cell_shift)
686
691
  if (!can_edit_bond(target)) return
687
692
  apply_bond_edit_result(
688
- apply_delete_bond(
689
- current_bond_edit_state(),
690
- target,
691
- editable_perceived_bond_pairs,
692
- ),
693
+ apply_delete_bond(current_bond_edit_state(), target, editable_perceived_bond_pairs),
693
694
  )
694
695
  }
695
696
 
@@ -703,12 +704,10 @@
703
704
  // intercept the same native click, only the first intersection should fire.
704
705
  // All threlte intersection events from one click share the same nativeEvent ref.
705
706
  let last_native_event: Event | null = null
706
- // extras.Instance does not always emit pointerdown, so edit-bonds also falls
707
- // back to click. When pointerdown did fire, skip the matching click once.
707
+ // Instanced-atom raycasts do not always emit pointerdown, so edit-bonds also
708
+ // falls back to click. When pointerdown did fire, skip the matching click once.
708
709
  let last_edit_bonds_pointerdown_site_idx: number | null = null
709
- let clear_edit_bonds_pointerdown_site_timeout:
710
- | ReturnType<typeof setTimeout>
711
- | null = null
710
+ let clear_edit_bonds_pointerdown_site_timeout: ReturnType<typeof setTimeout> | null = null
712
711
 
713
712
  function remember_edit_bonds_pointerdown_site(site_idx: number) {
714
713
  last_edit_bonds_pointerdown_site_idx = site_idx
@@ -737,9 +736,62 @@
737
736
  return true
738
737
  }
739
738
 
739
+ // Selection handlers shared by instanced atom meshes and per-site hit targets
740
+ // so the edit-bonds click semantics can't drift between the two paths
741
+ const handle_atom_pointerdown = (site_idx: number, event: PointerEvent) => {
742
+ if (measure_mode !== `edit-bonds` || bond_edit_mode !== `add`) return
743
+ select_edit_bonds_site(site_idx, event)
744
+ }
745
+ const handle_atom_click = (site_idx: number, event: MouseEvent) => {
746
+ if (measure_mode === `edit-bonds`) {
747
+ if (bond_edit_mode !== `add`) return
748
+ if (skip_duplicate_edit_bonds_click(site_idx)) {
749
+ event.stopPropagation()
750
+ return
751
+ }
752
+ }
753
+ toggle_selection(site_idx, event)
754
+ }
755
+
756
+ // Pointer handlers for an instanced atom mesh: intersection events carry the
757
+ // hit `instanceId`, which indexes into the mesh's `atoms` array. One handler
758
+ // set per mesh instead of one per atom. Inactive grid panes render without
759
+ // raycast handlers; ghosted edit-mode image atoms are non-interactive.
760
+ type InstanceEvent = { instanceId?: number }
761
+ const atom_instance_events = (
762
+ instance_atoms: { site_idx: number }[],
763
+ is_edit_image: boolean,
764
+ ) => {
765
+ if (!interactive || is_edit_image) return {}
766
+ const wrap =
767
+ <Event_ extends InstanceEvent>(handler: (site_idx: number, event: Event_) => void) =>
768
+ (event: Event_) => {
769
+ const site_idx = instance_atoms[event.instanceId ?? -1]?.site_idx
770
+ if (site_idx != null) handler(site_idx, event)
771
+ }
772
+ return {
773
+ onpointerenter: wrap(set_atom_hover),
774
+ onpointermove: wrap(set_atom_hover),
775
+ onpointerleave: wrap(schedule_atom_hover_clear),
776
+ onpointerdown: wrap<PointerEvent & InstanceEvent>(handle_atom_pointerdown),
777
+ onclick: wrap<MouseEvent & InstanceEvent>(handle_atom_click),
778
+ }
779
+ }
780
+
781
+ // Pointer props (hover + select) for per-site hit-target meshes (partial-occupancy
782
+ // sites), with the same interactivity gating as atom_instance_events
783
+ const atom_pointer_props = (site_idx: number, is_edit_image: boolean) =>
784
+ !interactive || is_edit_image
785
+ ? {}
786
+ : {
787
+ ...atom_hover_props(site_idx),
788
+ onpointerdown: (event: PointerEvent) => handle_atom_pointerdown(site_idx, event),
789
+ onclick: (event: MouseEvent) => handle_atom_click(site_idx, event),
790
+ }
791
+
740
792
  function toggle_selection(site_index: number, evt?: Event) {
741
793
  evt?.stopPropagation?.()
742
- const event_with_native = evt as Event & { nativeEvent?: unknown } | undefined
794
+ const event_with_native = evt as (Event & { nativeEvent?: unknown }) | undefined
743
795
  const native_event = event_with_native?.nativeEvent ?? evt
744
796
  if (native_event instanceof Event) {
745
797
  if (native_event === last_native_event) return
@@ -747,6 +799,9 @@
747
799
  }
748
800
 
749
801
  if (measure_mode === `edit-bonds`) {
802
+ // Only the active pane edits: inactive panes keep selection/hover overlays visible
803
+ // but must not change selection or add/restore bonds in shared state.
804
+ if (!interactive) return
750
805
  if (bond_edit_mode === `delete`) {
751
806
  measured_sites = []
752
807
  selected_sites = []
@@ -771,12 +826,16 @@
771
826
  }
772
827
 
773
828
  if (measure_mode === `edit-atoms`) {
829
+ // Inactive panes don't drive edit-atoms selection (gizmo/add-plane are interactive-gated)
830
+ if (!interactive) return
774
831
  // Block image atoms (detected by orig_site_idx property from PBC)
775
832
  const site = structure?.sites?.[site_index]
776
833
  if (site?.properties?.orig_site_idx != null) return
777
834
 
778
835
  const is_selected = selected_sites.includes(site_index)
779
- const is_shift = evt instanceof MouseEvent && evt.shiftKey
836
+ // threlte dispatches plain objects wrapping the DOM event, so read shift
837
+ // from the extracted native event (evt itself is never a MouseEvent for 3D hits)
838
+ const is_shift = native_event instanceof MouseEvent && native_event.shiftKey
780
839
 
781
840
  // In edit-atoms mode, selected_sites and measured_sites always stay in sync
782
841
  let new_sites: number[]
@@ -826,11 +885,15 @@
826
885
  $effect(() => {
827
886
  const count = structure?.sites?.length ?? 0
828
887
  if (count <= 0) {
829
- measured_sites = []
888
+ if (untrack(() => measured_sites.length) > 0) measured_sites = []
830
889
  return
831
890
  }
832
891
  untrack(() => {
833
- measured_sites = measured_sites.filter((idx) => idx >= 0 && idx < count)
892
+ // Only reassign when out-of-range indices were dropped: a fresh (equal)
893
+ // array identity on every structure change would ripple through all
894
+ // measured_sites bindings and their dependents each frame of a trajectory.
895
+ const filtered = measured_sites.filter((idx) => idx >= 0 && idx < count)
896
+ if (filtered.length !== measured_sites.length) measured_sites = filtered
834
897
  })
835
898
  })
836
899
 
@@ -842,9 +905,7 @@
842
905
  $effect.pre(() => {
843
906
  hovered_site = structure?.sites?.[hovered_idx ?? -1] ?? null
844
907
  })
845
- let lattice = $derived(
846
- structure && `lattice` in structure ? structure.lattice : null,
847
- )
908
+ let lattice = $derived(structure && `lattice` in structure ? structure.lattice : null)
848
909
 
849
910
  let visual_lattice = $derived(
850
911
  base_structure && `lattice` in base_structure ? base_structure.lattice : lattice,
@@ -852,11 +913,13 @@
852
913
 
853
914
  let rotation_target = $derived(
854
915
  lattice
855
- ? (math.scale(math.add(...lattice.matrix), 0.5) as Vec3)
916
+ ? math.scale(math.add(...lattice.matrix), 0.5)
856
917
  : structure
857
- ? get_center_of_mass(structure)
858
- : [0, 0, 0] as Vec3,
918
+ ? get_center_of_mass(structure)
919
+ : ([0, 0, 0] as Vec3),
859
920
  )
921
+ // Negated target for the inner un-translate group (recomputed only on target change)
922
+ let neg_rotation_target = $derived(math.scale(rotation_target, -1) as Vec3)
860
923
 
861
924
  let structure_size = $derived.by(() => {
862
925
  if (lattice) return (lattice.a + lattice.b + lattice.c) / 2
@@ -873,8 +936,8 @@
873
936
  // Excludes PBC image atoms (orig_site_idx) so toggling image atoms doesn't affect arrow sizing.
874
937
  let char_atom_spacing = $derived.by(() => {
875
938
  if (!lattice || !structure?.sites?.length) return structure_size
876
- const n_real = structure.sites.filter((site) =>
877
- site.properties?.orig_site_idx == null
939
+ const n_real = structure.sites.filter(
940
+ (site) => site.properties?.orig_site_idx == null,
878
941
  ).length
879
942
  return n_real > 0 ? Math.cbrt(lattice.volume / n_real) : structure_size
880
943
  })
@@ -905,9 +968,19 @@
905
968
 
906
969
  // Using $state because this is mutated in an effect based on viewport/structure size
907
970
  let computed_zoom = $state(untrack(() => initial_zoom))
971
+ // structure_size is read untracked so structure changes don't re-zoom the user's view;
972
+ // zoom only re-frames on a genuine viewport resize. Skip same-value width/height
973
+ // re-fires (a wrapping component can transiently re-emit clientWidth/Height during a
974
+ // structure swap) so they don't sneak in a re-zoom with the new structure_size.
975
+ let last_zoom_dims: [number, number] = [0, 0]
908
976
  $effect(() => {
909
977
  if (!(width > 0) || !(height > 0)) return
910
- const structure_max_dim = Math.max(1, untrack(() => structure_size))
978
+ if (width === last_zoom_dims[0] && height === last_zoom_dims[1]) return
979
+ last_zoom_dims = [width, height]
980
+ const structure_max_dim = Math.max(
981
+ 1,
982
+ untrack(() => structure_size),
983
+ )
911
984
  const viewer_min_dim = Math.min(width, height)
912
985
  const scale_factor = viewer_min_dim / (structure_max_dim * 50) // 50px per unit
913
986
  let new_zoom = initial_zoom * scale_factor
@@ -916,24 +989,69 @@
916
989
  computed_zoom = new_zoom
917
990
  })
918
991
 
919
- $effect.pre(() => { // Simple initial camera auto-position: proportional to structure size and fov
992
+ $effect.pre(() => {
993
+ // Simple initial camera auto-position: proportional to structure size and fov
920
994
  if (camera_position.every((val) => val === 0) && structure) {
921
995
  stored_initial_zoom = undefined
922
996
  const distance = Math.max(1, structure_size) * (60 / fov)
923
- camera_position = [distance, distance * 0.3, distance * 0.8]
997
+ // When a view direction is given (multi-side view), place the camera
998
+ // target-relative along it; otherwise use the default angled position.
999
+ // normalize_vec returns [0,0,0] for an absent or zero-length direction (arrays are
1000
+ // truthy, so a plain `camera_direction ?` check would miss [0,0,0]); treat that as
1001
+ // "no direction" and fall back to the default, since placing the camera on the
1002
+ // rotation target would be a degenerate zero-length view.
1003
+ const view_dir: Vec3 = camera_direction
1004
+ ? math.normalize_vec(camera_direction, [0, 0, 0])
1005
+ : [0, 0, 0]
1006
+ camera_position = view_dir.some((val) => val !== 0)
1007
+ ? math.add(rotation_target, math.scale(view_dir, distance))
1008
+ : [distance, distance * 0.3, distance * 0.8]
924
1009
  }
925
1010
  })
926
- $effect(() => {
927
- if (structure && show_bonds !== `never`) {
928
- // Determine if we should show bonds based on the setting and structure type
929
- const should_show_bonds = show_bonds === `always` ||
930
- (show_bonds === `crystals` && lattice) ||
931
- (show_bonds === `molecules` && !lattice)
932
-
933
- if (should_show_bonds) {
934
- bond_pairs = BONDING_STRATEGIES[bonding_strategy](structure, bonding_options)
935
- } else bond_pairs = []
936
- } else bond_pairs = []
1011
+ // Whether a never|always|crystals|molecules setting applies to the current structure
1012
+ const applies_to_structure = (when: ShowBonds): boolean =>
1013
+ when === `always` ||
1014
+ (when === `crystals` && Boolean(lattice)) ||
1015
+ (when === `molecules` && !lattice)
1016
+
1017
+ // Declutter while a symmetry-element overlay actually draws something (elements present
1018
+ // AND an enabled kind among them): hide coordination polyhedra/bonds and shrink atoms so
1019
+ // axes/planes/centers stay readable. Gating on visibility (not just `symmetry_elements`)
1020
+ // avoids hiding everything when nothing renders in its place — e.g. an inversion-only
1021
+ // cell under the rotation-only default. Pure derived overrides: the configured
1022
+ // appearance returns untouched the moment the overlay goes away.
1023
+ const declutter_active = $derived(
1024
+ symmetry_declutter &&
1025
+ has_visible_symmetry_overlay(symmetry_elements, symmetry_elements_props.show_kinds),
1026
+ )
1027
+ const effective_show_polyhedra: ShowBonds = $derived(
1028
+ declutter_active ? `never` : show_polyhedra,
1029
+ )
1030
+ // Calculated bonds are hidden in declutter mode (only their cylinders — bond_pairs
1031
+ // stay computed so tooltips and manually added bonds keep working)
1032
+ const effective_show_bonds: ShowBonds = $derived(declutter_active ? `never` : show_bonds)
1033
+ const effective_atom_radius = $derived(declutter_active ? atom_radius * 0.6 : atom_radius)
1034
+
1035
+ // Derived (not effect + state) so downstream consumers (filtering, polyhedra,
1036
+ // instanced bond buffers) recompute exactly once per structure change instead
1037
+ // of once with stale bonds and again after an effect flush. The mutable cache
1038
+ // lets atom drags freeze the last computed value (recompute happens on release).
1039
+ // Bonds are computed when either bond rendering or polyhedra need them. The
1040
+ // raw/effective mix is deliberate: RAW show_bonds keeps bond_pairs available
1041
+ // during symmetry declutter (cylinders hide via effective_show_bonds in
1042
+ // bonds_to_render, but tooltips + manually added bonds still need the data),
1043
+ // while EFFECTIVE show_polyhedra skips computing bonds whose only consumer —
1044
+ // the polyhedra $derived below, gated on the same effective value — won't run.
1045
+ let last_bond_pairs: BondPair[] = []
1046
+ let bond_pairs: BondPair[] = $derived.by(() => {
1047
+ if (dragging_atoms) return last_bond_pairs
1048
+ const want_bonds = applies_to_structure(show_bonds)
1049
+ const want_polyhedra = applies_to_structure(effective_show_polyhedra)
1050
+ last_bond_pairs =
1051
+ structure && (want_bonds || want_polyhedra)
1052
+ ? compute_bonds(structure, bonding_strategy, bonding_options)
1053
+ : []
1054
+ return last_bond_pairs
937
1055
  })
938
1056
 
939
1057
  // Compute property-based colors when not using element coloring
@@ -957,9 +1075,16 @@
957
1075
  return total_occu > 0 ? weighted_sum / total_occu : 1
958
1076
  }
959
1077
 
1078
+ // Disordered sites are often stored as separate split sites (one species each)
1079
+ // at the same position; merge_split_partial_sites groups them into one render
1080
+ // site whose `species` holds every element. Shared by atom rendering and the
1081
+ // hover tooltip so it lists all elements, not just the majority one.
1082
+ let render_sites = $derived(
1083
+ structure?.sites ? merge_split_partial_sites(structure.sites, hidden_elements) : [],
1084
+ )
1085
+
960
1086
  let atom_data = $derived.by(() => {
961
- if (!show_atoms || !structure?.sites) return []
962
- const render_sites = merge_split_partial_sites(structure.sites, hidden_elements)
1087
+ if (!show_atoms) return []
963
1088
  return render_sites.flatMap(({ site_idx, site, is_image_atom }) => {
964
1089
  const orig_idx = get_orig_site_idx(site, site_idx)
965
1090
 
@@ -967,19 +1092,34 @@
967
1092
  const prop_val = property_colors?.values[orig_idx]
968
1093
  if (prop_val !== undefined && hidden_prop_vals.has(prop_val)) return []
969
1094
 
1095
+ // Optionally hide atoms at the center of a rendered polyhedron
1096
+ if (polyhedra_hide_center_atoms && polyhedra_center_site_idxs.has(site_idx)) {
1097
+ return []
1098
+ }
1099
+
1100
+ // Phase-2 PBC images exist only to complete bonds/coordination polyhedra at
1101
+ // cell faces. When neither renders (polyhedra toggled off, symmetry declutter,
1102
+ // …) they'd float disconnected outside the cell — hide them.
1103
+ if (
1104
+ site.properties?.completion_image &&
1105
+ !applies_to_structure(effective_show_bonds) &&
1106
+ !applies_to_structure(effective_show_polyhedra)
1107
+ )
1108
+ return []
1109
+
970
1110
  // Calculate radius: same_size > site override > element override > default
971
1111
  // All radii scale uniformly with atom_radius for consistent slider behavior
972
1112
  const base_radius = same_size_atoms
973
1113
  ? 1
974
- : site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site)
975
- const radius = base_radius * atom_radius
1114
+ : (site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site))
1115
+ const radius = base_radius * effective_atom_radius
976
1116
 
977
1117
  // Use property color if available (e.g. coordination number, Wyckoff position)
978
1118
  // Otherwise, each species gets its own element color (important for disordered sites)
979
1119
  const site_property_color = property_colors?.colors[orig_idx]
980
1120
 
981
- const visible_species = site.species.filter(({ element }) =>
982
- !hidden_elements.has(element)
1121
+ const visible_species = site.species.filter(
1122
+ ({ element }) => !hidden_elements.has(element),
983
1123
  )
984
1124
  const slice_geometry = compute_slice_geometry(visible_species)
985
1125
  return slice_geometry.map((slice_data) => {
@@ -1007,13 +1147,12 @@
1007
1147
  const is_site_visible = (site_idx: number): boolean => {
1008
1148
  if (!structure?.sites) return false
1009
1149
  const site = structure.sites[site_idx]
1010
- const has_visible_element = site?.species.some(({ element }) =>
1011
- !hidden_elements.has(element)
1150
+ const has_visible_element = site?.species.some(
1151
+ ({ element }) => !hidden_elements.has(element),
1012
1152
  )
1013
1153
  const orig_idx = get_orig_site_idx(site, site_idx)
1014
1154
  const prop_val = property_colors?.values[orig_idx]
1015
- const prop_visible = prop_val === undefined ||
1016
- !hidden_prop_vals.has(prop_val)
1155
+ const prop_visible = prop_val === undefined || !hidden_prop_vals.has(prop_val)
1017
1156
  return has_visible_element && prop_visible
1018
1157
  }
1019
1158
 
@@ -1039,19 +1178,17 @@
1039
1178
  })
1040
1179
 
1041
1180
  let editable_perceived_bond_pairs = $derived(
1042
- perceived_bond_pairs.map((bond) => ({ ...bond, ...canonical_bond_target(bond) })),
1181
+ interactive && bond_edits_enabled
1182
+ ? perceived_bond_pairs.map((bond) => ({ ...bond, ...canonical_bond_target(bond) }))
1183
+ : [],
1043
1184
  )
1044
1185
 
1045
1186
  let filtered_bond_pairs = $derived.by(() => {
1046
1187
  if (!structure?.sites) return perceived_bond_pairs
1047
1188
 
1048
1189
  // Build set of removed bond keys for efficient lookup
1049
- const removed_keys = new Set(
1050
- removed_bonds.map(bond_key_for),
1051
- )
1052
- const added_keys = new Set(
1053
- added_bonds.map(bond_key_for),
1054
- )
1190
+ const removed_keys = new Set(removed_bonds.map(bond_key_for))
1191
+ const added_keys = new Set(added_bonds.map(bond_key_for))
1055
1192
  const order_overrides = new Map(
1056
1193
  bond_order_overrides.map((bond) => [bond_key_for(bond), bond.order]),
1057
1194
  )
@@ -1079,20 +1216,117 @@
1079
1216
  return [...calculated, ...added]
1080
1217
  })
1081
1218
 
1219
+ // Bonds drawn as cylinders. When show_bonds doesn't apply, calculated bonds are
1220
+ // hidden but manually added bonds stay visible (bond_pairs may still be computed
1221
+ // for polyhedra, so this can't rely on bond_pairs being empty).
1222
+ let bonds_to_render = $derived.by(() => {
1223
+ if (applies_to_structure(effective_show_bonds)) return filtered_bond_pairs
1224
+ const added_keys = new Set(added_bonds.map(bond_key_for))
1225
+ return filtered_bond_pairs.filter((bond) => added_keys.has(bond_key_for(bond)))
1226
+ })
1227
+
1082
1228
  let editable_bond_pairs = $derived(
1083
- bond_edits_enabled ? filtered_bond_pairs.filter(can_edit_bond) : [],
1229
+ interactive && bond_edits_enabled ? bonds_to_render.filter(can_edit_bond) : [],
1084
1230
  )
1085
1231
 
1232
+ // Coordination polyhedra around cation-like centers, derived from the same
1233
+ // (edited, filtered) bond graph as rendered bonds so the two never disagree.
1234
+ // Colors are resolved in polyhedra_buffers below, so color-scheme/mode changes
1235
+ // never recompute the hull geometry.
1236
+ let polyhedra: Polyhedron[] = $derived.by(() => {
1237
+ if (
1238
+ !structure?.sites ||
1239
+ dragging_atoms ||
1240
+ !applies_to_structure(effective_show_polyhedra) ||
1241
+ filtered_bond_pairs.length === 0
1242
+ )
1243
+ return []
1244
+ return compute_polyhedra(structure, filtered_bond_pairs, {
1245
+ min_neighbors: polyhedra_min_neighbors,
1246
+ max_neighbors: polyhedra_max_neighbors,
1247
+ excluded_center_elements: polyhedra_excluded_elements,
1248
+ included_center_elements: polyhedra_included_elements,
1249
+ })
1250
+ })
1251
+
1252
+ // Color of a site: property color (coordination/Wyckoff modes) or element color
1253
+ const polyhedra_site_color = (site_idx: number): string => {
1254
+ const site = structure?.sites[site_idx]
1255
+ const orig_idx = get_orig_site_idx(site, site_idx)
1256
+ const element = get_majority_element(site)
1257
+ return (
1258
+ property_colors?.colors[orig_idx] ?? (element && colors.element?.[element]) ?? `#808080`
1259
+ )
1260
+ }
1261
+
1262
+ // Separate derived so material-only changes (opacity, edge color) don't rebuild
1263
+ // buffers and color changes don't rebuild hulls
1264
+ let polyhedra_buffers = $derived.by(() => {
1265
+ if (polyhedra.length === 0) return null
1266
+ const get_vertex_color = (poly: Polyhedron, vertex_idx: number): string => {
1267
+ if (polyhedra_color_mode === `uniform`) return polyhedra_color
1268
+ if (polyhedra_color_mode === `center`) {
1269
+ return polyhedra_site_color(poly.center_site_idx)
1270
+ }
1271
+ // 'vertex' (default): each corner takes the color of the atom that forms it
1272
+ return polyhedra_site_color(poly.vertex_site_idxs[vertex_idx])
1273
+ }
1274
+ return merge_polyhedra_buffers(polyhedra, get_vertex_color)
1275
+ })
1276
+
1277
+ let polyhedra_center_site_idxs = $derived(
1278
+ new Set(polyhedra.map((poly) => poly.center_site_idx)),
1279
+ )
1280
+
1281
+ // Publish which elements currently anchor polyhedra (consumed by controls so
1282
+ // per-element toggles reflect the actual render state incl. spectator hiding)
1283
+ $effect(() => {
1284
+ if (!interactive) return
1285
+ const elems = [...new Set(polyhedra.map((poly) => poly.center_element))].sort()
1286
+ if (elems.join(`,`) !== polyhedra_rendered_elements.join(`,`)) {
1287
+ polyhedra_rendered_elements = elems
1288
+ }
1289
+ })
1290
+
1291
+ // Geometries with proper disposal on dependency change (same pattern as ReferencePlane)
1292
+ const buffer_geometry = (attrs: Record<string, Float32Array>): BufferGeometry => {
1293
+ const geo = new BufferGeometry()
1294
+ for (const [name, array] of Object.entries(attrs)) {
1295
+ geo.setAttribute(name, new BufferAttribute(array, 3))
1296
+ }
1297
+ return geo
1298
+ }
1299
+ let polyhedra_geometry: BufferGeometry | null = $state(null)
1300
+ $effect(() => {
1301
+ let geo: BufferGeometry | null = null
1302
+ if (polyhedra_buffers && polyhedra_buffers.triangle_count > 0) {
1303
+ const { positions: position, colors: color } = polyhedra_buffers
1304
+ geo = buffer_geometry({ position, color })
1305
+ geo.computeVertexNormals() // non-indexed -> per-face normals (flat shading)
1306
+ }
1307
+ polyhedra_geometry = geo
1308
+ return () => geo?.dispose()
1309
+ })
1310
+
1311
+ let polyhedra_edge_geometry: BufferGeometry | null = $state(null)
1312
+ $effect(() => {
1313
+ const geo =
1314
+ polyhedra_show_edges && polyhedra_buffers && polyhedra_buffers.edge_count > 0
1315
+ ? buffer_geometry({ position: polyhedra_buffers.edge_positions })
1316
+ : null
1317
+ polyhedra_edge_geometry = geo
1318
+ return () => geo?.dispose()
1319
+ })
1320
+
1086
1321
  let smart_site_label_offsets = $derived.by(() => {
1087
- const offsets = new SvelteMap<number, Vec3>()
1088
- if (filtered_bond_pairs.length === 0) return offsets
1322
+ // Plain Maps: built and consumed inside deriveds, so per-entry reactivity
1323
+ // (SvelteMap) would only add signal overhead for potentially thousands of sites
1324
+ const offsets = new Map<number, Vec3>()
1325
+ if (bonds_to_render.length === 0) return offsets
1089
1326
 
1090
- const bond_directions_by_site = new SvelteMap<number, Vec3[]>()
1327
+ const bond_directions_by_site = new Map<number, Vec3[]>()
1091
1328
  const add_bond_direction = (site_idx: number, pos_1: Vec3, pos_2: Vec3) => {
1092
- const direction = math.normalize_vec(
1093
- math.subtract(pos_2, pos_1),
1094
- [0, 0, 0],
1095
- )
1329
+ const direction = math.normalize_vec(math.subtract(pos_2, pos_1), [0, 0, 0])
1096
1330
  if (Math.hypot(...direction) < LABEL_OFFSET_EPS) return
1097
1331
  bond_directions_by_site.set(site_idx, [
1098
1332
  ...(bond_directions_by_site.get(site_idx) ?? []),
@@ -1100,7 +1334,7 @@
1100
1334
  ])
1101
1335
  }
1102
1336
 
1103
- for (const { site_idx_1, site_idx_2, pos_1, pos_2 } of filtered_bond_pairs) {
1337
+ for (const { site_idx_1, site_idx_2, pos_1, pos_2 } of bonds_to_render) {
1104
1338
  add_bond_direction(site_idx_1, pos_1, pos_2)
1105
1339
  add_bond_direction(site_idx_2, pos_2, pos_1)
1106
1340
  }
@@ -1111,7 +1345,7 @@
1111
1345
  })
1112
1346
 
1113
1347
  let instanced_bond_groups = $derived.by(() => {
1114
- if (!structure?.sites || filtered_bond_pairs.length === 0) return []
1348
+ if (!structure?.sites || bonds_to_render.length === 0) return []
1115
1349
 
1116
1350
  const group = {
1117
1351
  thickness: bond_thickness,
@@ -1124,14 +1358,14 @@
1124
1358
  }[],
1125
1359
  }
1126
1360
 
1127
- for (const bond_data of filtered_bond_pairs) {
1361
+ for (const bond_data of bonds_to_render) {
1128
1362
  const site_a = structure.sites[bond_data.site_idx_1]
1129
1363
  const site_b = structure.sites[bond_data.site_idx_2]
1130
1364
 
1131
1365
  const get_majority_color = (site: typeof site_a) => {
1132
1366
  if (!site?.species || site.species.length === 0) return bond_color
1133
1367
  const majority_species = site.species.reduce((max, spec) =>
1134
- spec.occu > max.occu ? spec : max
1368
+ spec.occu > max.occu ? spec : max,
1135
1369
  )
1136
1370
  return colors.element?.[majority_species.element] || bond_color
1137
1371
  }
@@ -1147,15 +1381,35 @@
1147
1381
  })
1148
1382
 
1149
1383
  let radius_by_site_idx = $derived.by(() => {
1150
- const map = new SvelteMap<number, number>()
1384
+ const map = new Map<number, number>()
1151
1385
  for (const atom of atom_data) {
1152
1386
  if (!map.has(atom.site_idx)) map.set(atom.site_idx, atom.radius)
1153
1387
  }
1154
1388
  return map
1155
1389
  })
1156
1390
 
1391
+ // Partial-occupancy atoms render as separate wedge (lune) meshes that converge
1392
+ // to a point at the sphere's poles, leaving the ball hard to hover from some
1393
+ // angles. Give each such site one invisible full-sphere hit target so it's as
1394
+ // reliably hoverable as an ordered atom (single solid sphere). One per site.
1395
+ let partial_hit_targets = $derived.by(() => {
1396
+ if (!interactive) return []
1397
+ const targets = new Map<number, EditableAtomHitTarget & { is_image_atom: boolean }>()
1398
+ for (const atom of atom_data) {
1399
+ if (!atom.has_partial_occupancy || targets.has(atom.site_idx)) continue
1400
+ targets.set(atom.site_idx, {
1401
+ site_idx: atom.site_idx,
1402
+ position: atom.position,
1403
+ radius: atom.radius,
1404
+ is_image_atom: atom.is_image_atom,
1405
+ })
1406
+ }
1407
+ return [...targets.values()]
1408
+ })
1409
+
1157
1410
  let editable_atom_hit_targets = $derived.by(() => {
1158
1411
  if (
1412
+ !interactive ||
1159
1413
  measure_mode !== `edit-bonds` ||
1160
1414
  bond_edit_mode !== `add` ||
1161
1415
  !bond_edits_enabled
@@ -1163,7 +1417,7 @@
1163
1417
  return []
1164
1418
  }
1165
1419
 
1166
- const targets = new SvelteMap<number, EditableAtomHitTarget>()
1420
+ const targets = new Map<number, EditableAtomHitTarget>()
1167
1421
  for (const atom of atom_data) {
1168
1422
  if (!can_select_bond_site(atom.site_idx)) continue
1169
1423
  if (targets.has(atom.site_idx)) continue
@@ -1179,13 +1433,47 @@
1179
1433
  // Get radius for a site (for highlight fallback when site is hidden/filtered)
1180
1434
  // Checks site_radius_overrides first for consistency with visible atoms
1181
1435
  const get_site_radius = (site: Site, site_idx: number | null): number => {
1182
- const override = site_idx !== null
1183
- ? site_radius_overrides?.get(site_idx)
1184
- : undefined
1185
- const base_radius = same_size_atoms ? 1 : override ?? calc_weighted_radius(site)
1186
- return base_radius * atom_radius
1436
+ const override = site_idx !== null ? site_radius_overrides?.get(site_idx) : undefined
1437
+ const base_radius = same_size_atoms ? 1 : (override ?? calc_weighted_radius(site))
1438
+ return base_radius * effective_atom_radius
1187
1439
  }
1188
1440
 
1441
+ // Sites to outline with a translucent sphere: hovered + all selected/active sites. Kept
1442
+ // independent of the pulse animation so this list (with its per-site radius lookups) only
1443
+ // rebuilds when highlighted sites change; pulsing opacity is applied per-frame in the
1444
+ // template instead, avoiding a rebuild every frame in every multi-view pane.
1445
+ type HighlightTarget = {
1446
+ kind: `hover` | `selected` | `active`
1447
+ site: Site
1448
+ site_idx: number | null
1449
+ color: string
1450
+ radius: number
1451
+ }
1452
+ let highlight_targets: HighlightTarget[] = $derived.by(() => {
1453
+ const targets: HighlightTarget[] = []
1454
+ const add = (
1455
+ kind: HighlightTarget[`kind`],
1456
+ site: Site | null,
1457
+ site_idx: number | null,
1458
+ color: string,
1459
+ ) => {
1460
+ if (!site) return
1461
+ const radius =
1462
+ site_idx !== null
1463
+ ? (radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx))
1464
+ : get_site_radius(site, site_idx)
1465
+ targets.push({ kind, site, site_idx, color, radius })
1466
+ }
1467
+ add(`hover`, hovered_site, hovered_idx, `white`)
1468
+ for (const idx of selected_sites ?? []) {
1469
+ add(`selected`, structure?.sites?.[idx] ?? null, idx, selection_highlight_color)
1470
+ }
1471
+ for (const idx of active_sites ?? []) {
1472
+ add(`active`, structure?.sites?.[idx] ?? null, idx, active_highlight_color)
1473
+ }
1474
+ return targets
1475
+ })
1476
+
1189
1477
  // Interpolate between spin-down (#3498db blue) and spin-up (#e74c3c red)
1190
1478
  // based on the z-component direction of a magnetic vector
1191
1479
  function spin_direction_color(vec: Vec3): string {
@@ -1194,9 +1482,9 @@
1194
1482
  const red = Math.round(52 + (231 - 52) * z_frac)
1195
1483
  const grn = Math.round(152 + (76 - 152) * z_frac)
1196
1484
  const blu = Math.round(219 + (60 - 219) * z_frac)
1197
- return `#${red.toString(16).padStart(2, `0`)}${
1198
- grn.toString(16).padStart(2, `0`)
1199
- }${blu.toString(16).padStart(2, `0`)}`
1485
+ return `#${red.toString(16).padStart(2, `0`)}${grn
1486
+ .toString(16)
1487
+ .padStart(2, `0`)}${blu.toString(16).padStart(2, `0`)}`
1200
1488
  }
1201
1489
 
1202
1490
  // Build one arrow layer per visible vector key. Auto-scales the longest
@@ -1216,7 +1504,7 @@
1216
1504
  let max_mag = 0
1217
1505
  const site_vec_maps = structure.sites.map((site, site_idx) => {
1218
1506
  if (!is_site_visible(site_idx)) return null
1219
- const map = new SvelteMap<string, Vec3>()
1507
+ const map = new Map<string, Vec3>()
1220
1508
  for (const { key, vec } of get_all_site_vectors(site)) {
1221
1509
  map.set(key, vec)
1222
1510
  if (active_set.has(key)) {
@@ -1228,9 +1516,7 @@
1228
1516
 
1229
1517
  // When normalize is on, treat all magnitudes as 1 so arrows have equal length
1230
1518
  const effective_max = vector_normalize ? 1 : max_mag
1231
- const auto_scale = effective_max > 1e-10
1232
- ? (char_atom_spacing * 1.8) / effective_max
1233
- : 1
1519
+ const auto_scale = effective_max > 1e-10 ? (char_atom_spacing * 1.8) / effective_max : 1
1234
1520
  const is_single = active_keys.length === 1
1235
1521
  const effective_global_scale = auto_scale * vector_scale
1236
1522
 
@@ -1240,40 +1526,40 @@
1240
1526
  // of the visual atom radius (0 = center, 0.5 = halfway to surface).
1241
1527
  // get_site_radius() returns the uniform scale applied to SphereGeometry(0.5),
1242
1528
  // so visual_radius = get_site_radius() * 0.5.
1243
- const site_offsets = (vector_origin_gap > 0 && !is_single)
1244
- ? structure.sites.map((site, site_idx) => {
1245
- const vec_map = site_vec_maps[site_idx]
1246
- if (!vec_map) return null
1247
- const site_keys = active_keys.filter((key) => vec_map.has(key))
1248
- const n_keys = site_keys.length
1249
- if (n_keys <= 1) return null
1250
- const visual_radius = get_site_radius(site, site_idx) * 0.5
1251
- const gap_abs = vector_origin_gap * visual_radius
1252
- let mean: Vec3 = [0, 0, 0]
1253
- for (const key of site_keys) {
1254
- const vec = vec_map.get(key)
1255
- if (vec) mean = math.add(mean, math.normalize_vec(vec)) as Vec3
1256
- }
1257
- const mean_dir = math.normalize_vec(mean, [0, 1, 0] as Vec3)
1258
- const [u_vec, v_vec] = math.compute_in_plane_basis(mean_dir)
1259
- const offsets = new SvelteMap<string, Vec3>()
1260
- for (const [idx, key] of site_keys.entries()) {
1261
- const angle = (2 * Math.PI * idx) / n_keys
1262
- const dx = math.scale(u_vec, gap_abs * Math.cos(angle)) as Vec3
1263
- const dy = math.scale(v_vec, gap_abs * Math.sin(angle)) as Vec3
1264
- offsets.set(key, math.add(dx, dy) as Vec3)
1265
- }
1266
- return offsets
1267
- })
1268
- : null
1529
+ const site_offsets =
1530
+ vector_origin_gap > 0 && !is_single
1531
+ ? structure.sites.map((site, site_idx) => {
1532
+ const vec_map = site_vec_maps[site_idx]
1533
+ if (!vec_map) return null
1534
+ const site_keys = active_keys.filter((key) => vec_map.has(key))
1535
+ const n_keys = site_keys.length
1536
+ if (n_keys <= 1) return null
1537
+ const visual_radius = get_site_radius(site, site_idx) * 0.5
1538
+ const gap_abs = vector_origin_gap * visual_radius
1539
+ let mean: Vec3 = [0, 0, 0]
1540
+ for (const key of site_keys) {
1541
+ const vec = vec_map.get(key)
1542
+ if (vec) mean = math.add(mean, math.normalize_vec(vec)) as Vec3
1543
+ }
1544
+ const mean_dir = math.normalize_vec(mean, [0, 1, 0] as Vec3)
1545
+ const [u_vec, v_vec] = math.compute_in_plane_basis(mean_dir)
1546
+ const offsets = new Map<string, Vec3>()
1547
+ for (const [idx, key] of site_keys.entries()) {
1548
+ const angle = (2 * Math.PI * idx) / n_keys
1549
+ const dx = math.scale(u_vec, gap_abs * Math.cos(angle))
1550
+ const dy = math.scale(v_vec, gap_abs * Math.sin(angle))
1551
+ offsets.set(key, math.add(dx, dy))
1552
+ }
1553
+ return offsets
1554
+ })
1555
+ : null
1269
1556
 
1270
1557
  const mag_interpolator = get_d3_interpolator(vector_color_scale)
1271
1558
 
1272
1559
  return active_keys.map((key, layer_idx) => {
1273
1560
  const layer_cfg = vector_configs[key]
1274
1561
  const layer_scale = effective_global_scale * (layer_cfg?.scale ?? 1.0)
1275
- const layer_color = layer_cfg?.color ??
1276
- VECTOR_PALETTE[layer_idx % VECTOR_PALETTE.length]
1562
+ const layer_color = layer_cfg?.color ?? VECTOR_PALETTE[layer_idx % VECTOR_PALETTE.length]
1277
1563
 
1278
1564
  const arrows = structure.sites
1279
1565
  .map((site, site_idx) => {
@@ -1289,11 +1575,12 @@
1289
1575
  arrow_color = layer_cfg.color
1290
1576
  } else if (!is_single) arrow_color = layer_color
1291
1577
  else {
1292
- const effective_mode = vector_color_mode === `auto`
1293
- ? (key.startsWith(`magmom`) || key.startsWith(`spin`)
1294
- ? `spin_direction`
1295
- : `element`)
1296
- : vector_color_mode
1578
+ const effective_mode =
1579
+ vector_color_mode === `auto`
1580
+ ? key.startsWith(`magmom`) || key.startsWith(`spin`)
1581
+ ? `spin_direction`
1582
+ : `element`
1583
+ : vector_color_mode
1297
1584
  if (effective_mode === `magnitude`) {
1298
1585
  const mag = Math.hypot(...vec)
1299
1586
  const norm = max_mag > 1e-10 ? mag / max_mag : 0
@@ -1303,19 +1590,18 @@
1303
1590
  } else if (effective_mode === `uniform`) {
1304
1591
  arrow_color = vector_color
1305
1592
  } else {
1306
- const majority_element = site.species.length > 0
1307
- ? site.species.reduce((max, spec) =>
1308
- spec.occu > max.occu ? spec : max
1309
- ).element
1310
- : undefined
1593
+ const majority_element =
1594
+ site.species.length > 0
1595
+ ? site.species.reduce((max, spec) => (spec.occu > max.occu ? spec : max))
1596
+ .element
1597
+ : undefined
1311
1598
  arrow_color =
1312
- (majority_element && colors.element?.[majority_element]) ||
1313
- vector_color
1599
+ (majority_element && colors.element?.[majority_element]) || vector_color
1314
1600
  }
1315
1601
  }
1316
1602
 
1317
1603
  const offset = site_offsets?.[site_idx]?.get(key)
1318
- const position = offset ? math.add(site.xyz, offset) as Vec3 : site.xyz
1604
+ const position = offset ? math.add(site.xyz, offset) : site.xyz
1319
1605
  const arrow_vec = vector_normalize ? math.normalize_vec(vec) : vec
1320
1606
 
1321
1607
  return {
@@ -1332,90 +1618,69 @@
1332
1618
  })
1333
1619
  })
1334
1620
 
1335
- let instanced_atom_groups = $derived(
1336
- Object.values(
1337
- atom_data
1338
- .filter((atom) => !atom.has_partial_occupancy)
1339
- .reduce(
1340
- (groups, atom) => {
1341
- const { element, radius, color, is_image_atom } = atom
1342
- // Separate image atoms into their own groups for distinct styling in edit-atoms mode
1343
- const key = `${element}-${format_num(radius, `.3~`)}-${color}-${
1344
- is_image_atom ? `img` : `base`
1345
- }`
1346
- const bucket = groups[key] ||
1347
- (groups[key] = { element, radius, color, is_image_atom, atoms: [] })
1348
- bucket.atoms.push(atom)
1349
- return groups
1350
- },
1351
- {} as Record<string, InstancedAtomGroup>,
1352
- ),
1353
- ),
1354
- )
1355
-
1356
- let unique_instanced_atoms = $derived(
1357
- Object.values(
1358
- instanced_atom_groups
1359
- .flatMap((group) => group.atoms)
1360
- .reduce((acc, atom) => {
1361
- acc[atom.site_idx] = atom
1362
- return acc
1363
- }, {} as Record<number, (typeof atom_data)[number]>),
1364
- ),
1365
- )
1366
-
1367
- let gizmo_props = $derived.by(() => {
1368
- const axis_options = Object.fromEntries(
1369
- [...AXIS_COLORS, ...NEG_AXIS_COLORS].map(([axis, color, hover_color]) => [
1370
- axis,
1371
- {
1372
- color,
1373
- labelColor: `#111`,
1374
- opacity: axis.startsWith(`n`) ? 0.9 : 0.8,
1375
- hover: {
1376
- color: hover_color,
1377
- labelColor: `#222222`,
1378
- opacity: axis.startsWith(`n`) ? 1 : 0.9,
1379
- },
1380
- },
1381
- ]),
1382
- )
1383
- return {
1384
- background: { enabled: false },
1385
- className: `responsive-gizmo`,
1386
- ...axis_options,
1387
- ...(typeof gizmo === `boolean` ? {} : gizmo),
1388
- offset: { left: 5, bottom: 5 },
1621
+ // Full-occupancy atoms split into base and PBC-image sets. Each set renders
1622
+ // as ONE InstancedMesh (per-atom color/radius live in instance buffers), so
1623
+ // no per-element grouping is needed. Image atoms get their own mesh because
1624
+ // they ghost (desaturate + translucent) and lose interactivity in edit-atoms mode.
1625
+ let instanced_atom_sets = $derived.by(() => {
1626
+ const base: typeof atom_data = []
1627
+ const image: typeof atom_data = []
1628
+ for (const atom of atom_data) {
1629
+ if (!atom.has_partial_occupancy) (atom.is_image_atom ? image : base).push(atom)
1389
1630
  }
1631
+ return { base, image }
1390
1632
  })
1391
1633
 
1392
- let orbit_controls_props = $derived({
1393
- position: [0, 0, 0],
1394
- enableRotate: rotate_speed > 0,
1395
- rotateSpeed: rotate_speed,
1396
- enableZoom: zoom_speed > 0,
1397
- zoomSpeed: camera_projection === `orthographic` ? zoom_speed * 2 : zoom_speed,
1398
- zoomToCursor: zoom_to_cursor,
1399
- enablePan: pan_speed > 0,
1400
- panSpeed: pan_speed,
1401
- target: camera_target ?? rotation_target,
1402
- maxZoom: max_zoom,
1403
- minZoom: min_zoom,
1404
- autoRotate: Boolean(auto_rotate),
1405
- autoRotateSpeed: auto_rotate,
1406
- enableDamping: Boolean(rotation_damping),
1407
- dampingFactor: rotation_damping,
1408
- onstart: () => {
1409
- camera_is_moving = true
1410
- cancel_atom_hover_clear()
1411
- hovered_idx = null
1412
- bond_context_menu = null
1413
- },
1414
- onend: () => {
1415
- camera_is_moving = false
1416
- },
1634
+ // One label anchor per visible site (sites can contribute several atom_data
1635
+ // entries, e.g. partial-occupancy wedges — label each site once)
1636
+ let label_entries = $derived.by(() => {
1637
+ if (!show_site_labels && !show_site_indices) return []
1638
+ const seen = new Set<number>()
1639
+ const entries: { site_idx: number; position: Vec3; radius: number }[] = []
1640
+ for (const atom of atom_data) {
1641
+ if (seen.has(atom.site_idx)) continue
1642
+ seen.add(atom.site_idx)
1643
+ entries.push({
1644
+ site_idx: atom.site_idx,
1645
+ position: atom.position,
1646
+ radius: atom.radius,
1647
+ })
1648
+ }
1649
+ return entries
1417
1650
  })
1418
1651
 
1652
+ // Partial-occupancy atoms render as separate wedge meshes (see template below).
1653
+ // Derived so the filter isn't re-run inline on every render of the atoms group.
1654
+ let partial_occupancy_atoms = $derived(
1655
+ atom_data.filter((atom) => atom.has_partial_occupancy),
1656
+ )
1657
+
1658
+ let orbit_controls_props = $derived(
1659
+ build_orbit_props({
1660
+ camera_projection,
1661
+ target: camera_target ?? rotation_target,
1662
+ rotate_speed,
1663
+ zoom_speed,
1664
+ zoom_to_cursor,
1665
+ pan_speed,
1666
+ max_zoom,
1667
+ min_zoom,
1668
+ auto_rotate,
1669
+ rotation_damping,
1670
+ set_camera_is_moving: (moving) => (camera_is_moving = moving),
1671
+ // Close hover tooltips + bond context menu while the camera moves. Only hide the
1672
+ // VISIBLE menu (not bond_context_target): clicking a menu button fires this
1673
+ // orbit-controls start handler before the button's own handler runs, which still
1674
+ // needs the target bond to apply the edit (see bond_context_target comment).
1675
+ onstart_extra: () => {
1676
+ cancel_atom_hover_clear()
1677
+ hovered_idx = null
1678
+ hovered_bond_key = null
1679
+ bond_context_menu = null
1680
+ },
1681
+ }),
1682
+ )
1683
+
1419
1684
  let measure_line_color = $derived.by(() => {
1420
1685
  if (typeof window === `undefined`) return
1421
1686
  const root_styles = getComputedStyle(document.documentElement)
@@ -1424,27 +1689,15 @@
1424
1689
  })
1425
1690
  </script>
1426
1691
 
1427
- {#snippet bond_instanced_mesh_snippet(
1428
- group: ComponentProps<typeof Bond>[`group`],
1429
- )}
1692
+ {#snippet bond_instanced_mesh_snippet(group: ComponentProps<typeof Bond>[`group`])}
1430
1693
  {#key group.instances.length}
1431
1694
  <Bond {group} />
1432
1695
  {/key}
1433
1696
  {/snippet}
1434
1697
 
1435
- {#snippet site_label_snippet(position: Vec3, site_idx: number)}
1698
+ {#snippet site_label_snippet(site_idx: number)}
1436
1699
  {@const site = structure!.sites[site_idx]}
1437
- {@const visual_radius = (radius_by_site_idx.get(site_idx) ?? 1) * 0.5}
1438
- <extras.HTML
1439
- center
1440
- position={position}
1441
- calculatePosition={make_label_position_calculator(
1442
- position,
1443
- () => smart_site_label_offsets.get(site_idx) ?? site_label_offset,
1444
- visual_radius,
1445
- label_screen_margin,
1446
- )}
1447
- >
1700
+ {#if site}
1448
1701
  {#if atom_label}
1449
1702
  {@render atom_label({ site, site_idx })}
1450
1703
  {:else}
@@ -1475,10 +1728,7 @@
1475
1728
  {#if site.species.length === 1}
1476
1729
  {site.species[0].element}{#if show_site_indices}-{site_idx + 1}{/if}
1477
1730
  {:else}
1478
- {#each site.species as
1479
- { element, occu, oxidation_state }
1480
- (`${element}-${occu}-${oxidation_state}`)
1481
- }
1731
+ {#each site.species as { element, occu, oxidation_state } (`${element}-${occu}-${oxidation_state}`)}
1482
1732
  {element}<sub>{format_num(occu, `.3~`).replace(`0.`, `.`)}</sub>
1483
1733
  {/each}
1484
1734
  {#if show_site_indices}-{site_idx + 1}{/if}
@@ -1488,34 +1738,20 @@
1488
1738
  {/if}
1489
1739
  </button>
1490
1740
  {/if}
1491
- </extras.HTML>
1741
+ {/if}
1492
1742
  {/snippet}
1493
1743
 
1494
- {#if camera_projection === `perspective`}
1495
- <T.PerspectiveCamera
1496
- makeDefault
1497
- position={camera_position}
1498
- {fov}
1499
- near={camera_near}
1500
- far={camera_far}
1501
- >
1502
- <extras.OrbitControls bind:ref={orbit_controls} {...orbit_controls_props}>
1503
- {#if gizmo}<extras.Gizmo {...gizmo_props} />{/if}
1504
- </extras.OrbitControls>
1505
- </T.PerspectiveCamera>
1506
- {:else}
1507
- <T.OrthographicCamera
1508
- makeDefault
1509
- position={camera_position}
1510
- zoom={computed_zoom}
1511
- near={-100}
1512
- far={camera_far}
1513
- >
1514
- <extras.OrbitControls bind:ref={orbit_controls} {...orbit_controls_props}>
1515
- {#if gizmo}<extras.Gizmo {...gizmo_props} />{/if}
1516
- </extras.OrbitControls>
1517
- </T.OrthographicCamera>
1518
- {/if}
1744
+ <SceneCamera
1745
+ {camera_projection}
1746
+ position={camera_position}
1747
+ {fov}
1748
+ zoom={computed_zoom}
1749
+ near={camera_near}
1750
+ far={camera_far}
1751
+ orbit_props={orbit_controls_props}
1752
+ {gizmo}
1753
+ bind:orbit_controls
1754
+ />
1519
1755
 
1520
1756
  <T.DirectionalLight position={[3, 10, 10]} intensity={directional_light} />
1521
1757
  <T.AmbientLight intensity={ambient_light} />
@@ -1523,100 +1759,39 @@
1523
1759
  <!-- Apply manual rotation around center: translate to origin, rotate, translate back -->
1524
1760
  <T.Group position={rotation_target}>
1525
1761
  <T.Group {rotation}>
1526
- <T.Group position={math.scale(rotation_target, -1)}>
1762
+ <T.Group position={neg_rotation_target}>
1527
1763
  {#if show_atoms}
1528
- <!-- Instanced rendering for full occupancy atoms -->
1529
- {#each instanced_atom_groups as atom_group (instanced_atom_group_key(atom_group, measure_mode))}
1530
- {@const { element, radius, color, is_image_atom, atoms } = atom_group}
1531
- {@const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom}
1532
- <extras.InstancedMesh
1533
- key={instanced_atom_group_key(atom_group, measure_mode)}
1534
- limit={atoms.length}
1535
- range={atoms.length}
1536
- frustumCulled={false}
1537
- >
1538
- <T.SphereGeometry args={[0.5, sphere_segments, sphere_segments]} />
1539
- <T.MeshStandardMaterial
1540
- color={edit_mode_image ? desaturate(color) : color}
1541
- opacity={edit_mode_image ? 0.5 : 1}
1542
- transparent={edit_mode_image}
1543
- />
1544
- {#each atoms as atom (atom.site_idx)}
1545
- <extras.Instance
1546
- position={atom.position}
1547
- scale={atom.radius}
1548
- {...atom_hover_props(atom.site_idx, edit_mode_image)}
1549
- onpointerdown={(event: PointerEvent) => {
1550
- if (
1551
- edit_mode_image ||
1552
- measure_mode !== `edit-bonds` ||
1553
- bond_edit_mode !== `add`
1554
- ) {
1555
- return
1556
- }
1557
- select_edit_bonds_site(atom.site_idx, event)
1558
- }}
1559
- onclick={(event: MouseEvent) => {
1560
- if (edit_mode_image) return
1561
- if (measure_mode === `edit-bonds`) {
1562
- if (bond_edit_mode !== `add`) return
1563
- if (skip_duplicate_edit_bonds_click(atom.site_idx)) {
1564
- event.stopPropagation()
1565
- return
1566
- }
1567
- }
1568
- toggle_selection(atom.site_idx, event)
1569
- }}
1570
- />
1571
- {/each}
1572
- </extras.InstancedMesh>
1573
- {/each}
1764
+ <!-- Instanced rendering for full-occupancy atoms: one InstancedMesh for
1765
+ base atoms and one for PBC image atoms (which ghost + lose interaction
1766
+ in edit-atoms mode). Pointer events are resolved via raycast instanceId. -->
1767
+ {#if instanced_atom_sets.base.length > 0}
1768
+ <InstancedAtoms
1769
+ atoms={instanced_atom_sets.base}
1770
+ {sphere_segments}
1771
+ {...atom_instance_events(instanced_atom_sets.base, false)}
1772
+ />
1773
+ {/if}
1774
+ {#if instanced_atom_sets.image.length > 0}
1775
+ {@const edit_mode_image = measure_mode === `edit-atoms`}
1776
+ <InstancedAtoms
1777
+ atoms={instanced_atom_sets.image}
1778
+ {sphere_segments}
1779
+ ghost={edit_mode_image}
1780
+ {...atom_instance_events(instanced_atom_sets.image, edit_mode_image)}
1781
+ />
1782
+ {/if}
1574
1783
 
1575
1784
  <!-- Regular rendering for partial occupancy atoms -->
1576
- {#each atom_data.filter((atom) => atom.has_partial_occupancy) as
1577
- atom
1578
- (atom.site_idx + atom.element + atom.occupancy)
1579
- }
1785
+ {#each partial_occupancy_atoms as atom (atom.site_idx + atom.element + atom.occupancy)}
1580
1786
  {@const partial_edit_image = measure_mode === `edit-atoms` && atom.is_image_atom}
1581
1787
  {@const ghost_opacity = partial_edit_image ? 0.5 : 1}
1582
- <T.Group
1583
- position={atom.position}
1584
- scale={atom.radius}
1585
- {...atom_hover_props(atom.site_idx, partial_edit_image)}
1586
- onpointerdown={(event: PointerEvent) => {
1587
- if (
1588
- partial_edit_image ||
1589
- measure_mode !== `edit-bonds` ||
1590
- bond_edit_mode !== `add`
1591
- ) {
1592
- return
1593
- }
1594
- select_edit_bonds_site(atom.site_idx, event)
1595
- }}
1596
- onclick={(event: MouseEvent) => {
1597
- if (partial_edit_image) return
1598
- if (measure_mode === `edit-bonds`) {
1599
- if (bond_edit_mode !== `add`) return
1600
- if (skip_duplicate_edit_bonds_click(atom.site_idx)) {
1601
- event.stopPropagation()
1602
- return
1603
- }
1604
- }
1605
- toggle_selection(atom.site_idx, event)
1606
- }}
1607
- >
1608
- {@const partial_color = partial_edit_image
1609
- ? desaturate(atom.color)
1610
- : atom.color}
1788
+ <!-- Visual only: pointer interaction handled by the invisible full-sphere
1789
+ hit targets below (wedge meshes leave gaps at the poles). -->
1790
+ <T.Group position={atom.position} scale={atom.radius}>
1791
+ {@const partial_color = partial_edit_image ? desaturate(atom.color) : atom.color}
1611
1792
  <T.Mesh>
1612
1793
  <T.SphereGeometry
1613
- args={[
1614
- 0.5,
1615
- sphere_segments,
1616
- sphere_segments,
1617
- atom.start_phi,
1618
- atom.phi_length,
1619
- ]}
1794
+ args={[0.5, sphere_segments, sphere_segments, atom.start_phi, atom.phi_length]}
1620
1795
  />
1621
1796
  <T.MeshStandardMaterial
1622
1797
  color={partial_color}
@@ -1625,7 +1800,7 @@
1625
1800
  />
1626
1801
  </T.Mesh>
1627
1802
 
1628
- {#if atom.has_partial_occupancy && atom.render_start_cap}
1803
+ {#if atom.render_start_cap}
1629
1804
  <T.Mesh rotation={[0, atom.start_phi, 0]}>
1630
1805
  <T.CircleGeometry
1631
1806
  args={[
@@ -1643,7 +1818,7 @@
1643
1818
  />
1644
1819
  </T.Mesh>
1645
1820
  {/if}
1646
- {#if atom.has_partial_occupancy && atom.render_end_cap}
1821
+ {#if atom.render_end_cap}
1647
1822
  <T.Mesh rotation={[0, atom.end_phi, 0]}>
1648
1823
  <T.CircleGeometry
1649
1824
  args={[
@@ -1662,31 +1837,47 @@
1662
1837
  </T.Mesh>
1663
1838
  {/if}
1664
1839
  </T.Group>
1840
+ {/each}
1665
1841
 
1666
- <!-- Render label only for the first species of this site to avoid duplicates -->
1667
- {#if (show_site_labels || show_site_indices) &&
1668
- atom.element === structure!.sites[atom.site_idx].species[0].element}
1669
- {@render site_label_snippet(atom.position, atom.site_idx)}
1670
- {/if}
1842
+ <!-- Invisible full-sphere hit targets for partial-occupancy sites so the
1843
+ whole ball is hoverable/clickable (wedge meshes leave gaps at the poles). -->
1844
+ {#each partial_hit_targets as hit (hit.site_idx)}
1845
+ {@const hit_edit_image = measure_mode === `edit-atoms` && hit.is_image_atom}
1846
+ <T.Mesh
1847
+ position={hit.position}
1848
+ scale={hit.radius}
1849
+ {...atom_pointer_props(hit.site_idx, hit_edit_image)}
1850
+ >
1851
+ <T.SphereGeometry args={[0.5, sphere_segments, sphere_segments]} />
1852
+ <T.MeshBasicMaterial transparent opacity={0} depthWrite={false} />
1853
+ </T.Mesh>
1671
1854
  {/each}
1672
1855
 
1673
- <!-- Site labels/indices for instanced atoms -->
1674
- {#if show_site_labels || show_site_indices}
1675
- {#each unique_instanced_atoms as atom (atom.site_idx)}
1676
- {@render site_label_snippet(atom.position, atom.site_idx)}
1677
- {/each}
1856
+ <!-- Site labels/indices: single overlay for all labels (one DOM container
1857
+ + one per-frame position pass instead of one threlte <HTML> per label) -->
1858
+ {#if label_entries.length > 0}
1859
+ <SiteLabels
1860
+ entries={label_entries}
1861
+ get_offset={(site_idx) =>
1862
+ smart_site_label_offsets.get(site_idx) ?? site_label_offset}
1863
+ screen_margin={label_screen_margin}
1864
+ >
1865
+ {#snippet label({ site_idx })}
1866
+ {@render site_label_snippet(site_idx)}
1867
+ {/snippet}
1868
+ </SiteLabels>
1678
1869
  {/if}
1679
1870
  {/if}
1680
1871
 
1872
+ <!-- Per-site vector arrows (forces, magmoms, ...) as instanced meshes:
1873
+ 2 draw calls per layer instead of 2 meshes per site -->
1681
1874
  {#each vector_layers as layer (layer.key)}
1682
- {#each layer.arrows as arrow (`${layer.key}-${arrow.site_idx}`)}
1683
- <Arrow
1684
- {...arrow}
1685
- shaft_radius={eff_shaft_radius}
1686
- arrow_head_radius={eff_head_radius}
1687
- arrow_head_length={eff_head_length}
1688
- />
1689
- {/each}
1875
+ <ArrowInstances
1876
+ arrows={layer.arrows}
1877
+ shaft_radius={eff_shaft_radius}
1878
+ arrow_head_radius={eff_head_radius}
1879
+ arrow_head_length={eff_head_length}
1880
+ />
1690
1881
  {/each}
1691
1882
 
1692
1883
  <!-- Instanced bond rendering with gradient colors -->
@@ -1696,17 +1887,39 @@
1696
1887
  {/each}
1697
1888
  {/if}
1698
1889
 
1890
+ <!-- Coordination polyhedra: all faces in one merged mesh, edges in one
1891
+ LineSegments (1-2 draw calls regardless of supercell size) -->
1892
+ {#if polyhedra_geometry}
1893
+ <T.Mesh geometry={polyhedra_geometry} frustumCulled={false} raycast={() => null}>
1894
+ <!-- depthWrite when mostly opaque: VESTA-like occlusion between polyhedra;
1895
+ fully translucent settings fall back to see-through blending -->
1896
+ <T.MeshStandardMaterial
1897
+ vertexColors
1898
+ transparent={polyhedra_opacity < 1}
1899
+ opacity={polyhedra_opacity}
1900
+ side={DoubleSide}
1901
+ depthWrite={polyhedra_opacity >= 0.65}
1902
+ flatShading
1903
+ />
1904
+ </T.Mesh>
1905
+ {#if polyhedra_edge_geometry}
1906
+ <T.LineSegments
1907
+ geometry={polyhedra_edge_geometry}
1908
+ frustumCulled={false}
1909
+ raycast={() => null}
1910
+ >
1911
+ <T.LineBasicMaterial color={polyhedra_edge_color} />
1912
+ </T.LineSegments>
1913
+ {/if}
1914
+ {/if}
1915
+
1699
1916
  <!-- Clickable bond hit-test cylinders in edit-bonds mode -->
1700
- {#if measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1701
- {#each editable_bond_pairs as
1702
- bond
1703
- (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`)
1704
- }
1917
+ {#if interactive && measure_mode === `edit-bonds` && editable_bond_pairs.length > 0}
1918
+ {#each editable_bond_pairs as bond (`bond-hit-${bond_edit_mode}-${rendered_bond_key_for(bond)}`)}
1705
1919
  {@const bond_key = rendered_bond_key_for(bond)}
1706
1920
  {@const is_hovered = hovered_bond_key === bond_key}
1707
1921
  {@const is_delete_mode = bond_edit_mode === `delete`}
1708
- {@const bond_hit_radius =
1709
- bond_thickness * (is_delete_mode ? 5 : 1.25)}
1922
+ {@const bond_hit_radius = bond_thickness * (is_delete_mode ? 5 : 1.25)}
1710
1923
  {@const bond_hover_radius = bond_thickness * 1.1}
1711
1924
  <T.Mesh
1712
1925
  matrixAutoUpdate={false}
@@ -1735,19 +1948,8 @@
1735
1948
  onpointermove={() => (hovered_bond_key = bond_key)}
1736
1949
  onpointerleave={() => (hovered_bond_key = null)}
1737
1950
  >
1738
- <T.CylinderGeometry
1739
- args={[
1740
- bond_hit_radius,
1741
- bond_hit_radius,
1742
- 1,
1743
- 6,
1744
- ]}
1745
- />
1746
- <T.MeshBasicMaterial
1747
- transparent
1748
- opacity={0}
1749
- depthWrite={false}
1750
- />
1951
+ <T.CylinderGeometry args={[bond_hit_radius, bond_hit_radius, 1, 6]} />
1952
+ <T.MeshBasicMaterial transparent opacity={0} depthWrite={false} />
1751
1953
  </T.Mesh>
1752
1954
  {#if is_hovered}
1753
1955
  <T.Mesh
@@ -1766,7 +1968,7 @@
1766
1968
  {/each}
1767
1969
  {/if}
1768
1970
 
1769
- {#if editable_atom_hit_targets.length > 0}
1971
+ {#if interactive && editable_atom_hit_targets.length > 0}
1770
1972
  {#each editable_atom_hit_targets as atom_hit (atom_hit.site_idx)}
1771
1973
  <T.Mesh
1772
1974
  position={atom_hit.position}
@@ -1777,16 +1979,12 @@
1777
1979
  }}
1778
1980
  >
1779
1981
  <T.SphereGeometry args={[0.5, 12, 12]} />
1780
- <T.MeshBasicMaterial
1781
- transparent
1782
- opacity={0}
1783
- depthWrite={false}
1784
- />
1982
+ <T.MeshBasicMaterial transparent opacity={0} depthWrite={false} />
1785
1983
  </T.Mesh>
1786
1984
  {/each}
1787
1985
  {/if}
1788
1986
 
1789
- {#if measure_mode === `edit-bonds` && bond_context_menu}
1987
+ {#if interactive && measure_mode === `edit-bonds` && bond_context_menu}
1790
1988
  {@const current_order = get_current_bond_order(
1791
1989
  bond_context_menu.site_idx_1,
1792
1990
  bond_context_menu.site_idx_2,
@@ -1850,68 +2048,37 @@
1850
2048
  </extras.HTML>
1851
2049
  {/if}
1852
2050
 
1853
- <!-- highlight hovered, active and selected sites -->
1854
- {#each [
1855
- {
1856
- kind: `hover`,
1857
- site: hovered_site,
1858
- opacity: 0.28,
1859
- color: `white`,
1860
- site_idx: hovered_idx,
1861
- },
1862
- ...((selected_sites ?? []).map((idx) => ({
1863
- kind: `selected`,
1864
- site: structure?.sites?.[idx] ?? null,
1865
- site_idx: idx,
1866
- opacity: pulse_opacity,
1867
- color: selection_highlight_color,
1868
- }))),
1869
- ...((active_sites ?? []).map((idx) => ({
1870
- kind: `active`,
1871
- site: structure?.sites?.[idx] ?? null,
1872
- site_idx: idx,
1873
- opacity: pulse_opacity, // Let it pulse freely
1874
- color: active_highlight_color,
1875
- }))),
1876
- ] as
1877
- entry
1878
- (`${entry.kind}-${entry.site_idx}`)
1879
- }
1880
- {@const { site, opacity, color, kind, site_idx } = entry}
1881
- {#if site}
1882
- {@const xyz = site.xyz}
1883
- {@const highlight_radius = site_idx !== null
1884
- ? radius_by_site_idx.get(site_idx) ?? get_site_radius(site, site_idx)
1885
- : get_site_radius(site, site_idx)}
1886
- <T.Mesh
1887
- position={xyz}
1888
- scale={1.2 * highlight_radius}
1889
- oncreate={disable_raycast}
1890
- >
1891
- <T.SphereGeometry args={[0.5, 22, 22]} />
1892
- <T.MeshStandardMaterial
1893
- {color}
1894
- transparent
1895
- {opacity}
1896
- emissive={color}
1897
- emissiveIntensity={kind === `selected` || kind === `active` ? 0.7 : 0.2}
1898
- depthTest={false}
1899
- depthWrite={false}
1900
- />
1901
- </T.Mesh>
1902
- {/if}
2051
+ <!-- highlight hovered, active and selected sites. The target list is a pulse-
2052
+ independent $derived; only the opacity reads the per-frame pulse value so the
2053
+ array isn't rebuilt every animation frame. -->
2054
+ {#each highlight_targets as entry (`${entry.kind}-${entry.site_idx}`)}
2055
+ {@const is_pulsing = entry.kind !== `hover`}
2056
+ <T.Mesh
2057
+ position={entry.site.xyz}
2058
+ scale={1.2 * entry.radius}
2059
+ oncreate={disable_raycast}
2060
+ >
2061
+ <T.SphereGeometry args={[0.5, 22, 22]} />
2062
+ <T.MeshStandardMaterial
2063
+ color={entry.color}
2064
+ transparent
2065
+ opacity={is_pulsing ? pulse_opacity : 0.28}
2066
+ emissive={entry.color}
2067
+ emissiveIntensity={is_pulsing ? 0.7 : 0.2}
2068
+ depthTest={false}
2069
+ depthWrite={false}
2070
+ />
2071
+ </T.Mesh>
1903
2072
  {/each}
1904
2073
 
1905
2074
  <!-- selection order labels (1, 2, 3, ...) for measurements and bond editing -->
1906
- {#if structure?.sites && (measured_sites?.length ?? 0) > 0 &&
1907
- (measure_mode === `distance` || measure_mode === `angle` ||
1908
- measure_mode === `edit-bonds`)}
2075
+ {#if structure?.sites && (measured_sites?.length ?? 0) > 0 && (measure_mode === `distance` || measure_mode === `angle` || measure_mode === `edit-bonds`)}
1909
2076
  {#each measured_sites as site_index, loop_idx (site_index)}
1910
2077
  {@const site = structure.sites[site_index]}
1911
2078
  {#if site}
1912
2079
  <!-- shift selected site labels down to avoid overlapping regular site labels-->
1913
- {@const selection_offset = math.add(site_label_offset, [0, -0.5, 0])}
1914
- {@const pos = math.add(site.xyz, selection_offset) as Vec3}
2080
+ {@const selection_offset = math.add<Vec3>(site_label_offset, [0, -0.5, 0])}
2081
+ {@const pos = math.add(site.xyz, selection_offset)}
1915
2082
  <extras.HTML center position={pos}>
1916
2083
  <span class="selection-label">{loop_idx + 1}</span>
1917
2084
  </extras.HTML>
@@ -1920,24 +2087,18 @@
1920
2087
  {/if}
1921
2088
 
1922
2089
  <!-- hovered site tooltip -->
1923
- {#if hovered_site && !camera_is_moving &&
1924
- (atom_tooltip_active || active_sites.includes(hovered_idx ?? -1))}
1925
- {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(
1926
- `, `,
1927
- )}
1928
- {@const xyz = hovered_site.xyz.map((val) => format_num(val, float_fmt)).join(
1929
- `, `,
1930
- )}
2090
+ {#if hovered_site && !camera_is_moving && (atom_tooltip_active || active_sites.includes(hovered_idx ?? -1))}
2091
+ {@const abc = hovered_site.abc.map((val) => format_num(val, float_fmt)).join(`, `)}
2092
+ {@const xyz = hovered_site.xyz.map((val) => format_num(val, float_fmt)).join(`, `)}
1931
2093
  {@const bond_neighbors = (() => {
1932
2094
  if (hovered_idx == null || !structure?.sites) return []
1933
2095
  return filtered_bond_pairs
1934
- .filter((bond) =>
1935
- bond.site_idx_1 === hovered_idx || bond.site_idx_2 === hovered_idx
2096
+ .filter(
2097
+ (bond) => bond.site_idx_1 === hovered_idx || bond.site_idx_2 === hovered_idx,
1936
2098
  )
1937
2099
  .map((bond) => {
1938
- const neighbor_idx = bond.site_idx_1 === hovered_idx
1939
- ? bond.site_idx_2
1940
- : bond.site_idx_1
2100
+ const neighbor_idx =
2101
+ bond.site_idx_1 === hovered_idx ? bond.site_idx_2 : bond.site_idx_1
1941
2102
  return structure.sites[neighbor_idx]?.species[0]?.element ?? `?`
1942
2103
  })
1943
2104
  })()}
@@ -1952,28 +2113,25 @@
1952
2113
  .map(([elem, count]) => `${elem}: ${count}`)
1953
2114
  return ` (${parts.join(`, `)})`
1954
2115
  })()}
2116
+ {@const tooltip_species =
2117
+ render_sites.find((rs) => rs.site_idx === hovered_idx)?.site.species ??
2118
+ hovered_site.species ??
2119
+ []}
1955
2120
  <CanvasTooltip position={hovered_site.xyz}>
1956
2121
  <!-- Element symbols with occupancies for disordered sites -->
1957
2122
  <div class="elements">
1958
- {#each hovered_site.species ?? [] as
1959
- { element, occu, oxidation_state: oxi_state },
1960
- idx
1961
- (`${element ?? ``}-${occu ?? ``}-${oxi_state ?? ``}-${idx}`)
1962
- }
1963
- {@const element_name = element_data.find((elem) =>
1964
- elem.symbol === element
1965
- )?.name ??
1966
- ``}
1967
- {#if idx > 0}&thinsp;{/if}
1968
- {#if occu !== 1}<span class="occupancy">{
1969
- format_num(occu, `.3~f`)
1970
- }</span>{/if}
1971
- <strong>
1972
- {element}{#if oxi_state != null && oxi_state !== 0}<sup>{Math.abs(
1973
- oxi_state,
1974
- )}{oxi_state > 0 ? `+` : `−`}</sup>{/if}
1975
- </strong>
1976
- {#if element_name}<span class="elem-name">{element_name}</span>{/if}
2123
+ {#each tooltip_species as { element, occu, oxidation_state: oxi_state }, idx (`${element ?? ``}-${occu ?? ``}-${oxi_state ?? ``}-${idx}`)}
2124
+ {@const element_name =
2125
+ element_data.find((elem) => elem.symbol === element)?.name ?? ``}
2126
+ <span class="species">
2127
+ {#if occu !== 1}<span class="occupancy">{format_num(occu, `.3~f`)}</span>{/if}
2128
+ <strong>
2129
+ {element}{#if oxi_state != null && oxi_state !== 0}<sup
2130
+ >{Math.abs(oxi_state)}{oxi_state > 0 ? `+` : `−`}</sup
2131
+ >{/if}
2132
+ </strong>
2133
+ {#if element_name}<span class="elem-name">{element_name}</span>{/if}
2134
+ </span>
1977
2135
  {/each}
1978
2136
  </div>
1979
2137
  <div class="coordinates fractional">abc: ({abc})</div>
@@ -1986,26 +2144,29 @@
1986
2144
 
1987
2145
  {#if visual_lattice}
1988
2146
  <Lattice matrix={visual_lattice.matrix} {...lattice_props} />
2147
+ {#if symmetry_elements.length > 0}
2148
+ <SymmetryElements
2149
+ elements={symmetry_elements}
2150
+ lattice={visual_lattice.matrix}
2151
+ {...symmetry_elements_props}
2152
+ />
2153
+ {/if}
1989
2154
  {/if}
1990
2155
 
1991
2156
  <!-- TransformControls for editing atoms in edit-atoms mode -->
1992
- {#if measure_mode === `edit-atoms` && selected_sites.length > 0 &&
1993
- structure?.sites}
2157
+ {#if interactive && measure_mode === `edit-atoms` && selected_sites.length > 0 && structure?.sites}
1994
2158
  {@const selected_atoms = selected_sites
1995
2159
  .map((idx) => structure?.sites?.[idx])
1996
2160
  .filter((site): site is Site => site != null)}
1997
2161
  {#if selected_atoms.length > 0}
1998
2162
  {@const avg = (dim: number) =>
1999
- selected_atoms.reduce((sum, atom) => sum + atom.xyz[dim], 0) /
2000
- selected_atoms.length}
2163
+ selected_atoms.reduce((sum, atom) => sum + atom.xyz[dim], 0) /
2164
+ selected_atoms.length}
2001
2165
  {@const centroid = [avg(0), avg(1), avg(2)] as Vec3}
2002
2166
  <!-- Invisible mesh at centroid for TransformControls to manipulate.
2003
2167
  During drag, use frozen_centroid so Svelte doesn't override TransformControls
2004
2168
  with the wrapped centroid (which jumps on PBC boundary crossings). -->
2005
- <T.Mesh
2006
- position={frozen_centroid ?? centroid}
2007
- bind:ref={transform_object}
2008
- >
2169
+ <T.Mesh position={frozen_centroid ?? centroid} bind:ref={transform_object}>
2009
2170
  <T.SphereGeometry args={[0.01, 4, 4]} />
2010
2171
  <T.MeshBasicMaterial transparent opacity={0} />
2011
2172
  </T.Mesh>
@@ -2044,7 +2205,7 @@
2044
2205
 
2045
2206
  <!-- Invisible plane for click-to-place atom in add-atom mode -->
2046
2207
  <!-- Uses onBeforeRender to orient normal toward camera so raycasts always hit -->
2047
- {#if measure_mode === `edit-atoms` && add_atom_mode}
2208
+ {#if interactive && measure_mode === `edit-atoms` && add_atom_mode}
2048
2209
  {@const center = rotation_target ?? [0, 0, 0]}
2049
2210
  <T.Mesh
2050
2211
  position={center}
@@ -2059,10 +2220,7 @@
2059
2220
  }}
2060
2221
  >
2061
2222
  <T.PlaneGeometry
2062
- args={[
2063
- Math.max(200, structure_size * 4),
2064
- Math.max(200, structure_size * 4),
2065
- ]}
2223
+ args={[Math.max(200, structure_size * 4), Math.max(200, structure_size * 4)]}
2066
2224
  />
2067
2225
  <T.MeshBasicMaterial transparent opacity={0} side={2} depthWrite={false} />
2068
2226
  </T.Mesh>
@@ -2082,27 +2240,23 @@
2082
2240
  {@const site_j = structure.sites[idx_j]}
2083
2241
  {@const pos_i = site_i.xyz}
2084
2242
  {@const pos_j = site_j.xyz}
2085
- <Cylinder
2086
- from={pos_i}
2087
- to={pos_j}
2088
- thickness={0.12}
2089
- color={measure_line_color}
2090
- />
2243
+ <Cylinder from={pos_i} to={pos_j} thickness={0.12} color={measure_line_color} />
2091
2244
  {@const midpoint = [
2092
- (pos_i[0] + pos_j[0]) / 2,
2093
- (pos_i[1] + pos_j[1]) / 2,
2094
- (pos_i[2] + pos_j[2]) / 2,
2095
- ] as Vec3}
2245
+ (pos_i[0] + pos_j[0]) / 2,
2246
+ (pos_i[1] + pos_j[1]) / 2,
2247
+ (pos_i[2] + pos_j[2]) / 2,
2248
+ ] as Vec3}
2096
2249
  {@const direct = math.euclidean_dist(pos_i, pos_j)}
2097
2250
  {@const pbc = lattice
2098
- ? measure.distance_pbc(pos_i, pos_j, lattice.matrix, undefined, lattice.pbc)
2099
- : direct}
2251
+ ? measure.distance_pbc(pos_i, pos_j, lattice.matrix, undefined, lattice.pbc)
2252
+ : direct}
2100
2253
  {@const differ = lattice ? Math.abs(pbc - direct) > 1e-6 : false}
2101
2254
  <extras.HTML center position={midpoint}>
2102
2255
  <span class="measure-label">
2103
2256
  {#if differ}
2104
2257
  PBC: {format_num(pbc, float_fmt)} Å<br /><small>
2105
- Direct: {format_num(direct, float_fmt)} Å</small>
2258
+ Direct: {format_num(direct, float_fmt)} Å</small
2259
+ >
2106
2260
  {:else}
2107
2261
  {format_num(pbc, float_fmt)} Å
2108
2262
  {/if}
@@ -2113,19 +2267,20 @@
2113
2267
  {:else if measure_mode === `angle` && measured_sites.length >= 3}
2114
2268
  {#each measured_sites as idx_center (idx_center)}
2115
2269
  {@const center = structure.sites[idx_center]}
2116
- {#each measured_sites.filter((idx) => idx !== idx_center) as
2117
- idx_a,
2118
- loop_idx
2119
- (idx_center + `-` + idx_a)
2120
- }
2121
- {#each measured_sites.filter((idx) => idx !== idx_center).slice(loop_idx + 1) as
2122
- idx_b
2123
- (idx_center + `-` + idx_a + `-` + idx_b)
2124
- }
2270
+ {#each measured_sites.filter((idx) => idx !== idx_center) as idx_a, loop_idx (idx_center + `-` + idx_a)}
2271
+ {#each measured_sites
2272
+ .filter((idx) => idx !== idx_center)
2273
+ .slice(loop_idx + 1) as idx_b (idx_center + `-` + idx_a + `-` + idx_b)}
2125
2274
  {@const site_a = structure.sites[idx_a]}
2126
2275
  {@const site_b = structure.sites[idx_b]}
2127
2276
  {@const disp = (to: Vec3) =>
2128
- measure.displacement_pbc(center.xyz, to, lattice?.matrix, undefined, lattice?.pbc)}
2277
+ measure.displacement_pbc(
2278
+ center.xyz,
2279
+ to,
2280
+ lattice?.matrix,
2281
+ undefined,
2282
+ lattice?.pbc,
2283
+ )}
2129
2284
  {@const v1 = disp(site_a.xyz)}
2130
2285
  {@const v2 = disp(site_b.xyz)}
2131
2286
  {@const n1 = Math.hypot(v1[0], v1[1], v1[2])}
@@ -2163,10 +2318,6 @@
2163
2318
  </T.Group>
2164
2319
 
2165
2320
  <style>
2166
- :global(.structure .responsive-gizmo) {
2167
- width: clamp(70px, 18cqmin, 100px) !important;
2168
- height: clamp(70px, 18cqmin, 100px) !important;
2169
- }
2170
2321
  .atom-label {
2171
2322
  background: var(--struct-atom-label-bg, rgba(0, 0, 0, 0.1));
2172
2323
  border: 0;
@@ -2180,6 +2331,13 @@
2180
2331
  .elements {
2181
2332
  margin-bottom: var(--canvas-tooltip-elements-margin);
2182
2333
  }
2334
+ .species {
2335
+ display: inline-block;
2336
+ white-space: nowrap;
2337
+ &:not(:first-child) {
2338
+ margin-left: var(--canvas-tooltip-species-gap, 0.5em);
2339
+ }
2340
+ }
2183
2341
  .occupancy {
2184
2342
  font-size: var(--canvas-tooltip-occu-font-size);
2185
2343
  opacity: var(--canvas-tooltip-occu-opacity);