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,100 +1,211 @@
1
- <script lang="ts">import {} from '../colors';
2
- import { get_hill_formula } from '../composition/format';
3
- import { extract_formula_elements } from '../composition/parse';
4
- import TemperatureSlider from '../convex-hull/TemperatureSlider.svelte';
5
- import Icon from '../Icon.svelte';
6
- import { format_num } from '../labels';
7
- import { set_fullscreen_bg, SettingsSection, toggle_fullscreen } from '../layout';
8
- import { convex_hull_2d, cross_3d, merge_coplanar_triangles, normalize_vec3, } from '../math';
9
- import DraggablePane from '../overlays/DraggablePane.svelte';
10
- import { ColorBar, ScatterPlot3DControls } from '../plot';
11
- import { Canvas, T } from '@threlte/core';
12
- import * as extras from '@threlte/extras';
13
- import { scaleLinear } from 'd3-scale';
14
- import { onDestroy, onMount, untrack } from 'svelte';
15
- import { SvelteMap, SvelteSet } from 'svelte/reactivity';
16
- import * as THREE from 'three';
17
- import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
18
- import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js';
19
- import ChemPotScene3D from './ChemPotScene3D.svelte';
20
- import { get_chempot_color_bar_config, make_chempot_color_scale } from './color';
21
- import { apply_element_padding, best_form_energy_for_formula, build_axis_ranges, compute_chempot_diagram, dedup_points, formula_key_from_composition, get_3d_domain_simplexes_and_ann_loc, get_energy_per_atom, get_min_entries_and_el_refs, pad_domain_points, } from './compute';
22
- import { with_hover_pointer } from './pointer';
23
- import { get_projection_source_entries, get_temp_filter_payload, get_valid_temperature, } from './temperature';
24
- import { CHEMPOT_DEFAULTS } from './types';
25
- let { entries = [], config = {}, width = $bindable(800), height = $bindable(600),
26
- // Auto-corrected to a valid available temperature when needed.
27
- temperature = $bindable(undefined), interpolate_temperature = CHEMPOT_DEFAULTS.interpolate_temperature, max_interpolation_gap = CHEMPOT_DEFAULTS.max_interpolation_gap, hover_info = $bindable(null), render_local_tooltip = true, } = $props();
28
- let formal_chempots_override = $state(null);
29
- let label_stable_override = $state(null);
30
- let element_padding_override = $state(null);
31
- let default_min_limit_override = $state(null);
32
- let draw_formula_meshes_override = $state(null);
33
- let draw_formula_lines_override = $state(null);
34
- const formal_chempots = $derived(formal_chempots_override ??
35
- (config.formal_chempots ?? CHEMPOT_DEFAULTS.formal_chempots));
36
- const label_stable = $derived(label_stable_override ?? (config.label_stable ?? CHEMPOT_DEFAULTS.label_stable));
37
- const element_padding = $derived(element_padding_override ??
38
- (config.element_padding ?? CHEMPOT_DEFAULTS.element_padding));
39
- const default_min_limit = $derived(default_min_limit_override ??
40
- (config.default_min_limit ?? CHEMPOT_DEFAULTS.default_min_limit));
41
- let formulas_to_draw_override = $state(null);
42
- const formulas_to_draw = $derived(formulas_to_draw_override ?? (config.formulas_to_draw ?? []));
43
- const draw_formula_meshes = $derived(draw_formula_meshes_override ??
44
- (config.draw_formula_meshes ?? CHEMPOT_DEFAULTS.draw_formula_meshes));
45
- const draw_formula_lines = $derived(draw_formula_lines_override ??
46
- (config.draw_formula_lines ?? CHEMPOT_DEFAULTS.draw_formula_lines));
47
- let color_mode_override = $state(null);
48
- let color_scale_override = $state(null);
49
- let reverse_color_scale_override = $state(null);
50
- const color_mode = $derived(color_mode_override ?? (config.color_mode ?? `arity`));
51
- const color_scale = $derived(color_scale_override ?? (config.color_scale ?? CHEMPOT_DEFAULTS.color_scale));
52
- const reverse_color_scale = $derived(reverse_color_scale_override ??
53
- (config.reverse_color_scale ?? CHEMPOT_DEFAULTS.reverse_color_scale));
54
- const show_tooltip = $derived(config.show_tooltip ?? CHEMPOT_DEFAULTS.show_tooltip);
55
- const tooltip_detail_level = $derived(config.tooltip_detail_level ?? CHEMPOT_DEFAULTS.tooltip_detail_level);
56
- const formula_colors = $derived(config.formula_colors?.length
57
- ? config.formula_colors
58
- : CHEMPOT_DEFAULTS.formula_colors);
59
- function normalize_projection_triplet(maybe_triplet, available_elements) {
60
- if (!maybe_triplet || maybe_triplet.length !== 3)
61
- return null;
62
- const deduped = Array.from(new Set(maybe_triplet));
63
- if (deduped.length !== 3)
64
- return null;
65
- if (deduped.some((element) => !available_elements.includes(element)))
66
- return null;
67
- return deduped;
68
- }
69
- let wrapper = $state();
70
- let fullscreen = $state(false);
71
- let export_pane_open = $state(false);
72
- let formula_picker_open = $state(false);
73
- let copy_status = $state(false);
74
- let copy_timeout_id = null;
75
- let container_width = $state(0);
76
- let container_height = $state(0);
77
- const base_aspect_ratio = $derived(height > 0 && width > 0 ? height / width : 1);
78
- const render_width = $derived(container_width > 0 ? container_width : width);
79
- const render_height = $derived(fullscreen
80
- ? (container_height > 0 ? container_height : height)
81
- : Math.round(render_width * base_aspect_ratio));
82
- let mounted = $state(false);
83
- onMount(() => mounted = true);
84
- let fixed_container_element = $state(null);
85
- let fixed_container_rect = $state(null);
86
- let fixed_container_frame_id = null;
87
- let orbit_controls_ref = $state(undefined);
88
- // Backside tracking: axes/ticks/labels render on the far side from the camera
89
- // back[i] = backside data coordinate value for data axis i
90
- // Matches ScatterPlot3DScene pattern where pos tracks the opposite side from camera
91
- let back = $state([0, 0, 0]);
92
- // Outward offset signs for tick/label placement (away from bounding box)
93
- let out_x = $state(-1); // sign for Three.js X (data axis 1) direction
94
- let out_y = $state(-1); // sign for Three.js Y (data axis 2) direction
95
- let camera_projection = $state(`orthographic`);
96
- let auto_rotate = $state(0);
97
- let display = $state({
1
+ <script lang="ts">
2
+ import { type D3InterpolateName } from '../colors'
3
+ import {
4
+ get_electro_neg_formula,
5
+ get_formula_label_segments,
6
+ type FormulaLabelSegment,
7
+ } from '../composition/format'
8
+ import { extract_formula_elements } from '../composition/parse'
9
+ import TemperatureSlider from '../convex-hull/TemperatureSlider.svelte'
10
+ import type { PhaseData } from '../convex-hull/types'
11
+ import Spinner from '../feedback/Spinner.svelte'
12
+ import Icon from '../Icon.svelte'
13
+ import { format_num } from '../labels'
14
+ import { set_fullscreen_bg, SettingsSection, toggle_fullscreen } from '../layout'
15
+ import type { Vec2, Vec3 } from '../math'
16
+ import {
17
+ convex_hull_2d,
18
+ cross_3d,
19
+ merge_coplanar_triangles,
20
+ normalize_vec3,
21
+ } from '../math'
22
+ import DraggablePane from '../overlays/DraggablePane.svelte'
23
+ import { ColorBar, ScatterPlot3DControls } from '../plot'
24
+ import {
25
+ constrain_tooltip_position,
26
+ pad_rect,
27
+ rects_overlap,
28
+ } from '../plot/layout'
29
+ import type {
30
+ AxisConfig3D,
31
+ CameraProjection3D,
32
+ DataSeries3D,
33
+ DisplayConfig3D,
34
+ } from '../plot/types'
35
+ import { Canvas, T } from '@threlte/core'
36
+ import * as extras from '@threlte/extras'
37
+ import { scaleLinear } from 'd3-scale'
38
+ import { onDestroy, onMount, untrack } from 'svelte'
39
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
40
+ import * as THREE from 'three'
41
+ import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
42
+ import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
43
+ import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js'
44
+ import { compute_chempot_async } from './async-compute.svelte'
45
+ import ChemPotScene3D from './ChemPotScene3D.svelte'
46
+ import { get_chempot_color_bar_config, make_chempot_color_scale } from './color'
47
+ import {
48
+ apply_element_padding,
49
+ bbox_diagonal,
50
+ best_form_energy_for_formula,
51
+ build_axis_ranges,
52
+ dedup_points,
53
+ formula_key_from_composition,
54
+ get_3d_domain_simplexes_and_ann_loc,
55
+ get_energy_per_atom,
56
+ get_min_entries_and_el_refs,
57
+ get_ternary_combinations,
58
+ get_visible_domain_labels,
59
+ pad_domain_points,
60
+ scale_to_font_range,
61
+ } from './compute'
62
+ import { with_hover_pointer } from './pointer'
63
+ import {
64
+ get_projection_source_entries,
65
+ get_temp_filter_payload,
66
+ get_valid_temperature,
67
+ } from './temperature'
68
+ import type {
69
+ ChemPotColorMode,
70
+ ChemPotDiagramConfig,
71
+ ChemPotDiagramData,
72
+ ChemPotHoverInfo,
73
+ ChemPotHoverInfo3D,
74
+ } from './types'
75
+ import { CHEMPOT_DEFAULTS } from './types'
76
+
77
+ let {
78
+ entries = [],
79
+ config = {},
80
+ width = $bindable(800),
81
+ height = $bindable(600),
82
+ // Auto-corrected to a valid available temperature when needed.
83
+ temperature = $bindable<number | undefined>(undefined),
84
+ interpolate_temperature = CHEMPOT_DEFAULTS.interpolate_temperature,
85
+ max_interpolation_gap = CHEMPOT_DEFAULTS.max_interpolation_gap,
86
+ hover_info = $bindable<ChemPotHoverInfo | null>(null),
87
+ render_local_tooltip = true,
88
+ }: {
89
+ entries: PhaseData[]
90
+ config?: ChemPotDiagramConfig
91
+ width?: number
92
+ height?: number
93
+ temperature?: number
94
+ interpolate_temperature?: boolean
95
+ max_interpolation_gap?: number
96
+ hover_info?: ChemPotHoverInfo | null
97
+ render_local_tooltip?: boolean
98
+ } = $props()
99
+
100
+ let formal_chempots_override = $state<boolean | null>(null)
101
+ let label_stable_override = $state<boolean | null>(null)
102
+ let element_padding_override = $state<number | null>(null)
103
+ let default_min_limit_override = $state<number | null>(null)
104
+ let draw_formula_meshes_override = $state<boolean | null>(null)
105
+ let draw_formula_lines_override = $state<boolean | null>(null)
106
+ const formal_chempots = $derived(
107
+ formal_chempots_override ??
108
+ (config.formal_chempots ?? CHEMPOT_DEFAULTS.formal_chempots),
109
+ )
110
+ const label_stable = $derived(
111
+ label_stable_override ?? (config.label_stable ?? CHEMPOT_DEFAULTS.label_stable),
112
+ )
113
+ const element_padding = $derived(
114
+ element_padding_override ??
115
+ (config.element_padding ?? CHEMPOT_DEFAULTS.element_padding),
116
+ )
117
+ const default_min_limit = $derived(
118
+ default_min_limit_override ??
119
+ (config.default_min_limit ?? CHEMPOT_DEFAULTS.default_min_limit),
120
+ )
121
+ let formulas_to_draw_override = $state<string[] | null>(null)
122
+ const formulas_to_draw = $derived(
123
+ formulas_to_draw_override ?? (config.formulas_to_draw ?? []),
124
+ )
125
+ const draw_formula_meshes = $derived(
126
+ draw_formula_meshes_override ??
127
+ (config.draw_formula_meshes ?? CHEMPOT_DEFAULTS.draw_formula_meshes),
128
+ )
129
+ const draw_formula_lines = $derived(
130
+ draw_formula_lines_override ??
131
+ (config.draw_formula_lines ?? CHEMPOT_DEFAULTS.draw_formula_lines),
132
+ )
133
+ let color_mode_override = $state<ChemPotColorMode | null>(null)
134
+ let color_scale_override = $state<D3InterpolateName | null>(null)
135
+ let reverse_color_scale_override = $state<boolean | null>(null)
136
+ const color_mode = $derived(
137
+ color_mode_override ?? (config.color_mode ?? `arity`),
138
+ )
139
+ const color_scale = $derived(
140
+ color_scale_override ?? (config.color_scale ?? CHEMPOT_DEFAULTS.color_scale),
141
+ )
142
+ const reverse_color_scale = $derived(
143
+ reverse_color_scale_override ??
144
+ (config.reverse_color_scale ?? CHEMPOT_DEFAULTS.reverse_color_scale),
145
+ )
146
+ const show_tooltip = $derived(config.show_tooltip ?? CHEMPOT_DEFAULTS.show_tooltip)
147
+ const tooltip_detail_level = $derived(
148
+ config.tooltip_detail_level ?? CHEMPOT_DEFAULTS.tooltip_detail_level,
149
+ )
150
+ const formula_colors = $derived(
151
+ config.formula_colors?.length
152
+ ? config.formula_colors
153
+ : CHEMPOT_DEFAULTS.formula_colors,
154
+ )
155
+
156
+ function formula_label_segments(formula: string): FormulaLabelSegment[] {
157
+ return get_formula_label_segments(get_electro_neg_formula(formula, true, ``, `.3~s`))
158
+ }
159
+
160
+ function normalize_projection_triplet(
161
+ maybe_triplet: string[] | undefined,
162
+ available_elements: string[],
163
+ ): string[] | null {
164
+ if (!maybe_triplet || maybe_triplet.length !== 3) return null
165
+ const deduped = Array.from(new Set(maybe_triplet))
166
+ if (deduped.length !== 3) return null
167
+ if (deduped.some((element) => !available_elements.includes(element))) return null
168
+ return deduped
169
+ }
170
+
171
+ let wrapper = $state<HTMLDivElement>()
172
+ let fullscreen = $state(false)
173
+ let export_pane_open = $state(false)
174
+ let formula_picker_open = $state(false)
175
+ let controls_open = $state(false)
176
+
177
+ // Mutual exclusion: only one pane open at a time.
178
+ // Separate effects so each reacts to its own pane opening independently —
179
+ // a single $derived ternary would create priority ordering where opening
180
+ // a "lower" pane while a "higher" one is open fails silently.
181
+ $effect(() => { if (export_pane_open) { formula_picker_open = false; controls_open = false } })
182
+ $effect(() => { if (formula_picker_open) { export_pane_open = false; controls_open = false } })
183
+ $effect(() => { if (controls_open) { export_pane_open = false; formula_picker_open = false } })
184
+ let copy_status = $state(false)
185
+ let copy_timeout_id: ReturnType<typeof setTimeout> | null = null
186
+ let container_width = $state(0)
187
+ let container_height = $state(0)
188
+ const base_aspect_ratio = $derived(height > 0 && width > 0 ? height / width : 1)
189
+ const render_width = $derived(container_width > 0 ? container_width : width)
190
+ const render_height = $derived(
191
+ fullscreen
192
+ ? (container_height > 0 ? container_height : height)
193
+ : Math.round(render_width * base_aspect_ratio),
194
+ )
195
+
196
+ let mounted = $state(false)
197
+ onMount(() => mounted = true)
198
+ let orbit_controls_ref = $state<OrbitControls | undefined>(undefined)
199
+ // Backside tracking: axes/ticks/labels render on the far side from the camera
200
+ // back[i] = backside data coordinate value for data axis i
201
+ // Matches ScatterPlot3DScene pattern where pos tracks the opposite side from camera
202
+ let back = $state([0, 0, 0])
203
+ // Outward offset signs for tick/label placement (away from bounding box)
204
+ let out_x = $state(-1) // sign for Three.js X (data axis 1) direction
205
+ let out_y = $state(-1) // sign for Three.js Y (data axis 2) direction
206
+ let camera_projection = $state<CameraProjection3D>(`orthographic`)
207
+ let auto_rotate = $state(0)
208
+ let display = $state<DisplayConfig3D>({
98
209
  show_axes: true,
99
210
  show_grid: true,
100
211
  show_axis_labels: true,
@@ -102,1700 +213,2019 @@ let display = $state({
102
213
  projections: { xy: false, xz: false, yz: false },
103
214
  projection_opacity: 0.15,
104
215
  projection_scale: 0.5,
105
- });
106
- let x_axis = $state({ label: ``, range: [null, null] });
107
- let y_axis = $state({ label: ``, range: [null, null] });
108
- let z_axis = $state({ label: ``, range: [null, null] });
109
- const projection_opacity = $derived(display.projection_opacity ?? 0.15);
110
- // Plotly/pymatgen uses Z-up with x-axis projecting left in isometric view.
111
- // Three.js uses Y-up with X projecting right. To match pymatgen's visual layout:
112
- // data[0] (plotly x, projects left) → Three.js Z (projects left)
113
- // data[1] (plotly y, projects right) → Three.js X (projects right)
114
- // data[2] (plotly z, projects up) → Three.js Y (projects up)
115
- function to_vec3(pt) {
116
- const [x_val, y_val, z_val] = to_render_xyz(pt);
117
- return new THREE.Vector3(x_val, y_val, z_val);
118
- }
119
- // Compute diagram data (requires >= 3 elements for 3D rendering)
120
- const { has_temp_data, available_temperatures, temp_filtered_entries } = $derived(get_temp_filter_payload(entries, temperature, config, {
121
- interpolate_temperature,
122
- max_interpolation_gap,
123
- }));
124
- // Keep bound temperature aligned with available data points.
125
- $effect(() => {
126
- const next_temperature = get_valid_temperature(temperature, has_temp_data, available_temperatures);
127
- if (next_temperature !== temperature)
128
- temperature = next_temperature;
129
- });
130
- const show_temperature_slider = $derived(has_temp_data && available_temperatures.length > 0);
131
- const projection_source_entries = $derived(get_projection_source_entries(entries, temp_filtered_entries));
132
- const all_entry_elements = $derived.by(() => Array.from(new SvelteSet(projection_source_entries.flatMap((entry) => Object.entries(entry.composition)
133
- .filter(([, amount]) => amount > 0)
134
- .map(([element]) => element)))).sort());
135
- const has_multinary_system = $derived(all_entry_elements.length > 3);
136
- let projection_elements_override = $state(null);
137
- const config_projection_elements = $derived(normalize_projection_triplet(config.elements, all_entry_elements));
138
- const projection_elements = $derived.by(() => {
139
- if (all_entry_elements.length < 3)
140
- return [];
216
+ })
217
+ let x_axis = $state<AxisConfig3D>({ label: ``, range: [null, null] })
218
+ let y_axis = $state<AxisConfig3D>({ label: ``, range: [null, null] })
219
+ let z_axis = $state<AxisConfig3D>({ label: ``, range: [null, null] })
220
+ const projection_opacity = $derived(display.projection_opacity ?? 0.15)
221
+
222
+ // Plotly/pymatgen uses Z-up with x-axis projecting left in isometric view.
223
+ // Three.js uses Y-up with X projecting right. To match pymatgen's visual layout:
224
+ // data[0] (plotly x, projects left) → Three.js Z (projects left)
225
+ // data[1] (plotly y, projects right) → Three.js X (projects right)
226
+ // data[2] (plotly z, projects up) Three.js Y (projects up)
227
+ function to_vec3(pt: number[]): THREE.Vector3 {
228
+ const [x_val, y_val, z_val] = to_render_xyz(pt)
229
+ return new THREE.Vector3(x_val, y_val, z_val)
230
+ }
231
+
232
+ // Compute diagram data (requires >= 3 elements for 3D rendering)
233
+ const { has_temp_data, available_temperatures, temp_filtered_entries } = $derived(
234
+ get_temp_filter_payload(entries, temperature, config, {
235
+ interpolate_temperature,
236
+ max_interpolation_gap,
237
+ }),
238
+ )
239
+
240
+ // Keep bound temperature aligned with available data points.
241
+ $effect(() => {
242
+ const next_temperature = get_valid_temperature(
243
+ temperature,
244
+ has_temp_data,
245
+ available_temperatures,
246
+ )
247
+ if (next_temperature !== temperature) temperature = next_temperature
248
+ })
249
+
250
+ const show_temperature_slider = $derived(
251
+ has_temp_data && available_temperatures.length > 0,
252
+ )
253
+
254
+ const projection_source_entries = $derived(
255
+ get_projection_source_entries(entries, temp_filtered_entries),
256
+ )
257
+
258
+ const all_entry_elements = $derived.by(() =>
259
+ Array.from(
260
+ new SvelteSet(
261
+ projection_source_entries.flatMap((entry) =>
262
+ Object.entries(entry.composition)
263
+ .filter(([, amount]) => amount > 0)
264
+ .map(([element]) => element)
265
+ ),
266
+ ),
267
+ ).sort()
268
+ )
269
+ const has_multinary_system = $derived(all_entry_elements.length > 3)
270
+ let projection_elements_override = $state<string[] | null>(null)
271
+ const config_projection_elements = $derived(
272
+ normalize_projection_triplet(config.elements, all_entry_elements),
273
+ )
274
+ const projection_elements = $derived.by(() => {
275
+ if (all_entry_elements.length < 3) return []
141
276
  if (!has_multinary_system) {
142
- return config_projection_elements ?? all_entry_elements.slice(0, 3);
143
- }
144
- const override_projection = normalize_projection_triplet(projection_elements_override ?? undefined, all_entry_elements);
145
- if (override_projection)
146
- return override_projection;
147
- if (config_projection_elements)
148
- return config_projection_elements;
149
- return all_entry_elements.slice(0, 3);
150
- });
151
- const effective_config = $derived({
277
+ return config_projection_elements ?? all_entry_elements.slice(0, 3)
278
+ }
279
+ const override_projection = normalize_projection_triplet(
280
+ projection_elements_override ?? undefined,
281
+ all_entry_elements,
282
+ )
283
+ if (override_projection) return override_projection
284
+ if (config_projection_elements) return config_projection_elements
285
+ return all_entry_elements.slice(0, 3)
286
+ })
287
+ const effective_config = $derived({
152
288
  ...config,
153
289
  elements: projection_elements.length === 3
154
- ? projection_elements
155
- : config.elements,
290
+ ? projection_elements
291
+ : config.elements,
156
292
  formal_chempots,
157
293
  label_stable,
158
294
  element_padding,
159
295
  default_min_limit,
160
296
  draw_formula_meshes,
161
297
  draw_formula_lines,
162
- });
163
- const diagram_data = $derived.by(() => {
164
- if (temp_filtered_entries.length < 3)
165
- return null;
166
- try {
167
- const data = compute_chempot_diagram(temp_filtered_entries, effective_config);
168
- return data.elements.length >= 3 ? data : null;
169
- }
170
- catch (err) {
171
- console.error(`ChemPotDiagram3D:`, err);
172
- return null;
173
- }
174
- });
175
- const plot_elements = $derived(diagram_data?.elements ?? projection_elements);
176
- const is_projection_mode = $derived(plot_elements.length > 0 &&
177
- plot_elements.length < all_entry_elements.length &&
178
- plot_elements.every((element) => all_entry_elements.includes(element)));
179
- const projection_presets = $derived.by(() => {
180
- const presets = [];
181
- const seen_triplets = new SvelteSet();
182
- const add_triplet = (candidate) => {
183
- if (!candidate)
184
- return;
185
- const key = candidate.join(`|`);
186
- if (seen_triplets.has(key))
187
- return;
188
- seen_triplets.add(key);
189
- presets.push(candidate);
190
- };
191
- add_triplet(config_projection_elements);
192
- add_triplet(plot_elements.length === 3 ? plot_elements : null);
193
- if (all_entry_elements.length >= 3) {
194
- const n_elements = all_entry_elements.length;
195
- for (let first_idx = 0; first_idx < n_elements; first_idx++) {
196
- for (let second_idx = first_idx + 1; second_idx < n_elements; second_idx++) {
197
- for (let third_idx = second_idx + 1; third_idx < n_elements; third_idx++) {
198
- add_triplet([
199
- all_entry_elements[first_idx],
200
- all_entry_elements[second_idx],
201
- all_entry_elements[third_idx],
202
- ]);
203
- if (presets.length >= 12)
204
- return presets;
205
- }
206
- }
207
- }
208
- }
209
- return presets;
210
- });
211
- const current_projection_key = $derived(plot_elements.join(`|`));
212
- let formula_filter_query = $state(``);
213
- const available_formulas = $derived.by(() => Object.keys(diagram_data?.domains ?? {}).sort());
214
- const filtered_formulas = $derived.by(() => {
215
- const query = formula_filter_query.trim().toLowerCase();
216
- if (!query)
217
- return available_formulas;
218
- return available_formulas.filter((formula) => formula.toLowerCase().includes(query));
219
- });
220
- const render_domains = $derived.by(() => {
221
- if (!diagram_data || plot_elements.length < 2)
222
- return [];
223
- const dim = diagram_data.elements.length;
224
- const indices = Array.from({ length: dim }, (_, idx) => idx);
298
+ })
299
+ let diagram_data = $state<ChemPotDiagramData | null>(null)
300
+ let diagram_computing = $state(false)
301
+ $effect(() => {
302
+ if (temp_filtered_entries.length < 3) {
303
+ diagram_data = null
304
+ diagram_computing = false
305
+ return
306
+ }
307
+ let cancelled = false
308
+ diagram_computing = true
309
+ compute_chempot_async(temp_filtered_entries, effective_config)
310
+ .then((data) => {
311
+ if (cancelled) return
312
+ diagram_data = data.elements.length >= 3 ? data : null
313
+ diagram_computing = false
314
+ })
315
+ .catch((err) => {
316
+ if (cancelled) return
317
+ console.error(`ChemPotDiagram3D:`, err)
318
+ diagram_data = null
319
+ diagram_computing = false
320
+ })
321
+ return () => { cancelled = true }
322
+ })
323
+
324
+ const plot_elements = $derived(diagram_data?.elements ?? projection_elements)
325
+ const is_projection_mode = $derived(
326
+ plot_elements.length > 0 &&
327
+ plot_elements.length < all_entry_elements.length &&
328
+ plot_elements.every((element) => all_entry_elements.includes(element)),
329
+ )
330
+ const projection_presets = $derived.by(() => {
331
+ const presets: string[][] = []
332
+ const seen = new Set<string>()
333
+ const add_triplet = (candidate: string[] | null): void => {
334
+ if (!candidate) return
335
+ const key = candidate.join(`|`)
336
+ if (seen.has(key)) return
337
+ seen.add(key)
338
+ presets.push(candidate)
339
+ }
340
+ add_triplet(config_projection_elements)
341
+ add_triplet(plot_elements.length === 3 ? plot_elements : null)
342
+ for (const combo of get_ternary_combinations(all_entry_elements)) {
343
+ add_triplet(combo)
344
+ if (presets.length >= 12) break
345
+ }
346
+ return presets
347
+ })
348
+ const current_projection_key = $derived(plot_elements.join(`|`))
349
+ let formula_filter_query = $state(``)
350
+ const available_formulas = $derived.by(() =>
351
+ Object.keys(diagram_data?.domains ?? {}).sort()
352
+ )
353
+ const filtered_formulas = $derived.by(() => {
354
+ const query = formula_filter_query.trim().toLowerCase()
355
+ if (!query) return available_formulas
356
+ return available_formulas.filter((formula) =>
357
+ formula.toLowerCase().includes(query)
358
+ )
359
+ })
360
+
361
+ // Process domains for rendering
362
+ interface DomainRenderData {
363
+ formula: string
364
+ points_3d: number[][]
365
+ ann_loc: number[]
366
+ is_draw_formula: boolean
367
+ label_font_size: number
368
+ }
369
+
370
+ interface HoverMeshData {
371
+ formula: string
372
+ geometry: THREE.BufferGeometry
373
+ info: ChemPotHoverInfo3D
374
+ }
375
+
376
+ interface FormulaEnergyStats {
377
+ matching_entry_count: number
378
+ min_energy_per_atom: number | null
379
+ max_energy_per_atom: number | null
380
+ }
381
+ type NumericColorMode = Exclude<ChemPotColorMode, `none` | `arity`>
382
+
383
+ const render_domains = $derived.by((): DomainRenderData[] => {
384
+ if (!diagram_data || plot_elements.length < 2) return []
385
+
386
+ const dim = diagram_data.elements.length
387
+ const indices = Array.from({ length: dim }, (_, idx) => idx)
225
388
  const new_lims = element_padding > 0
226
- ? apply_element_padding(diagram_data.domains, indices, element_padding, default_min_limit)
227
- : null;
228
- const result = [];
389
+ ? apply_element_padding(
390
+ diagram_data.domains,
391
+ indices,
392
+ element_padding,
393
+ default_min_limit,
394
+ )
395
+ : null
396
+
397
+ const result: DomainRenderData[] = []
229
398
  for (const [formula, pts] of Object.entries(diagram_data.domains)) {
230
- const padded = new_lims
231
- ? pad_domain_points(pts, indices, new_lims, default_min_limit, element_padding)
232
- : pts;
233
- if (padded.length < 2)
234
- continue;
235
- const is_draw = formulas_to_draw.includes(formula);
236
- const label_loc = padded[0].map((_, col_idx) => padded.reduce((sum, point) => sum + point[col_idx], 0) / padded.length);
237
- if (padded.length >= 3) {
238
- const { ann_loc } = get_3d_domain_simplexes_and_ann_loc(padded);
239
- result.push({
240
- formula,
241
- points_3d: padded,
242
- ann_loc,
243
- label_loc,
244
- is_draw_formula: is_draw,
245
- });
246
- }
247
- else {
248
- result.push({
249
- formula,
250
- points_3d: padded,
251
- ann_loc: label_loc,
252
- label_loc,
253
- is_draw_formula: is_draw,
254
- });
255
- }
256
- }
257
- return result;
258
- });
259
- const entry_energy_stats_by_formula = $derived.by(() => {
260
- const stats_by_formula = new SvelteMap();
261
- for (const entry of temp_filtered_entries) {
262
- const formula_key = formula_key_from_composition(entry.composition);
263
- const energy_per_atom = get_energy_per_atom(entry);
264
- const existing = stats_by_formula.get(formula_key);
399
+ const padded = new_lims
400
+ ? pad_domain_points(
401
+ pts,
402
+ indices,
403
+ new_lims,
404
+ default_min_limit,
405
+ element_padding,
406
+ )
407
+ : pts
408
+ if (padded.length < 2) continue
409
+ const is_draw = formulas_to_draw.includes(formula)
410
+ const centroid = padded[0].map((_, col_idx) =>
411
+ padded.reduce((sum, point) => sum + point[col_idx], 0) / padded.length
412
+ )
413
+ const ann_loc = padded.length >= 3
414
+ ? get_3d_domain_simplexes_and_ann_loc(padded).ann_loc
415
+ : centroid
416
+ result.push({
417
+ formula,
418
+ points_3d: padded,
419
+ ann_loc,
420
+ is_draw_formula: is_draw,
421
+ label_font_size: bbox_diagonal(padded),
422
+ })
423
+ }
424
+ const fonts = scale_to_font_range(result.map((d) => d.label_font_size), 9, 15)
425
+ for (let idx = 0; idx < result.length; idx++) result[idx].label_font_size = fonts[idx]
426
+ return result
427
+ })
428
+
429
+ const entry_energy_stats_by_formula = $derived.by(
430
+ (): SvelteMap<string, FormulaEnergyStats> => {
431
+ const stats_by_formula = new SvelteMap<string, FormulaEnergyStats>()
432
+ for (const entry of temp_filtered_entries) {
433
+ const formula_key = formula_key_from_composition(entry.composition)
434
+ const energy_per_atom = get_energy_per_atom(entry)
435
+ const existing = stats_by_formula.get(formula_key)
265
436
  if (!existing) {
266
- stats_by_formula.set(formula_key, {
267
- matching_entry_count: 1,
268
- min_energy_per_atom: energy_per_atom,
269
- max_energy_per_atom: energy_per_atom,
270
- });
271
- continue;
437
+ stats_by_formula.set(formula_key, {
438
+ matching_entry_count: 1,
439
+ min_energy_per_atom: energy_per_atom,
440
+ max_energy_per_atom: energy_per_atom,
441
+ })
442
+ continue
272
443
  }
273
444
  stats_by_formula.set(formula_key, {
274
- matching_entry_count: existing.matching_entry_count + 1,
275
- min_energy_per_atom: Math.min(existing.min_energy_per_atom ?? energy_per_atom, energy_per_atom),
276
- max_energy_per_atom: Math.max(existing.max_energy_per_atom ?? energy_per_atom, energy_per_atom),
277
- });
278
- }
279
- return stats_by_formula;
280
- });
281
- // === Region coloring ===
282
- // Categorical palette for arity mode (element count)
283
- const arity_colors = [`#3498db`, `#2ecc71`, `#e67e22`, `#9b59b6`];
284
- // Original (non-renormalized) elemental references for formation energy computation.
285
- // diagram_data.el_refs may be renormalized to zero when formal_chempots is true,
286
- // so we compute our own from the raw entries to get true DFT reference energies.
287
- const raw_el_refs = $derived(get_min_entries_and_el_refs(temp_filtered_entries).el_refs);
288
- const color_mode_labels = {
445
+ matching_entry_count: existing.matching_entry_count + 1,
446
+ min_energy_per_atom: Math.min(
447
+ existing.min_energy_per_atom ?? energy_per_atom,
448
+ energy_per_atom,
449
+ ),
450
+ max_energy_per_atom: Math.max(
451
+ existing.max_energy_per_atom ?? energy_per_atom,
452
+ energy_per_atom,
453
+ ),
454
+ })
455
+ }
456
+ return stats_by_formula
457
+ },
458
+ )
459
+
460
+ // === Region coloring ===
461
+ // Categorical palette for arity mode (element count)
462
+ const arity_colors = [`#3498db`, `#2ecc71`, `#e67e22`, `#9b59b6`] as const
463
+
464
+ // Original (non-renormalized) elemental references for formation energy computation.
465
+ // diagram_data.el_refs may be renormalized to zero when formal_chempots is true,
466
+ // so we compute our own from the raw entries to get true DFT reference energies.
467
+ const raw_el_refs = $derived(
468
+ get_min_entries_and_el_refs(temp_filtered_entries).el_refs,
469
+ )
470
+
471
+ const color_mode_labels: Record<NumericColorMode, string> = {
289
472
  energy: `Energy per atom (eV)`,
290
473
  formation_energy: `Formation energy (eV/atom)`,
291
474
  entries: `Entry count`,
292
- };
293
- function get_numeric_color_value(formula, active_color_mode) {
475
+ }
476
+ function get_numeric_color_value(
477
+ formula: string,
478
+ active_color_mode: NumericColorMode,
479
+ ): number | null {
294
480
  if (active_color_mode === `energy`) {
295
- return entry_energy_stats_by_formula.get(formula)?.min_energy_per_atom ?? null;
481
+ return entry_energy_stats_by_formula.get(formula)?.min_energy_per_atom ?? null
296
482
  }
297
483
  if (active_color_mode === `formation_energy`) {
298
- return best_form_energy_for_formula(temp_filtered_entries, formula, raw_el_refs) ?? null;
299
- }
300
- return entry_energy_stats_by_formula.get(formula)?.matching_entry_count ?? 0;
301
- }
302
- const domain_color_values = $derived.by(() => {
303
- if (color_mode === `none` || color_mode === `arity`)
304
- return null;
305
- const active_color_mode = color_mode;
306
- const value_by_formula = new SvelteMap();
307
- const values = [];
308
- for (const domain of render_domains) {
309
- const value = get_numeric_color_value(domain.formula, active_color_mode);
310
- if (value == null || !Number.isFinite(value))
311
- continue;
312
- values.push(value);
313
- value_by_formula.set(domain.formula, value);
314
- }
315
- return { value_by_formula, values };
316
- });
317
- // Per-domain color map keyed by formula
318
- const domain_colors = $derived.by(() => {
319
- const colors = new SvelteMap();
320
- if (color_mode === `none`)
321
- return colors;
322
- if (color_mode === `arity`) {
323
- for (const domain of render_domains) {
324
- const n_elements = extract_formula_elements(domain.formula).length;
325
- const idx = Math.min(n_elements, arity_colors.length) - 1;
326
- colors.set(domain.formula, arity_colors[Math.max(0, idx)]);
327
- }
328
- return colors;
484
+ return best_form_energy_for_formula(
485
+ temp_filtered_entries,
486
+ formula,
487
+ raw_el_refs,
488
+ ) ?? null
329
489
  }
330
- const values_payload = domain_color_values;
331
- const scale = make_chempot_color_scale(values_payload?.values ?? [], color_scale, reverse_color_scale);
490
+ return entry_energy_stats_by_formula.get(formula)?.matching_entry_count ?? 0
491
+ }
492
+ const domain_color_values = $derived.by(
493
+ (): { value_by_formula: SvelteMap<string, number>; values: number[] } | null => {
494
+ if (color_mode === `none` || color_mode === `arity`) return null
495
+ const active_color_mode = color_mode as NumericColorMode
496
+ const value_by_formula = new SvelteMap<string, number>()
497
+ const values: number[] = []
498
+ for (const domain of render_domains) {
499
+ const value = get_numeric_color_value(domain.formula, active_color_mode)
500
+ if (value == null || !Number.isFinite(value)) continue
501
+ values.push(value)
502
+ value_by_formula.set(domain.formula, value)
503
+ }
504
+ return { value_by_formula, values }
505
+ },
506
+ )
507
+
508
+ // Per-domain color map keyed by formula
509
+ const domain_colors = $derived.by((): SvelteMap<string, string> => {
510
+ const colors = new SvelteMap<string, string>()
511
+ if (color_mode === `none`) return colors
512
+
513
+ if (color_mode === `arity`) {
514
+ for (const domain of render_domains) {
515
+ const n_elements = extract_formula_elements(domain.formula).length
516
+ const idx = Math.min(n_elements, arity_colors.length) - 1
517
+ colors.set(domain.formula, arity_colors[Math.max(0, idx)])
518
+ }
519
+ return colors
520
+ }
521
+ const values_payload = domain_color_values
522
+ const scale = make_chempot_color_scale(
523
+ values_payload?.values ?? [],
524
+ color_scale,
525
+ reverse_color_scale,
526
+ )
332
527
  for (const domain of render_domains) {
333
- const value = values_payload?.value_by_formula.get(domain.formula);
334
- colors.set(domain.formula, value != null && scale ? scale(value) : `#999`);
335
- }
336
- return colors;
337
- });
338
- // Range and label for the color bar (null for none/arity which are categorical)
339
- const color_range = $derived.by(() => {
340
- const values = domain_color_values?.values ?? [];
341
- if (values.length === 0)
342
- return null;
343
- let lo = values[0], hi = values[0];
344
- for (let idx = 1; idx < values.length; idx++) {
345
- if (values[idx] < lo)
346
- lo = values[idx];
347
- if (values[idx] > hi)
348
- hi = values[idx];
528
+ const value = values_payload?.value_by_formula.get(domain.formula)
529
+ colors.set(domain.formula, value != null && scale ? scale(value) : `#999`)
349
530
  }
350
- return {
531
+ return colors
532
+ })
533
+
534
+ // Range and label for the color bar (null for none/arity which are categorical)
535
+ const color_range = $derived.by(
536
+ (): { min: number; max: number; label: string } | null => {
537
+ const values = domain_color_values?.values ?? []
538
+ if (values.length === 0) return null
539
+ let lo = values[0], hi = values[0]
540
+ for (let idx = 1; idx < values.length; idx++) {
541
+ if (values[idx] < lo) lo = values[idx]
542
+ if (values[idx] > hi) hi = values[idx]
543
+ }
544
+ return {
351
545
  min: lo,
352
546
  max: Math.max(hi, lo + 1e-6),
353
547
  label: color_mode === `none` || color_mode === `arity`
354
- ? ``
355
- : color_mode_labels[color_mode],
356
- };
357
- });
358
- const arity_legend_labels = $derived.by(() => {
359
- let has_four_plus_regions = false;
548
+ ? ``
549
+ : color_mode_labels[color_mode],
550
+ }
551
+ },
552
+ )
553
+
554
+ const arity_legend_labels = $derived.by((): string[] => {
555
+ let has_four_plus_regions = false
360
556
  for (const domain of render_domains) {
361
- if (extract_formula_elements(domain.formula).length >= 4) {
362
- has_four_plus_regions = true;
363
- break;
364
- }
557
+ if (extract_formula_elements(domain.formula).length >= 4) {
558
+ has_four_plus_regions = true
559
+ break
560
+ }
365
561
  }
366
562
  return has_four_plus_regions
367
- ? [`Unary`, `Binary`, `Ternary`, `4+`]
368
- : [`Unary`, `Binary`, `Ternary`];
369
- });
370
- // Stretch short axes to improve screen-space utilization for highly anisotropic systems.
371
- // Mapping is in rendered axis order: X=data[1], Y=data[2], Z=data[0].
372
- const render_axis_scale = $derived.by(() => {
373
- const points = render_domains.flatMap((domain) => domain.points_3d);
374
- if (points.length === 0)
375
- return [1, 1, 1];
376
- let min0 = Infinity, max0 = -Infinity;
377
- let min1 = Infinity, max1 = -Infinity;
378
- let min2 = Infinity, max2 = -Infinity;
563
+ ? [`Unary`, `Binary`, `Ternary`, `4+`]
564
+ : [`Unary`, `Binary`, `Ternary`]
565
+ })
566
+
567
+ // Stretch short axes to improve screen-space utilization for highly anisotropic systems.
568
+ // Mapping is in rendered axis order: X=data[1], Y=data[2], Z=data[0].
569
+ const render_axis_scale = $derived.by((): Vec3 => {
570
+ const points = render_domains.flatMap((domain) => domain.points_3d)
571
+ if (points.length === 0) return [1, 1, 1]
572
+ let min0 = Infinity, max0 = -Infinity
573
+ let min1 = Infinity, max1 = -Infinity
574
+ let min2 = Infinity, max2 = -Infinity
379
575
  for (const point of points) {
380
- if (point[0] < min0)
381
- min0 = point[0];
382
- if (point[0] > max0)
383
- max0 = point[0];
384
- if (point[1] < min1)
385
- min1 = point[1];
386
- if (point[1] > max1)
387
- max1 = point[1];
388
- if (point[2] < min2)
389
- min2 = point[2];
390
- if (point[2] > max2)
391
- max2 = point[2];
392
- }
393
- const span_x = Math.max(max1 - min1, 1e-6); // render X from data axis 1
394
- const span_y = Math.max(max2 - min2, 1e-6); // render Y from data axis 2
395
- const span_z = Math.max(max0 - min0, 1e-6); // render Z from data axis 0
396
- const max_span = Math.max(span_x, span_y, span_z);
576
+ if (point[0] < min0) min0 = point[0]
577
+ if (point[0] > max0) max0 = point[0]
578
+ if (point[1] < min1) min1 = point[1]
579
+ if (point[1] > max1) max1 = point[1]
580
+ if (point[2] < min2) min2 = point[2]
581
+ if (point[2] > max2) max2 = point[2]
582
+ }
583
+ const span_x = Math.max(max1 - min1, 1e-6) // render X from data axis 1
584
+ const span_y = Math.max(max2 - min2, 1e-6) // render Y from data axis 2
585
+ const span_z = Math.max(max0 - min0, 1e-6) // render Z from data axis 0
586
+ const max_span = Math.max(span_x, span_y, span_z)
397
587
  return [
398
- Math.min(Math.max(max_span / span_x, 1), 4),
399
- Math.min(Math.max(max_span / span_y, 1), 4),
400
- Math.min(Math.max(max_span / span_z, 1), 4),
401
- ];
402
- });
403
- function to_render_xyz(point) {
404
- const [scale_x, scale_y, scale_z] = render_axis_scale;
405
- return [point[1] * scale_x, point[2] * scale_y, point[0] * scale_z];
406
- }
407
- // Compute data center and extent for camera positioning (in swizzled coords)
408
- const { data_center, data_extent } = $derived.by(() => {
409
- const points = render_domains.flatMap((domain) => domain.points_3d);
588
+ Math.min(Math.max(max_span / span_x, 1), 4),
589
+ Math.min(Math.max(max_span / span_y, 1), 4),
590
+ Math.min(Math.max(max_span / span_z, 1), 4),
591
+ ]
592
+ })
593
+
594
+ function to_render_xyz(point: number[]): Vec3 {
595
+ const [scale_x, scale_y, scale_z] = render_axis_scale
596
+ return [point[1] * scale_x, point[2] * scale_y, point[0] * scale_z]
597
+ }
598
+
599
+ // Compute data center and extent for camera positioning (in swizzled coords)
600
+ const { data_center, data_extent } = $derived.by(() => {
601
+ const points = render_domains.flatMap((domain) => domain.points_3d)
410
602
  if (points.length === 0) {
411
- return { data_center: new THREE.Vector3(0, 0, 0), data_extent: 10 };
603
+ return { data_center: new THREE.Vector3(0, 0, 0), data_extent: 10 }
412
604
  }
413
605
  // Compute center in rendered coordinates (swizzled + axis scaling).
414
- let [sum_x, sum_y, sum_z] = [0, 0, 0];
606
+ let [sum_x, sum_y, sum_z] = [0, 0, 0]
415
607
  for (const point_3d of points) {
416
- const [x_val, y_val, z_val] = to_render_xyz(point_3d);
417
- sum_x += x_val;
418
- sum_y += y_val;
419
- sum_z += z_val;
420
- }
421
- const n_points = points.length;
422
- const center = new THREE.Vector3(sum_x / n_points, sum_y / n_points, sum_z / n_points);
608
+ const [x_val, y_val, z_val] = to_render_xyz(point_3d)
609
+ sum_x += x_val
610
+ sum_y += y_val
611
+ sum_z += z_val
612
+ }
613
+ const n_points = points.length
614
+ const center = new THREE.Vector3(
615
+ sum_x / n_points,
616
+ sum_y / n_points,
617
+ sum_z / n_points,
618
+ )
423
619
  // Compute max distance from center
424
- let max_dist = 0;
620
+ let max_dist = 0
425
621
  for (const point of points) {
426
- const [x_val, y_val, z_val] = to_render_xyz(point);
427
- const dist = Math.hypot(x_val - center.x, y_val - center.y, z_val - center.z);
428
- if (dist > max_dist)
429
- max_dist = dist;
430
- }
431
- return { data_center: center, data_extent: Math.max(max_dist * 1.3, 1) };
432
- });
433
- const default_camera_position = $derived([
622
+ const [x_val, y_val, z_val] = to_render_xyz(point)
623
+ const dist = Math.hypot(x_val - center.x, y_val - center.y, z_val - center.z)
624
+ if (dist > max_dist) max_dist = dist
625
+ }
626
+ return { data_center: center, data_extent: Math.max(max_dist * 1.3, 1) }
627
+ })
628
+ const default_camera_position = $derived<Vec3>([
434
629
  data_center.x + data_extent,
435
630
  data_center.y + data_extent,
436
631
  data_center.z + data_extent,
437
- ]);
438
- const default_camera_target = $derived([
632
+ ])
633
+ const default_camera_target = $derived<Vec3>([
439
634
  data_center.x,
440
635
  data_center.y,
441
636
  data_center.z,
442
- ]);
443
- const default_orthographic_zoom = $derived(Math.min(render_width, render_height) / (data_extent * 1.6));
444
- let camera_position_override = $state(null);
445
- let camera_target_override = $state(null);
446
- let orthographic_zoom_override = $state(null);
447
- const camera_position = $derived(camera_position_override ?? default_camera_position);
448
- const camera_target = $derived(camera_target_override ?? default_camera_target);
449
- const orthographic_zoom = $derived(orthographic_zoom_override ?? default_orthographic_zoom);
450
- let last_data_center = null;
451
- let last_data_extent = null;
452
- // Compute domain boundary edges via axis-aligned 2D convex hull projection.
453
- // Each domain in a chem pot diagram is a convex polygon/polyhedron. We project
454
- // to 2D (trying all 3 axis-aligned planes) and use the best projection's
455
- // convex hull boundary. This reliably handles both flat and 3D domains.
456
- function get_domain_edges(pts) {
457
- const unique = dedup_3d(pts);
458
- if (unique.length < 2)
459
- return [];
460
- if (unique.length === 2)
461
- return [[unique[0], unique[1]]];
637
+ ])
638
+ const default_orthographic_zoom = $derived(
639
+ Math.min(render_width, render_height) / (data_extent * 1.6),
640
+ )
641
+ let camera_position_override = $state<Vec3 | null>(null)
642
+ let camera_target_override = $state<Vec3 | null>(null)
643
+ let orthographic_zoom_override = $state<number | null>(null)
644
+ const camera_position = $derived(
645
+ camera_position_override ?? default_camera_position,
646
+ )
647
+ const camera_target = $derived(
648
+ camera_target_override ?? default_camera_target,
649
+ )
650
+ const orthographic_zoom = $derived(
651
+ orthographic_zoom_override ?? default_orthographic_zoom,
652
+ )
653
+ // Label scale factor: zoom relative to default, so labels grow/shrink with zoom
654
+ // Labels scale sub-linearly with zoom so they grow but don't dominate when zoomed in
655
+ const zoom_scale = $derived(
656
+ default_orthographic_zoom > 0 ? Math.sqrt(orthographic_zoom / default_orthographic_zoom) : 1,
657
+ )
658
+ let last_data_center: Vec3 | null = null
659
+ let last_data_extent: number | null = null
660
+
661
+ // Compute domain boundary edges via axis-aligned 2D convex hull projection.
662
+ // Each domain in a chem pot diagram is a convex polygon/polyhedron. We project
663
+ // to 2D (trying all 3 axis-aligned planes) and use the best projection's
664
+ // convex hull boundary. This reliably handles both flat and 3D domains.
665
+ function get_domain_edges(
666
+ pts: number[][],
667
+ ): [number[], number[]][] {
668
+ const unique = dedup_3d(pts)
669
+ if (unique.length < 2) return []
670
+ if (unique.length === 2) return [[unique[0], unique[1]]]
462
671
  if (unique.length === 3) {
463
- return [[unique[0], unique[1]], [unique[1], unique[2]], [unique[0], unique[2]]];
464
- }
465
- return get_2d_hull_edges(unique);
466
- }
467
- function polygon_area_2d(points_2d) {
468
- if (points_2d.length < 3)
469
- return 0;
470
- let area_twice = 0;
672
+ return [[unique[0], unique[1]], [unique[1], unique[2]], [unique[0], unique[2]]]
673
+ }
674
+ return get_2d_hull_edges(unique)
675
+ }
676
+
677
+ function polygon_area_2d(points_2d: Vec2[]): number {
678
+ if (points_2d.length < 3) return 0
679
+ let area_twice = 0
471
680
  for (let idx = 0; idx < points_2d.length; idx++) {
472
- const current = points_2d[idx];
473
- const next = points_2d[(idx + 1) % points_2d.length];
474
- area_twice += current[0] * next[1] - next[0] * current[1];
475
- }
476
- return Math.abs(area_twice) / 2;
477
- }
478
- // Compute domain edges from the single best axis-aligned projection
479
- // (largest non-degenerate hull area). Unioning multiple projections can add
480
- // non-physical diagonals for nearly coplanar domains.
481
- // Called only from get_domain_edges with 4+ unique points
482
- function get_2d_hull_edges(pts) {
483
- let selected_hull = [];
484
- let selected_coord_to_idx = null;
485
- let selected_hull_area = -1;
681
+ const current = points_2d[idx]
682
+ const next = points_2d[(idx + 1) % points_2d.length]
683
+ area_twice += current[0] * next[1] - next[0] * current[1]
684
+ }
685
+ return Math.abs(area_twice) / 2
686
+ }
687
+
688
+ // Compute domain edges from the single best axis-aligned projection
689
+ // (largest non-degenerate hull area). Unioning multiple projections can add
690
+ // non-physical diagonals for nearly coplanar domains.
691
+ // Called only from get_domain_edges with 4+ unique points
692
+ function get_2d_hull_edges(
693
+ pts: number[][],
694
+ ): [number[], number[]][] {
695
+ let selected_hull: Vec2[] = []
696
+ let selected_coord_to_idx: SvelteMap<string, number> | null = null
697
+ let selected_hull_area = -1
698
+
486
699
  for (const drop of [0, 1, 2]) {
487
- const axes = [0, 1, 2].filter((ax) => ax !== drop);
488
- // Skip this projection if points collapse to a line (near-zero range in
489
- // either projected axis). This avoids spurious edges from edge-on views.
490
- let min0 = Infinity, max0 = -Infinity, min1 = Infinity, max1 = -Infinity;
491
- for (const pt of pts) {
492
- const v0 = pt[axes[0]], v1 = pt[axes[1]];
493
- if (v0 < min0)
494
- min0 = v0;
495
- if (v0 > max0)
496
- max0 = v0;
497
- if (v1 < min1)
498
- min1 = v1;
499
- if (v1 > max1)
500
- max1 = v1;
501
- }
502
- const range0 = max0 - min0, range1 = max1 - min1;
503
- const max_2d_range = Math.max(range0, range1);
504
- if (max_2d_range < 1e-6 || Math.min(range0, range1) < max_2d_range * 0.01) {
505
- continue;
506
- }
507
- // Build coordinate lookup for this projection
508
- const coord_to_idx = new SvelteMap();
509
- const pts_2d = [];
510
- for (let idx = 0; idx < pts.length; idx++) {
511
- const p2 = [pts[idx][axes[0]], pts[idx][axes[1]]];
512
- pts_2d.push(p2);
513
- const key = `${p2[0].toFixed(6)},${p2[1].toFixed(6)}`;
514
- if (!coord_to_idx.has(key))
515
- coord_to_idx.set(key, idx);
516
- }
517
- const hull = convex_hull_2d(pts_2d);
518
- if (hull.length < 3)
519
- continue;
520
- const hull_area = polygon_area_2d(hull);
521
- if (hull_area <= selected_hull_area)
522
- continue;
523
- selected_hull = hull;
524
- selected_coord_to_idx = coord_to_idx;
525
- selected_hull_area = hull_area;
526
- }
527
- if (!selected_coord_to_idx || selected_hull.length < 3)
528
- return [];
529
- const edges = [];
700
+ const axes = [0, 1, 2].filter((ax) => ax !== drop)
701
+
702
+ // Skip this projection if points collapse to a line (near-zero range in
703
+ // either projected axis). This avoids spurious edges from edge-on views.
704
+ let min0 = Infinity, max0 = -Infinity, min1 = Infinity, max1 = -Infinity
705
+ for (const pt of pts) {
706
+ const v0 = pt[axes[0]], v1 = pt[axes[1]]
707
+ if (v0 < min0) min0 = v0
708
+ if (v0 > max0) max0 = v0
709
+ if (v1 < min1) min1 = v1
710
+ if (v1 > max1) max1 = v1
711
+ }
712
+ const range0 = max0 - min0, range1 = max1 - min1
713
+ const max_2d_range = Math.max(range0, range1)
714
+ if (max_2d_range < 1e-6 || Math.min(range0, range1) < max_2d_range * 0.01) {
715
+ continue
716
+ }
717
+
718
+ // Build coordinate lookup for this projection
719
+ const coord_to_idx = new SvelteMap<string, number>()
720
+ const pts_2d: Vec2[] = []
721
+ for (let idx = 0; idx < pts.length; idx++) {
722
+ const p2 = [pts[idx][axes[0]], pts[idx][axes[1]]] as Vec2
723
+ pts_2d.push(p2)
724
+ const key = `${p2[0].toFixed(6)},${p2[1].toFixed(6)}`
725
+ if (!coord_to_idx.has(key)) coord_to_idx.set(key, idx)
726
+ }
727
+
728
+ const hull = convex_hull_2d(pts_2d)
729
+ if (hull.length < 3) continue
730
+ const hull_area = polygon_area_2d(hull)
731
+ if (hull_area <= selected_hull_area) continue
732
+ selected_hull = hull
733
+ selected_coord_to_idx = coord_to_idx
734
+ selected_hull_area = hull_area
735
+ }
736
+
737
+ if (!selected_coord_to_idx || selected_hull.length < 3) return []
738
+
739
+ const edges: [number[], number[]][] = []
530
740
  for (let idx = 0; idx < selected_hull.length; idx++) {
531
- const point_a = selected_hull[idx];
532
- const point_b = selected_hull[(idx + 1) % selected_hull.length];
533
- const point_a_idx = selected_coord_to_idx.get(`${point_a[0].toFixed(6)},${point_a[1].toFixed(6)}`);
534
- const point_b_idx = selected_coord_to_idx.get(`${point_b[0].toFixed(6)},${point_b[1].toFixed(6)}`);
535
- if (point_a_idx == null || point_b_idx == null || point_a_idx >= pts.length ||
536
- point_b_idx >= pts.length) {
537
- console.warn(`get_2d_hull_edges: invalid edge`, {
538
- point_a,
539
- point_b,
540
- point_a_idx,
541
- point_b_idx,
542
- });
543
- continue;
544
- }
545
- edges.push([pts[point_a_idx], pts[point_b_idx]]);
741
+ const point_a = selected_hull[idx]
742
+ const point_b = selected_hull[(idx + 1) % selected_hull.length]
743
+ const point_a_idx = selected_coord_to_idx.get(
744
+ `${point_a[0].toFixed(6)},${point_a[1].toFixed(6)}`,
745
+ )
746
+ const point_b_idx = selected_coord_to_idx.get(
747
+ `${point_b[0].toFixed(6)},${point_b[1].toFixed(6)}`,
748
+ )
749
+ if (
750
+ point_a_idx == null || point_b_idx == null || point_a_idx >= pts.length ||
751
+ point_b_idx >= pts.length
752
+ ) {
753
+ console.warn(`get_2d_hull_edges: invalid edge`, {
754
+ point_a,
755
+ point_b,
756
+ point_a_idx,
757
+ point_b_idx,
758
+ })
759
+ continue
760
+ }
761
+ edges.push([pts[point_a_idx], pts[point_b_idx]])
546
762
  }
