matterviz 0.3.2 → 0.3.4

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 (281) 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/element/data.js +1 -1
  76. package/dist/feedback/ClickFeedback.svelte +16 -5
  77. package/dist/feedback/DragOverlay.svelte +10 -2
  78. package/dist/feedback/Spinner.svelte +4 -2
  79. package/dist/feedback/StatusMessage.svelte +8 -2
  80. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  81. package/dist/fermi-surface/FermiSurface.svelte +328 -187
  82. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  83. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  84. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  85. package/dist/fermi-surface/FermiSurfaceScene.svelte +535 -342
  86. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  87. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  88. package/dist/fermi-surface/compute.js +16 -20
  89. package/dist/fermi-surface/parse.js +24 -14
  90. package/dist/fermi-surface/symmetry.js +2 -7
  91. package/dist/fermi-surface/types.d.ts +3 -5
  92. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1019 -765
  93. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +1 -1
  94. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +76 -22
  95. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +2 -3
  96. package/dist/icons.js +47 -0
  97. package/dist/index.d.ts +2 -1
  98. package/dist/index.js +2 -1
  99. package/dist/io/decompress.js +1 -1
  100. package/dist/io/export.d.ts +3 -0
  101. package/dist/io/export.js +129 -143
  102. package/dist/io/is-binary.js +2 -3
  103. package/dist/io/url-drop.js +1 -2
  104. package/dist/isosurface/Isosurface.svelte +202 -148
  105. package/dist/isosurface/IsosurfaceControls.svelte +46 -28
  106. package/dist/isosurface/parse.js +34 -29
  107. package/dist/isosurface/slice.js +5 -10
  108. package/dist/isosurface/types.d.ts +2 -1
  109. package/dist/isosurface/types.js +61 -12
  110. package/dist/labels.js +11 -8
  111. package/dist/layout/FullscreenToggle.svelte +11 -2
  112. package/dist/layout/InfoCard.svelte +38 -6
  113. package/dist/layout/InfoTag.svelte +63 -32
  114. package/dist/layout/PropertyFilter.svelte +82 -37
  115. package/dist/layout/SettingsSection.svelte +85 -55
  116. package/dist/layout/SubpageGrid.svelte +10 -2
  117. package/dist/layout/json-tree/JsonNode.svelte +183 -138
  118. package/dist/layout/json-tree/JsonTree.svelte +499 -413
  119. package/dist/layout/json-tree/JsonValue.svelte +127 -99
  120. package/dist/layout/json-tree/utils.js +4 -2
  121. package/dist/marching-cubes.js +25 -2
  122. package/dist/math.d.ts +13 -17
  123. package/dist/math.js +133 -67
  124. package/dist/overlays/ContextMenu.svelte +65 -40
  125. package/dist/overlays/DraggablePane.svelte +211 -139
  126. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  127. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  128. package/dist/periodic-table/PropertySelect.svelte +25 -7
  129. package/dist/periodic-table/TableInset.svelte +8 -3
  130. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +446 -309
  131. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  132. package/dist/phase-diagram/PhaseDiagramControls.svelte +102 -43
  133. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  134. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +63 -40
  135. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +71 -28
  136. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +1 -1
  137. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +158 -101
  138. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  139. package/dist/phase-diagram/build-diagram.js +9 -9
  140. package/dist/phase-diagram/colors.js +1 -3
  141. package/dist/phase-diagram/parse.js +10 -9
  142. package/dist/phase-diagram/svg-to-diagram.js +53 -49
  143. package/dist/phase-diagram/utils.d.ts +1 -0
  144. package/dist/phase-diagram/utils.js +80 -25
  145. package/dist/plot/AxisLabel.svelte +28 -3
  146. package/dist/plot/BarPlot.svelte +1182 -734
  147. package/dist/plot/BarPlot.svelte.d.ts +2 -2
  148. package/dist/plot/BarPlotControls.svelte +31 -5
  149. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  150. package/dist/plot/ColorBar.svelte +479 -329
  151. package/dist/plot/ColorScaleSelect.svelte +27 -6
  152. package/dist/plot/ElementScatter.svelte +36 -15
  153. package/dist/plot/FillArea.svelte +152 -95
  154. package/dist/plot/Histogram.svelte +934 -571
  155. package/dist/plot/Histogram.svelte.d.ts +1 -1
  156. package/dist/plot/HistogramControls.svelte +53 -9
  157. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  158. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  159. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  160. package/dist/plot/Line.svelte +63 -28
  161. package/dist/plot/PlotControls.svelte +157 -114
  162. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  163. package/dist/plot/PlotLegend.svelte +174 -91
  164. package/dist/plot/PlotTooltip.svelte +45 -6
  165. package/dist/plot/PortalSelect.svelte +175 -147
  166. package/dist/plot/ReferenceLine.svelte +76 -22
  167. package/dist/plot/ReferenceLine3D.svelte +132 -107
  168. package/dist/plot/ReferencePlane.svelte +146 -121
  169. package/dist/plot/ScatterPlot.svelte +1681 -1091
  170. package/dist/plot/ScatterPlot.svelte.d.ts +2 -2
  171. package/dist/plot/ScatterPlot3D.svelte +256 -131
  172. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  173. package/dist/plot/ScatterPlot3DControls.svelte +113 -63
  174. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  175. package/dist/plot/ScatterPlot3DScene.svelte +608 -403
  176. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  177. package/dist/plot/ScatterPlotControls.svelte +65 -25
  178. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  179. package/dist/plot/ScatterPoint.svelte +98 -26
  180. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  181. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  182. package/dist/plot/Surface3D.svelte +159 -108
  183. package/dist/plot/ZeroLines.svelte +55 -3
  184. package/dist/plot/ZoomRect.svelte +4 -2
  185. package/dist/plot/axis-utils.js +1 -3
  186. package/dist/plot/data-cleaning.js +12 -28
  187. package/dist/plot/data-transform.js +2 -1
  188. package/dist/plot/fill-utils.js +2 -0
  189. package/dist/plot/layout.d.ts +4 -1
  190. package/dist/plot/layout.js +33 -14
  191. package/dist/plot/reference-line.d.ts +2 -2
  192. package/dist/plot/reference-line.js +7 -5
  193. package/dist/plot/scales.js +24 -36
  194. package/dist/plot/types.d.ts +11 -23
  195. package/dist/plot/types.js +6 -11
  196. package/dist/plot/utils/label-placement.d.ts +32 -15
  197. package/dist/plot/utils/label-placement.js +227 -66
  198. package/dist/plot/utils/series-visibility.js +2 -3
  199. package/dist/rdf/RdfPlot.svelte +143 -91
  200. package/dist/rdf/calc-rdf.js +4 -5
  201. package/dist/sanitize.d.ts +4 -0
  202. package/dist/sanitize.js +107 -0
  203. package/dist/settings.d.ts +18 -6
  204. package/dist/settings.js +46 -16
  205. package/dist/spectral/Bands.svelte +632 -453
  206. package/dist/spectral/BandsAndDos.svelte +90 -49
  207. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  208. package/dist/spectral/Dos.svelte +389 -258
  209. package/dist/spectral/helpers.js +55 -43
  210. package/dist/state.svelte.d.ts +1 -1
  211. package/dist/state.svelte.js +3 -2
  212. package/dist/structure/Arrow.svelte +59 -20
  213. package/dist/structure/AtomLegend.svelte +215 -134
  214. package/dist/structure/Bond.svelte +73 -47
  215. package/dist/structure/CanvasTooltip.svelte +10 -2
  216. package/dist/structure/CellSelect.svelte +72 -45
  217. package/dist/structure/Cylinder.svelte +33 -17
  218. package/dist/structure/Lattice.svelte +88 -33
  219. package/dist/structure/Structure.svelte +1063 -797
  220. package/dist/structure/Structure.svelte.d.ts +1 -1
  221. package/dist/structure/StructureControls.svelte +349 -118
  222. package/dist/structure/StructureExportPane.svelte +124 -89
  223. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  224. package/dist/structure/StructureInfoPane.svelte +304 -237
  225. package/dist/structure/StructureScene.svelte +879 -443
  226. package/dist/structure/StructureScene.svelte.d.ts +15 -7
  227. package/dist/structure/atom-properties.js +8 -8
  228. package/dist/structure/bonding.js +6 -7
  229. package/dist/structure/export.js +14 -29
  230. package/dist/structure/ferrox-wasm.js +1 -1
  231. package/dist/structure/index.d.ts +13 -3
  232. package/dist/structure/index.js +83 -23
  233. package/dist/structure/measure.d.ts +2 -2
  234. package/dist/structure/measure.js +4 -44
  235. package/dist/structure/parse.js +113 -141
  236. package/dist/structure/partial-occupancy.js +7 -10
  237. package/dist/structure/pbc.d.ts +1 -0
  238. package/dist/structure/pbc.js +16 -6
  239. package/dist/structure/supercell.d.ts +2 -2
  240. package/dist/structure/supercell.js +12 -22
  241. package/dist/structure/validation.js +1 -2
  242. package/dist/symmetry/SymmetryStats.svelte +84 -41
  243. package/dist/symmetry/WyckoffTable.svelte +26 -6
  244. package/dist/symmetry/cell-transform.js +5 -3
  245. package/dist/symmetry/index.js +8 -7
  246. package/dist/symmetry/spacegroups.js +148 -148
  247. package/dist/table/HeatmapTable.svelte +790 -554
  248. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  249. package/dist/table/ToggleMenu.svelte +125 -92
  250. package/dist/table/index.js +2 -4
  251. package/dist/theme/ThemeControl.svelte +21 -12
  252. package/dist/time.js +4 -1
  253. package/dist/tooltip/TooltipContent.svelte +33 -8
  254. package/dist/trajectory/Trajectory.svelte +758 -558
  255. package/dist/trajectory/TrajectoryError.svelte +14 -3
  256. package/dist/trajectory/TrajectoryExportPane.svelte +137 -83
  257. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  258. package/dist/trajectory/extract.js +10 -26
  259. package/dist/trajectory/format-detect.js +5 -5
  260. package/dist/trajectory/frame-reader.d.ts +1 -1
  261. package/dist/trajectory/frame-reader.js +5 -12
  262. package/dist/trajectory/helpers.d.ts +0 -1
  263. package/dist/trajectory/helpers.js +2 -17
  264. package/dist/trajectory/index.js +14 -12
  265. package/dist/trajectory/parse/ase.js +5 -4
  266. package/dist/trajectory/parse/hdf5.js +26 -18
  267. package/dist/trajectory/parse/index.js +13 -18
  268. package/dist/trajectory/parse/lammps.js +17 -7
  269. package/dist/trajectory/parse/vasp.js +5 -2
  270. package/dist/trajectory/parse/xyz.js +8 -7
  271. package/dist/trajectory/plotting.js +13 -8
  272. package/dist/utils.d.ts +1 -0
  273. package/dist/utils.js +13 -0
  274. package/dist/xrd/XrdPlot.svelte +337 -247
  275. package/dist/xrd/broadening.js +14 -9
  276. package/dist/xrd/calc-xrd.js +12 -18
  277. package/dist/xrd/parse.d.ts +1 -1
  278. package/dist/xrd/parse.js +17 -17
  279. package/package.json +99 -103
  280. package/readme.md +1 -1
  281. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,477 +1,907 @@
