matterviz 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/dist/EmptyState.svelte +10 -2
  2. package/dist/FilePicker.svelte +154 -96
  3. package/dist/Icon.svelte +20 -14
  4. package/dist/MillerIndexInput.svelte +27 -21
  5. package/dist/api/optimade.js +6 -6
  6. package/dist/app.css +216 -178
  7. package/dist/brillouin/BrillouinZone.svelte +299 -198
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  9. package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +74 -55
  11. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte +277 -165
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  15. package/dist/brillouin/BrillouinZoneTooltip.svelte +17 -7
  16. package/dist/brillouin/compute.js +11 -6
  17. package/dist/chempot-diagram/ChemPotDiagram.svelte +327 -0
  18. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  19. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +847 -0
  20. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  21. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +3194 -0
  22. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  23. package/dist/chempot-diagram/ChemPotScene3D.svelte +11 -0
  24. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  25. package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
  26. package/dist/chempot-diagram/async-compute.svelte.js +77 -0
  27. package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
  28. package/dist/chempot-diagram/chempot-worker.js +11 -0
  29. package/dist/chempot-diagram/color.d.ts +10 -0
  30. package/dist/chempot-diagram/color.js +32 -0
  31. package/dist/chempot-diagram/compute.d.ts +48 -0
  32. package/dist/chempot-diagram/compute.js +812 -0
  33. package/dist/chempot-diagram/index.d.ts +6 -0
  34. package/dist/chempot-diagram/index.js +6 -0
  35. package/dist/chempot-diagram/pointer.d.ts +16 -0
  36. package/dist/chempot-diagram/pointer.js +40 -0
  37. package/dist/chempot-diagram/temperature.d.ts +15 -0
  38. package/dist/chempot-diagram/temperature.js +36 -0
  39. package/dist/chempot-diagram/types.d.ts +86 -0
  40. package/dist/chempot-diagram/types.js +28 -0
  41. package/dist/colors/index.d.ts +3 -1
  42. package/dist/colors/index.js +9 -3
  43. package/dist/composition/BarChart.svelte +141 -77
  44. package/dist/composition/BubbleChart.svelte +107 -52
  45. package/dist/composition/Composition.svelte +100 -79
  46. package/dist/composition/Formula.svelte +108 -62
  47. package/dist/composition/FormulaFilter.svelte +973 -353
  48. package/dist/composition/FormulaFilter.svelte.d.ts +35 -1
  49. package/dist/composition/PieChart.svelte +199 -99
  50. package/dist/composition/PieChart.svelte.d.ts +1 -1
  51. package/dist/composition/format.d.ts +5 -0
  52. package/dist/composition/format.js +20 -3
  53. package/dist/composition/parse.js +14 -9
  54. package/dist/convex-hull/ConvexHull.svelte +93 -38
  55. package/dist/convex-hull/ConvexHull2D.svelte +551 -393
  56. package/dist/convex-hull/ConvexHull3D.svelte +1303 -825
  57. package/dist/convex-hull/ConvexHull4D.svelte +1012 -686
  58. package/dist/convex-hull/ConvexHullControls.svelte +115 -28
  59. package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
  60. package/dist/convex-hull/ConvexHullStats.svelte +821 -249
  61. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  62. package/dist/convex-hull/ConvexHullTooltip.svelte +41 -16
  63. package/dist/convex-hull/GasPressureControls.svelte +104 -61
  64. package/dist/convex-hull/StructurePopup.svelte +25 -4
  65. package/dist/convex-hull/TemperatureSlider.svelte +45 -25
  66. package/dist/convex-hull/barycentric-coords.js +13 -7
  67. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  68. package/dist/convex-hull/demo-temperature.js +40 -0
  69. package/dist/convex-hull/gas-thermodynamics.js +17 -12
  70. package/dist/convex-hull/helpers.d.ts +10 -1
  71. package/dist/convex-hull/helpers.js +79 -38
  72. package/dist/convex-hull/index.d.ts +1 -0
  73. package/dist/convex-hull/index.js +1 -0
  74. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  75. package/dist/convex-hull/thermodynamics.js +163 -69
  76. package/dist/convex-hull/types.d.ts +12 -12
  77. package/dist/convex-hull/types.js +0 -12
  78. package/dist/coordination/CoordinationBarPlot.svelte +232 -176
  79. package/dist/element/BohrAtom.svelte +56 -13
  80. package/dist/element/ElementHeading.svelte +7 -2
  81. package/dist/element/ElementPhoto.svelte +15 -9
  82. package/dist/element/ElementStats.svelte +10 -4
  83. package/dist/element/ElementTile.svelte +137 -73
  84. package/dist/element/Nucleus.svelte +39 -11
  85. package/dist/element/data.js +2 -14
  86. package/dist/element/data.json.gz +0 -0
  87. package/dist/element/types.d.ts +1 -0
  88. package/dist/feedback/ClickFeedback.svelte +16 -5
  89. package/dist/feedback/DragOverlay.svelte +10 -2
  90. package/dist/feedback/Spinner.svelte +4 -2
  91. package/dist/feedback/StatusMessage.svelte +8 -2
  92. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  93. package/dist/fermi-surface/FermiSurface.svelte +336 -239
  94. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  95. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  96. package/dist/fermi-surface/FermiSurfaceScene.svelte +536 -343
  97. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  98. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  99. package/dist/fermi-surface/compute.js +16 -20
  100. package/dist/fermi-surface/parse.js +37 -33
  101. package/dist/fermi-surface/symmetry.js +2 -7
  102. package/dist/fermi-surface/types.d.ts +3 -5
  103. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1527 -0
  104. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  105. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +225 -0
  106. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +30 -0
  107. package/dist/heatmap-matrix/index.d.ts +53 -0
  108. package/dist/heatmap-matrix/index.js +100 -0
  109. package/dist/heatmap-matrix/shared.d.ts +2 -0
  110. package/dist/heatmap-matrix/shared.js +4 -0
  111. package/dist/icons.d.ts +111 -0
  112. package/dist/icons.js +158 -0
  113. package/dist/index.d.ts +5 -2
  114. package/dist/index.js +5 -2
  115. package/dist/io/decompress.js +1 -1
  116. package/dist/io/export.d.ts +3 -0
  117. package/dist/io/export.js +138 -140
  118. package/dist/io/file-drop.d.ts +7 -0
  119. package/dist/io/file-drop.js +43 -0
  120. package/dist/io/index.d.ts +2 -2
  121. package/dist/io/index.js +2 -112
  122. package/dist/io/is-binary.js +2 -3
  123. package/dist/io/types.d.ts +1 -0
  124. package/dist/io/url-drop.d.ts +2 -0
  125. package/dist/io/url-drop.js +117 -0
  126. package/dist/isosurface/Isosurface.svelte +220 -110
  127. package/dist/isosurface/IsosurfaceControls.svelte +65 -28
  128. package/dist/isosurface/parse.js +104 -56
  129. package/dist/isosurface/slice.d.ts +2 -1
  130. package/dist/isosurface/slice.js +8 -13
  131. package/dist/isosurface/types.d.ts +14 -1
  132. package/dist/isosurface/types.js +152 -5
  133. package/dist/labels.d.ts +2 -1
  134. package/dist/labels.js +12 -8
  135. package/dist/layout/FullscreenToggle.svelte +11 -2
  136. package/dist/layout/InfoCard.svelte +38 -6
  137. package/dist/layout/InfoTag.svelte +125 -94
  138. package/dist/layout/PropertyFilter.svelte +82 -37
  139. package/dist/layout/SettingsSection.svelte +85 -55
  140. package/dist/layout/SubpageGrid.svelte +82 -0
  141. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  142. package/dist/layout/index.d.ts +1 -0
  143. package/dist/layout/index.js +1 -0
  144. package/dist/layout/json-tree/JsonNode.svelte +266 -223
  145. package/dist/layout/json-tree/JsonTree.svelte +516 -429
  146. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  147. package/dist/layout/json-tree/JsonValue.svelte +281 -173
  148. package/dist/layout/json-tree/types.d.ts +10 -2
  149. package/dist/layout/json-tree/utils.d.ts +2 -0
  150. package/dist/layout/json-tree/utils.js +37 -2
  151. package/dist/marching-cubes.js +25 -2
  152. package/dist/math.d.ts +20 -17
  153. package/dist/math.js +474 -57
  154. package/dist/overlays/ContextMenu.svelte +66 -40
  155. package/dist/overlays/DraggablePane.svelte +331 -154
  156. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  157. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  158. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  159. package/dist/periodic-table/PropertySelect.svelte +25 -7
  160. package/dist/periodic-table/TableInset.svelte +8 -3
  161. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +559 -267
  162. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  163. package/dist/phase-diagram/PhaseDiagramControls.svelte +131 -51
  164. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  165. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +126 -0
  166. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  167. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +160 -110
  168. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +8 -1
  169. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +217 -86
  170. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  171. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  172. package/dist/phase-diagram/build-diagram.js +9 -9
  173. package/dist/phase-diagram/colors.js +1 -3
  174. package/dist/phase-diagram/index.d.ts +2 -0
  175. package/dist/phase-diagram/index.js +2 -0
  176. package/dist/phase-diagram/parse.js +10 -9
  177. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  178. package/dist/phase-diagram/svg-to-diagram.js +869 -0
  179. package/dist/phase-diagram/types.d.ts +10 -0
  180. package/dist/phase-diagram/utils.d.ts +8 -4
  181. package/dist/phase-diagram/utils.js +219 -74
  182. package/dist/plot/AxisLabel.svelte +51 -0
  183. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  184. package/dist/plot/BarPlot.svelte +1461 -768
  185. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  186. package/dist/plot/BarPlotControls.svelte +33 -6
  187. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  188. package/dist/plot/ColorBar.svelte +533 -383
  189. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  190. package/dist/plot/ColorScaleSelect.svelte +28 -7
  191. package/dist/plot/ElementScatter.svelte +38 -16
  192. package/dist/plot/FillArea.svelte +152 -92
  193. package/dist/plot/Histogram.svelte +1162 -709
  194. package/dist/plot/Histogram.svelte.d.ts +1 -1
  195. package/dist/plot/HistogramControls.svelte +81 -18
  196. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  197. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  198. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  199. package/dist/plot/Line.svelte +63 -28
  200. package/dist/plot/PlotControls.svelte +221 -96
  201. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  202. package/dist/plot/PlotLegend.svelte +174 -91
  203. package/dist/plot/PlotTooltip.svelte +45 -6
  204. package/dist/plot/PortalSelect.svelte +175 -146
  205. package/dist/plot/ReferenceLine.svelte +77 -22
  206. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  207. package/dist/plot/ReferenceLine3D.svelte +132 -107
  208. package/dist/plot/ReferencePlane.svelte +146 -123
  209. package/dist/plot/ScatterPlot.svelte +1880 -1156
  210. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  211. package/dist/plot/ScatterPlot3D.svelte +256 -131
  212. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  213. package/dist/plot/ScatterPlot3DControls.svelte +300 -297
  214. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  215. package/dist/plot/ScatterPlot3DScene.svelte +608 -406
  216. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  217. package/dist/plot/ScatterPlotControls.svelte +150 -70
  218. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  219. package/dist/plot/ScatterPoint.svelte +98 -26
  220. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  221. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  222. package/dist/plot/Surface3D.svelte +159 -108
  223. package/dist/plot/ZeroLines.svelte +96 -0
  224. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  225. package/dist/plot/ZoomRect.svelte +23 -0
  226. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  227. package/dist/plot/axis-utils.d.ts +1 -1
  228. package/dist/plot/axis-utils.js +1 -3
  229. package/dist/plot/data-cleaning.js +12 -28
  230. package/dist/plot/data-transform.js +2 -1
  231. package/dist/plot/fill-utils.js +2 -0
  232. package/dist/plot/index.d.ts +6 -2
  233. package/dist/plot/index.js +6 -2
  234. package/dist/plot/interactions.d.ts +8 -10
  235. package/dist/plot/interactions.js +2 -3
  236. package/dist/plot/layout.d.ts +11 -2
  237. package/dist/plot/layout.js +44 -17
  238. package/dist/plot/reference-line.d.ts +5 -22
  239. package/dist/plot/reference-line.js +12 -84
  240. package/dist/plot/scales.js +24 -36
  241. package/dist/plot/types.d.ts +53 -40
  242. package/dist/plot/types.js +12 -7
  243. package/dist/plot/utils/label-placement.d.ts +32 -15
  244. package/dist/plot/utils/label-placement.js +227 -63
  245. package/dist/plot/utils/series-visibility.js +2 -3
  246. package/dist/plot/utils.d.ts +1 -0
  247. package/dist/plot/utils.js +14 -0
  248. package/dist/rdf/RdfPlot.svelte +173 -132
  249. package/dist/rdf/calc-rdf.js +4 -5
  250. package/dist/sanitize.d.ts +4 -0
  251. package/dist/sanitize.js +107 -0
  252. package/dist/settings.d.ts +21 -6
  253. package/dist/settings.js +63 -19
  254. package/dist/spectral/Bands.svelte +963 -412
  255. package/dist/spectral/Bands.svelte.d.ts +22 -2
  256. package/dist/spectral/BandsAndDos.svelte +90 -49
  257. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  258. package/dist/spectral/Dos.svelte +389 -258
  259. package/dist/spectral/helpers.d.ts +23 -1
  260. package/dist/spectral/helpers.js +119 -51
  261. package/dist/spectral/types.d.ts +2 -0
  262. package/dist/state.svelte.d.ts +1 -1
  263. package/dist/state.svelte.js +3 -2
  264. package/dist/structure/Arrow.svelte +59 -20
  265. package/dist/structure/AtomLegend.svelte +231 -129
  266. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  267. package/dist/structure/Bond.svelte +73 -47
  268. package/dist/structure/CanvasTooltip.svelte +10 -2
  269. package/dist/structure/CellSelect.svelte +148 -51
  270. package/dist/structure/Cylinder.svelte +33 -17
  271. package/dist/structure/Lattice.svelte +88 -33
  272. package/dist/structure/Structure.svelte +1077 -821
  273. package/dist/structure/Structure.svelte.d.ts +1 -1
  274. package/dist/structure/StructureControls.svelte +373 -139
  275. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  276. package/dist/structure/StructureExportPane.svelte +124 -89
  277. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  278. package/dist/structure/StructureInfoPane.svelte +304 -231
  279. package/dist/structure/StructureScene.svelte +919 -445
  280. package/dist/structure/StructureScene.svelte.d.ts +16 -7
  281. package/dist/structure/atom-properties.d.ts +6 -2
  282. package/dist/structure/atom-properties.js +42 -29
  283. package/dist/structure/bonding.js +6 -7
  284. package/dist/structure/export.js +22 -34
  285. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  286. package/dist/structure/ferrox-wasm-types.js +0 -3
  287. package/dist/structure/ferrox-wasm.d.ts +3 -2
  288. package/dist/structure/ferrox-wasm.js +2 -3
  289. package/dist/structure/index.d.ts +16 -0
  290. package/dist/structure/index.js +88 -6
  291. package/dist/structure/measure.d.ts +2 -2
  292. package/dist/structure/measure.js +4 -44
  293. package/dist/structure/parse.js +130 -155
  294. package/dist/structure/partial-occupancy.d.ts +25 -0
  295. package/dist/structure/partial-occupancy.js +99 -0
  296. package/dist/structure/pbc.d.ts +1 -0
  297. package/dist/structure/pbc.js +16 -6
  298. package/dist/structure/supercell.d.ts +2 -2
  299. package/dist/structure/supercell.js +12 -22
  300. package/dist/structure/validation.js +5 -3
  301. package/dist/symmetry/SymmetryStats.svelte +94 -37
  302. package/dist/symmetry/WyckoffTable.svelte +42 -14
  303. package/dist/symmetry/cell-transform.js +5 -3
  304. package/dist/symmetry/index.d.ts +7 -4
  305. package/dist/symmetry/index.js +87 -21
  306. package/dist/symmetry/spacegroups.js +148 -148
  307. package/dist/table/HeatmapTable.svelte +1112 -516
  308. package/dist/table/HeatmapTable.svelte.d.ts +12 -1
  309. package/dist/table/ToggleMenu.svelte +125 -90
  310. package/dist/table/index.d.ts +2 -0
  311. package/dist/table/index.js +2 -4
  312. package/dist/theme/ThemeControl.svelte +21 -12
  313. package/dist/time.js +4 -1
  314. package/dist/tooltip/TooltipContent.svelte +33 -8
  315. package/dist/trajectory/Trajectory.svelte +889 -687
  316. package/dist/trajectory/TrajectoryError.svelte +14 -3
  317. package/dist/trajectory/TrajectoryExportPane.svelte +148 -90
  318. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  319. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  320. package/dist/trajectory/constants.d.ts +6 -0
  321. package/dist/trajectory/constants.js +7 -0
  322. package/dist/trajectory/extract.js +13 -31
  323. package/dist/trajectory/format-detect.d.ts +9 -0
  324. package/dist/trajectory/format-detect.js +76 -0
  325. package/dist/trajectory/frame-reader.d.ts +17 -0
  326. package/dist/trajectory/frame-reader.js +332 -0
  327. package/dist/trajectory/helpers.d.ts +14 -0
  328. package/dist/trajectory/helpers.js +172 -0
  329. package/dist/trajectory/index.d.ts +1 -0
  330. package/dist/trajectory/index.js +23 -14
  331. package/dist/trajectory/parse/ase.d.ts +2 -0
  332. package/dist/trajectory/parse/ase.js +77 -0
  333. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  334. package/dist/trajectory/parse/hdf5.js +129 -0
  335. package/dist/trajectory/parse/index.d.ts +12 -0
  336. package/dist/trajectory/parse/index.js +299 -0
  337. package/dist/trajectory/parse/lammps.d.ts +5 -0
  338. package/dist/trajectory/parse/lammps.js +179 -0
  339. package/dist/trajectory/parse/vasp.d.ts +2 -0
  340. package/dist/trajectory/parse/vasp.js +68 -0
  341. package/dist/trajectory/parse/xyz.d.ts +2 -0
  342. package/dist/trajectory/parse/xyz.js +110 -0
  343. package/dist/trajectory/plotting.js +13 -8
  344. package/dist/trajectory/types.d.ts +11 -0
  345. package/dist/trajectory/types.js +1 -0
  346. package/dist/utils.d.ts +3 -0
  347. package/dist/utils.js +17 -0
  348. package/dist/xrd/XrdPlot.svelte +337 -245
  349. package/dist/xrd/broadening.js +14 -9
  350. package/dist/xrd/calc-xrd.js +12 -19
  351. package/dist/xrd/parse.d.ts +1 -1
  352. package/dist/xrd/parse.js +17 -17
  353. package/package.json +103 -101
  354. package/readme.md +4 -4
  355. package/dist/trajectory/parse.d.ts +0 -42
  356. package/dist/trajectory/parse.js +0 -1267
  357. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
  358. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,892 +1,1176 @@