547
- return edges;
548
- }
549
- // Build globally deduplicated edge geometry for domain boundaries using
550
- // 3D convex hull crease edges (not 2D projected hull).
551
- const edge_geometry = $derived.by(() => {
763
+
764
+ return edges
765
+ }
766
+
767
+ // Build globally deduplicated edge geometry for domain boundaries using
768
+ // 3D convex hull crease edges (not 2D projected hull).
769
+ const edge_geometry = $derived.by(() => {
552
770
  if (is_projection_mode) {
553
- const all_points = render_domains
554
- .filter((domain) => !domain.is_draw_formula)
555
- .flatMap((domain) => domain.points_3d);
556
- const unique_points = dedup_3d(all_points);
557
- if (unique_points.length >= 4) {
558
- try {
559
- const hull_vectors = unique_points.map((point) => to_vec3(point));
560
- const hull_geometry = new ConvexGeometry(hull_vectors);
561
- const hull_edges = new THREE.EdgesGeometry(hull_geometry);
562
- hull_geometry.dispose();
563
- return hull_edges;
564
- }
565
- catch {
566
- // Fall back to per-domain edges below.
567
- }
771
+ const all_points = render_domains
772
+ .filter((domain) => !domain.is_draw_formula)
773
+ .flatMap((domain) => domain.points_3d)
774
+ const unique_points = dedup_3d(all_points)
775
+ if (unique_points.length >= 4) {
776
+ try {
777
+ const hull_vectors = unique_points.map((point) => to_vec3(point))
778
+ const hull_geometry = new ConvexGeometry(hull_vectors)
779
+ const hull_edges = new THREE.EdgesGeometry(hull_geometry)
780
+ hull_geometry.dispose()
781
+ return hull_edges
782
+ } catch {
783
+ // Fall back to per-domain edges below.
568
784
  }
785
+ }
569
786
  }
570
- const seen = new SvelteSet();
571
- const positions = [];
787
+
788
+ const seen = new SvelteSet<string>()
789
+ const positions: number[] = []
572
790
  for (const domain of render_domains) {
573
- if (domain.is_draw_formula)
574
- continue;
575
- // Compute edges in swizzled (Three.js) coords since ConvexGeometry works there
576
- const swizzled = domain.points_3d.map((point) => to_render_xyz(point));
577
- for (const [pa, pb] of get_domain_edges(swizzled)) {
578
- const ka = pa.map((v) => v.toFixed(4)).join(`,`);
579
- const kb = pb.map((v) => v.toFixed(4)).join(`,`);
580
- const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
581
- if (seen.has(key))
582
- continue;
583
- seen.add(key);
584
- positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2]);
585
- }
586
- }
587
- const geom = new THREE.BufferGeometry();
588
- geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
589
- return geom;
590
- });
591
- // Build a single opaque convex hull mesh from ALL domain vertices for depth
592
- // occlusion. This seamless surface writes to the depth buffer, hiding wireframe
593
- // edges on the back side. Using all vertices together avoids gaps between domains.
594
- const occlusion_hull_geometry = $derived.by(() => {
791
+ if (domain.is_draw_formula) continue
792
+ // Compute edges in swizzled (Three.js) coords since ConvexGeometry works there
793
+ const swizzled = domain.points_3d.map((point) => to_render_xyz(point))
794
+ for (const [pa, pb] of get_domain_edges(swizzled)) {
795
+ const ka = pa.map((v) => v.toFixed(4)).join(`,`)
796
+ const kb = pb.map((v) => v.toFixed(4)).join(`,`)
797
+ const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`
798
+ if (seen.has(key)) continue
799
+ seen.add(key)
800
+ positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2])
801
+ }
802
+ }
803
+ const geom = new THREE.BufferGeometry()
804
+ geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3))
805
+ return geom
806
+ })
807
+
808
+ // Build a single opaque convex hull mesh from ALL domain vertices for depth
809
+ // occlusion. This seamless surface writes to the depth buffer, hiding wireframe
810
+ // edges on the back side. Using all vertices together avoids gaps between domains.
811
+ const occlusion_hull_geometry = $derived.by((): THREE.BufferGeometry | null => {
595
812
  try {
596
- const all_points = [];
597
- for (const domain of render_domains) {
598
- if (domain.is_draw_formula)
599
- continue;
600
- all_points.push(...domain.points_3d);
601
- }
602
- const unique_points = dedup_3d(all_points);
603
- if (unique_points.length < 4)
604
- return null;
605
- const vectors = unique_points.map((point) => to_vec3(point));
606
- return merge_coplanar_geometry(new ConvexGeometry(vectors));
607
- }
608
- catch {
609
- return null;
610
- }
611
- });
612
- // Non-indexed hull geometry with artificial closing faces removed.
613
- // The convex hull includes faces that close the diagram at the lower axis
614
- // limits flat walls and diagonal closing triangles. These are artificial
615
- // (they depend on how far we extend the axes) and clutter the view.
616
- // We detect them via their outward-pointing face normal: closing faces have
617
- // normals pointing entirely toward the negative octant (all components ≤ 0),
618
- // while meaningful domain boundaries always have at least one positive
619
- // normal component (pointing toward 0 eV / the elemental reference).
620
- const hull_base_geometry = $derived.by(() => {
621
- if (!occlusion_hull_geometry)
622
- return null;
813
+ const all_points: number[][] = []
814
+ for (const domain of render_domains) {
815
+ if (domain.is_draw_formula) continue
816
+ all_points.push(...domain.points_3d)
817
+ }
818
+ const unique_points = dedup_3d(all_points)
819
+ if (unique_points.length < 4) return null
820
+ const vectors = unique_points.map((point) => to_vec3(point))
821
+ return merge_coplanar_geometry(new ConvexGeometry(vectors))
822
+ } catch {
823
+ return null
824
+ }
825
+ })
826
+
827
+ // Non-indexed hull geometry with artificial closing faces removed.
828
+ // The convex hull includes faces that close the diagram at the lower axis
829
+ // limits flat walls and diagonal closing triangles. These are artificial
830
+ // (they depend on how far we extend the axes) and clutter the view.
831
+ // We detect them via their outward-pointing face normal: closing faces have
832
+ // normals pointing entirely toward the negative octant (all components 0),
833
+ // while meaningful domain boundaries always have at least one positive
834
+ // normal component (pointing toward 0 eV / the elemental reference).
835
+ const hull_base_geometry = $derived.by((): THREE.BufferGeometry | null => {
836
+ if (!occlusion_hull_geometry) return null
623
837
  const src = occlusion_hull_geometry.index
624
- ? occlusion_hull_geometry.toNonIndexed()
625
- : occlusion_hull_geometry.clone();
626
- const pos = src.getAttribute(`position`);
627
- const n_verts = pos.count;
628
- const n_faces = n_verts / 3;
838
+ ? occlusion_hull_geometry.toNonIndexed()
839
+ : occlusion_hull_geometry.clone()
840
+ const pos = src.getAttribute(`position`)
841
+ const n_verts = pos.count
842
+ const n_faces = n_verts / 3
629
843
  // Hull centroid for orienting face normals outward
630
- let hx = 0, hy = 0, hz = 0;
844
+ let hx = 0, hy = 0, hz = 0
631
845
  for (let vert_idx = 0; vert_idx < n_verts; vert_idx++) {
632
- hx += pos.getX(vert_idx);
633
- hy += pos.getY(vert_idx);
634
- hz += pos.getZ(vert_idx);
635
- }
636
- hx /= n_verts;
637
- hy /= n_verts;
638
- hz /= n_verts;
639
- const kept = [];
846
+ hx += pos.getX(vert_idx)
847
+ hy += pos.getY(vert_idx)
848
+ hz += pos.getZ(vert_idx)
849
+ }
850
+ hx /= n_verts
851
+ hy /= n_verts
852
+ hz /= n_verts
853
+ const kept: number[] = []
640
854
  for (let face_idx = 0; face_idx < n_faces; face_idx++) {
641
- const base = face_idx * 3;
642
- const va = [pos.getX(base), pos.getY(base), pos.getZ(base)];
643
- const vb = [pos.getX(base + 1), pos.getY(base + 1), pos.getZ(base + 1)];
644
- const vc = [pos.getX(base + 2), pos.getY(base + 2), pos.getZ(base + 2)];
645
- // Face normal via cross product of two edges
646
- let normal = cross_3d([vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]], [vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]]);
647
- // Orient outward (away from hull centroid)
648
- const dx = (va[0] + vb[0] + vc[0]) / 3 - hx;
649
- const dy = (va[1] + vb[1] + vc[1]) / 3 - hy;
650
- const dz = (va[2] + vb[2] + vc[2]) / 3 - hz;
651
- if (normal[0] * dx + normal[1] * dy + normal[2] * dz < 0) {
652
- normal = [-normal[0], -normal[1], -normal[2]];
653
- }
654
- // Closing faces point entirely toward negative octant (all 0).
655
- // Meaningful domain faces always have at least one positive component.
656
- if (normal[0] <= 0 && normal[1] <= 0 && normal[2] <= 0)
657
- continue;
658
- kept.push(...va, ...vb, ...vc);
855
+ const base = face_idx * 3
856
+ const va: Vec3 = [pos.getX(base), pos.getY(base), pos.getZ(base)]
857
+ const vb: Vec3 = [pos.getX(base + 1), pos.getY(base + 1), pos.getZ(base + 1)]
858
+ const vc: Vec3 = [pos.getX(base + 2), pos.getY(base + 2), pos.getZ(base + 2)]
859
+ // Face normal via cross product of two edges
860
+ let normal = cross_3d(
861
+ [vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]],
862
+ [vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]],
863
+ )
864
+ // Orient outward (away from hull centroid)
865
+ const dx = (va[0] + vb[0] + vc[0]) / 3 - hx
866
+ const dy = (va[1] + vb[1] + vc[1]) / 3 - hy
867
+ const dz = (va[2] + vb[2] + vc[2]) / 3 - hz
868
+ if (normal[0] * dx + normal[1] * dy + normal[2] * dz < 0) {
869
+ normal = [-normal[0], -normal[1], -normal[2]]
870
+ }
871
+ // Closing faces point entirely toward negative octant (all ≤ 0).
872
+ // Meaningful domain faces always have at least one positive component.
873
+ if (normal[0] <= 0 && normal[1] <= 0 && normal[2] <= 0) continue
874
+ kept.push(...va, ...vb, ...vc)
659
875
  }
660
876
  // Re-merge coplanar faces after the filter — the closing-face removal
661
877
  // can expose new coplanar adjacencies or leave fragments that should be
662
878
  // merged into cleaner fan triangulations.
663
- const merged = merge_coplanar_triangles(new Float32Array(kept));
664
- const geom = new THREE.BufferGeometry();
665
- geom.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3));
666
- const colors = new Float32Array(merged.length).fill(0.965);
667
- geom.setAttribute(`color`, new THREE.Float32BufferAttribute(colors, 3));
668
- return geom;
669
- });
670
- // Per-face domain assignment (stable — only changes when geometry or domains change).
671
- // Uses actual vertex centroid (mean of points_3d) for robust nearest-face matching.
672
- const face_domain_map = $derived.by(() => {
673
- if (!hull_base_geometry)
674
- return [];
675
- const pos = hull_base_geometry.getAttribute(`position`);
676
- const n_faces = pos.count / 3;
879
+ const merged = merge_coplanar_triangles(new Float32Array(kept))
880
+ const geom = new THREE.BufferGeometry()
881
+ geom.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3))
882
+ const colors = new Float32Array(merged.length).fill(0.965)
883
+ geom.setAttribute(`color`, new THREE.Float32BufferAttribute(colors, 3))
884
+ return geom
885
+ })
886
+
887
+ // Per-face domain assignment (stable only changes when geometry or domains change).
888
+ // Uses actual vertex centroid (mean of points_3d) for robust nearest-face matching.
889
+ const face_domain_map = $derived.by((): string[] => {
890
+ if (!hull_base_geometry) return []
891
+ const pos = hull_base_geometry.getAttribute(`position`)
892
+ const n_faces = pos.count / 3
893
+
677
894
  // Domain vertex centroids in render coords (swizzled + axis stretch), matching hull_base_geometry.
678
895
  const centroids = render_domains
679
- .filter((d) => !d.is_draw_formula && d.points_3d.length > 0)
680
- .map((d) => {
681
- let sx = 0, sy = 0, sz = 0;
896
+ .filter((d) => !d.is_draw_formula && d.points_3d.length > 0)
897
+ .map((d) => {
898
+ let sx = 0, sy = 0, sz = 0
682
899
  for (const pt of d.points_3d) {
683
- const [x_val, y_val, z_val] = to_render_xyz(pt);
684
- sx += x_val;
685
- sy += y_val;
686
- sz += z_val;
900
+ const [x_val, y_val, z_val] = to_render_xyz(pt)
901
+ sx += x_val
902
+ sy += y_val
903
+ sz += z_val
687
904
  }
688
- const n = d.points_3d.length;
689
- return { formula: d.formula, cx: sx / n, cy: sy / n, cz: sz / n };
690
- });
905
+ const n = d.points_3d.length
906
+ return { formula: d.formula, cx: sx / n, cy: sy / n, cz: sz / n }
907
+ })
908
+
691
909
  // Assign each face to the nearest domain centroid
692
- const result = [];
910
+ const result: string[] = []
693
911
  for (let face_idx = 0; face_idx < n_faces; face_idx++) {
694
- const base = face_idx * 3;
695
- const fcx = (pos.getX(base) + pos.getX(base + 1) + pos.getX(base + 2)) / 3;
696
- const fcy = (pos.getY(base) + pos.getY(base + 1) + pos.getY(base + 2)) / 3;
697
- const fcz = (pos.getZ(base) + pos.getZ(base + 1) + pos.getZ(base + 2)) / 3;
698
- let best_formula = ``;
699
- let best_dist = Infinity;
700
- for (const dc of centroids) {
701
- const dist = (fcx - dc.cx) ** 2 + (fcy - dc.cy) ** 2 + (fcz - dc.cz) ** 2;
702
- if (dist < best_dist) {
703
- best_dist = dist;
704
- best_formula = dc.formula;
705
- }
912
+ const base = face_idx * 3
913
+ const fcx = (pos.getX(base) + pos.getX(base + 1) + pos.getX(base + 2)) / 3
914
+ const fcy = (pos.getY(base) + pos.getY(base + 1) + pos.getY(base + 2)) / 3
915
+ const fcz = (pos.getZ(base) + pos.getZ(base + 1) + pos.getZ(base + 2)) / 3
916
+ let best_formula = ``
917
+ let best_dist = Infinity
918
+ for (const dc of centroids) {
919
+ const dist = (fcx - dc.cx) ** 2 + (fcy - dc.cy) ** 2 + (fcz - dc.cz) ** 2
920
+ if (dist < best_dist) {
921
+ best_dist = dist
922
+ best_formula = dc.formula
706
923
  }
707
- result.push(best_formula);
924
+ }
925
+ result.push(best_formula)
708
926
  }
927
+
709
928
  // Unify coplanar adjacent faces to the majority domain so that fan
710
929
  // triangulation edges within a single hull face don't create visible
711
930
  // color boundaries. Build adjacency via shared edge keys, group
712
931
  // coplanar neighbors, then assign each group to its most-common domain.
713
932
  if (n_faces > 1) {
714
- const tol = 1e-3;
715
- const round = (v) => Math.round(v / tol);
716
- const vkey = (vert_idx) => `${round(pos.getX(vert_idx))},${round(pos.getY(vert_idx))},${round(pos.getZ(vert_idx))}`;
717
- const ekey = (ka, kb) => ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
718
- // Compute face normals
719
- const normals = [];
720
- for (let face_idx = 0; face_idx < n_faces; face_idx++) {
721
- const base = face_idx * 3;
722
- const e1 = [
723
- pos.getX(base + 1) - pos.getX(base),
724
- pos.getY(base + 1) - pos.getY(base),
725
- pos.getZ(base + 1) - pos.getZ(base),
726
- ];
727
- const e2 = [
728
- pos.getX(base + 2) - pos.getX(base),
729
- pos.getY(base + 2) - pos.getY(base),
730
- pos.getZ(base + 2) - pos.getZ(base),
731
- ];
732
- normals.push(normalize_vec3(cross_3d(e1, e2)));
933
+ const tol = 1e-3
934
+ const round = (v: number): number => Math.round(v / tol)
935
+ const vkey = (vert_idx: number): string =>
936
+ `${round(pos.getX(vert_idx))},${round(pos.getY(vert_idx))},${
937
+ round(pos.getZ(vert_idx))
938
+ }`
939
+ const ekey = (ka: string, kb: string): string =>
940
+ ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`
941
+ // Compute face normals
942
+ const normals: Vec3[] = []
943
+ for (let face_idx = 0; face_idx < n_faces; face_idx++) {
944
+ const base = face_idx * 3
945
+ const e1: Vec3 = [
946
+ pos.getX(base + 1) - pos.getX(base),
947
+ pos.getY(base + 1) - pos.getY(base),
948
+ pos.getZ(base + 1) - pos.getZ(base),
949
+ ]
950
+ const e2: Vec3 = [
951
+ pos.getX(base + 2) - pos.getX(base),
952
+ pos.getY(base + 2) - pos.getY(base),
953
+ pos.getZ(base + 2) - pos.getZ(base),
954
+ ]
955
+ normals.push(normalize_vec3(cross_3d(e1, e2)))
956
+ }
957
+ // Build edge → face adjacency
958
+ const edge_faces = new SvelteMap<string, number[]>()
959
+ for (let face_idx = 0; face_idx < n_faces; face_idx++) {
960
+ const base = face_idx * 3
961
+ const keys = [vkey(base), vkey(base + 1), vkey(base + 2)]
962
+ for (
963
+ const ek of [
964
+ ekey(keys[0], keys[1]),
965
+ ekey(keys[1], keys[2]),
966
+ ekey(keys[0], keys[2]),
967
+ ]
968
+ ) {
969
+ const list = edge_faces.get(ek)
970
+ if (list) list.push(face_idx)
971
+ else edge_faces.set(ek, [face_idx])
733
972
  }
734
- // Build edge → face adjacency
735
- const edge_faces = new SvelteMap();
736
- for (let face_idx = 0; face_idx < n_faces; face_idx++) {
737
- const base = face_idx * 3;
738
- const keys = [vkey(base), vkey(base + 1), vkey(base + 2)];
739
- for (const ek of [
740
- ekey(keys[0], keys[1]),
741
- ekey(keys[1], keys[2]),
742
- ekey(keys[0], keys[2]),
743
- ]) {
744
- const list = edge_faces.get(ek);
745
- if (list)
746
- list.push(face_idx);
747
- else
748
- edge_faces.set(ek, [face_idx]);
749
- }
973
+ }
974
+ // Union-find for coplanar adjacent faces
975
+ const parent = Array.from({ length: n_faces }, (_, idx) => idx)
976
+ const find = (x: number): number => {
977
+ while (parent[x] !== x) {
978
+ parent[x] = parent[parent[x]]
979
+ x = parent[x]
750
980
  }
751
- // Union-find for coplanar adjacent faces
752
- const parent = Array.from({ length: n_faces }, (_, idx) => idx);
753
- const find = (x) => {
754
- while (parent[x] !== x) {
755
- parent[x] = parent[parent[x]];
756
- x = parent[x];
757
- }
758
- return x;
759
- };
760
- const union = (a_idx, b_idx) => {
761
- const ra = find(a_idx), rb = find(b_idx);
762
- if (ra !== rb)
763
- parent[ra] = rb;
764
- };
765
- for (const pair of edge_faces.values()) {
766
- if (pair.length !== 2)
767
- continue;
768
- const [fa, fb] = pair;
769
- const na = normals[fa], nb = normals[fb];
770
- if (Math.abs(na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2]) > 1 - tol) {
771
- union(fa, fb);
772
- }
981
+ return x
982
+ }
983
+ const union = (a_idx: number, b_idx: number): void => {
984
+ const ra = find(a_idx), rb = find(b_idx)
985
+ if (ra !== rb) parent[ra] = rb
986
+ }
987
+ for (const pair of edge_faces.values()) {
988
+ if (pair.length !== 2) continue
989
+ const [fa, fb] = pair
990
+ const na = normals[fa], nb = normals[fb]
991
+ if (Math.abs(na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2]) > 1 - tol) {
992
+ union(fa, fb)
773
993
  }
774
- // Assign majority domain to each coplanar group
775
- const groups = new SvelteMap();
776
- for (let face_idx = 0; face_idx < n_faces; face_idx++) {
777
- const root = find(face_idx);
778
- const grp = groups.get(root);
779
- if (grp)
780
- grp.push(face_idx);
781
- else
782
- groups.set(root, [face_idx]);
994
+ }
995
+ // Assign majority domain to each coplanar group
996
+ const groups = new SvelteMap<number, number[]>()
997
+ for (let face_idx = 0; face_idx < n_faces; face_idx++) {
998
+ const root = find(face_idx)
999
+ const grp = groups.get(root)
1000
+ if (grp) grp.push(face_idx)
1001
+ else groups.set(root, [face_idx])
1002
+ }
1003
+ for (const members of groups.values()) {
1004
+ if (members.length < 2) continue
1005
+ // Find most common domain in this group
1006
+ const counts = new SvelteMap<string, number>()
1007
+ for (const member_idx of members) {
1008
+ counts.set(result[member_idx], (counts.get(result[member_idx]) ?? 0) + 1)
783
1009
  }
784
- for (const members of groups.values()) {
785
- if (members.length < 2)
786
- continue;
787
- // Find most common domain in this group
788
- const counts = new SvelteMap();
789
- for (const member_idx of members) {
790
- counts.set(result[member_idx], (counts.get(result[member_idx]) ?? 0) + 1);
791
- }
792
- let majority = result[members[0]];
793
- let max_count = 0;
794
- for (const [formula, count] of counts) {
795
- if (count > max_count) {
796
- max_count = count;
797
- majority = formula;
798
- }
799
- }
800
- for (const member_idx of members)
801
- result[member_idx] = majority;
1010
+ let majority = result[members[0]]
1011
+ let max_count = 0
1012
+ for (const [formula, count] of counts) {
1013
+ if (count > max_count) {
1014
+ max_count = count
1015
+ majority = formula
1016
+ }
802
1017
  }
1018
+ for (const member_idx of members) result[member_idx] = majority
1019
+ }
803
1020
  }
804
- return result;
805
- });
806
- // Reactive color fill: creates a cloned geometry with vertex colors applied.
807
- // Only runs when color_mode or domain_colors change — no mutation of hull_base_geometry.
808
- const colored_hull_geometry = $derived.by(() => {
809
- const mapping = face_domain_map;
810
- if (!hull_base_geometry || mapping.length === 0)
811
- return hull_base_geometry;
812
- const geom = hull_base_geometry.clone();
813
- const color_attr = geom.getAttribute(`color`);
814
- const use_colors = color_mode !== `none` && domain_colors.size > 0;
1021
+
1022
+ return result
1023
+ })
1024
+
1025
+ // Reactive color fill: creates a cloned geometry with vertex colors applied.
1026
+ // Only runs when color_mode or domain_colors change — no mutation of hull_base_geometry.
1027
+ const colored_hull_geometry = $derived.by((): THREE.BufferGeometry | null => {
1028
+ const mapping = face_domain_map
1029
+ if (!hull_base_geometry || mapping.length === 0) return hull_base_geometry
1030
+
1031
+ const geom = hull_base_geometry.clone()
1032
+ const color_attr = geom.getAttribute(`color`) as THREE.BufferAttribute
1033
+ const use_colors = color_mode !== `none` && domain_colors.size > 0
815
1034
  const fb = use_colors
816
- ? [0.91, 0.91, 0.91] // #e8e8e8
817
- : [0.965, 0.965, 0.965]; // #f6f6f6
1035
+ ? [0.91, 0.91, 0.91] // #e8e8e8
1036
+ : [0.965, 0.965, 0.965] // #f6f6f6
1037
+
818
1038
  // Cache parsed RGB per formula to avoid redundant THREE.Color allocations
819
- const rgb_cache = new SvelteMap();
1039
+ const rgb_cache = new SvelteMap<string, Vec3>()
820
1040
  for (const [formula, hex] of domain_colors) {
821
- const clr = new THREE.Color(hex);
822
- rgb_cache.set(formula, [clr.r, clr.g, clr.b]);
1041
+ const clr = new THREE.Color(hex)
1042
+ rgb_cache.set(formula, [clr.r, clr.g, clr.b])
823
1043
  }
1044
+
824
1045
  for (let face_idx = 0; face_idx < mapping.length; face_idx++) {
825
- const rgb = use_colors ? rgb_cache.get(mapping[face_idx]) : null;
826
- const [red, green, blue] = rgb ?? fb;
827
- const base = face_idx * 3;
828
- for (let vert_idx = 0; vert_idx < 3; vert_idx++) {
829
- color_attr.setXYZ(base + vert_idx, red, green, blue);
830
- }
1046
+ const rgb = use_colors ? rgb_cache.get(mapping[face_idx]) : null
1047
+ const [red, green, blue] = rgb ?? fb
1048
+ const base = face_idx * 3
1049
+ for (let vert_idx = 0; vert_idx < 3; vert_idx++) {
1050
+ color_attr.setXYZ(base + vert_idx, red, green, blue)
1051
+ }
1052
+ }
1053
+ color_attr.needsUpdate = true
1054
+ return geom
1055
+ })
1056
+
1057
+ const visible_domain_labels = $derived.by(() => {
1058
+ if (!hull_base_geometry || face_domain_map.length === 0) {
1059
+ return render_domains.map((domain) => ({
1060
+ formula: domain.formula,
1061
+ position: swiz(domain.ann_loc[0], domain.ann_loc[1], domain.ann_loc[2]),
1062
+ label_font_size: domain.label_font_size,
1063
+ }))
831
1064
  }
832
- color_attr.needsUpdate = true;
833
- return geom;
834
- });
835
- $effect(() => {
836
- const geom = hull_base_geometry;
837
- return () => dispose_geometry(geom);
838
- });
839
- $effect(() => {
840
- const geom = colored_hull_geometry;
1065
+
1066
+ const pos = hull_base_geometry.getAttribute(`position`)
1067
+ const pinned_labels = render_domains
1068
+ .filter((domain) => domain.is_draw_formula)
1069
+ .map((domain) => ({
1070
+ formula: domain.formula,
1071
+ position: swiz(domain.ann_loc[0], domain.ann_loc[1], domain.ann_loc[2]),
1072
+ label_font_size: domain.label_font_size,
1073
+ }))
1074
+ const font_size_by_formula = new SvelteMap(
1075
+ render_domains.map((domain) => [domain.formula, domain.label_font_size]),
1076
+ )
1077
+ return get_visible_domain_labels(
1078
+ pos.array,
1079
+ face_domain_map,
1080
+ font_size_by_formula,
1081
+ pinned_labels,
1082
+ )
1083
+ })
1084
+
1085
+ $effect(() => {
1086
+ const geom = hull_base_geometry
1087
+ return () => dispose_geometry(geom)
1088
+ })
1089
+
1090
+ $effect(() => {
1091
+ const geom = colored_hull_geometry
841
1092
  // Don't dispose if it's the same object as hull_base_geometry (no clone was made)
842
- if (geom && geom !== hull_base_geometry)
843
- return () => dispose_geometry(geom);
844
- });
845
- // Domains on the outer surface: annotation point NOT strictly inside the hull.
846
- // Interior domains are hidden behind the surface and shouldn't show labels.
847
- const surface_formulas = $derived.by(() => {
848
- const on_surface = new SvelteSet();
1093
+ if (geom && geom !== hull_base_geometry) return () => dispose_geometry(geom)
1094
+ })
1095
+
1096
+ // Domains on the outer surface (used by the "Surface" formula overlay quick-select).
1097
+ const surface_formulas = $derived.by((): SvelteSet<string> => {
1098
+ const on_surface = new SvelteSet<string>()
849
1099
  if (!occlusion_hull_geometry) {
850
- for (const domain of render_domains)
851
- on_surface.add(domain.formula);
852
- return on_surface;
1100
+ for (const domain of render_domains) on_surface.add(domain.formula)
1101
+ return on_surface
853
1102
  }
854
1103
  // Raycast from each domain's centroid outward -- if it hits the hull,
855
1104
  // the centroid is inside (interior domain). Use multiple ray directions
856
1105
  // and count: if most hit, the point is interior.
857
- const raycaster = new THREE.Raycaster();
858
- const hull_mesh = new THREE.Mesh(occlusion_hull_geometry);
1106
+ const raycaster = new THREE.Raycaster()
1107
+ const hull_mesh = new THREE.Mesh(occlusion_hull_geometry)
859
1108
  const directions = [
860
- new THREE.Vector3(1, 0, 0),
861
- new THREE.Vector3(0, 1, 0),
862
- new THREE.Vector3(0, 0, 1),
863
- new THREE.Vector3(-1, 0, 0),
864
- new THREE.Vector3(0, -1, 0),
865
- new THREE.Vector3(0, 0, -1),
866
- ];
1109
+ new THREE.Vector3(1, 0, 0),
1110
+ new THREE.Vector3(0, 1, 0),
1111
+ new THREE.Vector3(0, 0, 1),
1112
+ new THREE.Vector3(-1, 0, 0),
1113
+ new THREE.Vector3(0, -1, 0),
1114
+ new THREE.Vector3(0, 0, -1),
1115
+ ]
867
1116
  for (const domain of render_domains) {
868
- if (domain.is_draw_formula) {
869
- on_surface.add(domain.formula);
870
- continue;
871
- }
872
- const origin = to_vec3(domain.ann_loc);
873
- // Count how many rays hit the hull from the centroid
874
- let hits = 0;
875
- for (const dir of directions) {
876
- raycaster.set(origin, dir);
877
- if (raycaster.intersectObject(hull_mesh).length > 0)
878
- hits++;
879
- }
880
- // If fewer than 4 of 6 rays hit, centroid is on or near the surface
881
- if (hits < 4)
882
- on_surface.add(domain.formula);
883
- }
884
- return on_surface;
885
- });
886
- // Deduplicate 3D points within tolerance (reuses compute.ts dedup_points)
887
- function dedup_3d(pts, tol = 1e-4) {
888
- return dedup_points(pts, tol).unique;
889
- }
890
- const controls_series = $derived([
1117
+ if (domain.is_draw_formula) {
1118
+ on_surface.add(domain.formula)
1119
+ continue
1120
+ }
1121
+ const origin = to_vec3(domain.ann_loc)
1122
+ // Count how many rays hit the hull from the centroid
1123
+ let hits = 0
1124
+ for (const dir of directions) {
1125
+ raycaster.set(origin, dir)
1126
+ if (raycaster.intersectObject(hull_mesh).length > 0) hits++
1127
+ }
1128
+ // If fewer than 4 of 6 rays hit, centroid is on or near the surface
1129
+ if (hits < 4) on_surface.add(domain.formula)
1130
+ }
1131
+ return on_surface
1132
+ })
1133
+
1134
+ // Deduplicate 3D points within tolerance (reuses compute.ts dedup_points)
1135
+ function dedup_3d(pts: number[][], tol: number = 1e-4): number[][] {
1136
+ return dedup_points(pts, tol).unique
1137
+ }
1138
+
1139
+ const controls_series = $derived<DataSeries3D[]>([
891
1140
  {
892
- x: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[1])),
893
- y: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[2])),
894
- z: render_domains.flatMap((domain) => domain.points_3d.map((point) => point[0])),
895
- label: `domains`,
1141
+ x: render_domains.flatMap((domain) =>
1142
+ domain.points_3d.map((point) => point[1])
1143
+ ),
1144
+ y: render_domains.flatMap((domain) =>
1145
+ domain.points_3d.map((point) => point[2])
1146
+ ),
1147
+ z: render_domains.flatMap((domain) =>
1148
+ domain.points_3d.map((point) => point[0])
1149
+ ),
1150
+ label: `domains`,
896
1151
  },
