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