1
- <script lang="ts">import { AXIS_COLORS, NEG_AXIS_COLORS } from '../colors';
2
- import { element_data } from '../element';
3
- import Isosurface from '../isosurface/Isosurface.svelte';
4
- import { DEFAULT_ISOSURFACE_SETTINGS } from '../isosurface/types';
5
- import { format_num } from '../labels';
6
- import * as math from '../math';
7
- import { DEFAULTS } from '../settings';
8
- import { colors } from '../state.svelte';
9
- import { Arrow, atomic_radii, Cylinder, get_center_of_mass, get_site_vector_info, Lattice, } from './';
10
- import { get_orig_site_idx, get_property_colors, } from './atom-properties';
11
- import * as measure from './measure';
12
- import { compute_slice_geometry, merge_split_partial_sites, PARTIAL_OCCUPANCY_CAP_ARC, } from './partial-occupancy';
13
- import { T, useThrelte } from '@threlte/core';
14
- import * as extras from '@threlte/extras';
15
- import { untrack } from 'svelte';
16
- import { SvelteMap, SvelteSet } from 'svelte/reactivity';
17
- import { Color } from 'three';
18
- import Bond from './Bond.svelte';
19
- import { BONDING_STRATEGIES, compute_bond_transform } from './bonding';
20
- import { CanvasTooltip } from './index';
21
- let pulse_time = $state(0);
22
- let pulse_opacity = $derived(0.15 + 0.25 * Math.sin(pulse_time * 5));
23
- $effect(() => {
24
- if (!selected_sites?.length && !active_sites?.length)
25
- return;
26
- if (typeof globalThis === `undefined`)
27
- return;
28
- const reduce = globalThis.matchMedia?.(`(prefers-reduced-motion: reduce)`).matches;
29
- if (reduce)
30
- return;
31
- let frame_id = 0;
1
+ <script lang="ts">
2
+ import type { D3InterpolateName } from '../colors'
3
+ import { AXIS_COLORS, get_d3_interpolator, NEG_AXIS_COLORS } from '../colors'
4
+ import type { ElementSymbol } from '../element'
5
+ import { element_data } from '../element'
6
+ import Isosurface from '../isosurface/Isosurface.svelte'
7
+ import type { IsosurfaceSettings, VolumetricData } from '../isosurface/types'
8
+ import { DEFAULT_ISOSURFACE_SETTINGS } from '../isosurface/types'
9
+ import { format_num } from '../labels'
10
+ import type { Vec3 } from '../math'
11
+ import * as math from '../math'
12
+ import type {
13
+ CameraProjection,
14
+ ShowBonds,
15
+ VectorColorMode,
16
+ VectorLayerConfig,
17
+ } from '../settings'
18
+ import { DEFAULTS } from '../settings'
19
+ import { sanitize_html } from '../sanitize'
20
+ import { colors } from '../state.svelte'
21
+ import type { AnyStructure, BondPair, MeasureMode, Site } from './'
22
+ import {
23
+ Arrow,
24
+ atomic_radii,
25
+ Cylinder,
26
+ get_all_site_vectors,
27
+ get_center_of_mass,
28
+ get_structure_vector_keys,
29
+ Lattice,
30
+ VECTOR_PALETTE,
31
+ } from './'
32
+ import type { AtomColorConfig } from './atom-properties'
33
+ import {
34
+ get_orig_site_idx,
35
+ get_property_colors,
36
+ } from './atom-properties'
37
+ import * as measure from './measure'
38
+ import {
39
+ compute_slice_geometry,
40
+ merge_split_partial_sites,
41
+ PARTIAL_OCCUPANCY_CAP_ARC,
42
+ } from './partial-occupancy'
43
+ import type { MoyoDataset } from '@spglib/moyo-wasm'
44
+ import { T, useThrelte } from '@threlte/core'
45
+ import * as extras from '@threlte/extras'
46
+ import { type ComponentProps, type Snippet, untrack } from 'svelte'
47
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
48
+ import { type Camera, Color, type Mesh, type Scene } from 'three'
49
+ import Bond from './Bond.svelte'
50
+ import type { BondingStrategy } from './bonding'
51
+ import { BONDING_STRATEGIES, compute_bond_transform } from './bonding'
52
+ import { CanvasTooltip } from './index'
53
+
54
+ type InstancedAtomGroup = {
55
+ element: string
56
+ radius: number
57
+ color: string
58
+ is_image_atom: boolean
59
+ atoms: (typeof atom_data)[number][]
60
+ }
61
+
62
+ let pulse_time = $state(0)
63
+ let pulse_opacity = $derived(0.15 + 0.25 * Math.sin(pulse_time * 5))
64
+ $effect(() => {
65
+ if (!selected_sites?.length && !active_sites?.length) return
66
+ if (typeof globalThis === `undefined`) return
67
+ const reduce = globalThis.matchMedia?.(`(prefers-reduced-motion: reduce)`).matches
68
+ if (reduce) return
69
+ let frame_id = 0
32
70
  const animate = () => {
33
- pulse_time += 0.015;
34
- frame_id = requestAnimationFrame(animate);
35
- };
36
- frame_id = requestAnimationFrame(animate);
37
- return () => cancelAnimationFrame(frame_id);
38
- });
39
- let { structure = undefined, base_structure = undefined, atom_radius = DEFAULTS.structure.atom_radius, same_size_atoms = false, camera_position = DEFAULTS.structure.camera_position, camera_target = undefined, camera_projection = DEFAULTS.structure.camera_projection, rotation_damping = DEFAULTS.structure.rotation_damping, max_zoom = DEFAULTS.structure.max_zoom, min_zoom = DEFAULTS.structure.min_zoom, rotate_speed = DEFAULTS.structure.rotate_speed, zoom_speed = DEFAULTS.structure.zoom_speed, pan_speed = DEFAULTS.structure.pan_speed, zoom_to_cursor = DEFAULTS.structure.zoom_to_cursor, show_atoms = DEFAULTS.structure.show_atoms, show_bonds = DEFAULTS.structure.show_bonds, show_site_labels = DEFAULTS.structure.show_site_labels, show_site_indices = DEFAULTS.structure.show_site_indices, site_label_size = DEFAULTS.structure.site_label_size, site_label_offset = $bindable(DEFAULTS.structure.site_label_offset), site_label_bg_color = `color-mix(in srgb, #000000 0%, transparent)`, site_label_color = `#ffffff`, site_label_padding = 3, show_force_vectors = DEFAULTS.structure.show_force_vectors, force_scale = DEFAULTS.structure.force_scale, force_color = DEFAULTS.structure.force_color, gizmo = DEFAULTS.structure.show_gizmo, hovered_idx = $bindable(null), hovered_site = $bindable(null), float_fmt = `.3~f`, auto_rotate = DEFAULTS.structure.auto_rotate, bond_thickness = DEFAULTS.structure.bond_thickness, bond_color = DEFAULTS.structure.bond_color, bonding_strategy = DEFAULTS.structure.bonding_strategy, bonding_options = {}, fov = DEFAULTS.structure.fov, initial_zoom = DEFAULTS.structure.initial_zoom, ambient_light = DEFAULTS.structure.ambient_light, directional_light = DEFAULTS.structure.directional_light, sphere_segments = DEFAULTS.structure.sphere_segments, lattice_props = {}, atom_label, camera_is_moving = $bindable(false), width = 0, height = 0, measure_mode = `distance`, selected_sites = $bindable([]), measured_sites = $bindable([]), added_bonds = $bindable([]), removed_bonds = $bindable([]), selection_highlight_color = `#6cf0ff`,
40
- // Active highlight group with different color
41
- active_sites = $bindable([]), active_highlight_color = `var(--struct-active-highlight-color, #2563eb)`, rotation = DEFAULTS.structure.rotation, scene = $bindable(), camera = $bindable(), orbit_controls = $bindable(), rotation_target_ref = $bindable(), initial_computed_zoom = $bindable(), hidden_elements = $bindable(new SvelteSet()), hidden_prop_vals = $bindable(new SvelteSet()), element_radius_overrides = $bindable({}), site_radius_overrides = $bindable(new SvelteMap()), atom_color_config = {
42
- mode: DEFAULTS.structure.atom_color_mode,
43
- scale: DEFAULTS.structure.atom_color_scale,
44
- scale_type: DEFAULTS.structure.atom_color_scale_type,
45
- }, sym_data = null,
46
- // Edit-atoms mode callbacks
47
- on_sites_moved, on_operation_start, on_add_atom, add_atom_mode = $bindable(false), add_element = $bindable(`C`), cursor = $bindable(`default`), dragging_atoms = $bindable(false), volumetric_data = undefined, isosurface_settings = DEFAULT_ISOSURFACE_SETTINGS, } = $props();
48
- const threlte = useThrelte();
49
- $effect(() => {
50
- scene = threlte.scene;
51
- camera = threlte.camera.current;
71
+ pulse_time += 0.015
72
+ frame_id = requestAnimationFrame(animate)
73
+ }
74
+ frame_id = requestAnimationFrame(animate)
75
+ return () => cancelAnimationFrame(frame_id)
76
+ })
77
+
78
+ let {
79
+ structure = undefined,
80
+ base_structure = undefined,
81
+ atom_radius = DEFAULTS.structure.atom_radius,
82
+ same_size_atoms = false,
83
+ camera_position = DEFAULTS.structure.camera_position,
84
+ camera_target = undefined,
85
+ camera_projection = DEFAULTS.structure.camera_projection,
86
+ rotation_damping = DEFAULTS.structure.rotation_damping,
87
+ max_zoom = DEFAULTS.structure.max_zoom,
88
+ min_zoom = DEFAULTS.structure.min_zoom,
89
+ rotate_speed = DEFAULTS.structure.rotate_speed,
90
+ zoom_speed = DEFAULTS.structure.zoom_speed,
91
+ pan_speed = DEFAULTS.structure.pan_speed,
92
+ zoom_to_cursor = DEFAULTS.structure.zoom_to_cursor,
93
+ show_atoms = DEFAULTS.structure.show_atoms,
94
+ show_bonds = DEFAULTS.structure.show_bonds,
95
+ show_site_labels = DEFAULTS.structure.show_site_labels,
96
+ show_site_indices = DEFAULTS.structure.show_site_indices,
97
+ site_label_size = DEFAULTS.structure.site_label_size,
98
+ site_label_offset = $bindable(DEFAULTS.structure.site_label_offset),
99
+ site_label_bg_color = `color-mix(in srgb, #000000 0%, transparent)`,
100
+ site_label_color = `#ffffff`,
101
+ site_label_padding = 3,
102
+ vector_configs = $bindable<Record<string, VectorLayerConfig>>({}),
103
+ vector_scale = DEFAULTS.structure.vector_scale,
104
+ vector_color = DEFAULTS.structure.vector_color,
105
+ vector_color_mode = DEFAULTS.structure.vector_color_mode as VectorColorMode,
106
+ vector_color_scale = DEFAULTS.structure.vector_color_scale,
107
+ vector_normalize = DEFAULTS.structure.vector_normalize,
108
+ vector_uniform_thickness = DEFAULTS.structure.vector_uniform_thickness,
109
+ vector_origin_gap = DEFAULTS.structure.vector_origin_gap,
110
+ vector_shaft_radius = DEFAULTS.structure.vector_shaft_radius,
111
+ vector_arrow_head_radius = DEFAULTS.structure.vector_arrow_head_radius,
112
+ vector_arrow_head_length = DEFAULTS.structure.vector_arrow_head_length,
113
+ gizmo = DEFAULTS.structure.show_gizmo,
114
+ hovered_idx = $bindable(null),
115
+ hovered_site = $bindable(null),
116
+ float_fmt = `.3~f`,
117
+ auto_rotate = DEFAULTS.structure.auto_rotate,
118
+ bond_thickness = DEFAULTS.structure.bond_thickness,
119
+ bond_color = DEFAULTS.structure.bond_color,
120
+ bonding_strategy = DEFAULTS.structure.bonding_strategy,
121
+ bonding_options = {},
122
+ fov = DEFAULTS.structure.fov,
123
+ initial_zoom = DEFAULTS.structure.initial_zoom,
124
+ ambient_light = DEFAULTS.structure.ambient_light,
125
+ directional_light = DEFAULTS.structure.directional_light,
126
+ sphere_segments = DEFAULTS.structure.sphere_segments,
127
+ lattice_props = {},
128
+ atom_label,
129
+ camera_is_moving = $bindable(false),
130
+ width = 0,
131
+ height = 0,
132
+ measure_mode = `distance`,
133
+ selected_sites = $bindable([]),
134
+ measured_sites = $bindable([]),
135
+ added_bonds = $bindable([]),
136
+ removed_bonds = $bindable([]),
137
+ selection_highlight_color = `#6cf0ff`,
138
+ // Active highlight group with different color
139
+ active_sites = $bindable([]),
140
+ active_highlight_color = `var(--struct-active-highlight-color, #2563eb)`,
141
+ rotation = DEFAULTS.structure.rotation,
142
+ scene = $bindable(),
143
+ camera = $bindable(),
144
+ orbit_controls = $bindable(),
145
+ rotation_target_ref = $bindable(),
146
+ initial_computed_zoom = $bindable(),
147
+ hidden_elements = $bindable(new SvelteSet()),
148
+ hidden_prop_vals = $bindable(new SvelteSet<number | string>()),
149
+ element_radius_overrides = $bindable<Partial<Record<ElementSymbol, number>>>({}),
150
+ site_radius_overrides = $bindable<SvelteMap<number, number>>(new SvelteMap()),
151
+ atom_color_config = {
152
+ mode: DEFAULTS.structure.atom_color_mode,
153
+ scale: DEFAULTS.structure.atom_color_scale as D3InterpolateName,
154
+ scale_type: DEFAULTS.structure.atom_color_scale_type,
155
+ },
156
+ sym_data = null,
157
+ // Edit-atoms mode callbacks
158
+ on_sites_moved,
159
+ on_operation_start,
160
+ on_add_atom,
161
+ add_atom_mode = $bindable(false),
162
+ add_element = $bindable(`C`),
163
+ cursor = $bindable(`default`),
164
+ dragging_atoms = $bindable(false),
165
+ volumetric_data = undefined,
166
+ isosurface_settings = DEFAULT_ISOSURFACE_SETTINGS,
167
+ }: {
168
+ structure?: AnyStructure
169
+ base_structure?: AnyStructure // The original structure without image atoms, used for property color calculation
170
+ atom_radius?: number // scale factor for atomic radii
171
+ same_size_atoms?: boolean // whether to use the same radius for all atoms. if not, the radius will be
172
+ // determined by the atomic radius of the element
173
+ camera_position?: [x: number, y: number, z: number] // initial camera position from which to render the scene
174
+ camera_target?: Vec3 // external orbit-controls target for pan synchronization
175
+ camera_projection?: CameraProjection // camera projection type
176
+ rotation_damping?: number // rotation damping factor (how quickly the rotation comes to rest after mouse release)
177
+ // zoom level of the camera
178
+ max_zoom?: number
179
+ min_zoom?: number
180
+ rotate_speed?: number // rotation speed. set to 0 to disable rotation.
181
+ zoom_speed?: number // zoom speed. set to 0 to disable zooming.
182
+ pan_speed?: number // pan speed. set to 0 to disable panning.
183
+ zoom_to_cursor?: boolean // zoom toward cursor position instead of scene center
184
+ show_atoms?: boolean
185
+ show_bonds?: ShowBonds
186
+ show_site_labels?: boolean
187
+ show_site_indices?: boolean
188
+ vector_configs?: Record<string, VectorLayerConfig>
189
+ vector_scale?: number
190
+ vector_color?: string
191
+ vector_color_mode?: VectorColorMode
192
+ vector_color_scale?: D3InterpolateName
193
+ vector_normalize?: boolean
194
+ vector_uniform_thickness?: boolean
195
+ vector_origin_gap?: number
196
+ vector_shaft_radius?: number
197
+ vector_arrow_head_radius?: number
198
+ vector_arrow_head_length?: number
199
+ gizmo?: boolean | ComponentProps<typeof extras.Gizmo>
200
+ hovered_idx?: number | null
201
+ hovered_site?: Site | null
202
+ float_fmt?: string
203
+ auto_rotate?: number
204
+ initial_zoom?: number
205
+ bond_thickness?: number
206
+ bond_color?: string
207
+ bonding_strategy?: BondingStrategy
208
+ bonding_options?: Record<string, unknown>
209
+ fov?: number
210
+ ambient_light?: number
211
+ directional_light?: number
212
+ sphere_segments?: number
213
+ lattice_props?: ComponentProps<typeof Lattice>
214
+ atom_label?: Snippet<[{ site: Site; site_idx: number }]>
215
+ site_label_size?: number
216
+ site_label_offset?: Vec3
217
+ site_label_bg_color?: string
218
+ site_label_color?: string
219
+ site_label_padding?: number
220
+ camera_is_moving?: boolean // used to prevent tooltip from showing while camera is moving
221
+ width?: number // Viewer dimensions for responsive zoom
222
+ height?: number
223
+ // measurement props
224
+ measure_mode?: MeasureMode
225
+ selected_sites?: number[]
226
+ measured_sites?: number[]
227
+ added_bonds?: [number, number][]
228
+ removed_bonds?: [number, number][]
229
+ selection_highlight_color?: string
230
+ // Support for active highlight group with different color
231
+ active_sites?: number[]
232
+ active_highlight_color?: string
233
+ rotation?: Vec3 // rotation control prop
234
+ // Expose scene and camera for external use (e.g. export pane)
235
+ scene?: Scene
236
+ camera?: Camera
237
+ orbit_controls?: ComponentProps<typeof extras.OrbitControls>[`ref`] // OrbitControls instance
238
+ rotation_target_ref?: Vec3 // Expose rotation target for reset
239
+ initial_computed_zoom?: number // Expose initial zoom for reset
240
+ hidden_elements?: Set<ElementSymbol>
241
+ hidden_prop_vals?: Set<number | string> // Track hidden property values (e.g. Wyckoff positions, coordination numbers)
242
+ element_radius_overrides?: Partial<Record<ElementSymbol, number>> // Per-element absolute radius in Angstroms
243
+ site_radius_overrides?: Map<number, number> | SvelteMap<number, number> // Per-site absolute radius in Angstroms
244
+ atom_color_config?: Partial<AtomColorConfig> // Atom coloring configuration
245
+ sym_data?: MoyoDataset | null // Symmetry data for Wyckoff coloring
246
+ // Edit-atoms mode callbacks and state
247
+ on_sites_moved?: (scene_indices: number[], delta: Vec3) => void
248
+ on_operation_start?: () => void
249
+ on_add_atom?: (xyz: Vec3, element: ElementSymbol) => void
250
+ add_atom_mode?: boolean // whether user is in click-to-place add-atom sub-mode
251
+ add_element?: ElementSymbol // element to add when clicking in add-atom mode
252
+ cursor?: string // cursor style for the 3D canvas
253
+ dragging_atoms?: boolean // true while TransformControls drag is active (skips expensive recalculations)
254
+ volumetric_data?: VolumetricData // Active volumetric data for isosurface rendering
255
+ isosurface_settings?: IsosurfaceSettings // Isosurface rendering settings
256
+ } = $props()
257
+
258
+ const threlte = useThrelte()
259
+ $effect(() => {
260
+ scene = threlte.scene
261
+ camera = threlte.camera.current
52
262
  if (threlte.renderer) {
53
- Object.assign(threlte.renderer.domElement, { __renderer: threlte.renderer });
263
+ Object.assign(threlte.renderer.domElement, { __renderer: threlte.renderer })
54
264
  }
55
- });
56
- // Expose rotation target for external reset
57
- $effect(() => {
58
- rotation_target_ref = rotation_target;
59
- });
60
- // Track initial computed zoom for reset
61
- let stored_initial_zoom = $state(undefined);
62
- $effect(() => {
265
+ })
266
+
267
+ // Expose rotation target for external reset
268
+ $effect(() => {
269
+ rotation_target_ref = rotation_target
270
+ })
271
+
272
+ // Track initial computed zoom for reset
273
+ let stored_initial_zoom = $state<number | undefined>(undefined)
274
+ $effect(() => {
63
275
  if (stored_initial_zoom === undefined && computed_zoom > 0) {
64
- stored_initial_zoom = computed_zoom;
276
+ stored_initial_zoom = computed_zoom
65
277
  }
66
- initial_computed_zoom = stored_initial_zoom;
67
- });
68
- let bond_pairs = $state([]);
69
- let active_tooltip = $state(null);
70
- let hovered_bond_key = $state(null);
71
- // Cursor style for the canvas, derived from mode and hover state
72
- let canvas_cursor = $derived.by(() => {
73
- if (measure_mode === `edit-atoms` && add_atom_mode)
74
- return `crosshair`;
278
+ initial_computed_zoom = stored_initial_zoom
279
+ })
280
+
281
+ let bond_pairs: BondPair[] = $state([])
282
+ let active_tooltip = $state<`atom` | `bond` | null>(null)
283
+ let hovered_bond_key = $state<string | null>(null)
284
+
285
+ // Cursor style for the canvas, derived from mode and hover state
286
+ let canvas_cursor = $derived.by(() => {
287
+ if (measure_mode === `edit-atoms` && add_atom_mode) return `crosshair`
75
288
  if (hovered_idx != null) {
76
- if (measure_mode === `edit-atoms`) {
77
- const site = structure?.sites?.[hovered_idx];
78
- if (site?.properties?.orig_site_idx != null)
79
- return `not-allowed`;
80
- return `pointer`;
81
- }
82
- return `pointer`;
289
+ if (measure_mode === `edit-atoms`) {
290
+ const site = structure?.sites?.[hovered_idx]
291
+ if (site?.properties?.orig_site_idx != null) return `not-allowed`
292
+ return `pointer`
293
+ }
294
+ return `pointer`
83
295
  }
84
- return `default`;
85
- });
86
- // Desaturate a color by blending it toward gray (for ghosting image atoms in edit mode)
87
- const gray = new Color(0x999999);
88
- function desaturate(hex, amount = 0.4) {
89
- return `#${new Color(hex ?? 0x999999).lerp(gray, amount).getHexString()}`;
90
- }
91
- // === Edit-atoms mode state ===
92
- let transform_object = $state(undefined);
93
- // Plain variable only used imperatively in TransformControls drag handlers
94
- let drag_start_centroid = null;
95
- // Frozen centroid set on drag start. While non-null, the TransformControls mesh
96
- // position stays at this fixed value so Svelte's reactive centroid updates (from
97
- // PBC wrapping) don't fight TransformControls. Cleared on mouseUp so the mesh
98
- // snaps to the new wrapped centroid.
99
- let frozen_centroid = $state(null);
100
- function get_bond_key(idx1, idx2) {
101
- return idx1 < idx2 ? `${idx1}-${idx2}` : `${idx2}-${idx1}`;
102
- }
103
- // Toggle a bond between two atoms: cycles through add → remove → restore states
104
- function toggle_bond(site_1, site_2) {
105
- const idx_i = Math.min(site_1, site_2);
106
- const idx_j = Math.max(site_1, site_2);
296
+ return `default`
297
+ })
298
+
299
+ // Desaturate a color by blending it toward gray (for ghosting image atoms in edit mode)
300
+ const gray = new Color(0x999999)
301
+ function desaturate(hex: string | undefined, amount = 0.4): string {
302
+ return `#${new Color(hex ?? 0x999999).lerp(gray, amount).getHexString()}`
303
+ }
304
+
305
+ // === Edit-atoms mode state ===
306
+ let transform_object = $state<Mesh | undefined>(undefined)
307
+ // Plain variable only used imperatively in TransformControls drag handlers
308
+ let drag_start_centroid: Vec3 | null = null
309
+ // Frozen centroid set on drag start. While non-null, the TransformControls mesh
310
+ // position stays at this fixed value so Svelte's reactive centroid updates (from
311
+ // PBC wrapping) don't fight TransformControls. Cleared on mouseUp so the mesh
312
+ // snaps to the new wrapped centroid.
313
+ let frozen_centroid = $state<Vec3 | null>(null)
314
+
315
+ const get_bond_key = (idx1: number, idx2: number): string =>
316
+ idx1 < idx2 ? `${idx1}-${idx2}` : `${idx2}-${idx1}`
317
+
318
+ // Toggle a bond between two atoms: cycles through add → remove → restore states
319
+ function toggle_bond(site_1: number, site_2: number) {
320
+ const idx_i = Math.min(site_1, site_2)
321
+ const idx_j = Math.max(site_1, site_2)
107
322
  // added/removed pairs are stored sorted, so direct comparison works
108
- const match = ([a, b]) => a === idx_i && b === idx_j;
109
- const added_idx = added_bonds.findIndex(match);
323
+ const match = ([a, b]: [number, number]) => a === idx_i && b === idx_j
324
+
325
+ const added_idx = added_bonds.findIndex(match)
110
326
  if (added_idx >= 0) {
111
- added_bonds = added_bonds.toSpliced(added_idx, 1);
112
- return;
327
+ added_bonds = added_bonds.toSpliced(added_idx, 1)
328
+ return
113
329
  }
114
- const removed_idx = removed_bonds.findIndex(match);
330
+
331
+ const removed_idx = removed_bonds.findIndex(match)
115
332
  if (removed_idx >= 0) {
116
- removed_bonds = removed_bonds.toSpliced(removed_idx, 1);
117
- return;
333
+ removed_bonds = removed_bonds.toSpliced(removed_idx, 1)
334
+ return
118
335
  }
336
+
119
337
  // bond_pairs may not be sorted, so use get_bond_key for comparison
120
- const key = `${idx_i}-${idx_j}`;
121
- if (bond_pairs.some((bond) => get_bond_key(bond.site_idx_1, bond.site_idx_2) === key))
122
- removed_bonds = [...removed_bonds, [idx_i, idx_j]];
123
- else
124
- added_bonds = [...added_bonds, [idx_i, idx_j]];
125
- }
126
- // Deduplicate clicks: when a highlight sphere and the underlying atom both
127
- // intercept the same native click, only the first intersection should fire.
128
- // All threlte intersection events from one click share the same nativeEvent ref.
129
- let last_native_event = null;
130
- function toggle_selection(site_index, evt) {
131
- evt?.stopPropagation?.();
132
- const native_event = evt
133
- ?.nativeEvent;
338
+ const key = `${idx_i}-${idx_j}`
339
+ if (
340
+ bond_pairs.some((bond) =>
341
+ get_bond_key(bond.site_idx_1, bond.site_idx_2) === key
342
+ )
343
+ ) removed_bonds = [...removed_bonds, [idx_i, idx_j]]
344
+ else added_bonds = [...added_bonds, [idx_i, idx_j]]
345
+ }
346
+
347
+ // Deduplicate clicks: when a highlight sphere and the underlying atom both
348
+ // intercept the same native click, only the first intersection should fire.
349
+ // All threlte intersection events from one click share the same nativeEvent ref.
350
+ let last_native_event: Event | null = null
351
+
352
+ function toggle_selection(site_index: number, evt?: Event) {
353
+ evt?.stopPropagation?.()
354
+ const native_event = (evt as Event & { nativeEvent?: unknown } | undefined)
355
+ ?.nativeEvent
134
356
  if (native_event instanceof Event) {
135
- if (native_event === last_native_event)
136
- return;
137
- last_native_event = native_event;
357
+ if (native_event === last_native_event) return
358
+ last_native_event = native_event
138
359
  }
360
+
139
361
  if (measure_mode === `edit-bonds`) {
140
- // In edit-bonds mode, select atoms to add/remove bonds between them
141
- const new_sites = measured_sites.includes(site_index)
142
- ? measured_sites.filter((idx) => idx !== site_index)
143
- : [...measured_sites, site_index];
144
- measured_sites = new_sites;
145
- selected_sites = new_sites;
146
- // When two atoms are selected, toggle the bond between them
147
- if (measured_sites.length === 2) {
148
- toggle_bond(measured_sites[0], measured_sites[1]);
149
- measured_sites = [];
150
- selected_sites = [];
151
- }
152
- return;
362
+ // In edit-bonds mode, select atoms to add/remove bonds between them
363
+ const new_sites = measured_sites.includes(site_index)
364
+ ? measured_sites.filter((idx) => idx !== site_index)
365
+ : [...measured_sites, site_index]
366
+
367
+ measured_sites = new_sites
368
+ selected_sites = new_sites
369
+
370
+ // When two atoms are selected, toggle the bond between them
371
+ if (measured_sites.length === 2) {
372
+ toggle_bond(measured_sites[0], measured_sites[1])
373
+ measured_sites = []
374
+ selected_sites = []
375
+ }
376
+ return
153
377
  }
378
+
154
379
  if (measure_mode === `edit-atoms`) {
155
- // Block image atoms (detected by orig_site_idx property from PBC)
156
- const site = structure?.sites?.[site_index];
157
- if (site?.properties?.orig_site_idx != null)
158
- return;
159
- const is_selected = selected_sites.includes(site_index);
160
- const is_shift = evt instanceof MouseEvent && evt.shiftKey;
161
- // In edit-atoms mode, selected_sites and measured_sites always stay in sync
162
- let new_sites;
163
- if (is_shift) {
164
- // Multi-select: toggle this site in/out of selection
165
- new_sites = is_selected
166
- ? selected_sites.filter((idx) => idx !== site_index)
167
- : [...selected_sites, site_index];
168
- }
169
- else {
170
- // Single-select: replace selection (or deselect if already selected)
171
- new_sites = is_selected ? [] : [site_index];
172
- }
173
- selected_sites = new_sites;
174
- measured_sites = new_sites;
175
- return;
380
+ // Block image atoms (detected by orig_site_idx property from PBC)
381
+ const site = structure?.sites?.[site_index]
382
+ if (site?.properties?.orig_site_idx != null) return
383
+
384
+ const is_selected = selected_sites.includes(site_index)
385
+ const is_shift = evt instanceof MouseEvent && evt.shiftKey
386
+
387
+ // In edit-atoms mode, selected_sites and measured_sites always stay in sync
388
+ let new_sites: number[]
389
+ if (is_shift) {
390
+ // Multi-select: toggle this site in/out of selection
391
+ new_sites = is_selected
392
+ ? selected_sites.filter((idx) => idx !== site_index)
393
+ : [...selected_sites, site_index]
394
+ } else {
395
+ // Single-select: replace selection (or deselect if already selected)
396
+ new_sites = is_selected ? [] : [site_index]
397
+ }
398
+ selected_sites = new_sites
399
+ measured_sites = new_sites
400
+ return
176
401
  }
177
- if (!measured_sites.includes(site_index) &&
178
- measured_sites.length >= measure.MAX_SELECTED_SITES) {
179
- console.warn(`Selection size limit reached (${measure.MAX_SELECTED_SITES}). Deselect some sites first.`);
180
- return;
402
+
403
+ if (
404
+ !measured_sites.includes(site_index) &&
405
+ measured_sites.length >= measure.MAX_SELECTED_SITES
406
+ ) {
407
+ console.warn(
408
+ `Selection size limit reached (${measure.MAX_SELECTED_SITES}). Deselect some sites first.`,
409
+ )
410
+ return
181
411
  }
412
+
182
413
  measured_sites = measured_sites.includes(site_index)
183
- ? measured_sites.filter((idx) => idx !== site_index)
184
- : [...measured_sites, site_index];
414
+ ? measured_sites.filter((idx) => idx !== site_index)
415
+ : [...measured_sites, site_index]
185
416
  selected_sites = selected_sites.includes(site_index)
186
- ? selected_sites.filter((idx) => idx !== site_index)
187
- : [...selected_sites, site_index];
188
- }
189
- $effect(() => {
190
- const count = structure?.sites?.length ?? 0;
417
+ ? selected_sites.filter((idx) => idx !== site_index)
418
+ : [...selected_sites, site_index]
419
+ }
420
+ $effect(() => {
421
+ const count = structure?.sites?.length ?? 0
191
422
  if (count <= 0) {
192
- measured_sites = [];
193
- return;
423
+ measured_sites = []
424
+ return
194
425
  }
195
426
  untrack(() => {
196
- measured_sites = measured_sites.filter((idx) => idx >= 0 && idx < count);
197
- });
198
- });
199
- $effect(() => {
200
- cursor = canvas_cursor;
201
- });
202
- extras.interactivity();
203
- $effect.pre(() => {
204
- hovered_site = structure?.sites?.[hovered_idx ?? -1] ?? null;
205
- });
206
- let lattice = $derived(structure && `lattice` in structure ? structure.lattice : null);
207
- let visual_lattice = $derived(base_structure && `lattice` in base_structure ? base_structure.lattice : lattice);
208
- let rotation_target = $derived(lattice
209
- ? math.scale(math.add(...lattice.matrix), 0.5)
210
- : structure
211
- ? get_center_of_mass(structure)
212
- : [0, 0, 0]);
213
- let structure_size = $derived(lattice ? (lattice.a + lattice.b + lattice.c) / 2 : 10);
214
- // Compute dynamic camera clipping planes based on structure size
215
- // This prevents z-fighting and disappearing objects when zooming in close on large supercells
216
- let camera_near = $derived(Math.max(0.01, structure_size * 0.01));
217
- let camera_far = $derived(Math.max(1000, structure_size * 100));
218
- // Using $state because this is mutated in an effect based on viewport/structure size
219
- let computed_zoom = $state(untrack(() => initial_zoom));
220
- $effect(() => {
221
- if (!(width > 0) || !(height > 0))
222
- return;
223
- const structure_max_dim = Math.max(1, untrack(() => structure_size));
224
- const viewer_min_dim = Math.min(width, height);
225
- const scale_factor = viewer_min_dim / (structure_max_dim * 50); // 50px per unit
226
- let new_zoom = initial_zoom * scale_factor;
227
- if (min_zoom && min_zoom > 0)
228
- new_zoom = Math.max(min_zoom, new_zoom);
229
- if (max_zoom && max_zoom > 0)
230
- new_zoom = Math.min(max_zoom, new_zoom);
231
- computed_zoom = new_zoom;
232
- });
233
- $effect.pre(() => {
427
+ measured_sites = measured_sites.filter((idx) => idx >= 0 && idx < count)
428
+ })
429
+ })
430
+
431
+ $effect(() => {
432
+ cursor = canvas_cursor
433
+ })
434
+
435
+ extras.interactivity()
436
+ $effect.pre(() => {
437
+ hovered_site = structure?.sites?.[hovered_idx ?? -1] ?? null
438
+ })
439
+ let lattice = $derived(
440
+ structure && `lattice` in structure ? structure.lattice : null,
441
+ )
442
+
443
+ let visual_lattice = $derived(
444
+ base_structure && `lattice` in base_structure ? base_structure.lattice : lattice,
445
+ )
446
+
447
+ let rotation_target = $derived(
448
+ lattice
449
+ ? (math.scale(math.add(...lattice.matrix), 0.5) as Vec3)
450
+ : structure
451
+ ? get_center_of_mass(structure)
452
+ : [0, 0, 0] as Vec3,
453
+ )
454
+
455
+ let structure_size = $derived(
456
+ lattice ? (lattice.a + lattice.b + lattice.c) / 2 : 10,
457
+ )
458
+
459
+ // Characteristic inter-atomic spacing: cube root of volume per atom.
460
+ // Excludes PBC image atoms (orig_site_idx) so toggling image atoms doesn't affect arrow sizing.
461
+ let char_atom_spacing = $derived.by(() => {
462
+ if (!lattice || !structure?.sites?.length) return structure_size
463
+ const n_real = structure.sites.filter((site) =>
464
+ site.properties?.orig_site_idx == null
465
+ ).length
466
+ return n_real > 0 ? Math.cbrt(lattice.volume / n_real) : structure_size
467
+ })
468
+
469
+ // When uniform thickness is on, convert negative (length-relative) radii to
470
+ // positive (absolute) values scaled by inter-atomic spacing.
471
+ // Already-positive (absolute) values are preserved as-is.
472
+ let eff_shaft_radius = $derived(
473
+ vector_uniform_thickness && vector_shaft_radius < 0
474
+ ? char_atom_spacing * -vector_shaft_radius
475
+ : vector_shaft_radius,
476
+ )
477
+ let eff_head_radius = $derived(
478
+ vector_uniform_thickness && vector_arrow_head_radius < 0
479
+ ? char_atom_spacing * -vector_arrow_head_radius
480
+ : vector_arrow_head_radius,
481
+ )
482
+ let eff_head_length = $derived(
483
+ vector_uniform_thickness && vector_arrow_head_length < 0
484
+ ? char_atom_spacing * -vector_arrow_head_length
485
+ : vector_arrow_head_length,
486
+ )
487
+
488
+ // Compute dynamic camera clipping planes based on structure size
489
+ // This prevents z-fighting and disappearing objects when zooming in close on large supercells
490
+ let camera_near = $derived(Math.max(0.01, structure_size * 0.01))
491
+ let camera_far = $derived(Math.max(1000, structure_size * 100))
492
+
493
+ // Using $state because this is mutated in an effect based on viewport/structure size
494
+ let computed_zoom = $state(untrack(() => initial_zoom))
495
+ $effect(() => {
496
+ if (!(width > 0) || !(height > 0)) return
497
+ const structure_max_dim = Math.max(1, untrack(() => structure_size))
498
+ const viewer_min_dim = Math.min(width, height)
499
+ const scale_factor = viewer_min_dim / (structure_max_dim * 50) // 50px per unit
500
+ let new_zoom = initial_zoom * scale_factor
501
+ if (min_zoom && min_zoom > 0) new_zoom = Math.max(min_zoom, new_zoom)
502
+ if (max_zoom && max_zoom > 0) new_zoom = Math.min(max_zoom, new_zoom)
503
+ computed_zoom = new_zoom
504
+ })
505
+
506
+ $effect.pre(() => { // Simple initial camera auto-position: proportional to structure size and fov
234
507
  if (camera_position.every((val) => val === 0) && structure) {
235
- const distance = Math.max(1, structure_size) * (60 / fov);
236
- camera_position = [distance, distance * 0.3, distance * 0.8];
508
+ stored_initial_zoom = undefined
509
+ const distance = Math.max(1, structure_size) * (60 / fov)
510
+ camera_position = [distance, distance * 0.3, distance * 0.8]
237
511
  }
238
- });
239
- $effect(() => {
512
+ })
513
+ $effect(() => {
240
514
  if (structure && show_bonds !== `never`) {
241
- // Determine if we should show bonds based on the setting and structure type
242
- const should_show_bonds = show_bonds === `always` ||
243
- (show_bonds === `crystals` && lattice) ||
244
- (show_bonds === `molecules` && !lattice);
245
- if (should_show_bonds) {
246
- bond_pairs = BONDING_STRATEGIES[bonding_strategy](structure, bonding_options);
247
- }
248
- else
249
- bond_pairs = [];
250
- }
251
- else
252
- bond_pairs = [];
253
- });
254
- // Compute property-based colors when not using element coloring
255
- // Use base_structure (original unit cell) for color calculation
256
- let property_colors = $derived(get_property_colors(base_structure || structure, atom_color_config, bonding_strategy, sym_data));
257
- // Compute weighted average radius for a site based on species occupancies
258
- // Normalizes by total occupancy so vacancy-containing sites render at full size
259
- const calc_weighted_radius = (site) => {
260
- const total_occu = site.species.reduce((sum, { occu }) => sum + occu, 0);
515
+ // Determine if we should show bonds based on the setting and structure type
516
+ const should_show_bonds = show_bonds === `always` ||
517
+ (show_bonds === `crystals` && lattice) ||
518
+ (show_bonds === `molecules` && !lattice)
519
+
520
+ if (should_show_bonds) {
521
+ bond_pairs = BONDING_STRATEGIES[bonding_strategy](structure, bonding_options)
522
+ } else bond_pairs = []
523
+ } else bond_pairs = []
524
+ })
525
+
526
+ // Compute property-based colors when not using element coloring
527
+ // Use base_structure (original unit cell) for color calculation
528
+ let property_colors = $derived(
529
+ get_property_colors(
530
+ base_structure || structure,
531
+ atom_color_config,
532
+ bonding_strategy,
533
+ sym_data,
534
+ ),
535
+ )
536
+ // Compute weighted average radius for a site based on species occupancies
537
+ // Normalizes by total occupancy so vacancy-containing sites render at full size
538
+ const calc_weighted_radius = (site: Site): number => {
539
+ const total_occu = site.species.reduce((sum, { occu }) => sum + occu, 0)
261
540
  const weighted_sum = site.species.reduce((sum, { element, occu }) => {
262
- const override = element_radius_overrides?.[element];
263
- return sum + occu * (override ?? atomic_radii[element] ?? 1);
264
- }, 0);
265
- return total_occu > 0 ? weighted_sum / total_occu : 1;
266
- };
267
- let atom_data = $derived.by(() => {
268
- if (!show_atoms || !structure?.sites)
269
- return [];
270
- const render_sites = merge_split_partial_sites(structure.sites, hidden_elements);
541
+ const override = element_radius_overrides?.[element as ElementSymbol]
542
+ return sum + occu * (override ?? atomic_radii[element] ?? 1)
543
+ }, 0)
544
+ return total_occu > 0 ? weighted_sum / total_occu : 1
545
+ }
546
+
547
+ let atom_data = $derived.by(() => {
548
+ if (!show_atoms || !structure?.sites) return []
549
+ const render_sites = merge_split_partial_sites(structure.sites, hidden_elements)
271
550
  return render_sites.flatMap(({ site_idx, site, is_image_atom }) => {
272
- const orig_idx = get_orig_site_idx(site, site_idx);
273
- // Skip sites with hidden property values
274
- const prop_val = property_colors?.values[orig_idx];
275
- if (prop_val !== undefined && hidden_prop_vals.has(prop_val))
276
- return [];
277
- // Calculate radius: same_size > site override > element override > default
278
- // All radii scale uniformly with atom_radius for consistent slider behavior
279
- const base_radius = same_size_atoms
280
- ? 1
281
- : site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site);
282
- const radius = base_radius * atom_radius;
283
- // Use property color if available (e.g. coordination number, Wyckoff position)
284
- // Otherwise, each species gets its own element color (important for disordered sites)
285
- const site_property_color = property_colors?.colors[orig_idx];
286
- const visible_species = site.species.filter(({ element }) => !hidden_elements.has(element));
287
- const slice_geometry = compute_slice_geometry(visible_species);
288
- return slice_geometry.map((slice_data) => {
289
- return {
290
- site_idx,
291
- element: slice_data.element,
292
- occupancy: slice_data.occupancy,
293
- position: site.xyz,
294
- radius,
295
- color: site_property_color ?? colors.element?.[slice_data.element],
296
- has_partial_occupancy: slice_data.occupancy < 1,
297
- start_phi: slice_data.start_phi,
298
- end_phi: slice_data.end_phi,
299
- phi_length: slice_data.phi_length,
300
- render_start_cap: slice_data.render_start_cap,
301
- render_end_cap: slice_data.render_end_cap,
302
- is_image_atom,
303
- };
304
- });
305
- });
306
- });
307
- let filtered_bond_pairs = $derived.by(() => {
308
- if (!structure?.sites)
309
- return bond_pairs;
310
- const is_site_visible = (site_idx) => {
311
- const site = structure.sites[site_idx];
312
- const has_visible_element = site?.species.some(({ element }) => !hidden_elements.has(element));
313
- const orig_idx = get_orig_site_idx(site, site_idx);
314
- const prop_val = property_colors?.values[orig_idx];
315
- const prop_visible = prop_val === undefined ||
316
- !hidden_prop_vals.has(prop_val);
317
- return has_visible_element && prop_visible;
318
- };
551
+ const orig_idx = get_orig_site_idx(site, site_idx)
552
+
553
+ // Skip sites with hidden property values
554
+ const prop_val = property_colors?.values[orig_idx]
555
+ if (prop_val !== undefined && hidden_prop_vals.has(prop_val)) return []
556
+
557
+ // Calculate radius: same_size > site override > element override > default
558
+ // All radii scale uniformly with atom_radius for consistent slider behavior
559
+ const base_radius = same_size_atoms
560
+ ? 1
561
+ : site_radius_overrides?.get(site_idx) ?? calc_weighted_radius(site)
562
+ const radius = base_radius * atom_radius
563
+
564
+ // Use property color if available (e.g. coordination number, Wyckoff position)
565
+ // Otherwise, each species gets its own element color (important for disordered sites)
566
+ const site_property_color = property_colors?.colors[orig_idx]
567
+
568
+ const visible_species = site.species.filter(({ element }) =>
569
+ !hidden_elements.has(element)
570
+ )
571
+ const slice_geometry = compute_slice_geometry(visible_species)
572
+ return slice_geometry.map((slice_data) => {
573
+ return {
574
+ site_idx,
575
+ element: slice_data.element,
576
+ occupancy: slice_data.occupancy,
577
+ position: site.xyz,
578
+ radius,
579
+ color: site_property_color ?? colors.element?.[slice_data.element],
580
+ has_partial_occupancy: slice_data.occupancy < 1,
581
+ start_phi: slice_data.start_phi,
582
+ end_phi: slice_data.end_phi,
583
+ phi_length: slice_data.phi_length,
584
+ render_start_cap: slice_data.render_start_cap,
585
+ render_end_cap: slice_data.render_end_cap,
586
+ is_image_atom,
587
+ }
588
+ })
589
+ })
590
+ })
591
+
592
+ // Shared visibility check: site has at least one non-hidden element and
593
+ // its property value (if any) isn't hidden. Used by both bond and vector filtering.
594
+ const is_site_visible = (site_idx: number): boolean => {
595
+ if (!structure?.sites) return false
596
+ const site = structure.sites[site_idx]
597
+ const has_visible_element = site?.species.some(({ element }) =>
598
+ !hidden_elements.has(element)
599
+ )
600
+ const orig_idx = get_orig_site_idx(site, site_idx)
601
+ const prop_val = property_colors?.values[orig_idx]
602
+ const prop_visible = prop_val === undefined ||
603
+ !hidden_prop_vals.has(prop_val)
604
+ return has_visible_element && prop_visible
605
+ }
606
+
607
+ let filtered_bond_pairs = $derived.by(() => {
608
+ if (!structure?.sites) return bond_pairs
609
+
319
610
  // Build set of removed bond keys for efficient lookup
320
- const removed_keys = new Set(removed_bonds.map(([idx_i, idx_j]) => get_bond_key(idx_i, idx_j)));
611
+ const removed_keys = new Set(
612
+ removed_bonds.map(([idx_i, idx_j]) => get_bond_key(idx_i, idx_j)),
613
+ )
614
+
321
615
  // Filter calculated bonds: exclude removed and hidden
322
616
  const calculated = bond_pairs.filter(({ site_idx_1, site_idx_2 }) => {
323
- if (removed_keys.has(get_bond_key(site_idx_1, site_idx_2)))
324
- return false;
325
- return is_site_visible(site_idx_1) && is_site_visible(site_idx_2);
326
- });
617
+ if (removed_keys.has(get_bond_key(site_idx_1, site_idx_2))) return false
618
+ return is_site_visible(site_idx_1) && is_site_visible(site_idx_2)
619
+ })
620
+
327
621
  // Create BondPair objects for manually added bonds