897
- ]);
898
- // Build formula overlay edge geometries (per formula, colored) using crease edges
899
- const formula_edge_data = $derived.by(() => {
900
- if (!draw_formula_lines || formulas_to_draw.length === 0)
901
- return [];
902
- const result = [];
1152
+ ])
1153
+
1154
+ // Build formula overlay edge geometries (per formula, colored) using crease edges
1155
+ const formula_edge_data = $derived.by(() => {
1156
+ if (!draw_formula_lines || formulas_to_draw.length === 0) return []
1157
+ const result: { geometry: THREE.BufferGeometry; color: string }[] = []
903
1158
  for (const domain of render_domains) {
904
- if (!domain.is_draw_formula)
905
- continue;
906
- const color_idx = formulas_to_draw.indexOf(domain.formula) %
907
- formula_colors.length;
908
- const swizzled = domain.points_3d.map((point) => to_render_xyz(point));
909
- const positions = [];
910
- for (const [pa, pb] of get_domain_edges(swizzled)) {
911
- positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2]);
912
- }
913
- const geom = new THREE.BufferGeometry();
914
- geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
915
- result.push({ geometry: geom, color: formula_colors[color_idx] });
916
- }
917
- return result;
918
- });
919
- // Build formula overlay mesh geometries (convex hull surface)
920
- const formula_mesh_data = $derived.by(() => {
921
- const result = [];
922
- if (!draw_formula_meshes)
923
- return result;
1159
+ if (!domain.is_draw_formula) continue
1160
+ const color_idx = formulas_to_draw.indexOf(domain.formula) %
1161
+ formula_colors.length
1162
+ const swizzled = domain.points_3d.map((point) => to_render_xyz(point))
1163
+ const positions: number[] = []
1164
+ for (const [pa, pb] of get_domain_edges(swizzled)) {
1165
+ positions.push(pa[0], pa[1], pa[2], pb[0], pb[1], pb[2])
1166
+ }
1167
+ const geom = new THREE.BufferGeometry()
1168
+ geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3))
1169
+ result.push({ geometry: geom, color: formula_colors[color_idx] })
1170
+ }
1171
+ return result
1172
+ })
1173
+
1174
+ // Build formula overlay mesh geometries (convex hull surface)
1175
+ const formula_mesh_data = $derived.by(() => {
1176
+ const result: { geometry: THREE.BufferGeometry; color: string }[] = []
1177
+ if (!draw_formula_meshes) return result
924
1178
  for (const domain of render_domains) {
925
- if (!domain.is_draw_formula || domain.points_3d.length < 4)
926
- continue;
927
- const color_idx = formulas_to_draw.indexOf(domain.formula) %
928
- formula_colors.length;
929
- const unique = dedup_3d(domain.points_3d);
930
- if (unique.length < 4)
931
- continue;
932
- const vectors = unique.map((pt) => to_vec3(pt));
933
- try {
934
- const geom = merge_coplanar_geometry(new ConvexGeometry(vectors));
935
- result.push({ geometry: geom, color: formula_colors[color_idx] });
936
- }
937
- catch {
938
- // Degenerate hull, skip
939
- }
940
- }
941
- return result;
942
- });
943
- function get_touches_limits(points_3d, lims) {
944
- const limit_tol = 1e-3;
945
- const touches_limits = [];
946
- for (let axis_idx = 0; axis_idx < Math.min(plot_elements.length, lims.length); axis_idx++) {
947
- const [axis_min, axis_max] = lims[axis_idx];
948
- const axis_name = plot_elements[axis_idx] ?? `axis_${axis_idx}`;
949
- const touches_min = points_3d.some((point) => Math.abs(point[axis_idx] - axis_min) < limit_tol);
950
- const touches_max = points_3d.some((point) => Math.abs(point[axis_idx] - axis_max) < limit_tol);
951
- if (touches_min)
952
- touches_limits.push(`${axis_name} lower bound`);
953
- if (touches_max)
954
- touches_limits.push(`${axis_name} upper bound`);
955
- }
956
- return touches_limits;
957
- }
958
- // Post-process ConvexGeometry to merge coplanar triangles, eliminating
959
- // internal diagonal edges across flat faces of the convex hull.
960
- function merge_coplanar_geometry(geom) {
961
- const non_indexed = geom.index ? geom.toNonIndexed() : geom;
962
- const pos = non_indexed.getAttribute(`position`);
963
- const merged = merge_coplanar_triangles(pos.array);
964
- const result = new THREE.BufferGeometry();
965
- result.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3));
966
- result.computeVertexNormals();
1179
+ if (!domain.is_draw_formula || domain.points_3d.length < 4) continue
1180
+ const color_idx = formulas_to_draw.indexOf(domain.formula) %
1181
+ formula_colors.length
1182
+ const unique = dedup_3d(domain.points_3d)
1183
+ if (unique.length < 4) continue
1184
+ const vectors = unique.map((pt) => to_vec3(pt))
1185
+ try {
1186
+ const geom = merge_coplanar_geometry(new ConvexGeometry(vectors))
1187
+ result.push({ geometry: geom, color: formula_colors[color_idx] })
1188
+ } catch {
1189
+ // Degenerate hull, skip
1190
+ }
1191
+ }
1192
+ return result
1193
+ })
1194
+
1195
+ function get_touches_limits(
1196
+ points_3d: number[][],
1197
+ lims: [number, number][],
1198
+ ): string[] {
1199
+ const limit_tol = 1e-3
1200
+ const touches_limits: string[] = []
1201
+ for (
1202
+ let axis_idx = 0;
1203
+ axis_idx < Math.min(plot_elements.length, lims.length);
1204
+ axis_idx++
1205
+ ) {
1206
+ const [axis_min, axis_max] = lims[axis_idx]
1207
+ const axis_name = plot_elements[axis_idx] ?? `axis_${axis_idx}`
1208
+ const touches_min = points_3d.some((point) =>
1209
+ Math.abs(point[axis_idx] - axis_min) < limit_tol
1210
+ )
1211
+ const touches_max = points_3d.some((point) =>
1212
+ Math.abs(point[axis_idx] - axis_max) < limit_tol
1213
+ )
1214
+ if (touches_min) touches_limits.push(`${axis_name} lower bound`)
1215
+ if (touches_max) touches_limits.push(`${axis_name} upper bound`)
1216
+ }
1217
+ return touches_limits
1218
+ }
1219
+
1220
+ // Post-process ConvexGeometry to merge coplanar triangles, eliminating
1221
+ // internal diagonal edges across flat faces of the convex hull.
1222
+ function merge_coplanar_geometry(geom: THREE.BufferGeometry): THREE.BufferGeometry {
1223
+ const non_indexed = geom.index ? geom.toNonIndexed() : geom
1224
+ const pos = non_indexed.getAttribute(`position`)
1225
+ const merged = merge_coplanar_triangles(pos.array as Float32Array)
1226
+ const result = new THREE.BufferGeometry()
1227
+ result.setAttribute(`position`, new THREE.Float32BufferAttribute(merged, 3))
1228
+ result.computeVertexNormals()
967
1229
  // Dispose intermediate geometry from toNonIndexed() (avoid double-dispose if same object)
968
- if (non_indexed !== geom)
969
- non_indexed.dispose();
1230
+ if (non_indexed !== geom) non_indexed.dispose()
970
1231
  // Callers always pass a freshly created ConvexGeometry, so we own it
971
- geom.dispose();
972
- return result;
973
- }
974
- function create_hover_geometry(points_3d) {
975
- const unique_points = dedup_3d(points_3d);
976
- if (unique_points.length < 3)
977
- return null;
1232
+ geom.dispose()
1233
+ return result
1234
+ }
1235
+
1236
+ function create_hover_geometry(
1237
+ points_3d: number[][],
1238
+ ): { geometry: THREE.BufferGeometry; n_vertices: number } | null {
1239
+ const unique_points = dedup_3d(points_3d)
1240
+ if (unique_points.length < 3) return null
978
1241
  // For exactly 3 unique points (planar/degenerate domain), create a triangle
979
1242
  // geometry directly since ConvexGeometry requires 4+ points for a 3D hull
980
1243
  if (unique_points.length === 3) {
981
- const geom = new THREE.BufferGeometry();
982
- const vectors = unique_points.map((pt) => to_vec3(pt));
983
- const verts = new Float32Array(vectors.flatMap((v) => [v.x, v.y, v.z]));
984
- geom.setAttribute(`position`, new THREE.Float32BufferAttribute(verts, 3));
985
- geom.setIndex([0, 1, 2, 2, 1, 0]); // both winding orders for double-sided pick
986
- geom.computeVertexNormals();
987
- return { geometry: geom, n_vertices: 3 };
1244
+ const geom = new THREE.BufferGeometry()
1245
+ const vectors = unique_points.map((pt) => to_vec3(pt))
1246
+ const verts = new Float32Array(vectors.flatMap((v) => [v.x, v.y, v.z]))
1247
+ geom.setAttribute(`position`, new THREE.Float32BufferAttribute(verts, 3))
1248
+ geom.setIndex([0, 1, 2, 2, 1, 0]) // both winding orders for double-sided pick
1249
+ geom.computeVertexNormals()
1250
+ return { geometry: geom, n_vertices: 3 }
988
1251
  }
989
1252
  try {
990
- return {
991
- geometry: merge_coplanar_geometry(new ConvexGeometry(unique_points.map((point) => to_vec3(point)))),
992
- n_vertices: unique_points.length,
993
- };
994
- }
995
- catch {
996
- return null;
997
- }
998
- }
999
- // Domain adjacency: two domains are neighbors if they share any vertex (within tolerance)
1000
- const domain_neighbors = $derived.by(() => {
1001
- const tol = 1e-4;
1002
- const vertex_owners = new SvelteMap();
1003
- for (const domain of render_domains) {
1004
- for (const pt of domain.points_3d) {
1005
- const key = pt.map((val) => (Math.round(val / tol) * tol).toFixed(4)).join(`,`);
1006
- const owners = vertex_owners.get(key);
1007
- if (owners) {
1008
- if (!owners.includes(domain.formula))
1009
- owners.push(domain.formula);
1010
- }
1011
- else
1012
- vertex_owners.set(key, [domain.formula]);
1013
- }
1253
+ return {
1254
+ geometry: merge_coplanar_geometry(
1255
+ new ConvexGeometry(unique_points.map((point) => to_vec3(point))),
1256
+ ),
1257
+ n_vertices: unique_points.length,
1258
+ }
1259
+ } catch {
1260
+ return null
1014
1261
  }
1015
- const neighbors = new SvelteMap();
1262
+ }
1263
+
1264
+ // Domain adjacency: two domains are neighbors if they share any vertex (within tolerance)
1265
+ const domain_neighbors = $derived.by((): SvelteMap<string, string[]> => {
1266
+ const tol = 1e-4
1267
+ const vertex_owners = new SvelteMap<string, string[]>()
1268
+ for (const domain of render_domains) {
1269
+ for (const pt of domain.points_3d) {
1270
+ const key = pt.map((val) => (Math.round(val / tol) * tol).toFixed(4)).join(
1271
+ `,`,
1272
+ )
1273
+ const owners = vertex_owners.get(key)
1274
+ if (owners) {
1275
+ if (!owners.includes(domain.formula)) owners.push(domain.formula)
1276
+ } else vertex_owners.set(key, [domain.formula])
1277
+ }
1278
+ }
1279
+ const neighbors = new SvelteMap<string, SvelteSet<string>>()
1016
1280
  for (const domain of render_domains) {
1017
- neighbors.set(domain.formula, new SvelteSet());
1281
+ neighbors.set(domain.formula, new SvelteSet())
1018
1282
  }
1019
1283
  for (const owners of vertex_owners.values()) {
1020
- if (owners.length < 2)
1021
- continue;
1022
- for (let idx = 0; idx < owners.length; idx++) {
1023
- for (let jdx = idx + 1; jdx < owners.length; jdx++) {
1024
- neighbors.get(owners[idx])?.add(owners[jdx]);
1025
- neighbors.get(owners[jdx])?.add(owners[idx]);
1026
- }
1284
+ if (owners.length < 2) continue
1285
+ for (let idx = 0; idx < owners.length; idx++) {
1286
+ for (let jdx = idx + 1; jdx < owners.length; jdx++) {
1287
+ neighbors.get(owners[idx])?.add(owners[jdx])
1288
+ neighbors.get(owners[jdx])?.add(owners[idx])
1027
1289
  }
1290
+ }
1028
1291
  }
1029
- const result = new SvelteMap();
1030
- for (const [formula, set] of neighbors)
1031
- result.set(formula, [...set].sort());
1032
- return result;
1033
- });
1034
- const hover_mesh_data = $derived.by(() => {
1035
- if (!diagram_data)
1036
- return [];
1037
- const result = [];
1038
- const lims = diagram_data.lims;
1039
- const energy_stats_by_formula = entry_energy_stats_by_formula;
1292
+ const result = new SvelteMap<string, string[]>()
1293
+ for (const [formula, set] of neighbors) result.set(formula, [...set].sort())
1294
+ return result
1295
+ })
1296
+
1297
+ const hover_mesh_data = $derived.by((): HoverMeshData[] => {
1298
+ if (!diagram_data) return []
1299
+ const result: HoverMeshData[] = []
1300
+ const lims = diagram_data.lims
1301
+ const energy_stats_by_formula = entry_energy_stats_by_formula
1302
+
1040
1303
  for (const domain of render_domains) {
1041
- if (domain.points_3d.length < 3)
1042
- continue;
1043
- const hover_geometry = create_hover_geometry(domain.points_3d);
1044
- if (!hover_geometry)
1045
- continue;
1046
- const { geometry, n_vertices } = hover_geometry;
1047
- const swizzled_points = domain.points_3d.map((point) => to_render_xyz(point));
1048
- const edge_count = get_domain_edges(swizzled_points).length;
1049
- const axis_ranges = build_axis_ranges(domain.points_3d, plot_elements);
1050
- const touches_limits = get_touches_limits(domain.points_3d, lims);
1051
- const energy_stats = energy_stats_by_formula.get(domain.formula) ?? {
1052
- matching_entry_count: 0,
1053
- min_energy_per_atom: null,
1054
- max_energy_per_atom: null,
1055
- };
1056
- const info = {
1057
- formula: domain.formula,
1058
- view: `3d`,
1059
- n_vertices,
1060
- n_edges: edge_count,
1061
- n_points: domain.points_3d.length,
1062
- ann_loc: domain.ann_loc,
1063
- axis_ranges,
1064
- touches_limits,
1065
- is_elemental: all_entry_elements.includes(domain.formula),
1066
- is_draw_formula: domain.is_draw_formula,
1067
- matching_entry_count: energy_stats.matching_entry_count,
1068
- min_energy_per_atom: energy_stats.min_energy_per_atom,
1069
- max_energy_per_atom: energy_stats.max_energy_per_atom,
1070
- neighbors: domain_neighbors.get(domain.formula) ?? [],
1071
- };
1072
- result.push({
1073
- formula: domain.formula,
1074
- geometry,
1075
- info,
1076
- });
1077
- }
1078
- return result;
1079
- });
1080
- function dispose_geometry(geometry) {
1081
- if (!geometry)
1082
- return;
1083
- geometry.dispose();
1084
- }
1085
- function dispose_geometries(geometries) {
1086
- for (const geometry of geometries)
1087
- dispose_geometry(geometry);
1088
- }
1089
- $effect(() => {
1090
- const geometry = edge_geometry;
1091
- return () => dispose_geometry(geometry);
1092
- });
1093
- $effect(() => {
1094
- const geometry = occlusion_hull_geometry;
1095
- return () => dispose_geometry(geometry);
1096
- });
1097
- $effect(() => {
1098
- const geometry = bounding_box_geometry;
1099
- return () => dispose_geometry(geometry);
1100
- });
1101
- $effect(() => {
1102
- const geometries = formula_edge_data.map((data) => data.geometry);
1103
- return () => dispose_geometries(geometries);
1104
- });
1105
- $effect(() => {
1106
- const geometries = formula_mesh_data.map((data) => data.geometry);
1107
- return () => dispose_geometries(geometries);
1108
- });
1109
- $effect(() => {
1110
- const geometries = hover_mesh_data.map((data) => data.geometry);
1111
- return () => dispose_geometries(geometries);
1112
- });
1113
- // === Grid, axes, ticks (matching ScatterPlot3D style) ===
1114
- // Bounding box of all data points in DATA coordinates (before swizzle)
1115
- const raw_data_bbox = $derived.by(() => {
1116
- const pts = render_domains.flatMap((d) => d.points_3d);
1117
- if (pts.length === 0)
1118
- return { mins: [0, 0, 0], maxs: [1, 1, 1] };
1119
- const mins = [Infinity, Infinity, Infinity];
1120
- const maxs = [-Infinity, -Infinity, -Infinity];
1304
+ if (domain.points_3d.length < 3) continue
1305
+ const hover_geometry = create_hover_geometry(domain.points_3d)
1306
+ if (!hover_geometry) continue
1307
+ const { geometry, n_vertices } = hover_geometry
1308
+
1309
+ const swizzled_points = domain.points_3d.map((point) => to_render_xyz(point))
1310
+ const edge_count = get_domain_edges(swizzled_points).length
1311
+ const axis_ranges = build_axis_ranges(domain.points_3d, plot_elements)
1312
+ const touches_limits = get_touches_limits(domain.points_3d, lims)
1313
+ const energy_stats = energy_stats_by_formula.get(domain.formula) ?? {
1314
+ matching_entry_count: 0,
1315
+ min_energy_per_atom: null,
1316
+ max_energy_per_atom: null,
1317
+ }
1318
+
1319
+ const info: ChemPotHoverInfo3D = {
1320
+ formula: domain.formula,
1321
+ view: `3d`,
1322
+ n_vertices,
1323
+ n_edges: edge_count,
1324
+ n_points: domain.points_3d.length,
1325
+ ann_loc: domain.ann_loc,
1326
+ axis_ranges,
1327
+ touches_limits,
1328
+ is_elemental: all_entry_elements.includes(domain.formula),
1329
+ is_draw_formula: domain.is_draw_formula,
1330
+ matching_entry_count: energy_stats.matching_entry_count,
1331
+ min_energy_per_atom: energy_stats.min_energy_per_atom,
1332
+ max_energy_per_atom: energy_stats.max_energy_per_atom,
1333
+ neighbors: domain_neighbors.get(domain.formula) ?? [],
1334
+ }
1335
+
1336
+ result.push({
1337
+ formula: domain.formula,
1338
+ geometry,
1339
+ info,
1340
+ })
1341
+ }
1342
+ return result
1343
+ })
1344
+
1345
+ function dispose_geometry(geometry: THREE.BufferGeometry | null | undefined): void {
1346
+ if (!geometry) return
1347
+ geometry.dispose()
1348
+ }
1349
+
1350
+ function dispose_geometries(
1351
+ geometries: (THREE.BufferGeometry | null | undefined)[],
1352
+ ): void {
1353
+ for (const geometry of geometries) dispose_geometry(geometry)
1354
+ }
1355
+
1356
+ $effect(() => {
1357
+ const geometry = edge_geometry
1358
+ return () => dispose_geometry(geometry)
1359
+ })
1360
+
1361
+ $effect(() => {
1362
+ const geometry = occlusion_hull_geometry
1363
+ return () => dispose_geometry(geometry)
1364
+ })
1365
+
1366
+ $effect(() => {
1367
+ const geometry = bounding_box_geometry
1368
+ return () => dispose_geometry(geometry)
1369
+ })
1370
+
1371
+ $effect(() => {
1372
+ const geometries = formula_edge_data.map((data) => data.geometry)
1373
+ return () => dispose_geometries(geometries)
1374
+ })
1375
+
1376
+ $effect(() => {
1377
+ const geometries = formula_mesh_data.map((data) => data.geometry)
1378
+ return () => dispose_geometries(geometries)
1379
+ })
1380
+
1381
+ $effect(() => {
1382
+ const geometries = hover_mesh_data.map((data) => data.geometry)
1383
+ return () => dispose_geometries(geometries)
1384
+ })
1385
+
1386
+ // === Grid, axes, ticks (matching ScatterPlot3D style) ===
1387
+
1388
+ // Bounding box of all data points in DATA coordinates (before swizzle)
1389
+ const raw_data_bbox = $derived.by(() => {
1390
+ const pts = render_domains.flatMap((d) => d.points_3d)
1391
+ if (pts.length === 0) return { mins: [0, 0, 0], maxs: [1, 1, 1] }
1392
+ const mins = [Infinity, Infinity, Infinity]
1393
+ const maxs = [-Infinity, -Infinity, -Infinity]
1121
1394
  for (const pt of pts) {
1122
- for (let dim = 0; dim < 3; dim++) {
1123
- if (pt[dim] < mins[dim])
1124
- mins[dim] = pt[dim];
1125
- if (pt[dim] > maxs[dim])
1126
- maxs[dim] = pt[dim];
1127
- }
1395
+ for (let dim = 0; dim < 3; dim++) {
1396
+ if (pt[dim] < mins[dim]) mins[dim] = pt[dim]
1397
+ if (pt[dim] > maxs[dim]) maxs[dim] = pt[dim]
1398
+ }
1128
1399
  }
1129
- return { mins, maxs };
1130
- });
1131
- // Axis range controls are in swizzled axis order:
1132
- // x-axis control -> data axis 1, y-axis control -> data axis 2, z-axis control -> data axis 0
1133
- const data_bbox = $derived.by(() => {
1134
- const mins = [...raw_data_bbox.mins];
1135
- const maxs = [...raw_data_bbox.maxs];
1136
- const range_by_data_axis = [
1137
- z_axis.range,
1138
- x_axis.range,
1139
- y_axis.range,
1140
- ];
1400
+ return { mins, maxs }
1401
+ })
1402
+
1403
+ // Axis range controls are in swizzled axis order:
1404
+ // x-axis control -> data axis 1, y-axis control -> data axis 2, z-axis control -> data axis 0
1405
+ const data_bbox = $derived.by(() => {
1406
+ const mins = [...raw_data_bbox.mins]
1407
+ const maxs = [...raw_data_bbox.maxs]
1408
+ const range_by_data_axis: ([number | null, number | null] | undefined)[] = [
1409
+ z_axis.range,
1410
+ x_axis.range,
1411
+ y_axis.range,
1412
+ ]
1141
1413
  for (let axis_idx = 0; axis_idx < 3; axis_idx++) {
1142
- const range = range_by_data_axis[axis_idx];
1143
- if (!range)
1144
- continue;
1145
- const [range_min, range_max] = range;
1146
- if (range_min !== null)
1147
- mins[axis_idx] = range_min;
1148
- if (range_max !== null)
1149
- maxs[axis_idx] = range_max;
1150
- }
1151
- return { mins, maxs };
1152
- });
1153
- // Generate nice tick values for each data axis using D3
1154
- function gen_ticks(min_val, max_val, count = 5) {
1414
+ const range = range_by_data_axis[axis_idx]
1415
+ if (!range) continue
1416
+ const [range_min, range_max] = range
1417
+ if (range_min !== null) mins[axis_idx] = range_min
1418
+ if (range_max !== null) maxs[axis_idx] = range_max
1419
+ }
1420
+ return { mins, maxs }
1421
+ })
1422
+
1423
+ // Generate nice tick values for each data axis using D3
1424
+ function gen_ticks(min_val: number, max_val: number, count: number = 5): number[] {
1155
1425
  if (!isFinite(min_val) || !isFinite(max_val) || min_val === max_val) {
1156
- return [min_val];
1426
+ return [min_val]
1157
1427
  }
1158
- return scaleLinear().domain([min_val, max_val]).nice().ticks(count);
1159
- }
1160
- // Ticks in DATA coordinates for each of the 3 data axes
1161
- const data_ticks = $derived([
1428
+ return scaleLinear().domain([min_val, max_val]).nice().ticks(count)
1429
+ }
1430
+
1431
+ // Ticks in DATA coordinates for each of the 3 data axes
1432
+ const data_ticks = $derived([
1162
1433
  gen_ticks(data_bbox.mins[0], data_bbox.maxs[0]),
1163
1434
  gen_ticks(data_bbox.mins[1], data_bbox.maxs[1]),
1164
1435
  gen_ticks(data_bbox.mins[2], data_bbox.maxs[2]),
1165
- ]);
1166
- // Niced ranges (from ticks) padded so the grid extends beyond the diagram.
1167
- // For horizontal axes (0,1): pad both sides.
1168
- // For vertical axis (2): use actual data range and round min down to an integer.
1169
- const niced_range = $derived.by(() => {
1170
- return [0, 1, 2].map((axis) => {
1171
- const ticks = data_ticks[axis];
1172
- const lo = ticks[0];
1173
- const hi = ticks.at(-1) ?? lo;
1174
- const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1;
1175
- if (axis === 2) {
1176
- const min_data = data_bbox.mins[2];
1177
- const max_data = data_bbox.maxs[2];
1178
- return [Math.floor(min_data), max_data];
1179
- }
1180
- return [lo - step, hi + step];
1181
- });
1182
- });
1183
- // Helper to create a line geometry from two Vec3 arrays
1184
- function make_line_geom(start, end) {
1185
- const geom = new THREE.BufferGeometry();
1186
- geom.setAttribute(`position`, new THREE.BufferAttribute(new Float32Array([...start, ...end]), 3));
1187
- return geom;
1188
- }
1189
- // Swizzle a data-coord triple to Three.js coords
1190
- function swiz(d0, d1, d2) {
1191
- const [scale_x, scale_y, scale_z] = render_axis_scale;
1192
- return [d1 * scale_x, d2 * scale_y, d0 * scale_z]; // data[0]→Z, data[1]→X, data[2]→Y
1193
- }
1194
- const axis_colors = [`#e74c3c`, `#2ecc71`, `#3498db`];
1195
- function chem_axis_label(data_axis) {
1196
- return formal_chempots
1197
- ? `\u0394\u03BC(${plot_elements[data_axis]}) (eV)`
1198
- : `\u03BC(${plot_elements[data_axis]}) (eV)`;
1199
- }
1200
- // Proportional offsets for tick marks and labels, scaled to data extent
1201
- const tick_size = $derived(data_extent * 0.015);
1202
- const tick_label_dist = $derived(data_extent * 0.04);
1203
- const axis_label_dist = $derived(data_extent * 0.08);
1204
- // Place axis label just past the outer end of the axis (the end closer to 0).
1205
- // In isometric 3D, the end near 0 projects outward at the front edge of the
1206
- // bounding box, while the negative end projects inward toward the center.
1207
- function outer_end(range) {
1208
- return Math.abs(range[0]) <= Math.abs(range[1]) ? range[0] : range[1];
1209
- }
1210
- // Direction from range center toward outer end (to extend the label beyond the grid)
1211
- function outer_dir(range) {
1212
- const end = outer_end(range);
1213
- const mid = (range[0] + range[1]) / 2;
1214
- return end >= mid ? 1 : -1;
1215
- }
1216
- // Grid/axis configuration for each data axis.
1217
- // Axes, ticks, and labels are placed on the backside (far from camera)
1218
- // matching ScatterPlot3DScene's dynamic backside tracking pattern.
1219
- const grid_config = $derived.by(() => {
1220
- const [r0, r1, r2] = niced_range;
1436
+ ])
1437
+
1438
+ // Niced ranges (from ticks) padded so the grid extends beyond the diagram.
1439
+ // For horizontal axes (0,1): pad both sides.
1440
+ // For vertical axis (2): use actual data range and round min down to an integer.
1441
+ const niced_range = $derived.by(() => {
1442
+ return [0, 1, 2].map((axis): Vec2 => {
1443
+ const ticks = data_ticks[axis]
1444
+ const lo = ticks[0]
1445
+ const hi = ticks.at(-1) ?? lo
1446
+ const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1
1447
+ if (axis === 2) {
1448
+ const min_data = data_bbox.mins[2]
1449
+ return [Math.floor(min_data), hi]
1450
+ }
1451
+ return [lo - step, hi + step]
1452
+ })
1453
+ })
1454
+
1455
+ // Helper to create a line geometry from two Vec3 arrays
1456
+ function make_line_geom(
1457
+ start: Vec3,
1458
+ end: Vec3,
1459
+ ): THREE.BufferGeometry {
1460
+ const geom = new THREE.BufferGeometry()
1461
+ geom.setAttribute(
1462
+ `position`,
1463
+ new THREE.BufferAttribute(new Float32Array([...start, ...end]), 3),
1464
+ )
1465
+ return geom
1466
+ }
1467
+
1468
+ // Swizzle a data-coord triple to Three.js coords
1469
+ function swiz(d0: number, d1: number, d2: number): Vec3 {
1470
+ const [scale_x, scale_y, scale_z] = render_axis_scale
1471
+ return [d1 * scale_x, d2 * scale_y, d0 * scale_z] // data[0]→Z, data[1]→X, data[2]→Y
1472
+ }
1473
+
1474
+ const axis_colors = [`#e74c3c`, `#2ecc71`, `#3498db`] as const
1475
+ function chem_axis_label(data_axis: number): string {
1476
+ const el = plot_elements[data_axis]
1477
+ const prefix = formal_chempots ? `\u0394` : ``
1478
+ return `${prefix}\u03BC<sub>${el}</sub> <span class="axis-unit">(eV)</span>`
1479
+ }
1480
+
1481
+ // Proportional offsets for tick marks and labels, scaled to data extent
1482
+ const tick_size = $derived(data_extent * 0.015)
1483
+ const tick_label_dist = $derived(data_extent * 0.04)
1484
+ const axis_label_dist = $derived(data_extent * 0.02)
1485
+
1486
+ // Place axis label just past the outer end of the axis (the end closer to 0).
1487
+ // In isometric 3D, the end near 0 projects outward at the front edge of the
1488
+ // bounding box, while the negative end projects inward toward the center.
1489
+ function outer_end(range: [number, number]): number {
1490
+ return Math.abs(range[0]) <= Math.abs(range[1]) ? range[0] : range[1]
1491
+ }
1492
+ // Direction from range center toward outer end (to extend the label beyond the grid)
1493
+ function outer_dir(range: [number, number]): number {
1494
+ const end = outer_end(range)
1495
+ const mid = (range[0] + range[1]) / 2
1496
+ return end >= mid ? 1 : -1
1497
+ }
1498
+
1499
+ // Grid/axis configuration for each data axis.
1500
+ // Axes, ticks, and labels are placed on the backside (far from camera)
1501
+ // matching ScatterPlot3DScene's dynamic backside tracking pattern.
1502
+ const grid_config = $derived.by(() => {
1503
+ const [r0, r1, r2] = niced_range
1504
+
1221
1505
  return [0, 1, 2].map((axis) => {
1222
- const ticks = data_ticks[axis];
1223
- const color = axis_colors[axis];
1224
- const label = axis === 0
1225
- ? (z_axis.label || chem_axis_label(0))
1226
- : axis === 1
1227
- ? (x_axis.label || chem_axis_label(1))
1228
- : (y_axis.label || chem_axis_label(2));
1229
- const tick_geoms = [];
1230
- const grid_geoms = [];
1231
- const tick_labels = [];
1232
- let line_geom;
1233
- let label_pos;
1234
- if (axis === 0) {
1235
- // Data axis 0 (Three.js Z, depth): axis at backside d1 and d2
1236
- const ls = swiz(r0[0], back[1], back[2]);
1237
- const le = swiz(r0[1], back[1], back[2]);
1238
- line_geom = make_line_geom(ls, le);
1239
- // Axis label past the outer end of the axis (near 0, projects outward)
1240
- label_pos = swiz(outer_end(r0) + outer_dir(r0) * axis_label_dist, back[1] + out_x * tick_label_dist * 0.5, back[2] + out_y * tick_label_dist);
1241
- for (const val of ticks) {
1242
- tick_geoms.push(make_line_geom(swiz(val, back[1], back[2]), swiz(val, back[1], back[2] + out_y * tick_size)));
1243
- grid_geoms.push(make_line_geom(swiz(val, r1[0], back[2]), swiz(val, r1[1], back[2])));
1244
- grid_geoms.push(make_line_geom(swiz(val, back[1], r2[0]), swiz(val, back[1], r2[1])));
1245
- tick_labels.push({
1246
- pos: swiz(val, back[1] + out_x * tick_label_dist * 0.5, back[2] + out_y * tick_label_dist),
1247
- text: format_num(val, `.3~g`),
1248
- });
1249
- }
1506
+ const ticks = data_ticks[axis]
1507
+ const color = axis_colors[axis]
1508
+ const label = axis === 0
1509
+ ? (z_axis.label || chem_axis_label(0))
1510
+ : axis === 1
1511
+ ? (x_axis.label || chem_axis_label(1))
1512
+ : (y_axis.label || chem_axis_label(2))
1513
+
1514
+ const tick_geoms: THREE.BufferGeometry[] = []
1515
+ const grid_geoms: THREE.BufferGeometry[] = []
1516
+ const tick_labels: { pos: Vec3; text: string }[] = []
1517
+ let line_geom: THREE.BufferGeometry
1518
+ let label_pos: Vec3
1519
+
1520
+ if (axis === 0) {
1521
+ // Data axis 0 (Three.js Z, depth): axis at backside d1 and d2
1522
+ const ls = swiz(r0[0], back[1], back[2])
1523
+ const le = swiz(r0[1], back[1], back[2])
1524
+ line_geom = make_line_geom(ls, le)
1525
+ // Axis label past the outer end of the axis (near 0, projects outward)
1526
+ label_pos = swiz(
1527
+ outer_end(r0) + outer_dir(r0) * axis_label_dist,
1528
+ back[1] + out_x * tick_label_dist * 0.5,
1529
+ back[2] + out_y * tick_label_dist,
1530
+ )
1531
+ for (const val of ticks) {
1532
+ tick_geoms.push(make_line_geom(
1533
+ swiz(val, back[1], back[2]),
1534
+ swiz(val, back[1], back[2] + out_y * tick_size),
1535
+ ))
1536
+ grid_geoms.push(
1537
+ make_line_geom(swiz(val, r1[0], back[2]), swiz(val, r1[1], back[2])),
1538
+ )
1539
+ grid_geoms.push(
1540
+ make_line_geom(swiz(val, back[1], r2[0]), swiz(val, back[1], r2[1])),
1541
+ )
1542
+ tick_labels.push({
1543
+ pos: swiz(
1544
+ val,
1545
+ back[1] + out_x * tick_label_dist * 0.5,
1546
+ back[2] + out_y * tick_label_dist,
1547
+ ),
1548
+ text: format_num(val, `.3~g`),
1549
+ })
1250
1550
  }
1251
- else if (axis === 1) {
1252
- // Data axis 1 (Three.js X, horizontal): axis at backside d0 and d2
1253
- const ls = swiz(back[0], r1[0], back[2]);
1254
- const le = swiz(back[0], r1[1], back[2]);
1255
- line_geom = make_line_geom(ls, le);
1256
- label_pos = swiz(back[0], outer_end(r1) + outer_dir(r1) * axis_label_dist, back[2] + out_y * tick_label_dist);
1257
- for (const val of ticks) {
1258
- tick_geoms.push(make_line_geom(swiz(back[0], val, back[2]), swiz(back[0], val, back[2] + out_y * tick_size)));
1259
- grid_geoms.push(make_line_geom(swiz(r0[0], val, back[2]), swiz(r0[1], val, back[2])));
1260
- grid_geoms.push(make_line_geom(swiz(back[0], val, r2[0]), swiz(back[0], val, r2[1])));
1261
- tick_labels.push({
1262
- pos: swiz(back[0], val, back[2] + out_y * tick_label_dist),
1263
- text: format_num(val, `.3~g`),
1264
- });
1265
- }
1551
+ } else if (axis === 1) {
1552
+ // Data axis 1 (Three.js X, horizontal): axis at backside d0 and d2
1553
+ const ls = swiz(back[0], r1[0], back[2])
1554
+ const le = swiz(back[0], r1[1], back[2])
1555
+ line_geom = make_line_geom(ls, le)
1556
+ label_pos = swiz(
1557
+ back[0],
1558
+ outer_end(r1) + outer_dir(r1) * axis_label_dist,
1559
+ back[2] + out_y * tick_label_dist,
1560
+ )
1561
+ for (const val of ticks) {
1562
+ tick_geoms.push(make_line_geom(
1563
+ swiz(back[0], val, back[2]),
1564
+ swiz(back[0], val, back[2] + out_y * tick_size),
1565
+ ))
1566
+ grid_geoms.push(
1567
+ make_line_geom(swiz(r0[0], val, back[2]), swiz(r0[1], val, back[2])),
1568
+ )
1569
+ grid_geoms.push(
1570
+ make_line_geom(swiz(back[0], val, r2[0]), swiz(back[0], val, r2[1])),
1571
+ )
1572
+ tick_labels.push({
1573
+ pos: swiz(back[0], val, back[2] + out_y * tick_label_dist),
1574
+ text: format_num(val, `.3~g`),
1575
+ })
1266
1576
  }
1267
- else {
1268
- // Data axis 2 (Three.js Y, vertical): axis at backside d0 and d1
1269
- const ls = swiz(back[0], back[1], r2[0]);
1270
- const le = swiz(back[0], back[1], r2[1]);
1271
- line_geom = make_line_geom(ls, le);
1272
- label_pos = swiz(back[0], back[1] + out_x * tick_label_dist, outer_end(r2) + outer_dir(r2) * axis_label_dist);
1273
- for (const val of ticks) {
1274
- tick_geoms.push(make_line_geom(swiz(back[0], back[1], val), swiz(back[0], back[1] + out_x * tick_size, val)));
1275
- grid_geoms.push(make_line_geom(swiz(r0[0], back[1], val), swiz(r0[1], back[1], val)));
1276
- grid_geoms.push(make_line_geom(swiz(back[0], r1[0], val), swiz(back[0], r1[1], val)));
1277
- tick_labels.push({
1278
- pos: swiz(back[0], back[1] + out_x * tick_label_dist, val),
1279
- text: format_num(val, `.3~g`),
1280
- });
1281
- }
1577
+ } else {
1578
+ // Data axis 2 (Three.js Y, vertical): axis at backside d0 and d1
1579
+ const ls = swiz(back[0], back[1], r2[0])
1580
+ const le = swiz(back[0], back[1], r2[1])
1581
+ line_geom = make_line_geom(ls, le)
1582
+ label_pos = swiz(
1583
+ back[0],
1584
+ back[1] + out_x * tick_label_dist,
1585
+ outer_end(r2) + outer_dir(r2) * axis_label_dist,
1586
+ )
1587
+ for (const val of ticks) {
1588
+ tick_geoms.push(make_line_geom(
1589
+ swiz(back[0], back[1], val),
1590
+ swiz(back[0], back[1] + out_x * tick_size, val),
1591
+ ))
1592
+ grid_geoms.push(
1593
+ make_line_geom(swiz(r0[0], back[1], val), swiz(r0[1], back[1], val)),
1594
+ )
1595
+ grid_geoms.push(
1596
+ make_line_geom(swiz(back[0], r1[0], val), swiz(back[0], r1[1], val)),
1597
+ )
1598
+ tick_labels.push({
1599
+ pos: swiz(back[0], back[1] + out_x * tick_label_dist, val),
1600
+ text: format_num(val, `.3~g`),
1601
+ })
1282
1602
  }
1283
- return {
1284
- axis,
1285
- color,
1286
- label,
1287
- line_geom,
1288
- tick_geoms,
1289
- grid_geoms,
1290
- tick_labels,
1291
- label_pos,
1292
- };
1293
- });
1294
- });
1295
- // Update backside positions when camera crosses axis planes.
1296
- // Only updates when sign changes to avoid triggering geometry recreation every frame.
1297
- function update_backside() {
1298
- const cam = orbit_controls_ref?.object?.position;
1299
- if (!cam)
1300
- return;
1301
- const [r0, r1, r2] = niced_range;
1603
+ }
1604
+
1605
+ return { axis, color, label, line_geom, tick_geoms, grid_geoms, tick_labels, label_pos }
1606
+ })
1607
+ })
1608
+
1609
+ let label_occlusion_frame: number | null = null
1610
+ let tick_labels_occluded = false
1611
+ const has_occluding_domain_labels = $derived(
1612
+ label_stable && visible_domain_labels.length > 0,
1613
+ )
1614
+ const can_update_label_occlusion = $derived(
1615
+ mounted &&
1616
+ display.show_axis_labels &&
1617
+ grid_config.length > 0 &&
1618
+ Number.isFinite(zoom_scale) &&
1619
+ container_width > 0 &&
1620
+ container_height > 0,
1621
+ )
1622
+
1623
+ function update_label_occlusion(): void {
1624
+ if (!wrapper) return
1625
+ const tick_labels = Array.from(
1626
+ wrapper.querySelectorAll<HTMLElement>(`.axis-tick-label`),
1627
+ )
1628
+ tick_labels_occluded = false
1629
+ for (const tick_label of tick_labels) {
1630
+ tick_label.style.visibility = ``
1631
+ }
1632
+ const domain_rects = Array.from(
1633
+ wrapper.querySelectorAll<HTMLElement>(`.domain-label`),
1634
+ )
1635
+ .filter((domain_label) => {
1636
+ const style = getComputedStyle(domain_label)
1637
+ return style.display !== `none` && style.visibility !== `hidden`
1638
+ })
1639
+ .map((domain_label) => pad_rect(domain_label.getBoundingClientRect(), 1))
1640
+ if (domain_rects.length === 0) return
1641
+
1642
+ for (const tick_label of tick_labels) {
1643
+ const style = getComputedStyle(tick_label)
1644
+ if (style.display === `none` || style.visibility === `hidden`) continue
1645
+ const tick_rect = tick_label.getBoundingClientRect()
1646
+ if (domain_rects.some((domain_rect) => rects_overlap(tick_rect, domain_rect))) {
1647
+ tick_label.style.visibility = `hidden`
1648
+ tick_labels_occluded = true
1649
+ }
1650
+ }
1651
+ }
1652
+
1653
+ function schedule_label_occlusion_update(): void {
1654
+ if (typeof requestAnimationFrame === `undefined`) return
1655
+ if (label_occlusion_frame !== null) cancelAnimationFrame(label_occlusion_frame)
1656
+ label_occlusion_frame = requestAnimationFrame(() => {
1657
+ label_occlusion_frame = null
1658
+ update_label_occlusion()
1659
+ })
1660
+ }
1661
+
1662
+ // Update backside positions when camera crosses axis planes.
1663
+ // Only updates when sign changes to avoid triggering geometry recreation every frame.
1664
+ function update_backside(): void {
1665
+ const cam = orbit_controls_ref?.object?.position
1666
+ if (!cam) return
1667
+ const [r0, r1, r2] = niced_range
1302
1668
  // swiz: data[0]→Z, data[1]→X, data[2]→Y
1303
- const new_back_0 = cam.z > data_center.z ? r0[0] : r0[1];
1304
- const new_back_1 = cam.x > data_center.x ? r1[0] : r1[1];
1305
- const new_back_2 = cam.y > data_center.y ? r2[0] : r2[1];
1669
+ const new_back_0 = cam.z > data_center.z ? r0[0] : r0[1]
1670
+ const new_back_1 = cam.x > data_center.x ? r1[0] : r1[1]
1671
+ const new_back_2 = cam.y > data_center.y ? r2[0] : r2[1]
1306
1672
  if (back[0] !== new_back_0 || back[1] !== new_back_1 || back[2] !== new_back_2) {
1307
- back = [new_back_0, new_back_1, new_back_2];
1308
- out_x = cam.x > data_center.x ? -1 : 1;
1309
- out_y = cam.y > data_center.y ? -1 : 1;
1673
+ back = [new_back_0, new_back_1, new_back_2]
1674
+ out_x = cam.x > data_center.x ? -1 : 1
1675
+ out_y = cam.y > data_center.y ? -1 : 1
1310
1676
  }
1311
- }
1312
- function store_camera_view_state() {
1677
+ }
1678
+
1679
+ function store_camera_view_state(): void {
1313
1680
  // Prime framing baseline on first user interaction so the next geometry
1314
1681
  // change can preserve zoom/center immediately (not only from second change).
1315
1682
  if (last_data_center === null) {
1316
- last_data_center = [data_center.x, data_center.y, data_center.z];
1683
+ last_data_center = [data_center.x, data_center.y, data_center.z]
1317
1684
  }
1318
1685
  if (last_data_extent === null) {
1319
- last_data_extent = data_extent;
1686
+ last_data_extent = data_extent
1320
1687
  }
1321
- const controls = orbit_controls_ref;
1322
- const controls_camera = controls?.object;
1688
+ const controls = orbit_controls_ref
1689
+ const controls_camera = controls?.object
1323
1690
  if (controls_camera) {
1324
- camera_position_override = [
1325
- controls_camera.position.x,
1326
- controls_camera.position.y,
1327
- controls_camera.position.z,
1328
- ];
1329
- if (controls_camera instanceof THREE.OrthographicCamera) {
1330
- orthographic_zoom_override = controls_camera.zoom;
1331
- }
1332
- }
1333
- const controls_target = controls?.target;
1691
+ camera_position_override = [
1692
+ controls_camera.position.x,
1693
+ controls_camera.position.y,
1694
+ controls_camera.position.z,
1695
+ ]
1696
+ if (controls_camera instanceof THREE.OrthographicCamera) {
1697
+ orthographic_zoom_override = controls_camera.zoom
1698
+ }
1699
+ }
1700
+ const controls_target = controls?.target
1334
1701
  if (controls_target) {
1335
- camera_target_override = [
1336
- controls_target.x,
1337
- controls_target.y,
1338
- controls_target.z,
1339
- ];
1340
- }
1341
- }
1342
- // Preserve user framing across temperature-driven geometry changes:
1343
- // shift camera/target with domain center and keep orthographic zoom relative to extent.
1344
- $effect(() => {
1702
+ camera_target_override = [
1703
+ controls_target.x,
1704
+ controls_target.y,
1705
+ controls_target.z,
1706
+ ]
1707
+ }
1708
+ }
1709
+
1710
+ // Preserve user framing across temperature-driven geometry changes:
1711
+ // shift camera/target with domain center and keep orthographic zoom relative to extent.
1712
+ $effect(() => {
1345
1713
  if (camera_position_override && camera_target_override && last_data_center) {
1346
- const [last_x, last_y, last_z] = last_data_center;
1347
- const delta_x = data_center.x - last_x;
1348
- const delta_y = data_center.y - last_y;
1349
- const delta_z = data_center.z - last_z;
1350
- if (delta_x !== 0 || delta_y !== 0 || delta_z !== 0) {
1351
- camera_position_override = [
1352
- camera_position_override[0] + delta_x,
1353
- camera_position_override[1] + delta_y,
1354
- camera_position_override[2] + delta_z,
1355
- ];
1356
- camera_target_override = [
1357
- camera_target_override[0] + delta_x,
1358
- camera_target_override[1] + delta_y,
1359
- camera_target_override[2] + delta_z,
1360
- ];
1361
- }
1714
+ const [last_x, last_y, last_z] = last_data_center
1715
+ const delta_x = data_center.x - last_x
1716
+ const delta_y = data_center.y - last_y
1717
+ const delta_z = data_center.z - last_z
1718
+ if (delta_x !== 0 || delta_y !== 0 || delta_z !== 0) {
1719
+ camera_position_override = [
1720
+ camera_position_override[0] + delta_x,
1721
+ camera_position_override[1] + delta_y,
1722
+ camera_position_override[2] + delta_z,
1723
+ ]
1724
+ camera_target_override = [
1725
+ camera_target_override[0] + delta_x,
1726
+ camera_target_override[1] + delta_y,
1727
+ camera_target_override[2] + delta_z,
1728
+ ]
1729
+ }
1730
+ }
1731
+ if (
1732
+ orthographic_zoom_override !== null &&
1733
+ last_data_extent !== null &&
1734
+ last_data_extent > 0 &&
1735
+ data_extent > 0
1736
+ ) {
1737
+ orthographic_zoom_override *= last_data_extent / data_extent
1362
1738
  }
1363
- if (orthographic_zoom_override !== null &&
1364
- last_data_extent !== null &&
1365
- last_data_extent > 0 &&
1366
- data_extent > 0) {
1367
- orthographic_zoom_override *= last_data_extent / data_extent;
1368
- }
1369
- last_data_center = [data_center.x, data_center.y, data_center.z];
1370
- last_data_extent = data_extent;
1371
- });
1372
- $effect(() => {
1373
- const controls = orbit_controls_ref;
1374
- if (!controls)
1375
- return;
1376
- const on_controls_change = () => {
1377
- update_backside();
1378
- store_camera_view_state();
1379
- };
1380
- controls.addEventListener(`change`, on_controls_change);
1381
- untrack(() => update_backside());
1382
- controls.update();
1383
- return () => controls.removeEventListener(`change`, on_controls_change);
1384
- });
1385
- $effect(() => {
1386
- set_fullscreen_bg(wrapper, fullscreen, `--chempot-3d-bg-fullscreen`);
1387
- });
1388
- $effect(() => {
1389
- const grid_geometries = grid_config;
1739
+ last_data_center = [data_center.x, data_center.y, data_center.z]
1740
+ last_data_extent = data_extent
1741
+ })
1742
+
1743
+ $effect(() => {
1744
+ const controls = orbit_controls_ref
1745
+ if (!controls) return
1746
+ const on_controls_change = (): void => {
1747
+ update_backside()
1748
+ store_camera_view_state()
1749
+ if (has_occluding_domain_labels) schedule_label_occlusion_update()
1750
+ }
1751
+ controls.addEventListener(`change`, on_controls_change)
1752
+ untrack(() => update_backside())
1753
+ controls.update()
1754
+ return () => controls.removeEventListener(`change`, on_controls_change)
1755
+ })
1756
+
1757
+ $effect(() => {
1758
+ if (!can_update_label_occlusion) return
1759
+ if (!has_occluding_domain_labels && !tick_labels_occluded) return
1760
+ schedule_label_occlusion_update()
1761
+ })
1762
+
1763
+ $effect(() => {
1764
+ set_fullscreen_bg(wrapper, fullscreen, `--chempot-3d-bg-fullscreen`)
1765
+ })
1766
+
1767
+ $effect(() => {
1768
+ const grid_geometries = grid_config
1390
1769
  return () => {
1391
- for (const grid_item of grid_geometries) {
1392
- dispose_geometry(grid_item.line_geom);
1393
- for (const tick_geometry of grid_item.tick_geoms) {
1394
- dispose_geometry(tick_geometry);
1395
- }
1396
- for (const line_geometry of grid_item.grid_geoms) {
1397
- dispose_geometry(line_geometry);
1398
- }
1770
+ for (const grid_item of grid_geometries) {
1771
+ dispose_geometry(grid_item.line_geom)
1772
+ for (const tick_geometry of grid_item.tick_geoms) {
1773
+ dispose_geometry(tick_geometry)
1774
+ }
1775
+ for (const line_geometry of grid_item.grid_geoms) {
1776
+ dispose_geometry(line_geometry)
1399
1777
  }
1400
- };
1401
- });
1402
- const projection_planes = $derived.by(() => {
1403
- const projections = display.projections;
1404
- if (!projections)
1405
- return [];
1406
- const [r0, r1, r2] = niced_range;
1407
- const s0 = (r0[1] - r0[0]) * (display.projection_scale ?? 0.5);
1408
- const s1 = (r1[1] - r1[0]) * (display.projection_scale ?? 0.5);
1409
- const s2 = (r2[1] - r2[0]) * (display.projection_scale ?? 0.5);
1410
- const planes = [];
1778
+ }
1779
+ }
1780
+ })
1781
+
1782
+ const projection_planes = $derived.by(() => {
1783
+ const projections = display.projections
1784
+ if (!projections) return []
1785
+ const [r0, r1, r2] = niced_range
1786
+ const s0 = (r0[1] - r0[0]) * (display.projection_scale ?? 0.5)
1787
+ const s1 = (r1[1] - r1[0]) * (display.projection_scale ?? 0.5)
1788
+ const s2 = (r2[1] - r2[0]) * (display.projection_scale ?? 0.5)
1789
+ const planes: {
1790
+ key: string
1791
+ pos: Vec3
1792
+ rot: Vec3
1793
+ size: [number, number]
1794
+ color: string
1795
+ }[] = []
1411
1796
  if (projections.xy) {
1412
- planes.push({
1413
- key: `xy`,
1414
- pos: swiz((r0[0] + r0[1]) / 2, (r1[0] + r1[1]) / 2, back[2]),
1415
- rot: [-Math.PI / 2, 0, 0],
1416
- size: [s1, s0],
1417
- color: `#5dade2`,
1418
- });
1797
+ planes.push({
1798
+ key: `xy`,
1799
+ pos: swiz((r0[0] + r0[1]) / 2, (r1[0] + r1[1]) / 2, back[2]),
1800
+ rot: [-Math.PI / 2, 0, 0],
1801
+ size: [s1, s0],
1802
+ color: `#5dade2`,
1803
+ })
1419
1804
  }
1420
1805
  if (projections.xz) {
1421
- planes.push({
1422
- key: `xz`,
1423
- pos: swiz((r0[0] + r0[1]) / 2, back[1], (r2[0] + r2[1]) / 2),
1424
- rot: [0, Math.PI / 2, 0],
1425
- size: [s0, s2],
1426
- color: `#58d68d`,
1427
- });
1806
+ planes.push({
1807
+ key: `xz`,
1808
+ pos: swiz((r0[0] + r0[1]) / 2, back[1], (r2[0] + r2[1]) / 2),
1809
+ rot: [0, Math.PI / 2, 0],
1810
+ size: [s0, s2],
1811
+ color: `#58d68d`,
1812
+ })
1428
1813
  }
1429
1814
  if (projections.yz) {
1430
- planes.push({
1431
- key: `yz`,
1432
- pos: swiz(back[0], (r1[0] + r1[1]) / 2, (r2[0] + r2[1]) / 2),
1433
- rot: [0, 0, 0],
1434
- size: [s1, s2],
1435
- color: `#f5b041`,
1436
- });
1437
- }
1438
- return planes;
1439
- });
1440
- const bounding_box_geometry = $derived.by(() => {
1441
- const [r0, r1, r2] = niced_range;
1815
+ planes.push({
1816
+ key: `yz`,
1817
+ pos: swiz(back[0], (r1[0] + r1[1]) / 2, (r2[0] + r2[1]) / 2),
1818
+ rot: [0, 0, 0],
1819
+ size: [s1, s2],
1820
+ color: `#f5b041`,
1821
+ })
1822
+ }
1823
+ return planes
1824
+ })
1825
+
1826
+ const bounding_box_geometry = $derived.by(() => {
1827
+ const [r0, r1, r2] = niced_range
1442
1828
  const vertices = [
1443
- swiz(r0[0], r1[0], r2[0]),
1444
- swiz(r0[1], r1[0], r2[0]),
1445
- swiz(r0[1], r1[1], r2[0]),
1446
- swiz(r0[0], r1[1], r2[0]),
1447
- swiz(r0[0], r1[0], r2[1]),
1448
- swiz(r0[1], r1[0], r2[1]),
1449
- swiz(r0[1], r1[1], r2[1]),
1450
- swiz(r0[0], r1[1], r2[1]),
1451
- ];
1829
+ swiz(r0[0], r1[0], r2[0]),
1830
+ swiz(r0[1], r1[0], r2[0]),
1831
+ swiz(r0[1], r1[1], r2[0]),
1832
+ swiz(r0[0], r1[1], r2[0]),
1833
+ swiz(r0[0], r1[0], r2[1]),
1834
+ swiz(r0[1], r1[0], r2[1]),
1835
+ swiz(r0[1], r1[1], r2[1]),
1836
+ swiz(r0[0], r1[1], r2[1]),
1837
+ ]
1452
1838
  const edges = [
1453
- [0, 1],
1454
- [1, 2],
1455
- [2, 3],
1456
- [3, 0],
1457
- [4, 5],
1458
- [5, 6],
1459
- [6, 7],
1460
- [7, 4],
1461
- [0, 4],
1462
- [1, 5],
1463
- [2, 6],
1464
- [3, 7],
1465
- ];
1466
- const positions = [];
1839
+ [0, 1],
1840
+ [1, 2],
1841
+ [2, 3],
1842
+ [3, 0],
1843
+ [4, 5],
1844
+ [5, 6],
1845
+ [6, 7],
1846
+ [7, 4],
1847
+ [0, 4],
1848
+ [1, 5],
1849
+ [2, 6],
1850
+ [3, 7],
1851
+ ]
1852
+ const positions: number[] = []
1467
1853
  for (const [start_idx, end_idx] of edges) {
1468
- const start = vertices[start_idx];
1469
- const end = vertices[end_idx];
1470
- positions.push(start[0], start[1], start[2], end[0], end[1], end[2]);
1471
- }
1472
- const geom = new THREE.BufferGeometry();
1473
- geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3));
1474
- return geom;
1475
- });
1476
- function reset_controls() {
1477
- formal_chempots_override = null;
1478
- label_stable_override = null;
1479
- element_padding_override = null;
1480
- default_min_limit_override = null;
1481
- draw_formula_meshes_override = null;
1482
- draw_formula_lines_override = null;
1483
- color_mode_override = null;
1484
- color_scale_override = null;
1485
- reverse_color_scale_override = null;
1486
- projection_elements_override = null;
1487
- formulas_to_draw_override = null;
1488
- formula_filter_query = ``;
1489
- }
1490
- function set_projection_axis(axis_idx, element) {
1491
- if (!all_entry_elements.includes(element))
1492
- return;
1493
- const next_projection = [...plot_elements];
1494
- if (next_projection.length !== 3)
1495
- return;
1496
- const current_owner_idx = next_projection.indexOf(element);
1854
+ const start = vertices[start_idx]
1855
+ const end = vertices[end_idx]
1856
+ positions.push(start[0], start[1], start[2], end[0], end[1], end[2])
1857
+ }
1858
+ const geom = new THREE.BufferGeometry()
1859
+ geom.setAttribute(`position`, new THREE.Float32BufferAttribute(positions, 3))
1860
+ return geom
1861
+ })
1862
+
1863
+ function reset_controls(): void {
1864
+ formal_chempots_override = null
1865
+ label_stable_override = null
1866
+ element_padding_override = null
1867
+ default_min_limit_override = null
1868
+ draw_formula_meshes_override = null
1869
+ draw_formula_lines_override = null
1870
+ color_mode_override = null
1871
+ color_scale_override = null
1872
+ reverse_color_scale_override = null
1873
+ projection_elements_override = null
1874
+ formulas_to_draw_override = null
1875
+ formula_filter_query = ``
1876
+ }
1877
+
1878
+ function set_projection_axis(axis_idx: number, element: string): void {
1879
+ if (!all_entry_elements.includes(element)) return
1880
+ const next_projection = [...plot_elements]
1881
+ if (next_projection.length !== 3) return
1882
+ const current_owner_idx = next_projection.indexOf(element)
1497
1883
  if (current_owner_idx !== -1 && current_owner_idx !== axis_idx) {
1498
- next_projection[current_owner_idx] = next_projection[axis_idx];
1499
- }
1500
- next_projection[axis_idx] = element;
1501
- const normalized = normalize_projection_triplet(next_projection, all_entry_elements);
1502
- if (normalized)
1503
- projection_elements_override = normalized;
1504
- }
1505
- function apply_projection_preset(preset_elements) {
1506
- const normalized = normalize_projection_triplet(preset_elements, all_entry_elements);
1507
- if (normalized)
1508
- projection_elements_override = normalized;
1509
- }
1510
- function toggle_formula_selection(formula) {
1511
- const selected_formulas = new SvelteSet(formulas_to_draw);
1512
- if (selected_formulas.has(formula))
1513
- selected_formulas.delete(formula);
1514
- else
1515
- selected_formulas.add(formula);
1516
- formulas_to_draw_override = [...selected_formulas];
1517
- }
1518
- function select_surface_formulas() {
1884
+ next_projection[current_owner_idx] = next_projection[axis_idx]
1885
+ }
1886
+ next_projection[axis_idx] = element
1887
+ const normalized = normalize_projection_triplet(
1888
+ next_projection,
1889
+ all_entry_elements,
1890
+ )
1891
+ if (normalized) projection_elements_override = normalized
1892
+ }
1893
+
1894
+ function apply_projection_preset(preset_elements: string[]): void {
1895
+ const normalized = normalize_projection_triplet(
1896
+ preset_elements,
1897
+ all_entry_elements,
1898
+ )
1899
+ if (normalized) projection_elements_override = normalized
1900
+ }
1901
+
1902
+ function toggle_formula_selection(formula: string): void {
1903
+ const selected_formulas = new SvelteSet(formulas_to_draw)
1904
+ if (selected_formulas.has(formula)) selected_formulas.delete(formula)
1905
+ else selected_formulas.add(formula)
1906
+ formulas_to_draw_override = [...selected_formulas]
1907
+ }
1908
+
1909
+ function select_surface_formulas(): void {
1519
1910
  formulas_to_draw_override = render_domains
1520
- .filter((domain) => surface_formulas.has(domain.formula))
1521
- .map((domain) => domain.formula);
1522
- }
1523
- function select_neighbor_formulas() {
1524
- if (hover_info?.view !== `3d`)
1525
- return;
1526
- const neighbors = domain_neighbors.get(hover_info.formula) ?? [];
1527
- formulas_to_draw_override = [hover_info.formula, ...neighbors];
1528
- }
1529
- function download_blob(blob, filename) {
1530
- const url = URL.createObjectURL(blob);
1531
- const link = document.createElement(`a`);
1532
- link.href = url;
1533
- link.download = filename;
1534
- link.click();
1535
- URL.revokeObjectURL(url);
1536
- }
1537
- let png_dpi = $state(150);
1538
- const export_basename = $derived(`chempot-${plot_elements.join(`-`)}`);
1539
- function get_view_settings() {
1540
- const camera_position = orbit_controls_ref?.object?.position;
1541
- const camera_target = orbit_controls_ref?.target;
1911
+ .filter((domain) => surface_formulas.has(domain.formula))
1912
+ .map((domain) => domain.formula)
1913
+ }
1914
+
1915
+ function select_neighbor_formulas(): void {
1916
+ if (hover_info?.view !== `3d`) return
1917
+ const neighbors = domain_neighbors.get(hover_info.formula) ?? []
1918
+ formulas_to_draw_override = [hover_info.formula, ...neighbors]
1919
+ }
1920
+
1921
+ function download_blob(blob: Blob, filename: string): void {
1922
+ const url = URL.createObjectURL(blob)
1923
+ const link = document.createElement(`a`)
1924
+ link.href = url
1925
+ link.download = filename
1926
+ link.click()
1927
+ URL.revokeObjectURL(url)
1928
+ }
1929
+
1930
+ let png_dpi = $state(150)
1931
+ const export_basename = $derived(`chempot-${plot_elements.join(`-`)}`)
1932
+
1933
+ function get_view_settings(): Record<string, unknown> {
1934
+ const camera_position = orbit_controls_ref?.object?.position
1935
+ const camera_target = orbit_controls_ref?.target
1542
1936
  return {
1543
- elements: plot_elements,
1544
- camera_projection,
1545
- auto_rotate,
1546
- color_mode,
1547
- color_scale,
1548
- reverse_color_scale,
1549
- camera_position: camera_position
1550
- ? [camera_position.x, camera_position.y, camera_position.z]
1551
- : null,
1552
- camera_target: camera_target
1553
- ? [camera_target.x, camera_target.y, camera_target.z]
1554
- : null,
1555
- };
1556
- }
1557
- function get_overlay_text_items(canvas_rect) {
1558
- if (!wrapper)
1559
- return [];
1560
- const text_items = [];
1561
- for (const element of wrapper.querySelectorAll(`.tick-label, .axis-label, .domain-label`)) {
1562
- const html_element = element;
1563
- const style = getComputedStyle(html_element);
1564
- if (style.display === `none` || style.visibility === `hidden`)
1565
- continue;
1566
- const element_rect = html_element.getBoundingClientRect();
1567
- text_items.push({
1568
- x: element_rect.left + element_rect.width / 2 - canvas_rect.left,
1569
- y: element_rect.top + element_rect.height / 2 - canvas_rect.top,
1570
- text: html_element.textContent ?? ``,
1571
- font: style.font || `${style.fontSize} ${style.fontFamily}`,
1572
- font_size: style.fontSize || `11px`,
1573
- font_family: style.fontFamily || `sans-serif`,
1574
- font_weight: style.fontWeight || `400`,
1575
- color: style.color || `#333`,
1576
- });
1577
- }
1578
- return text_items;
1579
- }
1580
- function export_png_file() {
1581
- if (!wrapper)
1582
- return;
1583
- const gl_canvas = wrapper.querySelector(`canvas`);
1584
- if (!(gl_canvas instanceof HTMLCanvasElement))
1585
- return;
1937
+ elements: plot_elements,
1938
+ camera_projection,
1939
+ auto_rotate,
1940
+ color_mode,
1941
+ color_scale,
1942
+ reverse_color_scale,
1943
+ camera_position: camera_position
1944
+ ? [camera_position.x, camera_position.y, camera_position.z]
1945
+ : null,
1946
+ camera_target: camera_target
1947
+ ? [camera_target.x, camera_target.y, camera_target.z]
1948
+ : null,
1949
+ }
1950
+ }
1951
+
1952
+ interface OverlayTextItem {
1953
+ x: number
1954
+ y: number
1955
+ text: string
1956
+ font: string
1957
+ font_size: string
1958
+ font_family: string
1959
+ font_weight: string
1960
+ color: string
1961
+ }
1962
+ function get_overlay_text_items(canvas_rect: DOMRect): OverlayTextItem[] {
1963
+ if (!wrapper) return []
1964
+ const text_items: OverlayTextItem[] = []
1965
+ for (
1966
+ const element of wrapper.querySelectorAll(
1967
+ `.tick-label, .axis-label, .domain-label`,
1968
+ )
1969
+ ) {
1970
+ const html_element = element as HTMLElement
1971
+ const style = getComputedStyle(html_element)
1972
+ if (style.display === `none` || style.visibility === `hidden`) continue
1973
+ const element_rect = html_element.getBoundingClientRect()
1974
+ text_items.push({
1975
+ x: element_rect.left + element_rect.width / 2 - canvas_rect.left,
1976
+ y: element_rect.top + element_rect.height / 2 - canvas_rect.top,
1977
+ text: html_element.textContent ?? ``,
1978
+ font: style.font || `${style.fontSize} ${style.fontFamily}`,
1979
+ font_size: style.fontSize || `11px`,
1980
+ font_family: style.fontFamily || `sans-serif`,
1981
+ font_weight: style.fontWeight || `400`,
1982
+ color: style.color || `#333`,
1983
+ })
1984
+ }
1985
+ return text_items
1986
+ }
1987
+
1988
+ function export_png_file(): void {
1989
+ if (!wrapper) return
1990
+ const gl_canvas = wrapper.querySelector(`canvas`)
1991
+ if (!(gl_canvas instanceof HTMLCanvasElement)) return
1992
+
1586
1993
  // Composite WebGL canvas + HTML overlay labels into a single image
1587
- const rect = gl_canvas.getBoundingClientRect();
1588
- const scale = Math.min(png_dpi / 72, 10);
1589
- const out = document.createElement(`canvas`);
1590
- out.width = Math.round(rect.width * scale);
1591
- out.height = Math.round(rect.height * scale);
1592
- const ctx = out.getContext(`2d`);
1593
- if (!ctx)
1594
- return;
1595
- ctx.scale(scale, scale);
1994
+ const rect = gl_canvas.getBoundingClientRect()
1995
+ const scale = Math.min(png_dpi / 72, 10)
1996
+ const out = document.createElement(`canvas`)
1997
+ out.width = Math.round(rect.width * scale)
1998
+ out.height = Math.round(rect.height * scale)
1999
+ const ctx = out.getContext(`2d`)
2000
+ if (!ctx) return
2001
+ ctx.scale(scale, scale)
2002
+
1596
2003
  // Draw the WebGL canvas as background
1597
- ctx.drawImage(gl_canvas, 0, 0, rect.width, rect.height);
2004
+ ctx.drawImage(gl_canvas, 0, 0, rect.width, rect.height)
2005
+
1598
2006
  // Draw all HTML overlay text (tick labels, axis labels, domain labels)
1599
2007
  for (const text_item of get_overlay_text_items(rect)) {
1600
- ctx.font = text_item.font;
1601
- ctx.fillStyle = text_item.color;
1602
- ctx.textAlign = `center`;
1603
- ctx.textBaseline = `middle`;
1604
- ctx.fillText(text_item.text, text_item.x, text_item.y);
2008
+ ctx.font = text_item.font
2009
+ ctx.fillStyle = text_item.color
2010
+ ctx.textAlign = `center`
2011
+ ctx.textBaseline = `middle`
2012
+ ctx.fillText(text_item.text, text_item.x, text_item.y)
1605
2013
  }
2014
+
1606
2015
  out.toBlob((blob) => {
1607
- if (!blob)
1608
- return;
1609
- download_blob(blob, `${export_basename}.png`);
1610
- }, `image/png`);
1611
- }
1612
- function xml_escape(text) {
2016
+ if (!blob) return
2017
+ download_blob(blob, `${export_basename}.png`)
2018
+ }, `image/png`)
2019
+ }
2020
+
2021
+ function xml_escape(text: string): string {
1613
2022
  return text
1614
- .replaceAll(`&`, `&amp;`)
1615
- .replaceAll(`<`, `&lt;`)
1616
- .replaceAll(`>`, `&gt;`)
1617
- .replaceAll(`"`, `&quot;`)
1618
- .replaceAll(`'`, `&#39;`);
1619
- }
1620
- function export_svg_file() {
1621
- if (!wrapper)
1622
- return;
1623
- const gl_canvas = wrapper.querySelector(`canvas`);
1624
- if (!(gl_canvas instanceof HTMLCanvasElement))
1625
- return;
1626
- const canvas_rect = gl_canvas.getBoundingClientRect();
1627
- if (canvas_rect.width === 0 || canvas_rect.height === 0)
1628
- return;
1629
- const png_data_url = gl_canvas.toDataURL(`image/png`);
1630
- const text_nodes = get_overlay_text_items(canvas_rect).map((text_item) => `<text x="${text_item.x.toFixed(2)}" y="${text_item.y.toFixed(2)}" text-anchor="middle" dominant-baseline="central" fill="${xml_escape(text_item.color)}" font-size="${xml_escape(text_item.font_size)}" font-family="${xml_escape(text_item.font_family)}" font-weight="${xml_escape(text_item.font_weight)}">${xml_escape(text_item.text)}</text>`);
1631
- const metadata = xml_escape(JSON.stringify(get_view_settings()));
2023
+ .replaceAll(`&`, `&amp;`)
2024
+ .replaceAll(`<`, `&lt;`)
2025
+ .replaceAll(`>`, `&gt;`)
2026
+ .replaceAll(`"`, `&quot;`)
2027
+ .replaceAll(`'`, `&#39;`)
2028
+ }
2029
+
2030
+ function export_svg_file(): void {
2031
+ if (!wrapper) return
2032
+ const gl_canvas = wrapper.querySelector(`canvas`)
2033
+ if (!(gl_canvas instanceof HTMLCanvasElement)) return
2034
+ const canvas_rect = gl_canvas.getBoundingClientRect()
2035
+ if (canvas_rect.width === 0 || canvas_rect.height === 0) return
2036
+ const png_data_url = gl_canvas.toDataURL(`image/png`)
2037
+ const text_nodes = get_overlay_text_items(canvas_rect).map((text_item) =>
2038
+ `<text x="${text_item.x.toFixed(2)}" y="${
2039
+ text_item.y.toFixed(2)
2040
+ }" text-anchor="middle" dominant-baseline="central" fill="${
2041
+ xml_escape(text_item.color)
2042
+ }" font-size="${xml_escape(text_item.font_size)}" font-family="${
2043
+ xml_escape(text_item.font_family)
2044
+ }" font-weight="${xml_escape(text_item.font_weight)}">${
2045
+ xml_escape(text_item.text)
2046
+ }</text>`
2047
+ )
2048
+ const metadata = xml_escape(JSON.stringify(get_view_settings()))
1632
2049
  const svg = [
1633
- `<?xml version="1.0" encoding="UTF-8"?>`,
1634
- `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas_rect.width}" height="${canvas_rect.height}" viewBox="0 0 ${canvas_rect.width} ${canvas_rect.height}">`,
1635
- `<metadata>${metadata}</metadata>`,
1636
- `<image href="${png_data_url}" x="0" y="0" width="${canvas_rect.width}" height="${canvas_rect.height}" />`,
1637
- ...text_nodes,
1638
- `</svg>`,
1639
- ].join(``);
1640
- download_blob(new Blob([svg], { type: `image/svg+xml` }), `${export_basename}.svg`);
1641
- }
1642
- function export_view_json_file() {
1643
- const json_text = JSON.stringify(get_view_settings(), null, 2);
1644
- download_blob(new Blob([json_text], { type: `application/json` }), `${export_basename}-view.json`);
1645
- }
1646
- function export_glb_file() {
1647
- const gltf_exporter = new GLTFExporter();
1648
- const export_root = new THREE.Group();
2050
+ `<?xml version="1.0" encoding="UTF-8"?>`,
2051
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas_rect.width}" height="${canvas_rect.height}" viewBox="0 0 ${canvas_rect.width} ${canvas_rect.height}">`,
2052
+ `<metadata>${metadata}</metadata>`,
2053
+ `<image href="${png_data_url}" x="0" y="0" width="${canvas_rect.width}" height="${canvas_rect.height}" />`,
2054
+ ...text_nodes,
2055
+ `</svg>`,
2056
+ ].join(``)
2057
+ download_blob(
2058
+ new Blob([svg], { type: `image/svg+xml` }),
2059
+ `${export_basename}.svg`,
2060
+ )
2061
+ }
2062
+
2063
+ function export_view_json_file(): void {
2064
+ const json_text = JSON.stringify(get_view_settings(), null, 2)
2065
+ download_blob(
2066
+ new Blob([json_text], { type: `application/json` }),
2067
+ `${export_basename}-view.json`,
2068
+ )
2069
+ }
2070
+
2071
+ function export_glb_file(): void {
2072
+ const gltf_exporter = new GLTFExporter()
2073
+ const export_root = new THREE.Group()
1649
2074
  if (colored_hull_geometry) {
1650
- export_root.add(new THREE.Mesh(colored_hull_geometry.clone(), new THREE.MeshBasicMaterial({
2075
+ export_root.add(
2076
+ new THREE.Mesh(
2077
+ colored_hull_geometry.clone(),
2078
+ new THREE.MeshBasicMaterial({
1651
2079
  vertexColors: true,
1652
2080
  transparent: true,
1653
2081
  opacity: color_mode === `none` ? 0.25 : 0.4,
1654
2082
  side: THREE.DoubleSide,
1655
- })));
1656
- }
1657
- export_root.add(new THREE.LineSegments(edge_geometry.clone(), new THREE.LineBasicMaterial({ color: 0x333333 })));
2083
+ }),
2084
+ ),
2085
+ )
2086
+ }
2087
+ export_root.add(
2088
+ new THREE.LineSegments(
2089
+ edge_geometry.clone(),
2090
+ new THREE.LineBasicMaterial({ color: 0x333333 }),
2091
+ ),
2092
+ )
1658
2093
  for (const { geometry, color } of formula_mesh_data) {
1659
- export_root.add(new THREE.Mesh(geometry.clone(), new THREE.MeshBasicMaterial({
2094
+ export_root.add(
2095
+ new THREE.Mesh(
2096
+ geometry.clone(),
2097
+ new THREE.MeshBasicMaterial({
1660
2098
  color: new THREE.Color(color),
1661
2099
  transparent: true,
1662
2100
  opacity: 0.13,
1663
2101
  side: THREE.DoubleSide,
1664
- })));
2102
+ }),
2103
+ ),
2104
+ )
1665
2105
  }