1
- <script lang="ts">import { ELEMENT_COLOR_SCHEMES } from '../colors';
2
- import { normalize_show_controls } from '../controls';
3
- import Spinner from '../feedback/Spinner.svelte';
4
- import Icon from '../Icon.svelte';
5
- import { decompress_file, handle_url_drop, load_from_url } from '../io';
6
- import { parse_volumetric_file } from '../isosurface/parse';
7
- import { auto_isosurface_settings, DEFAULT_ISOSURFACE_SETTINGS, } from '../isosurface/types';
8
- import { ELEM_SYMBOLS } from '../labels';
9
- import { set_fullscreen_bg, toggle_fullscreen } from '../layout';
10
- import { create_cart_to_frac, create_frac_to_cart } from '../math';
11
- import { DEFAULTS } from '../settings';
12
- import { colors } from '../state.svelte';
13
- import { get_element_counts, get_pbc_image_sites } from './';
14
- import { wrap_to_unit_cell } from './pbc';
15
- import { is_valid_supercell_input, make_supercell } from './supercell';
16
- import * as symmetry from '../symmetry';
17
- import { transform_cell } from '../symmetry';
18
- import { Canvas } from '@threlte/core';
19
- import { untrack } from 'svelte';
20
- import { click_outside, tooltip } from 'svelte-multiselect';
21
- import { SvelteMap, SvelteSet } from 'svelte/reactivity';
22
- import { get_property_colors } from './atom-properties';
23
- import AtomLegend from './AtomLegend.svelte';
24
- import CellSelect from './CellSelect.svelte';
25
- import { MAX_SELECTED_SITES } from './measure';
26
- import { normalize_fractional_coords, parse_any_structure } from './parse';
27
- import StructureControls from './StructureControls.svelte';
28
- import StructureExportPane from './StructureExportPane.svelte';
29
- import StructureInfoPane from './StructureInfoPane.svelte';
30
- import StructureScene from './StructureScene.svelte';
31
- // Local reactive state for scene and lattice props. Deeply reactive so nested mutations propagate.
32
- // Deep-clone to prevent mutations from leaking to global defaults across component instances.
33
- let scene_props = $state(structuredClone(DEFAULTS.structure));
34
- let lattice_props = $state({
1
+ <script lang="ts">
2
+ import type { ColorSchemeName } from '../colors'
3
+ import { ELEMENT_COLOR_SCHEMES } from '../colors'
4
+ import type { ShowControlsProp } from '../controls'
5
+ import { normalize_show_controls } from '../controls'
6
+ import type { ElementSymbol } from '../element'
7
+ import { StatusMessage } from '../feedback'
8
+ import Spinner from '../feedback/Spinner.svelte'
9
+ import Icon from '../Icon.svelte'
10
+ import { create_file_drop_handler, load_from_url } from '../io'
11
+ import { parse_volumetric_file } from '../isosurface/parse'
12
+ import type { IsosurfaceSettings, VolumetricData } from '../isosurface/types'
13
+ import {
14
+ auto_isosurface_settings,
15
+ DEFAULT_ISOSURFACE_SETTINGS,
16
+ tile_volumetric_data,
17
+ } from '../isosurface/types'
18
+ import { ELEM_SYMBOLS } from '../labels'
19
+ import { set_fullscreen_bg, toggle_fullscreen } from '../layout'
20
+ import type { Vec3 } from '../math'
21
+ import { create_cart_to_frac, create_frac_to_cart } from '../math'
22
+ import { DEFAULTS } from '../settings'
23
+ import { sanitize_html } from '../sanitize'
24
+ import { colors } from '../state.svelte'
25
+ import type { AnyStructure, Crystal, MeasureMode } from './'
26
+ import {
27
+ default_vector_configs,
28
+ get_element_counts,
29
+ get_pbc_image_sites,
30
+ get_structure_vector_keys,
31
+ } from './'
32
+ import { wrap_to_unit_cell } from './pbc'
33
+ import {
34
+ is_valid_supercell_input,
35
+ make_supercell,
36
+ parse_supercell_scaling,
37
+ } from './supercell'
38
+ import type { CellType, SymmetrySettings } from '../symmetry'
39
+ import * as symmetry from '../symmetry'
40
+ import { transform_cell } from '../symmetry'
41
+ import type { MoyoDataset } from '@spglib/moyo-wasm'
42
+ import { Canvas } from '@threlte/core'
43
+ import type { ComponentProps, Snippet } from 'svelte'
44
+ import { untrack } from 'svelte'
45
+ import { click_outside, tooltip } from 'svelte-multiselect'
46
+ import type { HTMLAttributes } from 'svelte/elements'
47
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
48
+ import type { Camera, OrthographicCamera, Scene } from 'three'
49
+ import type { AtomColorConfig } from './atom-properties'
50
+ import { get_property_colors } from './atom-properties'
51
+ import AtomLegend from './AtomLegend.svelte'
52
+ import CellSelect from './CellSelect.svelte'
53
+ import type { StructureHandlerData } from './index'
54
+ import { MAX_SELECTED_SITES } from './measure'
55
+ import { normalize_fractional_coords, parse_any_structure } from './parse'
56
+ import StructureControls from './StructureControls.svelte'
57
+ import StructureExportPane from './StructureExportPane.svelte'
58
+ import StructureInfoPane from './StructureInfoPane.svelte'
59
+ import StructureScene from './StructureScene.svelte'
60
+
61
+ // Type alias for event handlers to reduce verbosity
62
+ type EventHandler = (data: StructureHandlerData) => void
63
+
64
+ // Local reactive state for scene and lattice props. Deeply reactive so nested mutations propagate.
65
+ // Deep-clone to prevent mutations from leaking to global defaults across component instances.
66
+ let scene_props = $state(
67
+ structuredClone(DEFAULTS.structure) as typeof DEFAULTS.structure & {
68
+ camera_target?: Vec3
69
+ },
70
+ )
71
+ let lattice_props = $state({
35
72
  cell_edge_opacity: DEFAULTS.structure.cell_edge_opacity,
36
73
  cell_surface_opacity: DEFAULTS.structure.cell_surface_opacity,
37
74
  cell_edge_color: DEFAULTS.structure.cell_edge_color,
38
75
  cell_surface_color: DEFAULTS.structure.cell_surface_color,
39
76
  cell_edge_width: DEFAULTS.structure.cell_edge_width,
40
77
  show_cell_vectors: DEFAULTS.structure.show_cell_vectors,
41
- });
42
- let { structure = $bindable(), scene_props: scene_props_in = $bindable(), lattice_props: lattice_props_in = $bindable(), controls_open = $bindable(false), info_pane_open = $bindable(false), enable_measure_mode = $bindable(true), measure_mode = $bindable(`distance`), background_color = $bindable(), background_opacity = $bindable(0.1), show_controls, fullscreen = $bindable(false), wrapper = $bindable(), width = $bindable(0), height = $bindable(0), reset_text = `Reset camera (or double-click)`, color_scheme = $bindable(`Vesta`), atom_color_config = $bindable({
43
- mode: DEFAULTS.structure.atom_color_mode,
44
- scale: DEFAULTS.structure.atom_color_scale,
45
- scale_type: DEFAULTS.structure.atom_color_scale_type,
46
- }), hovered = $bindable(false), dragover = $bindable(false), allow_file_drop = true, enable_info_pane = true, png_dpi = $bindable(150), show_image_atoms = $bindable(true), supercell_scaling = $bindable(`1x1x1`), fullscreen_toggle = DEFAULTS.structure.fullscreen_toggle, bottom_left, data_url, structure_string, on_file_drop, spinner_props = {}, loading = $bindable(false), error_msg = $bindable(), performance_mode = $bindable(`quality`),
47
- // expose selected site indices for external control/highlighting
48
- selected_sites = $bindable([]),
49
- // expose measured site indices for overlays/labels
50
- measured_sites = $bindable([]),
51
- // expose the displayed structure (with image atoms and supercell) for external use
52
- displayed_structure = $bindable(),
53
- // Track hidden elements across component lifecycle
54
- hidden_elements = $bindable(new SvelteSet()),
55
- // Track hidden property values (e.g. Wyckoff positions, coordination numbers)
56
- hidden_prop_vals = $bindable(new SvelteSet()),
57
- // Per-element radius overrides (absolute values in Angstroms)
58
- element_radius_overrides = $bindable({}),
59
- // Per-site radius overrides (absolute values in Angstroms)
60
- site_radius_overrides = $bindable(new SvelteMap()),
61
- // Symmetry analysis data (bindable for external access)
62
- sym_data = $bindable(null),
63
- // Symmetry analysis settings (bindable for external control)
64
- symmetry_settings = $bindable(symmetry.default_sym_settings),
65
- // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
66
- // Useful for LAMMPS files where atom types are mapped to H, He, Li by default
67
- element_mapping = $bindable(),
68
- // Cell type: original, conventional, or primitive (requires symmetry analysis)
69
- cell_type = $bindable(`original`),
70
- // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
71
- volumetric_data = $bindable(),
72
- // Isosurface rendering settings
73
- isosurface_settings = $bindable({
74
- ...DEFAULT_ISOSURFACE_SETTINGS,
75
- }),
76
- // Active volume index when multiple volumes are present
77
- active_volume_idx = $bindable(0), children, top_right_controls, on_file_load, on_error, on_fullscreen_change, on_camera_move, on_camera_reset, ...rest } = $props();
78
- // Initialize models from incoming props; mutations come from UI controls; we mirror into local dicts (NOTE only doing shallow merge)
79
- $effect.pre(() => {
78
+ })
79
+
80
+ let {
81
+ structure = $bindable(),
82
+ scene_props: scene_props_in = $bindable(),
83
+ lattice_props: lattice_props_in = $bindable(),
84
+ controls_open = $bindable(false),
85
+ info_pane_open = $bindable(false),
86
+ enable_measure_mode = $bindable(true),
87
+ measure_mode = $bindable<MeasureMode>(`distance`),
88
+ background_color = $bindable(),
89
+ background_opacity = $bindable(0.1),
90
+ show_controls,
91
+ fullscreen = $bindable(false),
92
+ wrapper = $bindable(),
93
+ width = $bindable(0),
94
+ height = $bindable(0),
95
+ reset_text = `Reset camera (or double-click)`,
96
+ color_scheme = $bindable(`Vesta`),
97
+ atom_color_config = $bindable({
98
+ mode: DEFAULTS.structure.atom_color_mode,
99
+ scale: DEFAULTS.structure.atom_color_scale,
100
+ scale_type: DEFAULTS.structure.atom_color_scale_type,
101
+ }),
102
+ hovered = $bindable(false),
103
+ dragover = $bindable(false),
104
+ allow_file_drop = true,
105
+ enable_info_pane = true,
106
+ png_dpi = $bindable(150),
107
+ show_image_atoms = $bindable(true),
108
+ supercell_scaling = $bindable(`1x1x1`),
109
+ fullscreen_toggle = DEFAULTS.structure.fullscreen_toggle,
110
+ bottom_left,
111
+ data_url,
112
+ structure_string,
113
+ on_file_drop,
114
+ spinner_props = {},
115
+ loading = $bindable(false),
116
+ error_msg = $bindable(),
117
+ performance_mode = $bindable(`quality`),
118
+ // expose selected site indices for external control/highlighting
119
+ selected_sites = $bindable([]),
120
+ // expose measured site indices for overlays/labels
121
+ measured_sites = $bindable([]),
122
+ // expose the displayed structure (with image atoms and supercell) for external use
123
+ displayed_structure = $bindable(),
124
+ // Track hidden elements across component lifecycle
125
+ hidden_elements = $bindable(new SvelteSet<ElementSymbol>()),
126
+ // Track hidden property values (e.g. Wyckoff positions, coordination numbers)
127
+ hidden_prop_vals = $bindable(new SvelteSet<number | string>()),
128
+ // Per-element radius overrides (absolute values in Angstroms)
129
+ element_radius_overrides = $bindable<Partial<Record<ElementSymbol, number>>>({}),
130
+ // Per-site radius overrides (absolute values in Angstroms)
131
+ site_radius_overrides = $bindable<SvelteMap<number, number>>(new SvelteMap()),
132
+ // Symmetry analysis data (bindable for external access)
133
+ sym_data = $bindable(null),
134
+ // Symmetry analysis settings (bindable for external control)
135
+ symmetry_settings = $bindable(symmetry.default_sym_settings),
136
+ // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
137
+ // Useful for LAMMPS files where atom types are mapped to H, He, Li by default
138
+ element_mapping = $bindable(),
139
+ // Cell type: original, conventional, or primitive (requires symmetry analysis)
140
+ cell_type = $bindable(`original`),
141
+ // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
142
+ volumetric_data = $bindable<VolumetricData[]>(),
143
+ // Isosurface rendering settings
144
+ isosurface_settings = $bindable<IsosurfaceSettings>({
145
+ ...DEFAULT_ISOSURFACE_SETTINGS,
146
+ }),
147
+ // Active volume index when multiple volumes are present
148
+ active_volume_idx = $bindable(0),
149
+ children,
150
+ top_right_controls,
151
+ on_file_load,
152
+ on_error,
153
+ on_fullscreen_change,
154
+ on_camera_move,
155
+ on_camera_reset,
156
+ ...rest
157
+ }:
158
+ & {
159
+ structure?: AnyStructure
160
+ scene_props?: ComponentProps<typeof StructureScene>
161
+ /**
162
+ * Controls visibility configuration.
163
+ * - 'always': controls always visible
164
+ * - 'hover': controls visible on component hover (default)
165
+ * - 'never': controls never visible
166
+ * - object: { mode, hidden, style } for fine-grained control
167
+ *
168
+ * Control names: 'reset-camera', 'fullscreen', 'measure-mode', 'info-pane', 'export-pane', 'controls'
169
+ */
170
+ show_controls?: ShowControlsProp
171
+ fullscreen?: boolean
172
+ // bindable width of the canvas
173
+ width?: number
174
+ // bindable height of the canvas
175
+ height?: number
176
+ // Canvas wrapper element (for export pane)
177
+ wrapper?: HTMLDivElement
178
+ // PNG export DPI setting
179
+ png_dpi?: number
180
+ reset_text?: string
181
+ hovered?: boolean
182
+ dragover?: boolean
183
+ allow_file_drop?: boolean
184
+ enable_info_pane?: boolean
185
+ enable_measure_mode?: boolean
186
+ measure_mode?: MeasureMode
187
+ info_pane_open?: boolean
188
+ fullscreen_toggle?: Snippet<[{ fullscreen: boolean }]> | boolean
189
+ bottom_left?: Snippet<[{ structure?: AnyStructure }]>
190
+ top_right_controls?: Snippet // Additional controls to render at the end of the control buttons row
191
+ data_url?: string // URL to load structure from (alternative to providing structure directly)
192
+ // Generic callback for when files are dropped - receives raw content and filename
193
+ on_file_drop?: (content: string | ArrayBuffer, filename: string) => void
194
+ // spinner props (passed to Spinner component)
195
+ spinner_props?: ComponentProps<typeof Spinner>
196
+ loading?: boolean
197
+ error_msg?: string
198
+ // Performance mode: 'quality' (default) or 'speed' for large structures
199
+ performance_mode?: `quality` | `speed`
200
+ // allow parent components to control highlighted/selected site indices
201
+ selected_sites?: number[]
202
+ // explicit measured sites for distance/angle overlays
203
+ measured_sites?: number[]
204
+ // expose the displayed structure (with image atoms and/or supercell) for external use
205
+ displayed_structure?: AnyStructure
206
+ // Track which elements are hidden (bindable across frames in trajectories)
207
+ hidden_elements?: Set<ElementSymbol>
208
+ // Track which property values are hidden (e.g. Wyckoff positions, coordination numbers)
209
+ hidden_prop_vals?: Set<number | string>
210
+ // Per-element radius overrides (absolute values in Angstroms)
211
+ element_radius_overrides?: Partial<Record<ElementSymbol, number>>
212
+ // Per-site radius overrides (absolute values in Angstroms)
213
+ // Accepts Map or SvelteMap for flexibility with external callers
214
+ site_radius_overrides?: Map<number, number> | SvelteMap<number, number>
215
+ // Symmetry analysis data (bindable for external access)
216
+ sym_data?: MoyoDataset | null
217
+ // Symmetry analysis settings (bindable for external control)
218
+ symmetry_settings?: Partial<SymmetrySettings>
219
+ // Map element symbols to different elements (e.g. {'H': 'Na', 'He': 'Cl'})
220
+ element_mapping?: Partial<Record<ElementSymbol, ElementSymbol>>
221
+ // Cell type: original, conventional, or primitive (requires symmetry analysis)
222
+ cell_type?: CellType
223
+ // Volumetric data for isosurface rendering (parsed from CHGCAR or .cube files)
224
+ volumetric_data?: VolumetricData[]
225
+ // Isosurface rendering settings
226
+ isosurface_settings?: IsosurfaceSettings
227
+ // Active volume index when multiple volumes are present
228
+ active_volume_idx?: number
229
+ // structure content as string (alternative to providing structure directly or via data_url)
230
+ structure_string?: string
231
+ // Atom coloring configuration
232
+ atom_color_config?: Partial<AtomColorConfig>
233
+ children?: Snippet<[{ structure?: AnyStructure; fullscreen: boolean }]>
234
+ on_file_load?: EventHandler
235
+ on_error?: EventHandler
236
+ on_fullscreen_change?: EventHandler
237
+ on_camera_move?: EventHandler
238
+ on_camera_reset?: EventHandler
239
+ }
240
+ & Omit<ComponentProps<typeof StructureControls>, `children` | `onclose`>
241
+ & Omit<HTMLAttributes<HTMLDivElement>, `children`> = $props()
242
+
243
+ // Initialize models from incoming props; mutations come from UI controls; we mirror into local dicts (NOTE only doing shallow merge)
244
+ $effect.pre(() => {
80
245
  if (scene_props_in && typeof scene_props_in === `object`) {
81
- Object.assign(scene_props, scene_props_in);
246
+ Object.assign(scene_props, scene_props_in)
82
247
  }
83
248
  if (lattice_props_in && typeof lattice_props_in === `object`) {
84
- Object.assign(lattice_props, lattice_props_in);
249
+ Object.assign(lattice_props, lattice_props_in)
85
250
  }
86
- });
87
- // Load structure from URL when data_url is provided
88
- $effect(() => {
251
+ })
252
+
253
+ // Load structure from URL when data_url is provided
254
+ $effect(() => {
89
255
  if (data_url && !structure) {
90
- loading = true;
91
- error_msg = undefined;
92
- load_from_url(data_url, (content, filename) => {
93
- if (on_file_drop)
94
- on_file_drop(content, filename);
95
- else {
96
- // Parse structure internally when no handler provided
97
- try {
98
- const text_content = content instanceof ArrayBuffer
99
- ? new TextDecoder().decode(content)
100
- : content;
101
- const parsed = parse_file_content(text_content, filename);
102
- emit_file_load_event(parsed, filename, content);
103
- }
104
- catch (error) {
105
- error_msg = `Failed to parse structure: ${error instanceof Error ? error.message : String(error)}`;
106
- on_error?.({ error_msg, filename });
107
- }
108
- }
109
- })
110
- .then(() => loading = false)
111
- .catch((error) => {
112
- console.error(`Failed to load structure from URL:`, error);
113
- error_msg = `Failed to load structure: ${error.message}`;
114
- loading = false;
115
- on_error?.({ error_msg, filename: data_url });
116
- });
117
- }
118
- });
119
- $effect(() => {
120
- if (!structure_string || data_url)
121
- return;
122
- loading = true;
123
- error_msg = undefined;
124
- try {
125
- const parsed = parse_any_structure(structure_string, `string`);
126
- if (parsed) {
127
- structure = parsed;
128
- untrack(() => emit_file_load_event(parsed, `string`, structure_string));
129
- }
256
+ loading = true
257
+ error_msg = undefined
258
+
259
+ load_from_url(data_url, (content, filename) => {
260
+ if (on_file_drop) on_file_drop(content, filename)
130
261
  else {
131
- throw new Error(`Failed to parse structure from string`);
262
+ // Parse structure internally when no handler provided
263
+ try {
264
+ const text_content = content instanceof ArrayBuffer
265
+ ? new TextDecoder().decode(content)
266
+ : content
267
+ const parsed = parse_file_content(text_content, filename)
268
+ emit_file_load_event(parsed, filename, content)
269
+ } catch (error) {
270
+ error_msg = `Failed to parse structure: ${
271
+ error instanceof Error ? error.message : String(error)
272
+ }`
273
+ on_error?.({ error_msg, filename })
274
+ }
132
275
  }
276
+ })
277
+ .then(() => loading = false)
278
+ .catch((error: Error) => {
279
+ console.error(`Failed to load structure from URL:`, error)
280
+ error_msg = `Failed to load structure: ${error.message}`
281
+ loading = false
282
+ on_error?.({ error_msg, filename: data_url })
283
+ })
133
284
  }
134
- catch (err) {
135
- error_msg = `Failed to parse structure from string: ${err instanceof Error ? err.message : String(err)}`;
136
- untrack(() => on_error?.({ error_msg, filename: `string` }));
137
- }
138
- finally {
139
- loading = false;
285
+ })
286
+
287
+ $effect(() => { // Parse structure from string when structure_string is provided
288
+ if (!structure_string || data_url) return
289
+ loading = true
290
+ error_msg = undefined
291
+ clear_camera_state()
292
+ try {
293
+ const parsed = parse_any_structure(structure_string, `string`)
294
+ if (parsed) {
295
+ structure = parsed
296
+ untrack(() => emit_file_load_event(parsed, `string`, structure_string))
297
+ } else {
298
+ throw new Error(`Failed to parse structure from string`)
299
+ }
300
+ } catch (err) {
301
+ error_msg = `Failed to parse structure from string: ${
302
+ err instanceof Error ? err.message : String(err)
303
+ }`
304
+ untrack(() => on_error?.({ error_msg, filename: `string` }))
305
+ } finally {
306
+ loading = false
140
307
  }
141
- });
142
- // Track if force vectors were auto-enabled to prevent repeated triggering
143
- let force_vectors_auto_enabled = $state(false);
144
- // Auto-enable force vectors when structure has force data
145
- $effect(() => {
146
- if (structure?.sites && !force_vectors_auto_enabled) {
147
- const has_force_data = structure.sites.some((site) => site.properties?.force && Array.isArray(site.properties.force));
148
- // Enable force vectors if structure has force data
149
- if (has_force_data && !scene_props.show_force_vectors) {
150
- scene_props.show_force_vectors = true;
151
- scene_props.force_scale ??= DEFAULTS.structure.force_scale;
152
- scene_props.force_color ??= DEFAULTS.structure.force_color;
153
- force_vectors_auto_enabled = true;
154
- }
308
+ })
309
+
310
+ // Auto-populate vector_configs when structure has vector data (force, magmom, spin, etc.)
311
+ // Skip if configs were externally provided. Clear auto-generated configs on structure change.
312
+ let vectors_auto_populated_for: AnyStructure | undefined = undefined
313
+ let last_auto_configs: Record<string, unknown> | undefined = undefined
314
+
315
+ $effect(() => {
316
+ if (!structure?.sites || structure === vectors_auto_populated_for) return
317
+ const keys = get_structure_vector_keys(structure)
318
+ // Clear auto-generated configs from previous structure; preserve externally-modified ones
319
+ const existing = scene_props.vector_configs
320
+ if (last_auto_configs && existing === last_auto_configs) {
321
+ scene_props.vector_configs = {}
322
+ last_auto_configs = undefined
323
+ } else if (existing && Object.keys(existing).length > 0) {
324
+ vectors_auto_populated_for = structure
325
+ return
155
326
  }
156
- });
157
- // Optimize scene props for performance based on structure size and mode
158
- $effect(() => {
327
+ vectors_auto_populated_for = structure
328
+ if (keys.length === 0) return
329
+ const configs = default_vector_configs(keys)
330
+ scene_props.vector_configs = configs
331
+ // Read back the proxied reference — Svelte 5 $state wraps objects in
332
+ // proxies, so `scene_props.vector_configs !== configs`. Storing the proxy
333
+ // lets the identity check above detect unmodified auto-configs.
334
+ // See https://svelte.dev/e/state_proxy_equality_mismatch
335
+ last_auto_configs = scene_props.vector_configs
336
+ scene_props.vector_scale ??= DEFAULTS.structure.vector_scale
337
+ scene_props.vector_color ??= DEFAULTS.structure.vector_color
338
+ })
339
+
340
+ // Optimize scene props for performance based on structure size and mode
341
+ $effect(() => {
159
342
  if (structure?.sites && performance_mode === `speed`) {
160
- const site_count = structure.sites.length;
161
- const current_sphere_segments = scene_props.sphere_segments || 20;
162
- // Reduce sphere segments for large structures in speed mode
163
- if (site_count > 200) {
164
- scene_props.sphere_segments = Math.min(current_sphere_segments, 12);
165
- }
343
+ const site_count = structure.sites.length
344
+ const current_sphere_segments = scene_props.sphere_segments || 20
345
+
346
+ // Reduce sphere segments for large structures in speed mode
347
+ if (site_count > 200) {
348
+ scene_props.sphere_segments = Math.min(current_sphere_segments, 12)
349
+ }
166
350
  }
167
- });
168
- $effect(() => {
169
- colors.element = ELEMENT_COLOR_SCHEMES[color_scheme];
170
- });
171
- // Compute property-based colors for legend display
172
- let property_colors = $derived(get_property_colors(structure, atom_color_config, scene_props.bonding_strategy, sym_data));
173
- let symmetry_run_id = 0;
174
- let symmetry_error = $state();
175
- // Trigger symmetry analysis when structure is loaded or settings change.
176
- // Skip during atom drags — symmetry doesn't change from moving atoms,
177
- // and WASM analysis on every drag frame causes severe frame drops.
178
- $effect(() => {
179
- if (dragging_atoms)
180
- return;
351
+ })
352
+
353
+ $effect(() => {
354
+ colors.element = ELEMENT_COLOR_SCHEMES[color_scheme as ColorSchemeName]
355
+ })
356
+
357
+ // Compute property-based colors for legend display
358
+ let property_colors = $derived(
359
+ get_property_colors(
360
+ structure,
361
+ atom_color_config,
362
+ scene_props.bonding_strategy,
363
+ sym_data,
364
+ ),
365
+ )
366
+
367
+ let symmetry_run_id = 0
368
+ let symmetry_error = $state<string>()
369
+ let last_symmetry_structure_ref: AnyStructure | null = null
370
+
371
+ // Trigger symmetry analysis when structure is loaded or settings change.
372
+ // Skip during atom drags — symmetry doesn't change from moving atoms,
373
+ // and WASM analysis on every drag frame causes severe frame drops.
374
+ $effect(() => {
375
+ if (dragging_atoms) return
181
376
  if (!structure || !(`lattice` in structure)) {
182
- untrack(() => {
183
- sym_data = null;
184
- symmetry_error = undefined;
185
- });
186
- return;
377
+ untrack(() => {
378
+ sym_data = null
379
+ symmetry_error = undefined
380
+ })
381
+ last_symmetry_structure_ref = null
382
+ return
383
+ }
384
+
385
+ const current_structure = structure
386
+ const structure_changed = current_structure !== last_symmetry_structure_ref
387
+ if (structure_changed) {
388
+ untrack(() => {
389
+ sym_data = null
390
+ symmetry_error = undefined
391
+ })
392
+ last_symmetry_structure_ref = current_structure
393
+ } else {
394
+ // Keep previous symmetry data while recomputing so bound consumers
395
+ // (e.g. SymmetryStats inputs) do not unmount and lose focus.
396
+ untrack(() => symmetry_error = undefined)
187
397
  }
188
- const current_structure = structure;
189
- const run_id = ++symmetry_run_id;
398
+ const run_id = ++symmetry_run_id
190
399
  // Destructure symmetry_settings to ensure Svelte tracks changes to symprec and algo
191
400
  // (reading just the object reference isn't sufficient for fine-grained reactivity)
192
- const { symprec, algo } = symmetry_settings ?? symmetry.default_sym_settings;
193
- const current_settings = { symprec, algo };
194
- // Use untrack to prevent cascading reactivity when resetting state
195
- untrack(() => [sym_data, symmetry_error] = [null, undefined]);
401
+ const { symprec, algo } = symmetry_settings ?? symmetry.default_sym_settings
402
+ const current_settings = { symprec, algo }
403
+ // Skip symmetry auto-analysis in unit tests; happy-dom can't fetch WASM assets
404
+ if (typeof process !== `undefined` && process.env?.VITEST) return
405
+
196
406
  symmetry.ensure_moyo_wasm_ready()
197
- .then(() => run_id === symmetry_run_id
198
- ? symmetry.analyze_structure_symmetry(current_structure, current_settings)
199
- : null)
200
- .then((data) => {
407
+ .then(() =>
408
+ run_id === symmetry_run_id
409
+ ? symmetry.analyze_structure_symmetry(current_structure, current_settings)
410
+ : null
411
+ )
412
+ .then((data) => {
201
413
  if (data && run_id === symmetry_run_id) {
202
- untrack(() => sym_data = data);
414
+ untrack(() => sym_data = data)
203
415
  }
204
- })
205
- .catch((err) => {
416
+ })
417
+ .catch((err) => {
206
418
  if (run_id === symmetry_run_id) {
207
- symmetry_error = `Symmetry analysis failed: ${err?.message || err}`;
208
- console.error(`Symmetry analysis failed:`, err);
419
+ untrack(() => sym_data = null)
420
+ symmetry_error = `Symmetry analysis failed: ${err?.message || err}`
421
+ console.error(`Symmetry analysis failed:`, err)
209
422
  }
210
- });
211
- });
212
- let measure_menu_open = $state(false);
213
- let export_pane_open = $state(false);
214
- // Bond customization state
215
- let added_bonds = $state([]);
216
- let removed_bonds = $state([]);
217
- // === Edit-atoms mode state ===
218
- let dragging_atoms = $state(false);
219
- let undo_stack = $state([]);
220
- let redo_stack = $state([]);
221
- const MAX_HISTORY = 20;
222
- // Flag set before internal edits (undo/redo/delete/add/move) to distinguish
223
- // them from external structure changes (file load, trajectory step, etc.)
224
- let is_internal_edit = false;
225
- // Add-atom sub-mode state (bound to StructureScene)
226
- let add_atom_mode = $state(false);
227
- let add_element = $state(`C`);
228
- let canvas_cursor = $state(`default`);
229
- let change_element_mode = $state(false);
230
- let change_element_value = $state(``);
231
- // Ephemeral toast message for edit operations
232
- let toast_msg = $state(null);
233
- let toast_timer;
234
- function show_toast(msg, duration_ms = 2000) {
235
- clearTimeout(toast_timer);
236
- toast_msg = msg;
237
- toast_timer = setTimeout(() => (toast_msg = null), duration_ms);
238
- }
239
- // Normalize and validate element symbol (e.g. "fe" → "Fe", "Xx" → null)
240
- function normalize_element(input) {
423
+ })
424
+ })
425
+
426
+ let measure_menu_open = $state(false)
427
+ let export_pane_open = $state(false)
428
+
429
+ // Bond customization state
430
+ let added_bonds = $state<[number, number][]>([])
431
+ let removed_bonds = $state<[number, number][]>([])
432
+
433
+ // === Edit-atoms mode state ===
434
+ let dragging_atoms = $state(false)
435
+ let undo_stack = $state<AnyStructure[]>([])
436
+ let redo_stack = $state<AnyStructure[]>([])
437
+ const MAX_HISTORY = 20
438
+ // Flag set before internal edits (undo/redo/delete/add/move) to distinguish
439
+ // them from external structure changes (file load, trajectory step, etc.)
440
+ let is_internal_edit = false
441
+ // Add-atom sub-mode state (bound to StructureScene)
442
+ let add_atom_mode = $state(false)
443
+ let add_element = $state<ElementSymbol>(`C` as ElementSymbol)
444
+ let canvas_cursor = $state(`default`)
445
+ let change_element_mode = $state(false)
446
+ let change_element_value = $state(``)
447
+ // Ephemeral toast message for edit operations
448
+ let toast_msg = $state<string | null>(null)
449
+ let toast_timer: ReturnType<typeof setTimeout> | undefined
450
+ function show_toast(msg: string, duration_ms = 2000) {
451
+ clearTimeout(toast_timer)
452
+ toast_msg = msg
453
+ toast_timer = setTimeout(() => (toast_msg = null), duration_ms)
454
+ }
455
+
456
+ // Normalize and validate element symbol (e.g. "fe" → "Fe", "Xx" → null)
457
+ function normalize_element(input: string): ElementSymbol | null {
241
458
  const normalized = (input.charAt(0).toUpperCase() +
242
- input.slice(1).toLowerCase());
243
- return ELEM_SYMBOLS.includes(normalized) ? normalized : null;
244
- }
245
- function clear_selection() {
246
- selected_sites = [];
247
- measured_sites = [];
248
- dragging_atoms = false;
249
- }
250
- function push_undo() {
251
- if (!structure)
252
- return;
459
+ input.slice(1).toLowerCase()) as ElementSymbol
460
+ return ELEM_SYMBOLS.includes(normalized) ? normalized : null
461
+ }
462
+
463
+ function clear_selection() {
464
+ selected_sites = []
465
+ measured_sites = []
466
+ dragging_atoms = false
467
+ }
468
+
469
+ function push_undo() {
470
+ if (!structure) return
253
471
  if (undo_stack.length >= MAX_HISTORY) {
254
- undo_stack.splice(0, undo_stack.length - MAX_HISTORY + 1);
472
+ undo_stack.splice(0, undo_stack.length - MAX_HISTORY + 1)
255
473
  }
256
- undo_stack.push($state.snapshot(structure));
257
- redo_stack.length = 0;
258
- }
259
- // Shared undo/redo: pop from `source`, push current state onto `target`
260
- function apply_history(source, target) {
261
- if (source.length === 0 || !structure)
262
- return;
263
- const restored = source.pop();
264
- if (!restored)
265
- return;
266
- is_internal_edit = true;
267
- target.push($state.snapshot(structure));
268
- structure = restored;
269
- clear_selection();
270
- }
271
- const undo = () => apply_history(undo_stack, redo_stack);
272
- const redo = () => apply_history(redo_stack, undo_stack);
273
- // Clear undo/redo stacks when structure changes externally (file load, etc.)
274
- // Internal edits set is_internal_edit=true before modifying structure.
275
- // This $effect runs after microtask, so the flag is still set from the edit.
276
- $effect(() => {
474
+ undo_stack.push($state.snapshot(structure) as AnyStructure)
475
+ redo_stack.length = 0
476
+ }
477
+
478
+ // Shared undo/redo: pop from `source`, push current state onto `target`
479
+ function apply_history(source: AnyStructure[], target: AnyStructure[]) {
480
+ if (source.length === 0 || !structure) return
481
+ const restored = source.pop()
482
+ if (!restored) return
483
+ is_internal_edit = true
484
+ target.push($state.snapshot(structure) as AnyStructure)
485
+ structure = restored
486
+ clear_selection()
487
+ }
488
+
489
+ const undo = () => apply_history(undo_stack, redo_stack)
490
+ const redo = () => apply_history(redo_stack, undo_stack)
491
+
492
+ // Clear undo/redo stacks when structure changes externally (file load, etc.)
493
+ // Internal edits set is_internal_edit=true before modifying structure.
494
+ // This $effect runs after microtask, so the flag is still set from the edit.
495
+ $effect(() => {
277
496
  // Track structure to re-run when it changes
278
- void structure;
497
+ void structure
279
498
  if (is_internal_edit) {
280
- is_internal_edit = false;
281
- return;
499
+ is_internal_edit = false
500
+ return
282
501
  }
283
502
  // External change — clear history and stale edit-atoms state
284
503
  untrack(() => {
285
- if (undo_stack.length > 0 || redo_stack.length > 0) {
286
- undo_stack = [];
287
- redo_stack = [];
288
- }
289
- if (measure_mode === `edit-atoms`) {
290
- if (selected_sites.length > 0 || measured_sites.length > 0)
291
- clear_selection();
292
- if (site_radius_overrides?.size > 0)
293
- site_radius_overrides.clear();
294
- }
295
- });
296
- });
297
- // Clear selection when switching measure/edit mode so stale state doesn't carry over
298
- let mode_first_run = true;
299
- $effect(() => {
300
- void measure_mode; // track reactively
504
+ if (undo_stack.length > 0 || redo_stack.length > 0) {
505
+ undo_stack = []
506
+ redo_stack = []
507
+ }
508
+ if (measure_mode === `edit-atoms`) {
509
+ if (selected_sites.length > 0 || measured_sites.length > 0) clear_selection()
510
+ if (site_radius_overrides?.size > 0) site_radius_overrides.clear()
511
+ }
512
+ })
513
+ })
514
+
515
+ // Clear selection when switching measure/edit mode so stale state doesn't carry over
516
+ let mode_first_run = true
517
+ $effect(() => {
518
+ void measure_mode // track reactively
301
519
  if (mode_first_run) {
302
- mode_first_run = false;
303
- return;
520
+ mode_first_run = false
521
+ return
304
522
  }
305
523
  untrack(() => {
306
- if (selected_sites.length > 0 || measured_sites.length > 0)
307
- clear_selection();
308
- });
309
- });
310
- // Auto-bake cell type transform and clear stale state when entering edit-atoms mode
311
- $effect(() => {
312
- if (measure_mode !== `edit-atoms`)
313
- return;
524
+ if (selected_sites.length > 0 || measured_sites.length > 0) clear_selection()
525
+ })
526
+ })
527
+
528
+ // Auto-bake cell type transform and clear stale state when entering edit-atoms mode
529
+ $effect(() => {
530
+ if (measure_mode !== `edit-atoms`) return
314
531
  untrack(() => {
315
- // Clear bond edits from edit-bonds mode to avoid stale state
316
- if (added_bonds.length > 0 || removed_bonds.length > 0) {
317
- added_bonds = [];
318
- removed_bonds = [];
319
- }
320
- if (cell_type !== `original` && cell_transformed_structure && structure) {
321
- // Bake the transformed cell: push original to undo, replace structure
322
- is_internal_edit = true;
323
- push_undo();
324
- structure = $state.snapshot(cell_transformed_structure);
325
- cell_type = `original`;
326
- }
327
- });
328
- });
329
- let controls_config = $derived(normalize_show_controls(show_controls));
330
- // Normalize structure coordinates: wrap fractional coords to [0,1) and recompute Cartesian
331
- // This ensures atoms are rendered inside the unit cell regardless of data source
332
- let normalized_structure = $derived.by(() => {
333
- if (!structure || !(`lattice` in structure))
334
- return structure;
335
- return normalize_fractional_coords(structure);
336
- });
337
- // Apply cell type transformation (original, conventional, or primitive)
338
- // This must happen BEFORE supercell transformation
339
- let cell_transformed_structure = $derived.by(() => {
340
- if (!normalized_structure || !(`lattice` in normalized_structure) ||
341
- cell_type === `original`) {
342
- return normalized_structure;
532
+ // Clear bond edits from edit-bonds mode to avoid stale state
533
+ if (added_bonds.length > 0 || removed_bonds.length > 0) {
534
+ added_bonds = []
535
+ removed_bonds = []
536
+ }
537
+ if (cell_type !== `original` && cell_transformed_structure && structure) {
538
+ // Bake the transformed cell: push original to undo, replace structure
539
+ is_internal_edit = true
540
+ push_undo()
541
+ structure = $state.snapshot(cell_transformed_structure) as AnyStructure
542
+ cell_type = `original`
543
+ }
544
+ })
545
+ })
546
+
547
+ let controls_config = $derived(normalize_show_controls(show_controls))
548
+
549
+ // Normalize structure coordinates: wrap fractional coords to [0,1) and recompute Cartesian
550
+ // This ensures atoms are rendered inside the unit cell regardless of data source
551
+ let normalized_structure = $derived.by(() => {
552
+ if (!structure || !(`lattice` in structure)) return structure
553
+ return normalize_fractional_coords(structure) as AnyStructure
554
+ })
555
+
556
+ // Apply cell type transformation (original, conventional, or primitive)
557
+ // This must happen BEFORE supercell transformation
558
+ let cell_transformed_structure = $derived.by(() => {
559
+ if (
560
+ !normalized_structure || !(`lattice` in normalized_structure) ||
561
+ cell_type === `original`
562
+ ) {
563
+ return normalized_structure
343
564
  }
344
565
  // Cell type transformation requires symmetry data
345
566
  if (!sym_data) {
346
- return normalized_structure;
567
+ return normalized_structure
347
568
  }
348
569
  try {
349
- return transform_cell(normalized_structure, cell_type, sym_data);
570
+ return transform_cell(normalized_structure as Crystal, cell_type, sym_data)
571
+ } catch (error) {
572
+ console.error(`Failed to transform cell to ${cell_type}:`, error)
573
+ return normalized_structure
350
574
  }
351
- catch (error) {
352
- console.error(`Failed to transform cell to ${cell_type}:`, error);
353
- return normalized_structure;
575
+ })
576
+
577
+ // Create supercell if needed (uses cell_transformed_structure as base)
578
+ let supercell_structure = $state(structure)
579
+ let supercell_loading = $state(false)
580
+ let has_supercell = $derived(
581
+ !!supercell_scaling && ![``, `1x1x1`, `1`].includes(supercell_scaling),
582
+ )
583
+
584
+ // Tile volumetric data to match supercell when active.
585
+ // Gate on !supercell_loading so the tiled volume and supercell structure update
586
+ // in the same frame (large supercells defer structure via setTimeout).
587
+ let supercell_volume = $derived.by(() => {
588
+ const vol = volumetric_data?.[active_volume_idx]
589
+ if (!vol || !has_supercell || supercell_loading) return vol
590
+ try {
591
+ return tile_volumetric_data(vol, parse_supercell_scaling(supercell_scaling))
592
+ } catch {
593
+ return vol
354
594
  }
355
- });
356
- // Create supercell if needed (uses cell_transformed_structure as base)
357
- let supercell_structure = $state(structure);
358
- let supercell_loading = $state(false);
359
- let has_supercell = $derived(!!supercell_scaling && ![``, `1x1x1`, `1`].includes(supercell_scaling));
360
- $effect(() => {
361
- const base_structure = cell_transformed_structure;
595
+ })
596
+
597
+ let supercell_timeout: ReturnType<typeof setTimeout> | undefined
598
+ $effect(() => {
599
+ const base_structure = cell_transformed_structure
600
+ clearTimeout(supercell_timeout)
362
601
  if (!base_structure || !(`lattice` in base_structure) || !has_supercell) {
363
- supercell_structure = base_structure;
364
- supercell_loading = false;
365
- }
366
- else if (!is_valid_supercell_input(supercell_scaling)) {
367
- supercell_structure = base_structure;
368
- supercell_loading = false;
369
- }
370
- else {
371
- // For large supercells, show loading state and use async generation
372
- const sites_count = base_structure.sites?.length || 0;
373
- const [nx_str, ny_str, nz_str] = supercell_scaling.split(/[x×]/);
374
- const scaling_mult = (parseInt(nx_str) || 1) * (parseInt(ny_str) || 1) *
375
- (parseInt(nz_str) || 1);
376
- const estimated_sites = sites_count * scaling_mult;
377
- // Show spinner for supercells with >1000 estimated sites or scaling >8
378
- const show_loading = estimated_sites > 1000 || scaling_mult > 8;
379
- if (show_loading) {
380
- supercell_loading = true;
381
- // Use setTimeout to allow UI to update before heavy computation
382
- setTimeout(() => {
383
- try {
384
- if (base_structure && `lattice` in base_structure) {
385
- supercell_structure = make_supercell(base_structure, supercell_scaling);
386
- }
387
- }
388
- catch (error) {
389
- console.error(`Failed to create supercell:`, error);
390
- supercell_structure = base_structure;
391
- }
392
- finally {
393
- supercell_loading = false;
394
- }
395
- }, 10);
396
- }
397
- else {
602
+ supercell_structure = base_structure
603
+ supercell_loading = false
604
+ } else if (!is_valid_supercell_input(supercell_scaling)) {
605
+ supercell_structure = base_structure
606
+ supercell_loading = false
607
+ } else {
608
+ // For large supercells, show loading state and use async generation
609
+ const sites_count = base_structure.sites?.length || 0
610
+ const [nx_str, ny_str, nz_str] = supercell_scaling.split(/[x×]/)
611
+ const scaling_mult = (parseInt(nx_str) || 1) * (parseInt(ny_str) || 1) *
612
+ (parseInt(nz_str) || 1)
613
+ const estimated_sites = sites_count * scaling_mult
614
+
615
+ // Show spinner for supercells with >1000 estimated sites or scaling >8
616
+ const show_loading = estimated_sites > 1000 || scaling_mult > 8
617
+
618
+ if (show_loading) {
619
+ supercell_loading = true
620
+ // Use setTimeout to allow UI to update before heavy computation
621
+ supercell_timeout = setTimeout(() => {
622
+ try {
398
623
  if (base_structure && `lattice` in base_structure) {
399
- supercell_structure = make_supercell(base_structure, supercell_scaling);
624
+ supercell_structure = make_supercell(
625
+ base_structure as Crystal,
626
+ supercell_scaling,
627
+ )
400
628
  }
401
- supercell_loading = false;
629
+ } catch (error) {
630
+ console.error(`Failed to create supercell:`, error)
631
+ supercell_structure = base_structure
632
+ } finally {
633
+ supercell_loading = false
634
+ }
635
+ }, 10)
636
+ } else {
637
+ if (base_structure && `lattice` in base_structure) {
638
+ supercell_structure = make_supercell(
639
+ base_structure as Crystal,
640
+ supercell_scaling,
641
+ )
402
642
  }
643
+ supercell_loading = false
644
+ }
403
645
  }
404
- });
405
- // Clear selections and site overrides when transformations change site indices
406
- // (skip first run to preserve parent-provided selections)
407
- let first_run = true;
408
- $effect(() => {
409
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
410
- ;
411
- [supercell_scaling, show_image_atoms, structure, cell_type];
646
+ })
647
+
648
+ // Clear selections, site overrides, and stale camera target when transformations
649
+ // change site indices (skip first run to preserve parent-provided selections)
650
+ let first_run = true
651
+ $effect(() => {
652
+ void [supercell_scaling, show_image_atoms, structure, cell_type] // track reactively
412
653
  if (first_run) {
413
- first_run = false;
414
- return;
654
+ first_run = false
655
+ return
415
656
  }
416
657
  untrack(() => {
417
- // In edit-atoms mode, structure changes are intentional user edits
418
- // (move/add/delete) — preserve the selection so TransformControls stays active
419
- if (measure_mode === `edit-atoms`)
420
- return;
421
- if (selected_sites.length > 0 || measured_sites.length > 0)
422
- clear_selection();
423
- // Clear site radius overrides since site indices are no longer valid
424
- if (site_radius_overrides?.size > 0)
425
- site_radius_overrides.clear();
426
- });
427
- });
428
- // Apply element mapping then image atoms to the supercell structure.
429
- // Skip get_pbc_image_sites during atom drags — the vector math + doubled site
430
- // count causes frame drops. Image atoms reappear instantly on drag release.
431
- $effect(() => {
432
- let struct = supercell_structure;
658
+ // In edit-atoms mode, structure changes are intentional user edits
659
+ // (move/add/delete) — preserve the selection so TransformControls stays active
660
+ if (measure_mode === `edit-atoms`) return
661
+ if (selected_sites.length > 0 || measured_sites.length > 0) clear_selection()
662
+ // Clear site radius overrides since site indices are no longer valid
663
+ if (site_radius_overrides?.size > 0) site_radius_overrides.clear()
664
+ // Clear stale camera target so orbit controls re-center on the new cell
665
+ scene_props.camera_target = undefined
666
+ })
667
+ })
668
+
669
+ // Apply element mapping then image atoms to the supercell structure.
670
+ // Skip get_pbc_image_sites during atom drags — the vector math + doubled site
671
+ // count causes frame drops. Image atoms reappear instantly on drag release.
672
+ $effect(() => {
673
+ let struct = supercell_structure
433
674
  if (struct && element_mapping && Object.keys(element_mapping).length > 0) {
434
- const mapping = element_mapping; // capture for TypeScript narrowing
435
- struct = {
436
- ...struct,
437
- sites: struct.sites.map((site) => ({
438
- ...site,
439
- species: site.species.map((sp) => ({
440
- ...sp,
441
- element: mapping[sp.element] ?? sp.element,
442
- })),
443
- label: mapping[site.label] ?? site.label,
444
- })),
445
- };
675
+ const mapping = element_mapping // capture for TypeScript narrowing
676
+ struct = {
677
+ ...struct,
678
+ sites: struct.sites.map((site) => ({
679
+ ...site,
680
+ species: site.species.map((sp) => ({
681
+ ...sp,
682
+ element: mapping[sp.element as ElementSymbol] ?? sp.element,
683
+ })),
684
+ label: mapping[site.label as ElementSymbol] ?? site.label,
685
+ })),
686
+ }
446
687
  }
447
688
  displayed_structure =
448
- !dragging_atoms && show_image_atoms && struct && `lattice` in struct &&
449
- struct.lattice
450
- ? get_pbc_image_sites(struct)
451
- : struct;
452
- });
453
- // Track if camera has ever been moved from initial position
454
- let camera_has_moved = $state(false);
455
- let camera_is_moving = $state(false);
456
- let scene = $state(undefined);
457
- let camera = $state(undefined);
458
- let orbit_controls = $state(undefined);
459
- let rotation_target_ref = $state(undefined);
460
- let initial_computed_zoom = $state(undefined);
461
- let camera_move_timeout = $state(null);
462
- // Mutual exclusion: opening one pane closes others
463
- $effect(() => {
689
+ !dragging_atoms && show_image_atoms && struct && `lattice` in struct &&
690
+ struct.lattice
691
+ ? get_pbc_image_sites(struct)
692
+ : struct
693
+ })
694
+
695
+ // Track if camera has ever been moved from initial position
696
+ let camera_has_moved = $state(false)
697
+ let camera_is_moving = $state(false)
698
+ let scene = $state<Scene | undefined>(undefined)
699
+ let camera = $state<Camera | undefined>(undefined)
700
+ let orbit_controls = $state<
701
+ ComponentProps<typeof StructureScene>[`orbit_controls`]
702
+ >(undefined)
703
+ let rotation_target_ref = $state<Vec3 | undefined>(undefined)
704
+ let initial_computed_zoom = $state<number | undefined>(undefined)
705
+
706
+ // Mutual exclusion: opening one pane closes others
707
+ $effect(() => {
464
708
  if (info_pane_open) {
465
- untrack(() => [controls_open, export_pane_open] = [false, false]);
709
+ untrack(() => [controls_open, export_pane_open] = [false, false])
466
710
  }
467
- });
468
- $effect(() => {
711
+ })
712
+ $effect(() => {
469
713
  if (controls_open) {
470
- untrack(() => [info_pane_open, export_pane_open] = [false, false]);
714
+ untrack(() => [info_pane_open, export_pane_open] = [false, false])
471
715
  }
472
- });
473
- $effect(() => {
716
+ })
717
+ $effect(() => {
474
718
  if (export_pane_open) {
475
- untrack(() => [info_pane_open, controls_open] = [false, false]);
719
+ untrack(() => [info_pane_open, controls_open] = [false, false])
476
720
  }
477
- });
478
- // Reset tracking when structure changes
479
- $effect(() => {
480
- if (structure)
481
- camera_has_moved = false;
482
- });
483
- // Set camera_has_moved to true when camera starts moving
484
- $effect(() => untrack(() => {
485
- if (camera_is_moving) {
486
- camera_has_moved = true;
487
- // Debounce camera move events to avoid excessive emissions
488
- if (camera_move_timeout)
489
- clearTimeout(camera_move_timeout);
490
- camera_move_timeout = setTimeout(() => {
491
- const { camera_position } = scene_props;
492
- on_camera_move?.({ structure, camera_has_moved, camera_position });
493
- }, 200);
721
+ })
722
+
723
+ // Reset tracking when structure changes
724
+ $effect(() => {
725
+ if (structure) camera_has_moved = false
726
+ })
727
+
728
+ // Clear stale camera target and position so StructureScene uses the new
729
+ // structure's rotation_target (unit cell center) and auto-positions the camera.
730
+ function clear_camera_state() {
731
+ scene_props.camera_target = undefined
732
+ scene_props.camera_position = [0, 0, 0]
733
+ }
734
+
735
+ const read_orbit_target = (): Vec3 | undefined => {
736
+ if (!orbit_controls?.target) return
737
+ const { x, y, z } = orbit_controls.target
738
+ return [x, y, z]
739
+ }
740
+
741
+ const read_camera_position = (): Vec3 | undefined =>
742
+ camera
743
+ ? [camera.position.x, camera.position.y, camera.position.z]
744
+ : scene_props.camera_position
745
+
746
+ // Emit debounced camera updates while controls are active.
747
+ $effect(() => {
748
+ if (!camera_is_moving) return
749
+ camera_has_moved = true
750
+
751
+ const emit_camera_move = () => {
752
+ const camera_position = read_camera_position()
753
+ if (camera_position === undefined) return
754
+ const camera_target = read_orbit_target()
755
+ scene_props.camera_position = camera_position
756
+ scene_props.camera_target = camera_target
757
+ on_camera_move?.({
758
+ structure,
759
+ camera_has_moved,
760
+ camera_position,
761
+ camera_target,
762
+ })
494
763
  }
495
- }));
496
- function reset_camera() {
497
- // Reset camera position to trigger automatic positioning
498
- scene_props.camera_position = [0, 0, 0];
499
- camera_has_moved = false;
500
- // Manually reset zoom and pan using the exposed initial values
764
+
765
+ emit_camera_move()
766
+ const emit_interval = setInterval(emit_camera_move, 200)
767
+ return () => clearInterval(emit_interval)
768
+ })
769
+
770
+ function reset_camera() {
771
+ // Reset camera position to trigger automatic positioning.
772
+ scene_props.camera_position = [0, 0, 0]
773
+ scene_props.camera_target = rotation_target_ref
774
+ camera_has_moved = false
775
+
776
+ let camera_position: Vec3 = [0, 0, 0]
777
+ let camera_target: Vec3 | undefined = rotation_target_ref
778
+
779
+ // Reset pan/zoom and ensure controls target returns to structure center.
501
780
  if (orbit_controls && camera) {
502
- // Reset the target to the structure center (pan reset)
503
- if (orbit_controls.target && rotation_target_ref) {
504
- const [x, y, z] = rotation_target_ref;
505
- orbit_controls.target.set(x, y, z);
506
- }
507
- // Reset zoom for orthographic camera
508
- if (`zoom` in camera && initial_computed_zoom !== undefined) {
509
- const ortho_camera = camera;
510
- ortho_camera.zoom = initial_computed_zoom;
511
- ortho_camera.updateProjectionMatrix();
512
- }
513
- // Call update to apply changes immediately
514
- if (typeof orbit_controls.update === `function`) {
515
- orbit_controls.update();
516
- }
781
+ if (
782
+ `reset` in orbit_controls &&
783
+ typeof orbit_controls.reset === `function`
784
+ ) orbit_controls.reset()
785
+ if (orbit_controls.target && rotation_target_ref) {
786
+ const [target_x, target_y, target_z] = rotation_target_ref
787
+ orbit_controls.target.set(target_x, target_y, target_z)
788
+ }
789
+
790
+ // Reset zoom for orthographic camera
791
+ if (`zoom` in camera && initial_computed_zoom !== undefined) {
792
+ const ortho_camera = camera as OrthographicCamera
793
+ ortho_camera.zoom = initial_computed_zoom
794
+ ortho_camera.updateProjectionMatrix()
795
+ }
796
+
797
+ // Call update to apply changes immediately
798
+ if (typeof orbit_controls.update === `function`) orbit_controls.update()
799
+ camera_position = read_camera_position() ?? camera_position
800
+ camera_target = read_orbit_target()
517
801
  }
518
- on_camera_reset?.({ structure, camera_has_moved, camera_position: [0, 0, 0] });
519
- }
520
- const emit_file_load_event = (structure, filename, content) => on_file_load?.({
521
- structure: structure,
522
- filename,
523
- file_size: typeof content === `string`
802
+
803
+ scene_props.camera_position = camera_position
804
+ scene_props.camera_target = camera_target
805
+ on_camera_reset?.({ structure, camera_has_moved, camera_position, camera_target })
806
+ }
807
+
808
+ const emit_file_load_event = (
809
+ structure: AnyStructure,
810
+ filename: string,
811
+ content: string | ArrayBuffer,
812
+ ) =>
813
+ on_file_load?.({
814
+ structure: structure,
815
+ filename,
816
+ file_size: typeof content === `string`
524
817
  ? new Blob([content]).size
525
818
  : content.byteLength,
526
- total_atoms: structure.sites?.length || 0,
527
- });
528
- // Try to parse content as a volumetric file, setting both structure and volumetric data.
529
- // Delegates format detection entirely to parse_volumetric_file (filename + content sniffing).
530
- // Returns the parsed structure on success, or null if the file isn't a volumetric format.
531
- function try_parse_volumetric(text_content, filename) {
532
- const vol_result = parse_volumetric_file(text_content, filename);
533
- if (!vol_result)
534
- return null;
819
+ total_atoms: structure.sites?.length || 0,
820
+ })
821
+
822
+ // Try to parse content as a volumetric file, setting both structure and volumetric data.
823
+ // Delegates format detection entirely to parse_volumetric_file (filename + content sniffing).
824
+ // Returns the parsed structure on success, or null if the file isn't a volumetric format.
825
+ function try_parse_volumetric(
826
+ text_content: string,
827
+ filename: string,
828
+ ): AnyStructure | null {
829
+ const vol_result = parse_volumetric_file(text_content, filename)
830
+ if (!vol_result) return null
535
831
  // parse_volumetric_file extracts structure from file header;
536
832
  // parsers set pbc so the lattice conforms to Crystal's LatticeType
537
- structure = vol_result.structure;
538
- volumetric_data = vol_result.volumes;
833
+ structure = vol_result.structure as AnyStructure
834
+ volumetric_data = vol_result.volumes
539
835
  // Auto-compute reasonable isosurface settings from data range
540
- const vol = vol_result.volumes[0];
836
+ const vol = vol_result.volumes[0]
541
837
  if (vol) {
542
- isosurface_settings = auto_isosurface_settings(vol.data_range);
543
- active_volume_idx = 0;
838
+ isosurface_settings = auto_isosurface_settings(vol.data_range)
839
+ active_volume_idx = 0
544
840
  }
545
- return structure;
546
- }
547
- // Parse file content, trying volumetric format first then falling back to plain structure.
548
- // Returns the parsed structure on success, throws on failure.
549
- function parse_file_content(text_content, filename) {
550
- const vol_struct = try_parse_volumetric(text_content, filename);
551
- if (vol_struct)
552
- return vol_struct;
841
+ return structure
842
+ }
843
+
844
+ // Parse file content, trying volumetric format first then falling back to plain structure.
845
+ // Returns the parsed structure on success, throws on failure.
846
+ function parse_file_content(text_content: string, filename: string): AnyStructure {
847
+ clear_camera_state()
848
+ const vol_struct = try_parse_volumetric(text_content, filename)
849
+ if (vol_struct) return vol_struct
553
850
  // Clear stale volumetric data when loading a non-volumetric file
554
- volumetric_data = [];
555
- const parsed = parse_any_structure(text_content, filename);
556
- if (!parsed)
557
- throw new Error(`Failed to parse structure from ${filename}`);
558
- structure = parsed;
559
- return parsed;
560
- }
561
- async function handle_file_drop(event) {
562
- event.preventDefault();
563
- dragover = false;
564
- if (!allow_file_drop)
565
- return;
566
- loading = true;
567
- error_msg = undefined; // Clear previous error when a new file is dropped
568
- try {
569
- // Handle URL-based files (e.g. from FilePicker)
570
- const handled = await handle_url_drop(event, on_file_drop || ((content, filename) => {
571
- try {
572
- const text_content = content instanceof ArrayBuffer
573
- ? new TextDecoder().decode(content)
574
- : content;
575
- const parsed = parse_file_content(text_content, filename);
576
- emit_file_load_event(parsed, filename, content);
577
- }
578
- catch (err) {
579
- error_msg = `Failed to parse structure: ${err}`;
580
- on_error?.({ error_msg, filename });
581
- }
582
- })).catch(() => false);
583
- if (handled)
584
- return;
585
- // Handle file system drops
586
- const file = event.dataTransfer?.files[0];
587
- if (file) {
588
- try {
589
- const { content, filename } = await decompress_file(file);
590
- if (content) {
591
- if (on_file_drop)
592
- on_file_drop(content, filename);
593
- else {
594
- // Parse structure internally when no handler provided
595
- try {
596
- const parsed = parse_file_content(content, filename);
597
- emit_file_load_event(parsed, filename, content);
598
- }
599
- catch (err) {
600
- error_msg = `Failed to parse structure: ${err}`;
601
- on_error?.({ error_msg, filename });
602
- }
603
- }
604
- }
605
- }
606
- catch (error) {
607
- error_msg = `Failed to load file ${file.name}: ${error}`;
608
- on_error?.({ error_msg, filename: file.name });
609
- }
610
- }
611
- }
612
- finally {
613
- loading = false;
614
- }
615
- }
616
- function handle_keydown(event) {
851
+ volumetric_data = []
852
+ const parsed = parse_any_structure(text_content, filename)
853
+ if (!parsed) throw new Error(`Failed to parse structure from ${filename}`)
854
+ structure = parsed
855
+ return parsed
856
+ }
857
+
858
+ const handle_file_drop = create_file_drop_handler({
859
+ allow: () => allow_file_drop,
860
+ on_drop: (content, filename) => {
861
+ if (on_file_drop) return on_file_drop(content, filename)
862
+ try {
863
+ const text_content = content instanceof ArrayBuffer
864
+ ? new TextDecoder().decode(content)
865
+ : content
866
+ const parsed = parse_file_content(text_content, filename)
867
+ emit_file_load_event(parsed, filename, content)
868
+ } catch (err) {
869
+ error_msg = `Failed to parse structure: ${
870
+ err instanceof Error ? err.message : String(err)
871
+ }`
872
+ on_error?.({ error_msg, filename })
873
+ }
874
+ },
875
+ on_error: (msg) => {
876
+ error_msg = msg
877
+ on_error?.({ error_msg: msg })
878
+ },
879
+ set_loading: (val) => {
880
+ loading = val
881
+ if (val) [error_msg, dragover] = [undefined, false]
882
+ },
883
+ })
884
+
885
+ function handle_keydown(event: KeyboardEvent) {
617
886
  // Don't handle shortcuts if user is typing in an input field
618
- const target = event.target;
887
+ const target = event.target as HTMLElement
619
888
  const is_input_focused = target.tagName === `INPUT` ||
620
- target.tagName === `TEXTAREA`;
889
+ target.tagName === `TEXTAREA`
890
+
621
891
  // Allow Escape to cancel add-atom mode even when the element input is focused
622
892
  if (event.key === `Escape` && measure_mode === `edit-atoms` && add_atom_mode) {
623
- event.preventDefault();
624
- add_atom_mode = false;
625
- return;
893
+ event.preventDefault()
894
+ add_atom_mode = false
895
+ return
626
896
  }
627
- if (is_input_focused)
628
- return;
897
+
898
+ if (is_input_focused) return
899
+
629
900
  // Edit-atoms mode shortcuts (including undo/redo)
630
901
  if (measure_mode === `edit-atoms`) {
631
- // Undo/redo shortcuts (Ctrl/Cmd + Z/Y) — only active in edit-atoms mode
632
- if (event.ctrlKey || event.metaKey) {
633
- const key = event.key.toLowerCase();
634
- if (key === `z` && !event.shiftKey) {
635
- event.preventDefault();
636
- undo();
637
- show_toast(`Undo (${undo_stack.length} left)`);
638
- return;
639
- }
640
- else if (key === `y` || (key === `z` && event.shiftKey)) {
641
- event.preventDefault();
642
- redo();
643
- show_toast(`Redo (${redo_stack.length} left)`);
644
- return;
645
- }
646
- }
647
- if (event.key === `Delete` || event.key === `Backspace`) {
648
- // Delete selected atoms
649
- if (selected_sites.length > 0 && structure?.sites) {
650
- event.preventDefault();
651
- is_internal_edit = true;
652
- push_undo();
653
- const to_delete = scene_to_structure_indices(selected_sites, true);
654
- const n_deleted = to_delete.size;
655
- clear_selection();
656
- structure = {
657
- ...structure,
658
- sites: structure.sites.filter((_, idx) => !to_delete.has(idx)),
659
- };
660
- // Clear per-site overrides since indices shifted after deletion
661
- if (site_radius_overrides?.size > 0)
662
- site_radius_overrides.clear();
663
- added_bonds = [];
664
- removed_bonds = [];
665
- show_toast(`Deleted ${n_deleted} site${n_deleted > 1 ? `s` : ``}`);
666
- }
667
- return;
902
+ // Undo/redo shortcuts (Ctrl/Cmd + Z/Y) — only active in edit-atoms mode
903
+ if (event.ctrlKey || event.metaKey) {
904
+ const key = event.key.toLowerCase()
905
+ if (key === `z` && !event.shiftKey) {
906
+ event.preventDefault()
907
+ undo()
908
+ show_toast(`Undo (${undo_stack.length} left)`)
909
+ return
910
+ } else if (key === `y` || (key === `z` && event.shiftKey)) {
911
+ event.preventDefault()
912
+ redo()
913
+ show_toast(`Redo (${redo_stack.length} left)`)
914
+ return
668
915
  }
669
- const key = event.key.toLowerCase();
670
- const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
671
- if (key === `a` && plain) {
672
- // Enter add-atom sub-mode (plain 'a' only, not Ctrl+A/Cmd+A/Alt+A)
673
- event.preventDefault();
674
- add_atom_mode = !add_atom_mode;
675
- return;
916
+ }
917
+
918
+ if (event.key === `Delete` || event.key === `Backspace`) {
919
+ // Delete selected atoms
920
+ if (selected_sites.length > 0 && structure?.sites) {
921
+ event.preventDefault()
922
+ is_internal_edit = true
923
+ push_undo()
924
+ const to_delete = scene_to_structure_indices(selected_sites, true)
925
+ const n_deleted = to_delete.size
926
+ clear_selection()
927
+ structure = {
928
+ ...structure,
929
+ sites: structure.sites.filter((_, idx) => !to_delete.has(idx)),
930
+ }
931
+ // Clear per-site overrides since indices shifted after deletion
932
+ if (site_radius_overrides?.size > 0) site_radius_overrides.clear()
933
+ added_bonds = []
934
+ removed_bonds = []
935
+ show_toast(`Deleted ${n_deleted} site${n_deleted > 1 ? `s` : ``}`)
676
936
  }
677
- // Change element of selected atoms
678
- if (key === `e` && plain && selected_sites.length > 0) {
679
- event.preventDefault();
680
- change_element_mode = !change_element_mode;
681
- return;
937
+ return
938
+ }
939
+ const key = event.key.toLowerCase()
940
+ const plain = !event.ctrlKey && !event.metaKey && !event.altKey
941
+
942
+ if (key === `a` && plain) {
943
+ // Enter add-atom sub-mode (plain 'a' only, not Ctrl+A/Cmd+A/Alt+A)
944
+ event.preventDefault()
945
+ add_atom_mode = !add_atom_mode
946
+ return
947
+ }
948
+ // Change element of selected atoms
949
+ if (key === `e` && plain && selected_sites.length > 0) {
950
+ event.preventDefault()
951
+ change_element_mode = !change_element_mode
952
+ return
953
+ }
954
+ // Duplicate selected atoms at a small offset
955
+ if (
956
+ key === `d` && (event.ctrlKey || event.metaKey) &&
957
+ selected_sites.length > 0 && structure?.sites
958
+ ) {
959
+ event.preventDefault()
960
+ is_internal_edit = true
961
+ push_undo()
962
+ const orig_indices = scene_to_structure_indices(selected_sites)
963
+ const cart_to_frac = get_cart_to_frac()
964
+ const new_sites = structure.sites
965
+ .filter((_, idx) => orig_indices.has(idx))
966
+ .map((site) => {
967
+ const new_xyz: Vec3 = [
968
+ site.xyz[0] + 0.5,
969
+ site.xyz[1] + 0.5,
970
+ site.xyz[2] + 0.5,
971
+ ]
972
+ return {
973
+ ...site,
974
+ xyz: new_xyz,
975
+ abc: cart_to_frac?.(new_xyz) ?? new_xyz,
976
+ properties: { ...site.properties },
977
+ }
978
+ })
979
+ const base_idx = structure.sites.length
980
+ structure = {
981
+ ...structure,
982
+ sites: [...structure.sites, ...new_sites],
682
983
  }
683
- // Duplicate selected atoms at a small offset
684
- if (key === `d` && (event.ctrlKey || event.metaKey) &&
685
- selected_sites.length > 0 && structure?.sites) {
686
- event.preventDefault();
687
- is_internal_edit = true;
688
- push_undo();
689
- const orig_indices = scene_to_structure_indices(selected_sites);
690
- const cart_to_frac = get_cart_to_frac();
691
- const new_sites = structure.sites
692
- .filter((_, idx) => orig_indices.has(idx))
693
- .map((site) => {
694
- const new_xyz = [
695
- site.xyz[0] + 0.5,
696
- site.xyz[1] + 0.5,
697
- site.xyz[2] + 0.5,
698
- ];
699
- return {
700
- ...site,
701
- xyz: new_xyz,
702
- abc: cart_to_frac?.(new_xyz) ?? new_xyz,
703
- properties: { ...site.properties },
704
- };
705
- });
706
- const base_idx = structure.sites.length;
707
- structure = {
708
- ...structure,
709
- sites: [...structure.sites, ...new_sites],
710
- };
711
- // Select the newly duplicated atoms
712
- selected_sites = new_sites.map((_, idx) => base_idx + idx);
713
- measured_sites = [...selected_sites];
714
- show_toast(`Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`);
715
- return;
984
+ // Select the newly duplicated atoms
985
+ selected_sites = new_sites.map((_, idx) => base_idx + idx)
986
+ measured_sites = [...selected_sites]
987
+ show_toast(
988
+ `Duplicated ${new_sites.length} site${new_sites.length > 1 ? `s` : ``}`,
989
+ )
990
+ return
991
+ }
992
+
993
+ // add_atom_mode Escape is already handled above (before is_input_focused guard)
994
+ if (event.key === `Escape`) {
995
+ if (change_element_mode) {
996
+ change_element_mode = false
997
+ return
716
998
  }
717
- // add_atom_mode Escape is already handled above (before is_input_focused guard)
718
- if (event.key === `Escape`) {
719
- if (change_element_mode) {
720
- change_element_mode = false;
721
- return;
722
- }
723
- if (selected_sites.length > 0) {
724
- clear_selection();
725
- return;
726
- }
999
+ if (selected_sites.length > 0) {
1000
+ clear_selection()
1001
+ return
727
1002
  }
1003
+ }
728
1004
  }
729
- // Interface shortcuts
730
- if (event.key === `f` && fullscreen_toggle)
731
- toggle_fullscreen(wrapper);
732
- else if (event.key === `i` && enable_info_pane)
733
- info_pane_open = !info_pane_open;
734
- else if (event.key === `Escape`) {
735
- // Prioritize closing panes, then exit edit modes, then exit fullscreen
736
- if (info_pane_open)
737
- info_pane_open = false;
738
- else if (controls_open)
739
- controls_open = false;
740
- else if (export_pane_open)
741
- export_pane_open = false;
742
- else if (measure_mode === `edit-bonds` || measure_mode === `edit-atoms`) {
743
- measure_mode = `distance`;
744
- }
1005
+
1006
+ // Interface shortcuts (require Ctrl/Cmd modifier to avoid accidental triggers)
1007
+ const has_modifier = event.ctrlKey || event.metaKey
1008
+ if (event.key === `f` && has_modifier && fullscreen_toggle) {
1009
+ event.preventDefault()
1010
+ toggle_fullscreen(wrapper)
1011
+ } else if (event.key === `i` && has_modifier && enable_info_pane) {
1012
+ event.preventDefault()
1013
+ info_pane_open = !info_pane_open
1014
+ } else if (event.key === `Escape`) {
1015
+ // Prioritize closing panes, then exit edit modes, then exit fullscreen
1016
+ if (info_pane_open) info_pane_open = false
1017
+ else if (controls_open) controls_open = false
1018
+ else if (export_pane_open) export_pane_open = false
1019
+ else if (measure_mode === `edit-bonds` || measure_mode === `edit-atoms`) {
1020
+ measure_mode = `distance`
1021
+ }
745
1022
  }
746
- }
747
- // === Edit-atoms mode helpers ===
748
- // Map scene indices (into displayed_structure) back to raw structure indices.
749
- // Handles supercell atoms via orig_unit_cell_idx property.
750
- // skip_image_atoms: when true, image atoms (PBC ghosts) are excluded from the result.
751
- function scene_to_structure_indices(scene_indices, skip_image_atoms = false) {
752
- const result = new SvelteSet();
1023
+ }
1024
+
1025
+ // === Edit-atoms mode helpers ===
1026
+
1027
+ // Map scene indices (into displayed_structure) back to raw structure indices.
1028
+ // Handles supercell atoms via orig_unit_cell_idx property.
1029
+ // skip_image_atoms: when true, image atoms (PBC ghosts) are excluded from the result.
1030
+ function scene_to_structure_indices(
1031
+ scene_indices: number[],
1032
+ skip_image_atoms = false,
1033
+ ): SvelteSet<number> {
1034
+ const result = new SvelteSet<number>()
753
1035
  for (const scene_idx of scene_indices) {
754
- const displayed_site = displayed_structure?.sites?.[scene_idx];
755
- if (!displayed_site)
756
- continue;
757
- if (skip_image_atoms && displayed_site.properties?.orig_site_idx != null) {
758
- continue;
759
- }
760
- if (has_supercell && displayed_site.properties?.orig_unit_cell_idx != null) {
761
- result.add(displayed_site.properties.orig_unit_cell_idx);
762
- }
763
- else if (displayed_site.properties?.orig_site_idx != null) {
764
- // Image atom (PBC ghost) — map back to its original site index
765
- result.add(displayed_site.properties.orig_site_idx);
766
- }
767
- else {
768
- result.add(scene_idx);
769
- }
1036
+ const displayed_site = displayed_structure?.sites?.[scene_idx]
1037
+ if (!displayed_site) continue
1038
+ if (skip_image_atoms && displayed_site.properties?.orig_site_idx != null) {
1039
+ continue
1040
+ }
1041
+
1042
+ if (has_supercell && displayed_site.properties?.orig_unit_cell_idx != null) {
1043
+ result.add(displayed_site.properties.orig_unit_cell_idx as number)
1044
+ } else if (displayed_site.properties?.orig_site_idx != null) {
1045
+ // Image atom (PBC ghost) — map back to its original site index
1046
+ result.add(displayed_site.properties.orig_site_idx as number)
1047
+ } else {
1048
+ result.add(scene_idx)
1049
+ }
770
1050
  }
771
- return result;
772
- }
773
- // Try to create a Cartesian→fractional converter for the current structure's lattice
774
- function get_cart_to_frac() {
775
- if (!structure || !(`lattice` in structure))
776
- return undefined;
1051
+ return result
1052
+ }
1053
+
1054
+ // Try to create a Cartesian→fractional converter for the current structure's lattice
1055
+ function get_cart_to_frac(): ((xyz: Vec3) => Vec3) | undefined {
1056
+ if (!structure || !(`lattice` in structure)) return undefined
777
1057
  try {
778
- return create_cart_to_frac(structure.lattice.matrix);
1058
+ return create_cart_to_frac((structure as Crystal).lattice.matrix)
1059
+ } catch {
1060
+ console.warn(`Failed to compute lattice inverse for fractional coordinates`)
1061
+ return undefined
779
1062
  }
780
- catch {
781
- console.warn(`Failed to compute lattice inverse for fractional coordinates`);
782
- return undefined;
783
- }
784
- }
785
- // Handle atom moves from TransformControls. Applies Cartesian delta and wraps
786
- // fractional coords inline so normalize_fractional_coords hits its fast path.
787
- function handle_sites_moved(scene_indices, delta) {
788
- if (!structure?.sites)
789
- return;
790
- is_internal_edit = true;
791
- const orig_indices = scene_to_structure_indices(scene_indices);
1063
+ }
1064
+
1065
+ // Handle atom moves from TransformControls. Applies Cartesian delta and wraps
1066
+ // fractional coords inline so normalize_fractional_coords hits its fast path.
1067
+ function handle_sites_moved(scene_indices: number[], delta: Vec3) {
1068
+ if (!structure?.sites) return
1069
+ is_internal_edit = true
1070
+
1071
+ const orig_indices = scene_to_structure_indices(scene_indices)
792
1072
  // For crystals, wrap to [0,1) inline so normalize_fractional_coords fast-paths.
793
1073
  // For molecules (no lattice), just apply the Cartesian delta directly.
794
1074
  const lattice = `lattice` in structure
795
- ? structure.lattice.matrix
796
- : null;
797
- const cart_to_frac = lattice ? create_cart_to_frac(lattice) : null;
798
- const frac_to_cart = lattice ? create_frac_to_cart(lattice) : null;
1075
+ ? (structure as Crystal).lattice.matrix
1076
+ : null
1077
+ const cart_to_frac = lattice ? create_cart_to_frac(lattice) : null
1078
+ const frac_to_cart = lattice ? create_frac_to_cart(lattice) : null
799
1079
  structure = {
800
- ...structure,
801
- sites: structure.sites.map((site, idx) => {
802
- if (!orig_indices.has(idx))
803
- return site;
804
- const new_xyz = [
805
- site.xyz[0] + delta[0],
806
- site.xyz[1] + delta[1],
807
- site.xyz[2] + delta[2],
808
- ];
809
- if (!cart_to_frac || !frac_to_cart) {
810
- return { ...site, xyz: new_xyz, abc: new_xyz };
811
- }
812
- const wrapped_abc = wrap_to_unit_cell(cart_to_frac(new_xyz));
813
- return { ...site, xyz: frac_to_cart(wrapped_abc), abc: wrapped_abc };
814
- }),
815
- };
816
- }
817
- // Change element symbol of selected atoms
818
- function handle_change_element(new_element) {
819
- if (!structure?.sites || selected_sites.length === 0)
820
- return;
821
- const elem = normalize_element(new_element);
822
- if (!elem)
823
- return;
824
- is_internal_edit = true;
825
- push_undo();
826
- const orig_indices = scene_to_structure_indices(selected_sites);
1080
+ ...structure,
1081
+ sites: structure.sites.map((site, idx) => {
1082
+ if (!orig_indices.has(idx)) return site
1083
+ const new_xyz: Vec3 = [
1084
+ site.xyz[0] + delta[0],
1085
+ site.xyz[1] + delta[1],
1086
+ site.xyz[2] + delta[2],
1087
+ ]
1088
+ if (!cart_to_frac || !frac_to_cart) {
1089
+ return { ...site, xyz: new_xyz, abc: new_xyz }
1090
+ }
1091
+ const wrapped_abc = wrap_to_unit_cell(cart_to_frac(new_xyz))
1092
+ return { ...site, xyz: frac_to_cart(wrapped_abc), abc: wrapped_abc }
1093
+ }),
1094
+ }
1095
+ }
1096
+
1097
+ // Change element symbol of selected atoms
1098
+ function handle_change_element(new_element: string) {
1099
+ if (!structure?.sites || selected_sites.length === 0) return
1100
+ const elem = normalize_element(new_element)
1101
+ if (!elem) return
1102
+ is_internal_edit = true
1103
+ push_undo()
1104
+ const orig_indices = scene_to_structure_indices(selected_sites)
827
1105
  structure = {
828
- ...structure,
829
- sites: structure.sites.map((site, idx) => {
830
- if (!orig_indices.has(idx))
831
- return site;
832
- return {
833
- ...site,
834
- species: [{ element: elem, occu: 1, oxidation_state: 0 }],
835
- label: elem,
836
- };
837
- }),
838
- };
839
- change_element_mode = false;
840
- change_element_value = ``;
841
- show_toast(`Changed ${orig_indices.size} site${orig_indices.size > 1 ? `s` : ``} to ${elem}`);
842
- }
843
- // Handle add-atom from StructureScene click-to-place
844
- function handle_add_atom(xyz, element) {
845
- if (!structure)
846
- return;
847
- const elem = normalize_element(element);
1106
+ ...structure,
1107
+ sites: structure.sites.map((site, idx) => {
1108
+ if (!orig_indices.has(idx)) return site
1109
+ return {
1110
+ ...site,
1111
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
1112
+ label: elem,
1113
+ }
1114
+ }),
1115
+ }
1116
+ change_element_mode = false
1117
+ change_element_value = ``
1118
+ show_toast(
1119
+ `Changed ${orig_indices.size} site${
1120
+ orig_indices.size > 1 ? `s` : ``
1121
+ } to ${elem}`,
1122
+ )
1123
+ }
1124
+
1125
+ // Handle add-atom from StructureScene click-to-place
1126
+ function handle_add_atom(xyz: Vec3, element: ElementSymbol) {
1127
+ if (!structure) return
1128
+ const elem = normalize_element(element)
848
1129
  if (!elem) {
849
- return console.warn(`Invalid element symbol "${element}", ignoring add-atom`);
1130
+ return console.warn(`Invalid element symbol "${element}", ignoring add-atom`)
850
1131
  }
851
- is_internal_edit = true;
852
- push_undo();
1132
+ is_internal_edit = true
1133
+ push_undo()
853
1134
  structure = {
854
- ...structure,
855
- sites: [...structure.sites, {
856
- species: [{ element: elem, occu: 1, oxidation_state: 0 }],
857
- xyz,
858
- abc: get_cart_to_frac()?.(xyz) ?? xyz,
859
- label: elem,
860
- properties: {},
861
- }],
862
- };
863
- show_toast(`Added ${elem} at (${xyz.map((c) => c.toFixed(2)).join(`, `)})`);
864
- }
865
- // Only set background override when background_color is explicitly provided
866
- $effect(() => {
867
- if (typeof window !== `undefined` && wrapper && background_color) {
868
- // Convert opacity (0-1) to hex alpha value (00-FF)
869
- const alpha_hex = Math.round(background_opacity * 255)
870
- .toString(16)
871
- .padStart(2, `0`);
872
- wrapper.style.setProperty(`--struct-bg-override`, `${background_color}${alpha_hex}`);
1135
+ ...structure,
1136
+ sites: [...structure.sites, {
1137
+ species: [{ element: elem, occu: 1, oxidation_state: 0 }],
1138
+ xyz,
1139
+ abc: get_cart_to_frac()?.(xyz) ?? xyz,
1140
+ label: elem,
1141
+ properties: {},
1142
+ }],
873
1143
  }
874
- else if (typeof window !== `undefined` && wrapper) {
875
- // Remove override to use theme system
876
- wrapper.style.removeProperty(`--struct-bg-override`);
1144
+ show_toast(`Added ${elem} at (${xyz.map((c) => c.toFixed(2)).join(`, `)})`)
1145
+ }
1146
+
1147
+ // Only set background override when background_color is explicitly provided
1148
+ $effect(() => {
1149
+ if (typeof window !== `undefined` && wrapper && background_color) {
1150
+ // Convert opacity (0-1) to hex alpha value (00-FF)
1151
+ const alpha_hex = Math.round(background_opacity * 255)
1152
+ .toString(16)
1153
+ .padStart(2, `0`)
1154
+ wrapper.style.setProperty(
1155
+ `--struct-bg-override`,
1156
+ `${background_color}${alpha_hex}`,
1157
+ )
1158
+ } else if (typeof window !== `undefined` && wrapper) {
1159
+ // Remove override to use theme system
1160
+ wrapper.style.removeProperty(`--struct-bg-override`)
877
1161
  }
878
- });
879
- $effect(() => {
1162
+ })
1163
+
1164
+ $effect(() => { // fullscreen and background
880
1165
  if (typeof window !== `undefined`) {
881
- if (fullscreen && !document.fullscreenElement && wrapper) {
882
- wrapper.requestFullscreen().catch(console.error);
883
- }
884
- else if (!fullscreen && document.fullscreenElement) {
885
- document.exitFullscreen();
886
- }
1166
+ if (fullscreen && !document.fullscreenElement && wrapper) {
1167
+ wrapper.requestFullscreen().catch(console.error)
1168
+ } else if (!fullscreen && document.fullscreenElement) {
1169
+ document.exitFullscreen()
1170
+ }
887
1171
  }
888
- set_fullscreen_bg(wrapper, fullscreen, `--struct-bg-fullscreen`);
889
- });
1172
+ set_fullscreen_bg(wrapper, fullscreen, `--struct-bg-fullscreen`)
1173
+ })
890
1174
  </script>
891
1175
 
892
1176
  <svelte:document
@@ -948,10 +1232,7 @@ $effect(() => {
948
1232
  style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)"
949
1233
  />
950
1234
  {:else if error_msg}
951
- <div class="error-state">
952
- <p class="error">{error_msg}</p>
953
- <button onclick={() => (error_msg = undefined)}>Dismiss</button>
954
- </div>
1235
+ <StatusMessage bind:message={error_msg} type="error" dismissible />
955
1236
  {:else if (structure?.sites?.length ?? 0) > 0}
956
1237
  <section
957
1238
  class="control-buttons {controls_config.class}"
@@ -1055,7 +1336,7 @@ $effect(() => {
1055
1336
  onclick={() => [measure_mode, measure_menu_open] = [mode, false]}
1056
1337
  >
1057
1338
  <Icon {icon} style="transform: scale({scale})" />
1058
- <span>{@html label}</span>
1339
+ <span>{@html sanitize_html(label)}</span>
1059
1340
  </button>
1060
1341
  {/each}
1061
1342
  </div>
@@ -1216,14 +1497,14 @@ $effect(() => {
1216
1497
  <!-- prevent from rendering in vitest runner since WebGLRenderingContext not available -->
1217
1498
  {#if typeof WebGLRenderingContext !== `undefined`}
1218
1499
  <!-- prevent HTML labels from rendering outside of the canvas -->
1219
- <div style="overflow: hidden; height: 100%; flex: 1">
1500
+ <div style="overflow: hidden; height: 100%; width: 100%">
1220
1501
  <Canvas>
1221
1502
  <StructureScene
1222
1503
  structure={displayed_structure}
1223
1504
  base_structure={cell_transformed_structure}
1224
1505
  {...scene_props}
1225
1506
  {lattice_props}
1226
- volumetric_data={volumetric_data?.[active_volume_idx]}
1507
+ volumetric_data={supercell_volume}
1227
1508
  {isosurface_settings}
1228
1509
  bind:camera_is_moving
1229
1510
  bind:selected_sites
@@ -1356,7 +1637,8 @@ $effect(() => {
1356
1637
  pointer-events: auto;
1357
1638
  }
1358
1639
  /* Mode: hover - controls visible on component hover */
1359
- .structure:hover section.control-buttons.hover-visible {
1640
+ .structure:hover section.control-buttons.hover-visible,
1641
+ .structure:focus-within section.control-buttons.hover-visible {
1360
1642
  opacity: 1;
1361
1643
  pointer-events: auto;
1362
1644
  }
@@ -1364,7 +1646,7 @@ $effect(() => {
1364
1646
  section.control-buttons > :global(button) {
1365
1647
  background-color: transparent;
1366
1648
  display: flex;
1367
- padding: 4px;
1649
+ padding: 1px 6px;
1368
1650
  border-radius: var(--border-radius, 3pt);
1369
1651
  font-size: clamp(0.85em, 2cqmin, 1.3em);
1370
1652
  }
@@ -1416,7 +1698,7 @@ $effect(() => {
1416
1698
  }
1417
1699
  .measure-mode-dropdown > button {
1418
1700
  background: transparent;
1419
- padding: 0 0 0 4px;
1701
+ padding: 1px 6px;
1420
1702
  font-size: clamp(0.85em, 2cqmin, 1.3em);
1421
1703
  }
1422
1704
  .selection-limit-text {
@@ -1432,32 +1714,6 @@ $effect(() => {
1432
1714
  display: grid;
1433
1715
  place-content: center;
1434
1716
  }
1435
- .error-state {
1436
- display: flex;
1437
- flex-direction: column;
1438
- align-items: center;
1439
- justify-content: center;
1440
- height: var(--struct-height, 500px);
1441
- padding: 2rem;
1442
- text-align: center;
1443
- box-sizing: border-box;
1444
- }
1445
- .error-state p {
1446
- color: var(--error-color, #ff6b6b);
1447
- margin: 0 0 1rem;
1448
- }
1449
- .error-state button {
1450
- padding: 0.5rem 1rem;
1451
- background: var(--error-color, #ff6b6b);
1452
- color: white;
1453
- border: none;
1454
- border-radius: var(--border-radius, 3pt);
1455
- cursor: pointer;
1456
- font-size: 0.9rem;
1457
- }
1458
- .error-state button:hover {
1459
- background: var(--error-color-hover, #ff5252);
1460
- }
1461
1717
  .symmetry-error {
1462
1718
  position: absolute;
1463
1719
  bottom: 1rem;