328
- const added = added_bonds
329
- .map(([idx_i, idx_j]) => {
330
- if (!is_site_visible(idx_i) || !is_site_visible(idx_j))
331
- return null;
332
- const site1 = structure.sites[idx_i];
333
- const site2 = structure.sites[idx_j];
334
- if (!site1 || !site2)
335
- return null;
336
- const pos_1 = site1.xyz;
337
- const pos_2 = site2.xyz;
338
- const dist = math.euclidean_dist(pos_1, pos_2);
622
+ const added: BondPair[] = added_bonds
623
+ .map(([idx_i, idx_j]) => {
624
+ if (!is_site_visible(idx_i) || !is_site_visible(idx_j)) return null
625
+ const site1 = structure.sites[idx_i]
626
+ const site2 = structure.sites[idx_j]
627
+ if (!site1 || !site2) return null
628
+
629
+ const pos_1 = site1.xyz
630
+ const pos_2 = site2.xyz
631
+ const dist = math.euclidean_dist(pos_1, pos_2)
632
+
339
633
  return {
340
- pos_1,
341
- pos_2,
342
- site_idx_1: idx_i,
343
- site_idx_2: idx_j,
344
- bond_length: dist,
345
- strength: 1.0,
346
- transform_matrix: compute_bond_transform(pos_1, pos_2),
347
- };
348
- })
349
- .filter((bond) => bond !== null);
350
- return [...calculated, ...added];
351
- });
352
- let instanced_bond_groups = $derived.by(() => {
353
- if (!structure?.sites || filtered_bond_pairs.length === 0)
354
- return [];
634
+ pos_1,
635
+ pos_2,
636
+ site_idx_1: idx_i,
637
+ site_idx_2: idx_j,
638
+ bond_length: dist,
639
+ strength: 1.0,
640
+ transform_matrix: compute_bond_transform(pos_1, pos_2),
641
+ }
642
+ })
643
+ .filter((bond): bond is BondPair => bond !== null)
644
+
645
+ return [...calculated, ...added]
646
+ })
647
+
648
+ let instanced_bond_groups = $derived.by(() => {
649
+ if (!structure?.sites || filtered_bond_pairs.length === 0) return []
650
+
355
651
  const group = {
356
- thickness: bond_thickness,
357
- ambient_light,
358
- directional_light,
359
- instances: [],
360
- };
652
+ thickness: bond_thickness,
653
+ ambient_light,
654
+ directional_light,
655
+ instances: [] as {
656
+ matrix: Float32Array
657
+ color_start: string
658
+ color_end: string
659
+ }[],
660
+ }
661
+
361
662
  for (const bond_data of filtered_bond_pairs) {
362
- const site_a = structure.sites[bond_data.site_idx_1];
363
- const site_b = structure.sites[bond_data.site_idx_2];
364
- const get_majority_color = (site) => {
365
- if (!site?.species || site.species.length === 0)
366
- return bond_color;
367
- const majority_species = site.species.reduce((max, spec) => spec.occu > max.occu ? spec : max);
368
- return colors.element?.[majority_species.element] || bond_color;
369
- };
370
- const color_start = get_majority_color(site_a);
371
- const color_end = get_majority_color(site_b);
372
- const instance = { matrix: bond_data.transform_matrix, color_start, color_end };
373
- group.instances.push(instance);
663
+ const site_a = structure.sites[bond_data.site_idx_1]
664
+ const site_b = structure.sites[bond_data.site_idx_2]
665
+
666
+ const get_majority_color = (site: typeof site_a) => {
667
+ if (!site?.species || site.species.length === 0) return bond_color
668
+ const majority_species = site.species.reduce((max, spec) =>
669
+ spec.occu > max.occu ? spec : max
670
+ )
671
+ return colors.element?.[majority_species.element] || bond_color
672
+ }
673
+
674
+ const color_start = get_majority_color(site_a)
675
+ const color_end = get_majority_color(site_b)
676
+ const instance = { matrix: bond_data.transform_matrix, color_start, color_end }
677
+ group.instances.push(instance)
374
678
  }
375
- return group.instances.length > 0 ? [group] : [];
376
- });
377
- let radius_by_site_idx = $derived.by(() => {
378
- const map = new SvelteMap();
679
+
680
+ return group.instances.length > 0 ? [group] : []
681
+ })
682
+
683
+ let radius_by_site_idx = $derived.by(() => {
684
+ const map = new SvelteMap<number, number>()
379
685
  for (const atom of atom_data) {
380
- if (!map.has(atom.site_idx))
381
- map.set(atom.site_idx, atom.radius);
686
+ if (!map.has(atom.site_idx)) map.set(atom.site_idx, atom.radius)
382
687
  }
383
- return map;
384
- });
385
- // Get radius for a site (for highlight fallback when site is hidden/filtered)
386
- // Checks site_radius_overrides first for consistency with visible atoms
387
- const get_site_radius = (site, site_idx) => {
688
+ return map
689
+ })
690
+
691
+ // Get radius for a site (for highlight fallback when site is hidden/filtered)
692
+ // Checks site_radius_overrides first for consistency with visible atoms
693
+ const get_site_radius = (site: Site, site_idx: number | null): number => {
388
694
  const override = site_idx !== null
389
- ? site_radius_overrides?.get(site_idx)
390
- : undefined;
391
- const base_radius = same_size_atoms ? 1 : override ?? calc_weighted_radius(site);
392
- return base_radius * atom_radius;
393
- };
394
- // Interpolate between spin-down (#3498db blue) and spin-up (#e74c3c red)
395
- // based on the z-component direction of a magnetic vector
396
- function spin_direction_color(vec) {
397
- const mag = Math.hypot(...vec);
398
- const z_frac = mag > 1e-10 ? (vec[2] / mag + 1) / 2 : 0.5; // 0=down, 1=up
399
- const red = Math.round(52 + (231 - 52) * z_frac);
400
- const grn = Math.round(152 + (76 - 152) * z_frac);
401
- const blu = Math.round(219 + (60 - 219) * z_frac);
402
- return `#${red.toString(16).padStart(2, `0`)}${grn.toString(16).padStart(2, `0`)}${blu.toString(16).padStart(2, `0`)}`;
403
- }
404
- // Extract per-site vectors from force, magmom, or spin properties.
405
- // For magmom/spin, color interpolates between spin-up (red) and spin-down (blue).
406
- let force_data = $derived.by(() => {
407
- if (!show_force_vectors || !structure?.sites)
408
- return [];
409
- return structure.sites
410
- .map((site) => {
411
- const info = get_site_vector_info(site);
412
- if (!info)
413
- return null;
414
- let arrow_color;
415
- if (info.key !== `force`) {
416
- arrow_color = spin_direction_color(info.vec);
695
+ ? site_radius_overrides?.get(site_idx)
696
+ : undefined
697
+ const base_radius = same_size_atoms ? 1 : override ?? calc_weighted_radius(site)
698
+ return base_radius * atom_radius
699
+ }
700
+
701
+ // Interpolate between spin-down (#3498db blue) and spin-up (#e74c3c red)
702
+ // based on the z-component direction of a magnetic vector
703
+ function spin_direction_color(vec: Vec3): string {
704
+ const mag = Math.hypot(...vec)
705
+ const z_frac = mag > 1e-10 ? (vec[2] / mag + 1) / 2 : 0.5 // 0=down, 1=up
706
+ const red = Math.round(52 + (231 - 52) * z_frac)
707
+ const grn = Math.round(152 + (76 - 152) * z_frac)
708
+ const blu = Math.round(219 + (60 - 219) * z_frac)
709
+ return `#${red.toString(16).padStart(2, `0`)}${
710
+ grn.toString(16).padStart(2, `0`)
711
+ }${blu.toString(16).padStart(2, `0`)}`
712
+ }
713
+
714
+ // Build one arrow layer per visible vector key. Auto-scales the longest
715
+ // vector to 1.8× char_atom_spacing (cube root of volume per atom).
716
+ // When vector_normalize is on, effective_max is 1 so all arrows get equal length.
717
+ // Single active key preserves legacy coloring (element for force,
718
+ // spin-direction for magmom/spin). Multiple keys use flat palette colors.
719
+ let vector_layers = $derived.by(() => {
720
+ if (!structure?.sites) return []
721
+ const keys = get_structure_vector_keys(structure)
722
+ const active_keys = keys.filter((key) => vector_configs[key]?.visible !== false)
723
+ if (active_keys.length === 0) return []
724
+
725
+ // Build per-site lookup; skip hidden sites so they don't contribute
726
+ // arrows or affect autoscaling. null entries = hidden site.
727
+ const active_set = new Set(active_keys)
728
+ let max_mag = 0
729
+ const site_vec_maps = structure.sites.map((site, site_idx) => {
730
+ if (!is_site_visible(site_idx)) return null
731
+ const map = new SvelteMap<string, Vec3>()
732
+ for (const { key, vec } of get_all_site_vectors(site)) {
733
+ map.set(key, vec)
734
+ if (active_set.has(key)) {
735
+ max_mag = Math.max(max_mag, Math.hypot(...vec))
736
+ }
737
+ }
738
+ return map
739
+ })
740
+
741
+ // When normalize is on, treat all magnitudes as 1 so arrows have equal length
742
+ const effective_max = vector_normalize ? 1 : max_mag
743
+ const auto_scale = effective_max > 1e-10
744
+ ? (char_atom_spacing * 1.8) / effective_max
745
+ : 1
746
+ const is_single = active_keys.length === 1
747
+ const effective_global_scale = auto_scale * vector_scale
748
+
749
+ // When vector_origin_gap > 0 and multiple vectors exist at a site,
750
+ // arrange arrow origins on a regular polygon centered on the atom, in a
751
+ // plane perpendicular to the mean vector direction. The gap is a fraction
752
+ // of the visual atom radius (0 = center, 0.5 = halfway to surface).
753
+ // get_site_radius() returns the uniform scale applied to SphereGeometry(0.5),
754
+ // so visual_radius = get_site_radius() * 0.5.
755
+ const site_offsets = (vector_origin_gap > 0 && !is_single)
756
+ ? structure.sites.map((site, site_idx) => {
757
+ const vec_map = site_vec_maps[site_idx]
758
+ if (!vec_map) return null
759
+ const site_keys = active_keys.filter((key) => vec_map.has(key))
760
+ const n_keys = site_keys.length
761
+ if (n_keys <= 1) return null
762
+ const visual_radius = get_site_radius(site, site_idx) * 0.5
763
+ const gap_abs = vector_origin_gap * visual_radius
764
+ let mean: Vec3 = [0, 0, 0]
765
+ for (const key of site_keys) {
766
+ const vec = vec_map.get(key)
767
+ if (vec) mean = math.add(mean, math.normalize_vec3(vec)) as Vec3
417
768
  }
418
- else {
419
- const majority_element = site.species.length > 0
420
- ? site.species.reduce((max, spec) => spec.occu > max.occu ? spec : max)
421
- .element
422
- : undefined;
423
- arrow_color = (majority_element && colors.element?.[majority_element]) ||
424
- force_color;
769
+ const mean_dir = math.normalize_vec3(mean, [0, 1, 0] as Vec3)
770
+ const [u_vec, v_vec] = math.compute_in_plane_basis(mean_dir)
771
+ const offsets = new SvelteMap<string, Vec3>()
772
+ for (const [idx, key] of site_keys.entries()) {
773
+ const angle = (2 * Math.PI * idx) / n_keys
774
+ const dx = math.scale(u_vec, gap_abs * Math.cos(angle)) as Vec3
775
+ const dy = math.scale(v_vec, gap_abs * Math.sin(angle)) as Vec3
776
+ offsets.set(key, math.add(dx, dy) as Vec3)
425
777
  }
426
- return {
427
- position: site.xyz,
428
- vector: info.vec,
429
- scale: force_scale,
778
+ return offsets
779
+ })
780
+ : null
781
+
782
+ const mag_interpolator = get_d3_interpolator(vector_color_scale)
783
+
784
+ return active_keys.map((key, layer_idx) => {
785
+ const layer_cfg = vector_configs[key]
786
+ const layer_scale = effective_global_scale * (layer_cfg?.scale ?? 1.0)
787
+ const layer_color = layer_cfg?.color ??
788
+ VECTOR_PALETTE[layer_idx % VECTOR_PALETTE.length]
789
+
790
+ const arrows = structure.sites
791
+ .map((site, site_idx) => {
792
+ const vec_map = site_vec_maps[site_idx]
793
+ if (!vec_map) return null
794
+ const vec = vec_map.get(key)
795
+ if (!vec) return null
796
+
797
+ // Resolve color mode: explicit per-key color always wins,
798
+ // then multi-key uses palette, then mode-based coloring
799
+ let arrow_color: string
800
+ if (layer_cfg?.color) {
801
+ arrow_color = layer_cfg.color
802
+ } else if (!is_single) arrow_color = layer_color
803
+ else {
804
+ const effective_mode = vector_color_mode === `auto`
805
+ ? (key.startsWith(`magmom`) || key.startsWith(`spin`)
806
+ ? `spin_direction`
807
+ : `element`)
808
+ : vector_color_mode
809
+ if (effective_mode === `magnitude`) {
810
+ const mag = Math.hypot(...vec)
811
+ const norm = max_mag > 1e-10 ? mag / max_mag : 0
812
+ arrow_color = mag_interpolator(norm)
813
+ } else if (effective_mode === `spin_direction`) {
814
+ arrow_color = spin_direction_color(vec)
815
+ } else if (effective_mode === `uniform`) {
816
+ arrow_color = vector_color
817
+ } else {
818
+ const majority_element = site.species.length > 0
819
+ ? site.species.reduce((max, spec) =>
820
+ spec.occu > max.occu ? spec : max
821
+ ).element
822
+ : undefined
823
+ arrow_color =
824
+ (majority_element && colors.element?.[majority_element]) ||
825
+ vector_color
826
+ }
827
+ }
828
+
829
+ const offset = site_offsets?.[site_idx]?.get(key)
830
+ const position = offset ? math.add(site.xyz, offset) as Vec3 : site.xyz
831
+ const arrow_vec = vector_normalize ? math.normalize_vec3(vec) : vec
832
+
833
+ return {
834
+ site_idx,
835
+ position,
836
+ vector: arrow_vec,
837
+ scale: layer_scale,
430
838
  color: arrow_color,
431
- };
839
+ }
840
+ })
841
+ .filter((item): item is NonNullable<typeof item> => item !== null)
842
+
843
+ return { key, arrows }
432
844
  })
433
- .filter((item) => item !== null);
434
- });
435
- let instanced_atom_groups = $derived(Object.values(atom_data
436
- .filter((atom) => !atom.has_partial_occupancy)
437
- .reduce((groups, atom) => {
438
- const { element, radius, color, is_image_atom } = atom;
439
- // Separate image atoms into their own groups for distinct styling in edit-atoms mode
440
- const key = `${element}-${format_num(radius, `.3~`)}-${color}-${is_image_atom ? `img` : `base`}`;
441
- const bucket = groups[key] ||
442
- (groups[key] = { element, radius, color, is_image_atom, atoms: [] });
443
- bucket.atoms.push(atom);
444
- return groups;
445
- }, {})));
446
- let unique_instanced_atoms = $derived(Object.values(instanced_atom_groups
447
- .flatMap((group) => group.atoms)
448
- .reduce((acc, atom) => {
449
- acc[atom.site_idx] = atom;
450
- return acc;
451
- }, {})));
452
- let gizmo_props = $derived.by(() => {
453
- const axis_options = Object.fromEntries([...AXIS_COLORS, ...NEG_AXIS_COLORS].map(([axis, color, hover_color]) => [
845
+ })
846
+
847
+ let instanced_atom_groups = $derived(
848
+ Object.values(
849
+ atom_data
850
+ .filter((atom) => !atom.has_partial_occupancy)
851
+ .reduce(
852
+ (groups, atom) => {
853
+ const { element, radius, color, is_image_atom } = atom
854
+ // Separate image atoms into their own groups for distinct styling in edit-atoms mode
855
+ const key = `${element}-${format_num(radius, `.3~`)}-${color}-${
856
+ is_image_atom ? `img` : `base`
857
+ }`
858
+ const bucket = groups[key] ||
859
+ (groups[key] = { element, radius, color, is_image_atom, atoms: [] })
860
+ bucket.atoms.push(atom)
861
+ return groups
862
+ },
863
+ {} as Record<string, InstancedAtomGroup>,
864
+ ),
865
+ ),
866
+ )
867
+
868
+ let unique_instanced_atoms = $derived(
869
+ Object.values(
870
+ instanced_atom_groups
871
+ .flatMap((group) => group.atoms)
872
+ .reduce((acc, atom) => {
873
+ acc[atom.site_idx] = atom
874
+ return acc
875
+ }, {} as Record<number, (typeof atom_data)[number]>),
876
+ ),
877
+ )
878
+
879
+ let gizmo_props = $derived.by(() => {
880
+ const axis_options = Object.fromEntries(
881
+ [...AXIS_COLORS, ...NEG_AXIS_COLORS].map(([axis, color, hover_color]) => [
454
882
  axis,
455
883
  {
456
- color,
457
- labelColor: `#111`,
458
- opacity: axis.startsWith(`n`) ? 0.9 : 0.8,
459
- hover: {
460
- color: hover_color,
461
- labelColor: `#222222`,
462
- opacity: axis.startsWith(`n`) ? 1 : 0.9,
463
- },
884
+ color,
885
+ labelColor: `#111`,
886
+ opacity: axis.startsWith(`n`) ? 0.9 : 0.8,
887
+ hover: {
888
+ color: hover_color,
889
+ labelColor: `#222222`,
890
+ opacity: axis.startsWith(`n`) ? 1 : 0.9,
891
+ },
464
892
  },
465
- ]));
893
+ ]),
894
+ )
466
895
  return {
467
- background: { enabled: false },
468
- className: `responsive-gizmo`,
469
- ...axis_options,
470
- ...(typeof gizmo === `boolean` ? {} : gizmo),
471
- offset: { left: 5, bottom: 5 },
472
- };
473
- });
474
- let orbit_controls_props = $derived({
896
+ background: { enabled: false },
897
+ className: `responsive-gizmo`,
898
+ ...axis_options,
899
+ ...(typeof gizmo === `boolean` ? {} : gizmo),
900
+ offset: { left: 5, bottom: 5 },
901
+ }
902
+ })
903
+
904
+ let orbit_controls_props = $derived({
475
905
  position: [0, 0, 0],
476
906
  enableRotate: rotate_speed > 0,
477
907
  rotateSpeed: rotate_speed,
@@ -488,20 +918,20 @@ let orbit_controls_props = $derived({
488
918
  enableDamping: Boolean(rotation_damping),
489
919
  dampingFactor: rotation_damping,
490
920
  onstart: () => {
491
- camera_is_moving = true;
492
- hovered_idx = null;
921
+ camera_is_moving = true
922
+ hovered_idx = null
493
923
  },
494
924
  onend: () => {
495
- camera_is_moving = false;
925
+ camera_is_moving = false
496
926
  },
497
- });
498
- let measure_line_color = $derived.by(() => {
499
- if (typeof window === `undefined`)
500
- return;
501
- const root_styles = getComputedStyle(document.documentElement);
502
- const text_color = root_styles.getPropertyValue(`--text-color`).trim();
503
- return text_color || `#808080`;
504
- });
927
+ })
928
+
929
+ let measure_line_color = $derived.by(() => {
930
+ if (typeof window === `undefined`) return
931
+ const root_styles = getComputedStyle(document.documentElement)
932
+ const text_color = root_styles.getPropertyValue(`--text-color`).trim()
933
+ return text_color || `#808080`
934
+ })
505
935
  </script>
506
936
 
507
937
  {#snippet bond_instanced_mesh_snippet(
@@ -530,11 +960,11 @@ let measure_line_color = $derived.by(() => {
530
960
  {#if site.species.length === 1}
531
961
  {site.species[0].element}{#if show_site_indices}-{site_idx + 1}{/if}
532
962
  {:else}
533
- {@html site.species.map((spec) =>
963
+ {@html sanitize_html(site.species.map((spec) =>
534
964
  `${spec.element}<sub>${
535
965
  format_num(spec.occu, `.3~`).replace(`0.`, `.`)
536
966
  }</sub>`
537
- ).join(``)}{#if show_site_indices}-{
967
+ ).join(``))}{#if show_site_indices}-{
538
968
  site_idx + 1
539
969
  }{/if}
540
970
  {/if}
@@ -588,6 +1018,7 @@ let measure_line_color = $derived.by(() => {
588
1018
  {@const edit_mode_image = measure_mode === `edit-atoms` && is_image_atom}
589
1019
  <extras.InstancedMesh
590
1020
  key="{element}-{format_num(radius, `.3~`)}-{color}-{is_image_atom ? `img` : `base`}-{edit_mode_image}"
1021
+ limit={atoms.length}
591
1022
  range={atoms.length}
592
1023
  frustumCulled={false}
593
1024
  >
@@ -718,11 +1149,16 @@ let measure_line_color = $derived.by(() => {
718
1149
  {/if}
719
1150
  {/if}
720
1151
 
721
- {#if force_data.length > 0}
722
- {#each force_data as force (force.position.join(`,`) + force.vector.join(`,`))}
723
- <Arrow {...force} />
1152
+ {#each vector_layers as layer (layer.key)}
1153
+ {#each layer.arrows as arrow (`${layer.key}-${arrow.site_idx}`)}
1154
+ <Arrow
1155
+ {...arrow}
1156
+ shaft_radius={eff_shaft_radius}
1157
+ arrow_head_radius={eff_head_radius}
1158
+ arrow_head_length={eff_head_length}
1159
+ />
724
1160
  {/each}
725
- {/if}
1161
+ {/each}
726
1162
 
727
1163
  <!-- Instanced bond rendering with gradient colors -->
728
1164
  {#if instanced_bond_groups.length > 0}
@@ -891,7 +1327,7 @@ let measure_line_color = $derived.by(() => {
891
1327
  {#if occu !== 1}<span class="occupancy">{
892
1328
  format_num(occu, `.3~f`)
893
1329
  }</span>{/if}
894
- <strong>{element}{@html oxi_str}</strong>
1330
+ <strong>{element}{@html sanitize_html(oxi_str)}</strong>
895
1331
  {#if element_name}<span class="elem-name">{element_name}</span>{/if}
896
1332
  {/each}
897
1333
  </div>