1666
2106
  if (draw_formula_lines) {
1667
- for (const { geometry, color } of formula_edge_data) {
1668
- export_root.add(new THREE.LineSegments(geometry.clone(), new THREE.LineBasicMaterial({ color: new THREE.Color(color) })));
1669
- }
1670
- }
1671
- gltf_exporter.parse(export_root, (result) => {
1672
- if (!(result instanceof ArrayBuffer))
1673
- return;
1674
- download_blob(new Blob([result], { type: `model/gltf-binary` }), `${export_basename}.glb`);
1675
- }, (err) => {
1676
- console.error(`Failed to export GLB:`, err);
1677
- }, { binary: true, onlyVisible: false });
1678
- }
1679
- function get_json_string() {
1680
- return JSON.stringify({
2107
+ for (const { geometry, color } of formula_edge_data) {
2108
+ export_root.add(
2109
+ new THREE.LineSegments(
2110
+ geometry.clone(),
2111
+ new THREE.LineBasicMaterial({ color: new THREE.Color(color) }),
2112
+ ),
2113
+ )
2114
+ }
2115
+ }
2116
+ gltf_exporter.parse(
2117
+ export_root,
2118
+ (result) => {
2119
+ if (!(result instanceof ArrayBuffer)) return
2120
+ download_blob(
2121
+ new Blob([result], { type: `model/gltf-binary` }),
2122
+ `${export_basename}.glb`,
2123
+ )
2124
+ },
2125
+ (err) => {
2126
+ console.error(`Failed to export GLB:`, err)
2127
+ },
2128
+ { binary: true, onlyVisible: false },
2129
+ )
2130
+ }
2131
+
2132
+ function get_json_string(): string {
2133
+ return JSON.stringify(
2134
+ {
1681
2135
  elements: diagram_data?.elements ?? [],
1682
2136
  domains: render_domains.map((domain) => ({
1683
- formula: domain.formula,
1684
- points_3d: domain.points_3d,
2137
+ formula: domain.formula,
2138
+ points_3d: domain.points_3d,
1685
2139
  })),
1686
2140
  lims: diagram_data?.lims ?? [],
1687
2141
  view: get_view_settings(),
1688
- }, null, 2);
1689
- }
1690
- function export_json_file() {
1691
- download_blob(new Blob([get_json_string()], { type: `application/json` }), `${export_basename}.json`);
1692
- }
1693
- async function copy_json() {
2142
+ },
2143
+ null,
2144
+ 2,
2145
+ )
2146
+ }
2147
+
2148
+ function export_json_file(): void {
2149
+ download_blob(
2150
+ new Blob([get_json_string()], { type: `application/json` }),
2151
+ `${export_basename}.json`,
2152
+ )
2153
+ }
2154
+
2155
+ async function copy_json(): Promise<void> {
1694
2156
  try {
1695
- await navigator.clipboard.writeText(get_json_string());
1696
- copy_status = true;
2157
+ await navigator.clipboard.writeText(get_json_string())
2158
+ copy_status = true
2159
+ } catch (err) {
2160
+ copy_status = false
2161
+ console.error(`Failed to copy JSON to clipboard:`, err)
1697
2162
  }
1698
- catch (err) {
1699
- copy_status = false;
1700
- console.error(`Failed to copy JSON to clipboard:`, err);
1701
- }
1702
- if (copy_timeout_id !== null)
1703
- clearTimeout(copy_timeout_id);
2163
+ if (copy_timeout_id !== null) clearTimeout(copy_timeout_id)
1704
2164
  copy_timeout_id = setTimeout(() => {
1705
- copy_status = false;
1706
- copy_timeout_id = null;
1707
- }, 1000);
1708
- }
1709
- onDestroy(() => {
1710
- if (copy_timeout_id !== null)
1711
- clearTimeout(copy_timeout_id);
1712
- if (fixed_container_frame_id !== null) {
1713
- cancelAnimationFrame(fixed_container_frame_id);
1714
- fixed_container_frame_id = null;
1715
- }
1716
- });
1717
- function find_fixed_container_element() {
1718
- if (!wrapper)
1719
- return null;
1720
- let ancestor_element = wrapper.parentElement;
1721
- while (ancestor_element && ancestor_element !== document.documentElement) {
1722
- const computed_style = getComputedStyle(ancestor_element);
1723
- const contain_value = computed_style.contain;
1724
- const container_type_value = computed_style.getPropertyValue(`container-type`)
1725
- .trim();
1726
- const creates_fixed_containing_block = computed_style.transform !== `none` ||
1727
- computed_style.perspective !== `none` ||
1728
- computed_style.filter !== `none` ||
1729
- computed_style.backdropFilter !== `none` ||
1730
- contain_value.includes(`layout`) ||
1731
- contain_value.includes(`paint`) ||
1732
- contain_value.includes(`strict`) ||
1733
- contain_value.includes(`content`) ||
1734
- (container_type_value && container_type_value !== `normal`);
1735
- if (creates_fixed_containing_block)
1736
- return ancestor_element;
1737
- ancestor_element = ancestor_element.parentElement;
1738
- }
1739
- return null;
1740
- }
1741
- function refresh_fixed_container_rect(container_element = fixed_container_element) {
1742
- fixed_container_rect = container_element?.getBoundingClientRect() ?? null;
1743
- }
1744
- function queue_fixed_container_rect_refresh() {
1745
- if (fixed_container_frame_id !== null)
1746
- return;
1747
- fixed_container_frame_id = requestAnimationFrame(() => {
1748
- fixed_container_frame_id = null;
1749
- refresh_fixed_container_rect();
1750
- });
1751
- }
1752
- $effect(() => {
1753
- const next_fixed_container_element = find_fixed_container_element();
1754
- fixed_container_element = next_fixed_container_element;
1755
- refresh_fixed_container_rect(next_fixed_container_element);
1756
- });
1757
- onMount(() => {
1758
- const handle_layout_change = () => queue_fixed_container_rect_refresh();
1759
- window.addEventListener(`resize`, handle_layout_change);
1760
- window.addEventListener(`scroll`, handle_layout_change, true);
1761
- return () => {
1762
- window.removeEventListener(`resize`, handle_layout_change);
1763
- window.removeEventListener(`scroll`, handle_layout_change, true);
1764
- };
1765
- });
1766
- let locked_hover_formula = $state(null);
1767
- function set_hover_info(domain_data, raw_event) {
1768
- hover_info = with_hover_pointer(domain_data.info, raw_event, fixed_container_rect);
1769
- }
1770
- function clear_hover_lock() {
1771
- locked_hover_formula = null;
1772
- hover_info = null;
1773
- }
1774
- function handle_phase_hover(domain_data, raw_event) {
1775
- if (locked_hover_formula && locked_hover_formula !== domain_data.formula)
1776
- return;
1777
- set_hover_info(domain_data, raw_event);
1778
- }
1779
- function toggle_phase_lock(domain_data, raw_event) {
2165
+ copy_status = false
2166
+ copy_timeout_id = null
2167
+ }, 1000)
2168
+ }
2169
+
2170
+ onDestroy(() => {
2171
+ if (copy_timeout_id !== null) clearTimeout(copy_timeout_id)
2172
+ if (label_occlusion_frame !== null) cancelAnimationFrame(label_occlusion_frame)
2173
+ })
2174
+
2175
+ let locked_hover_formula = $state<string | null>(null)
2176
+ let tooltip_el = $state<HTMLElement>()
2177
+
2178
+ const tooltip_pos = $derived.by(() => {
2179
+ const pointer = hover_info?.pointer
2180
+ if (!pointer) return { x: 4, y: 4 }
2181
+ return constrain_tooltip_position(
2182
+ pointer.x, pointer.y,
2183
+ tooltip_el?.offsetWidth ?? 200,
2184
+ tooltip_el?.offsetHeight ?? 100,
2185
+ container_width, container_height,
2186
+ { offset: 0 },
2187
+ )
2188
+ })
2189
+
2190
+ function set_hover_info(domain_data: HoverMeshData, raw_event: unknown): void {
2191
+ hover_info = with_hover_pointer<ChemPotHoverInfo>(
2192
+ domain_data.info,
2193
+ raw_event,
2194
+ wrapper?.getBoundingClientRect() ?? null,
2195
+ )
2196
+ }
2197
+
2198
+ function clear_hover_lock(): void {
2199
+ locked_hover_formula = null
2200
+ hover_info = null
2201
+ }
2202
+
2203
+ function handle_phase_hover(domain_data: HoverMeshData, raw_event: unknown): void {
2204
+ if (locked_hover_formula && locked_hover_formula !== domain_data.formula) return
2205
+ set_hover_info(domain_data, raw_event)
2206
+ }
2207
+
2208
+ function toggle_phase_lock(domain_data: HoverMeshData, raw_event: unknown): void {
1780
2209
  if (locked_hover_formula === domain_data.formula) {
1781
- clear_hover_lock();
1782
- return;
1783
- }
1784
- locked_hover_formula = domain_data.formula;
1785
- set_hover_info(domain_data, raw_event);
1786
- }
1787
- // Color mode cycling (keyboard shortcut 'c')
1788
- const color_modes = [
2210
+ clear_hover_lock()
2211
+ return
2212
+ }
2213
+ locked_hover_formula = domain_data.formula
2214
+ set_hover_info(domain_data, raw_event)
2215
+ }
2216
+
2217
+ // Color mode cycling (keyboard shortcut 'c')
2218
+ const color_modes: ChemPotColorMode[] = [
1789
2219
  `none`,
1790
2220
  `energy`,
1791
2221
  `formation_energy`,
1792
2222
  `arity`,
1793
2223
  `entries`,
1794
- ];
1795
- function cycle_color_mode() {
1796
- const idx = color_modes.indexOf(color_mode);
1797
- color_mode_override = color_modes[(idx + 1) % color_modes.length];
1798
- }
2224
+ ]
2225
+ function cycle_color_mode(): void {
2226
+ const idx = color_modes.indexOf(color_mode)
2227
+ color_mode_override = color_modes[(idx + 1) % color_modes.length]
2228
+ }
1799
2229
  </script>
1800
2230
 
1801
2231
  <svelte:document
@@ -1947,7 +2377,7 @@ function cycle_color_mode() {
1947
2377
  formula_colors.length
1948
2378
  ]}
1949
2379
  ></span>
1950
- {get_hill_formula(formula, true, ``)}
2380
+ {get_electro_neg_formula(formula, true, ``, `.3~s`)}
1951
2381
  </label>
1952
2382
  {/each}
1953
2383
  {/if}
@@ -1955,6 +2385,7 @@ function cycle_color_mode() {
1955
2385
  </DraggablePane>
1956
2386
 
1957
2387
  <ScatterPlot3DControls
2388
+ bind:show={controls_open}
1958
2389
  bind:x_axis
1959
2390
  bind:y_axis
1960
2391
  bind:z_axis
@@ -2163,7 +2594,12 @@ function cycle_color_mode() {
2163
2594
  bind:temperature
2164
2595
  />
2165
2596
  {/if}
2166
- {#if !diagram_data}
2597
+ <div class="canvas-clip">
2598
+ {#if diagram_computing}
2599
+ <div class="computing-state">
2600
+ <Spinner text="Computing chemical potential domains..." style="--spinner-size: 1.2em" />
2601
+ </div>
2602
+ {:else if !diagram_data}
2167
2603
  <div class="error-state" role="alert" aria-live="polite">
2168
2604
  <p>Cannot compute chemical potential diagram.</p>
2169
2605
  <p>Need at least 2 elements with elemental reference entries.</p>
@@ -2344,7 +2780,7 @@ function cycle_color_mode() {
2344
2780
  portal={wrapper}
2345
2781
  zIndexRange={[1, 0]}
2346
2782
  >
2347
- <span class="tick-label">{tick.text}</span>
2783
+ <span class="tick-label axis-tick-label">{tick.text}</span>
2348
2784
  </extras.HTML>
2349
2785
  {/each}
2350
2786
  <!-- Axis label -->
@@ -2354,26 +2790,28 @@ function cycle_color_mode() {
2354
2790
  portal={wrapper}
2355
2791
  zIndexRange={[1, 0]}
2356
2792
  >
2357
- <span class="axis-label" style:color={gc.color}>{gc.label}</span>
2793
+ <span class="axis-label" style:color={gc.color}>{@html gc.label}</span>
2358
2794
  </extras.HTML>
2359
2795
  {/if}
2360
2796
  {/each}
2361
2797
 
2362
- <!-- Domain labels (only for surface domains, not interior ones) -->
2798
+ <!-- Domain labels -->
2363
2799
  {#if label_stable}
2364
- {#each render_domains.filter((d) => surface_formulas.has(d.formula)) as
2365
- domain
2366
- (domain.formula)
2367
- }
2800
+ {#each visible_domain_labels as domain (domain.formula)}
2368
2801
  <extras.HTML
2369
- position={swiz(domain.label_loc[0], domain.label_loc[1], domain.label_loc[2])}
2802
+ position={domain.position}
2370
2803
  center
2371
2804
  portal={wrapper}
2372
- zIndexRange={[1, 0]}
2805
+ zIndexRange={[5, 5]}
2373
2806
  >
2374
2807
  <span
2375
2808
  class="domain-label"
2376
- >{@html get_hill_formula(domain.formula, false, ``)}</span>
2809
+ style:font-size="{(domain.label_font_size * zoom_scale).toFixed(1)}px"
2810
+ >
2811
+ {#each formula_label_segments(domain.formula) as segment}
2812
+ <span class:formula-subscript={segment.subscript}>{segment.text}</span>
2813
+ {/each}
2814
+ </span>
2377
2815
  </extras.HTML>
2378
2816
  {/each}
2379
2817
  {/if}
@@ -2409,11 +2847,16 @@ function cycle_color_mode() {
2409
2847
  {/if}
2410
2848
  {#if render_local_tooltip && show_tooltip && hover_info?.view === `3d`}
2411
2849
  <aside
2850
+ bind:this={tooltip_el}
2412
2851
  class="phase-tooltip"
2413
- style:left="{hover_info.pointer?.x ?? 4}px"
2414
- style:top="{hover_info.pointer?.y ?? 4}px"
2852
+ style:left="{tooltip_pos.x}px"
2853
+ style:top="{tooltip_pos.y}px"
2415
2854
  >
2416
- <h4>{@html get_hill_formula(hover_info.formula, false, ``)}</h4>
2855
+ <h4>
2856
+ {#each formula_label_segments(hover_info.formula) as segment}
2857
+ <span class:formula-subscript={segment.subscript}>{segment.text}</span>
2858
+ {/each}
2859
+ </h4>
2417
2860
  {#if locked_hover_formula === hover_info.formula}
2418
2861
  <p>Pinned · Press Esc to unlock</p>
2419
2862
  {/if}
@@ -2451,6 +2894,7 @@ function cycle_color_mode() {
2451
2894
  {/if}
2452
2895
  </aside>
2453
2896
  {/if}
2897
+ </div>
2454
2898
  </div>
2455
2899
 
2456
2900
  <style>
@@ -2458,12 +2902,17 @@ function cycle_color_mode() {
2458
2902
  position: relative;
2459
2903
  overflow: clip;
2460
2904
  }
2905
+ .canvas-clip {
2906
+ position: relative;
2907
+ overflow: clip;
2908
+ width: 100%;
2909
+ height: 100%;
2910
+ }
2461
2911
  .chempot-diagram-3d:fullscreen {
2462
2912
  background: var(--chempot-3d-bg-fullscreen, var(--bg-color, #fff));
2463
2913
  }
2464
- /* Threlte <extras.HTML portal={wrapper}> appends absolutely-positioned divs
2465
- directly to the wrapper. Without pointer-events: none, they intercept mouse
2466
- events and prevent the Three.js raycaster from detecting hover meshes. */
2914
+ /* Threlte <extras.HTML portal={wrapper}> appends absolutely-positioned overlay divs
2915
+ for 3D labels. pointer-events: none prevents them from blocking raycasting. */
2467
2916
  .chempot-diagram-3d > :global(div[style*='position: absolute'][style*='top: 0']) {
2468
2917
  pointer-events: none !important;
2469
2918
  }
@@ -2474,6 +2923,21 @@ function cycle_color_mode() {
2474
2923
  display: flex;
2475
2924
  gap: 8px;
2476
2925
  z-index: 20;
2926
+ opacity: 0;
2927
+ transition: opacity 0.25s ease;
2928
+ pointer-events: none;
2929
+ }
2930
+ .chempot-diagram-3d:hover > section,
2931
+ .chempot-diagram-3d:focus-within > section,
2932
+ .chempot-diagram-3d > section:has(:global(.pane-open)) {
2933
+ opacity: 1;
2934
+ pointer-events: auto;
2935
+ }
2936
+ @media (hover: none) {
2937
+ .chempot-diagram-3d > section {
2938
+ opacity: 1;
2939
+ pointer-events: auto;
2940
+ }
2477
2941
  }
2478
2942
  .chempot-diagram-3d > section > :global(button),
2479
2943
  .chempot-diagram-3d > section > :global(.pane-toggle) {
@@ -2511,6 +2975,14 @@ function cycle_color_mode() {
2511
2975
  .chempot-diagram-3d :global(.export-row > label) {
2512
2976
  margin: 0;
2513
2977
  }
2978
+ .chempot-diagram-3d :global(.export-row button) {
2979
+ width: 1.4em;
2980
+ height: 1.4em;
2981
+ padding: 0;
2982
+ display: inline-flex;
2983
+ align-items: center;
2984
+ justify-content: center;
2985
+ }
2514
2986
  .chempot-diagram-3d :global(.chempot-checks) {
2515
2987
  display: flex;
2516
2988
  flex-wrap: wrap;
@@ -2552,16 +3024,17 @@ function cycle_color_mode() {
2552
3024
  }
2553
3025
  .chempot-diagram-3d :global(.overlay-actions) {
2554
3026
  display: flex;
2555
- gap: 4pt;
3027
+ gap: 3pt;
2556
3028
  margin: 0 0 4pt;
2557
3029
  }
2558
3030
  .chempot-diagram-3d :global(.overlay-actions button) {
2559
- border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
3031
+ border: none;
2560
3032
  border-radius: 3px;
2561
3033
  padding: 2px 6px;
2562
- background: transparent;
3034
+ background: color-mix(in srgb, currentColor 10%, transparent);
2563
3035
  cursor: pointer;
2564
3036
  color: var(--text-color, currentColor);
3037
+ font-size: 0.85em;
2565
3038
  }
2566
3039
  .chempot-diagram-3d :global(.overlay-search) {
2567
3040
  display: flex;
@@ -2574,21 +3047,40 @@ function cycle_color_mode() {
2574
3047
  min-width: 10em;
2575
3048
  }
2576
3049
  .chempot-diagram-3d :global(.formula-list) {
3050
+ display: flex;
3051
+ flex-wrap: wrap;
3052
+ gap: 3pt;
2577
3053
  max-height: min(42vh, 18rem);
2578
3054
  overflow: auto;
2579
- border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
2580
- border-radius: 4px;
2581
- padding: 4pt;
3055
+ padding: 2pt 0;
2582
3056
  }
2583
3057
  .chempot-diagram-3d :global(.formula-list label) {
2584
- display: flex;
3058
+ display: inline-flex;
2585
3059
  align-items: center;
2586
- gap: 5pt;
2587
- margin: 2pt 0;
3060
+ gap: 3pt;
3061
+ padding: 1px 5px;
3062
+ border-radius: 3px;
3063
+ font-size: 0.88em;
3064
+ cursor: pointer;
3065
+ background: color-mix(in srgb, currentColor 6%, transparent);
3066
+ }
3067
+ .chempot-diagram-3d :global(.formula-list label:has(input:checked)) {
3068
+ background: color-mix(in srgb, currentColor 16%, transparent);
3069
+ }
3070
+ .chempot-diagram-3d :global(.formula-list input[type='checkbox']) {
3071
+ position: absolute;
3072
+ width: 1px;
3073
+ height: 1px;
3074
+ overflow: hidden;
3075
+ clip: rect(0 0 0 0);
3076
+ }
3077
+ .chempot-diagram-3d :global(.formula-list label:has(input:focus-visible)) {
3078
+ outline: 2px solid Highlight;
3079
+ outline-offset: 1px;
2588
3080
  }
2589
3081
  .chempot-diagram-3d :global(.formula-color-dot) {
2590
- width: 0.65em;
2591
- height: 0.65em;
3082
+ width: 0.55em;
3083
+ height: 0.55em;
2592
3084
  border-radius: 50%;
2593
3085
  flex-shrink: 0;
2594
3086
  }
@@ -2604,6 +3096,12 @@ function cycle_color_mode() {
2604
3096
  min-width: 0;
2605
3097
  padding: 2px 4px;
2606
3098
  }
3099
+ .computing-state {
3100
+ display: flex;
3101
+ align-items: center;
3102
+ justify-content: center;
3103
+ min-height: 200px;
3104
+ }
2607
3105
  .error-state {
2608
3106
  display: flex;
2609
3107
  flex-direction: column;
@@ -2620,19 +3118,27 @@ function cycle_color_mode() {
2620
3118
  .axis-label {
2621
3119
  font: bold 13px sans-serif;
2622
3120
  }
3121
+ .axis-label :global(.axis-unit) {
3122
+ font-weight: 300;
3123
+ opacity: 0.7;
3124
+ }
2623
3125
  .tick-label {
2624
3126
  font-size: 10px;
2625
3127
  color: var(--text-color, #333);
2626
3128
  }
2627
3129
  .domain-label {
2628
- font: 11px sans-serif;
3130
+ font-family: sans-serif;
2629
3131
  color: var(--text-color, #333);
2630
3132
  opacity: 0.7;
2631
3133
  white-space: nowrap;
2632
3134
  pointer-events: none;
2633
3135
  }
3136
+ .formula-subscript {
3137
+ font-size: calc(11em / 12);
3138
+ vertical-align: -0.28em;
3139
+ }
2634
3140
  .phase-tooltip {
2635
- position: fixed;
3141
+ position: absolute;
2636
3142
  max-width: min(32rem, 92vw);
2637
3143
  background: var(
2638
3144
  --tooltip-bg,
@@ -2642,14 +3148,14 @@ function cycle_color_mode() {
2642
3148
  border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
2643
3149
  border-radius: 6px;
2644
3150
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
2645
- padding: 8px 10px;
3151
+ padding: 4px 6px;
2646
3152
  font-size: 12px;
2647
- line-height: 1.35;
3153
+ line-height: 1.25;
2648
3154
  pointer-events: none;
2649
3155
  z-index: 100;
2650
3156
  }
2651
3157
  .phase-tooltip h4 {
2652
- margin: 0 0 4px;
3158
+ margin: 0 0 2px;
2653
3159
  font-size: 13px;
2654
3160
  }
2655
3161
  .phase-tooltip p {
@@ -2659,7 +3165,7 @@ function cycle_color_mode() {
2659
3165
  text-overflow: ellipsis;
2660
3166
  }
2661
3167
  .phase-tooltip h5 {
2662
- margin-top: 6px;
3168
+ margin-top: 4px;
2663
3169
  margin-bottom: 0;
2664
3170
  font-size: 12px;
2665
3171
  font-weight: 600;