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,949 +1,1418 @@
1
- <script lang="ts">import { add_alpha, AXIS_COLORS, is_dark_mode, NEG_AXIS_COLORS, PLOT_COLORS, vesta_hex, watch_dark_mode, } from '../colors';
2
- import { normalize_show_controls } from '../controls';
3
- import { ClickFeedback, DragOverlay, Spinner } from '../feedback';
4
- import Icon from '../Icon.svelte';
5
- import { format_num } from '../labels';
6
- import { set_fullscreen_bg, setup_fullscreen_effect, toggle_fullscreen, } from '../layout';
7
- import { to_radians } from '../math';
8
- import { ColorBar, PlotTooltip } from '../plot';
9
- import { DEFAULTS } from '../settings';
10
- import { Canvas, T } from '@threlte/core';
11
- import * as extras from '@threlte/extras';
12
- import { ticks } from 'd3-array';
13
- import { SvelteMap } from 'svelte/reactivity';
14
- import { PerspectiveCamera, WebGLRenderer } from 'three';
15
- import { get_ternary_3d_coordinates, get_triangle_centroid, get_triangle_edges, get_triangle_vertical_edges, TRIANGLE_VERTICES, } from './barycentric-coords';
16
- import ConvexHullControls from './ConvexHullControls.svelte';
17
- import ConvexHullInfoPane from './ConvexHullInfoPane.svelte';
18
- import ConvexHullTooltip from './ConvexHullTooltip.svelte';
19
- import GasPressureControls from './GasPressureControls.svelte';
20
- import * as helpers from './helpers';
21
- import { CONVEX_HULL_STYLE, default_controls, default_hull_config } from './index';
22
- import StructurePopup from './StructurePopup.svelte';
23
- import TemperatureSlider from './TemperatureSlider.svelte';
24
- import * as thermo from './thermodynamics';
25
- let { entries = [], controls = {}, config = {}, on_point_click, on_point_hover, fullscreen = $bindable(DEFAULTS.convex_hull.ternary.fullscreen), enable_fullscreen = true, enable_info_pane = true, wrapper = $bindable(), label_threshold = 50, show_stable = $bindable(DEFAULTS.convex_hull.ternary.show_stable), show_unstable = $bindable(DEFAULTS.convex_hull.ternary.show_unstable), show_hull_faces = $bindable(DEFAULTS.convex_hull.ternary.show_hull_faces), hull_face_opacity = $bindable(DEFAULTS.convex_hull.ternary.hull_face_opacity), hull_face_color_mode = $bindable(DEFAULTS.convex_hull.ternary.hull_face_color_mode), element_colors = vesta_hex, color_mode = $bindable(DEFAULTS.convex_hull.ternary.color_mode), color_scale = $bindable(DEFAULTS.convex_hull.ternary.color_scale), info_pane_open = $bindable(DEFAULTS.convex_hull.ternary.info_pane_open), legend_pane_open = $bindable(DEFAULTS.convex_hull.ternary.legend_pane_open), max_hull_dist_show_phases = $bindable(DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases), max_hull_dist_show_labels = $bindable(DEFAULTS.convex_hull.ternary.max_hull_dist_show_labels), show_stable_labels = $bindable(DEFAULTS.convex_hull.ternary.show_stable_labels), show_unstable_labels = $bindable(DEFAULTS.convex_hull.ternary.show_unstable_labels), on_file_drop, enable_click_selection = true, enable_structure_preview = true, energy_source_mode = $bindable(`precomputed`), phase_stats = $bindable(null), stable_entries = $bindable([]), unstable_entries = $bindable([]), highlighted_entries = $bindable([]), highlight_style = {}, selected_entry = $bindable(null), temperature = $bindable(), interpolate_temperature = true, max_interpolation_gap = 500, gizmo = true, gas_config, gas_pressures = $bindable({}), children, tooltip, ...rest } = $props();
26
- const merged_controls = $derived({ ...default_controls, ...controls });
27
- const controls_config = $derived(normalize_show_controls(merged_controls.show));
28
- const merged_config = $derived({
1
+ <script lang="ts">
2
+ import type { D3InterpolateName } from '../colors'
3
+ import {
4
+ add_alpha,
5
+ AXIS_COLORS,
6
+ is_dark_mode,
7
+ NEG_AXIS_COLORS,
8
+ PLOT_COLORS,
9
+ vesta_hex,
10
+ watch_dark_mode,
11
+ } from '../colors'
12
+ import {
13
+ get_formula_label_segments,
14
+ type FormulaLabelSegment,
15
+ } from '../composition/format'
16
+ import { normalize_show_controls } from '../controls'
17
+ import { sanitize_html } from '../sanitize'
18
+ import { ClickFeedback, DragOverlay, Spinner } from '../feedback'
19
+ import Icon from '../Icon.svelte'
20
+ import { format_num } from '../labels'
21
+ import {
22
+ set_fullscreen_bg,
23
+ setup_fullscreen_effect,
24
+ toggle_fullscreen,
25
+ } from '../layout'
26
+ import { to_radians, type Vec3 } from '../math'
27
+ import { ColorBar, PlotTooltip } from '../plot'
28
+ import {
29
+ centered_rect,
30
+ pad_rect,
31
+ rects_overlap,
32
+ rect_within_rect,
33
+ type Rect,
34
+ } from '../plot/layout'
35
+ import { DEFAULTS } from '../settings'
36
+ import type { AnyStructure } from '../structure'
37
+ import { Canvas, T } from '@threlte/core'
38
+ import * as extras from '@threlte/extras'
39
+ import { ticks } from 'd3-array'
40
+ import { PerspectiveCamera, WebGLRenderer } from 'three'
41
+ import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
42
+ import {
43
+ get_ternary_3d_coordinates,
44
+ get_triangle_centroid,
45
+ get_triangle_edges,
46
+ get_triangle_vertical_edges,
47
+ TRIANGLE_VERTICES,
48
+ } from './barycentric-coords'
49
+ import ConvexHullControls from './ConvexHullControls.svelte'
50
+ import ConvexHullInfoPane from './ConvexHullInfoPane.svelte'
51
+ import ConvexHullTooltip from './ConvexHullTooltip.svelte'
52
+ import GasPressureControls from './GasPressureControls.svelte'
53
+ import * as helpers from './helpers'
54
+ import type { BaseConvexHullProps, Hull3DProps } from './index'
55
+ import { CONVEX_HULL_STYLE, default_controls, default_hull_config } from './index'
56
+ import StructurePopup from './StructurePopup.svelte'
57
+ import TemperatureSlider from './TemperatureSlider.svelte'
58
+ import * as thermo from './thermodynamics'
59
+ import type {
60
+ ConvexHullEntry,
61
+ ConvexHullTriangle,
62
+ HighlightStyle,
63
+ HoverData3D,
64
+ HullFaceColorMode,
65
+ LabelPlacement,
66
+ Point3D,
67
+ } from './types'
68
+ import { compute_hull_stability } from './helpers'
69
+
70
+ let {
71
+ entries = [],
72
+ controls = {},
73
+ config = {},
74
+ on_point_click,
75
+ on_point_hover,
76
+ fullscreen = $bindable(DEFAULTS.convex_hull.ternary.fullscreen),
77
+ enable_fullscreen = true,
78
+ enable_info_pane = true,
79
+ wrapper = $bindable(),
80
+ label_threshold = 50,
81
+ show_stable = $bindable(DEFAULTS.convex_hull.ternary.show_stable),
82
+ show_unstable = $bindable(DEFAULTS.convex_hull.ternary.show_unstable),
83
+ show_hull_faces = $bindable(DEFAULTS.convex_hull.ternary.show_hull_faces),
84
+ hull_face_opacity = $bindable(DEFAULTS.convex_hull.ternary.hull_face_opacity),
85
+ hull_face_color_mode = $bindable(
86
+ DEFAULTS.convex_hull.ternary.hull_face_color_mode as HullFaceColorMode,
87
+ ),
88
+ element_colors = vesta_hex,
89
+ color_mode = $bindable(DEFAULTS.convex_hull.ternary.color_mode),
90
+ color_scale = $bindable(
91
+ DEFAULTS.convex_hull.ternary.color_scale as D3InterpolateName,
92
+ ),
93
+ info_pane_open = $bindable(DEFAULTS.convex_hull.ternary.info_pane_open),
94
+ legend_pane_open = $bindable(DEFAULTS.convex_hull.ternary.legend_pane_open),
95
+ max_hull_dist_show_phases = $bindable(
96
+ DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases,
97
+ ),
98
+ max_hull_dist_show_labels = $bindable(
99
+ DEFAULTS.convex_hull.ternary.max_hull_dist_show_labels,
100
+ ),
101
+ show_stable_labels = $bindable(DEFAULTS.convex_hull.ternary.show_stable_labels),
102
+ show_unstable_labels = $bindable(
103
+ DEFAULTS.convex_hull.ternary.show_unstable_labels,
104
+ ),
105
+ on_file_drop,
106
+ enable_click_selection = true,
107
+ enable_structure_preview = true,
108
+ energy_source_mode = $bindable(`precomputed`),
109
+ phase_stats = $bindable(null),
110
+ stable_entries = $bindable([]),
111
+ unstable_entries = $bindable([]),
112
+ highlighted_entries = $bindable([]),
113
+ highlight_style = {},
114
+ selected_entry = $bindable(null),
115
+ temperature = $bindable(),
116
+ interpolate_temperature = true,
117
+ max_interpolation_gap = 500,
118
+ gizmo = true,
119
+ gas_config,
120
+ gas_pressures = $bindable({}),
121
+ children,
122
+ tooltip,
123
+ ...rest
124
+ }: BaseConvexHullProps<ConvexHullEntry> & Hull3DProps & {
125
+ highlight_style?: HighlightStyle
126
+ } = $props()
127
+
128
+ const merged_controls = $derived({ ...default_controls, ...controls })
129
+ const controls_config = $derived(normalize_show_controls(merged_controls.show))
130
+ const merged_config = $derived({
29
131
  ...default_hull_config,
30
132
  ...config,
31
133
  colors: { ...default_hull_config.colors, ...(config.colors || {}) },
32
134
  margin: { t: 40, r: 40, b: 60, l: 60, ...(config.margin || {}) },
33
- });
34
- // Temperature-dependent free energy support
35
- const { has_temp_data, available_temperatures } = $derived(helpers.analyze_temperature_data(entries));
36
- // Initialize or reset temperature when it's undefined or no longer valid
37
- $effect(() => {
38
- if (has_temp_data &&
39
- available_temperatures.length > 0 &&
40
- (temperature === undefined || !available_temperatures.includes(temperature)))
41
- temperature = available_temperatures[0];
42
- });
43
- // Filter entries by temperature when in temperature mode
44
- const temp_filtered_entries = $derived(has_temp_data && temperature !== undefined
45
- ? helpers.filter_entries_at_temperature(entries, temperature, {
135
+ })
136
+
137
+ // Temperature-dependent free energy support
138
+ const { has_temp_data, available_temperatures } = $derived(
139
+ helpers.analyze_temperature_data(entries),
140
+ )
141
+
142
+ // Initialize or reset temperature when it's undefined or no longer valid
143
+ $effect(() => {
144
+ if (
145
+ has_temp_data &&
146
+ available_temperatures.length > 0 &&
147
+ (temperature === undefined || !available_temperatures.includes(temperature))
148
+ ) temperature = available_temperatures[0]
149
+ })
150
+
151
+ // Filter entries by temperature when in temperature mode
152
+ const temp_filtered_entries = $derived(
153
+ has_temp_data && temperature !== undefined
154
+ ? helpers.filter_entries_at_temperature(entries, temperature, {
46
155
  interpolate: interpolate_temperature,
47
156
  max_interpolation_gap,
48
- })
49
- : entries);
50
- // Gas-dependent chemical potential support (corrections based on T, P)
51
- // Default to DEFAULT_GAS_TEMP (room temperature) when no temperature specified
52
- const { entries: gas_corrected_entries, analysis: gas_analysis, merged_config: merged_gas_config, } = $derived(helpers.get_gas_corrected_entries(temp_filtered_entries, gas_config, gas_pressures, temperature ?? helpers.DEFAULT_GAS_TEMP));
53
- let { // Compute energy mode information
54
- has_precomputed_e_form, has_precomputed_hull, can_compute_e_form, can_compute_hull, energy_mode, unary_refs, } = $derived(helpers.compute_energy_mode_info(gas_corrected_entries, thermo.find_lowest_energy_unary_refs, energy_source_mode));
55
- const effective_entries = $derived(helpers.get_effective_entries(gas_corrected_entries, energy_mode, unary_refs, thermo.compute_e_form_per_atom));
56
- // Process convex hull data with unified PhaseData interface using effective entries
57
- const pd_data = $derived(thermo.process_hull_entries(effective_entries));
58
- // Pre-compute polymorph stats once for O(1) tooltip lookups
59
- const polymorph_stats_map = $derived(helpers.compute_all_polymorph_stats(effective_entries));
60
- const elements = $derived.by(() => {
157
+ })
158
+ : entries,
159
+ )
160
+
161
+ // Gas-dependent chemical potential support (corrections based on T, P)
162
+ // Default to DEFAULT_GAS_TEMP (room temperature) when no temperature specified
163
+ const {
164
+ entries: gas_corrected_entries,
165
+ analysis: gas_analysis,
166
+ merged_config: merged_gas_config,
167
+ } = $derived(
168
+ helpers.get_gas_corrected_entries(
169
+ temp_filtered_entries,
170
+ gas_config,
171
+ gas_pressures,
172
+ temperature ?? helpers.DEFAULT_GAS_TEMP,
173
+ ),
174
+ )
175
+
176
+ let { // Compute energy mode information
177
+ has_precomputed_e_form,
178
+ has_precomputed_hull,
179
+ can_compute_e_form,
180
+ can_compute_hull,
181
+ energy_mode,
182
+ unary_refs,
183
+ } = $derived(
184
+ helpers.compute_energy_mode_info(
185
+ gas_corrected_entries,
186
+ thermo.find_lowest_energy_unary_refs,
187
+ energy_source_mode,
188
+ ),
189
+ )
190
+
191
+ const effective_entries = $derived(
192
+ helpers.get_effective_entries(
193
+ gas_corrected_entries,
194
+ energy_mode,
195
+ unary_refs,
196
+ thermo.compute_e_form_per_atom,
197
+ ),
198
+ )
199
+
200
+ // Process convex hull data with unified PhaseData interface using effective entries
201
+ const pd_data = $derived(thermo.process_hull_entries(effective_entries))
202
+
203
+ // Pre-compute polymorph stats once for O(1) tooltip lookups
204
+ const polymorph_stats_map = $derived(
205
+ helpers.compute_all_polymorph_stats(effective_entries),
206
+ )
207
+
208
+ const elements = $derived.by(() => {
61
209
  if (pd_data.elements.length > 3) {
62
- console.error(`ConvexHull3D: Dataset contains ${pd_data.elements.length} elements, but ternary diagrams require exactly 3. Found: [${pd_data.elements.join(`, `)}]`);
63
- return [];
210
+ console.error(
211
+ `ConvexHull3D: Dataset contains ${pd_data.elements.length} elements, but ternary diagrams require exactly 3. Found: [${
212
+ pd_data.elements.join(`, `)
213
+ }]`,
214
+ )
215
+ return []
64
216
  }
65
- return pd_data.elements;
66
- });
67
- // 1) Raw 3D coordinates (formation-energy z), independent of hull state
68
- const coords_entries = $derived.by(() => {
69
- if (elements.length !== 3)
70
- return [];
217
+
218
+ return pd_data.elements
219
+ })
220
+
221
+ // 1) Raw 3D coordinates (formation-energy z), independent of hull state
222
+ const coords_entries = $derived.by(() => {
223
+ if (elements.length !== 3) return []
71
224
  try {
72
- // Pass precomputed el_refs to avoid recomputing in error diagnostics
73
- const coords = get_ternary_3d_coordinates(pd_data.entries, elements, pd_data.el_refs);
74
- return coords;
225
+ // Pass precomputed el_refs to avoid recomputing in error diagnostics
226
+ const coords = get_ternary_3d_coordinates(
227
+ pd_data.entries,
228
+ elements,
229
+ pd_data.el_refs,
230
+ )
231
+ return coords
232
+ } catch (error) {
233
+ console.error(`Error computing ternary coordinates:`, error)
234
+ return []
75
235
  }
76
- catch (error) {
77
- console.error(`Error computing ternary coordinates:`, error);
78
- return [];
79
- }
80
- });
81
- const hull_faces = $derived.by(() => {
82
- if (coords_entries.length === 0)
83
- return [];
84
- const points = coords_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }));
236
+ })
237
+
238
+ // Compute lower convex hull faces (triangles) for 3D rendering (low energy hull only)
239
+ // Must be defined before all_enriched_entries which uses hull_model
240
+ const hull_faces = $derived.by((): ConvexHullTriangle[] => {
241
+ if (coords_entries.length === 0) return []
242
+ // Excluded entries don't participate in hull construction
243
+ const hull_entries = coords_entries.filter((e) => !e.exclude_from_hull)
244
+ if (hull_entries.length === 0) return []
245
+ const points = hull_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }))
85
246
  try {
86
- return thermo.compute_lower_hull_triangles(points);
247
+ return thermo.compute_lower_hull_triangles(points)
248
+ } catch (error) {
249
+ console.error(`Error computing convex hull:`, error)
250
+ return []
87
251
  }
88
- catch (error) {
89
- console.error(`Error computing convex hull:`, error);
90
- return [];
91
- }
92
- });
93
- // Cached hull model for e_above_hull queries; recompute only when faces change
94
- let hull_model = $derived.by(() => thermo.build_lower_hull_model(hull_faces));
95
- // Enrich coords with e_above_hull from cached hull model (before filtering)
96
- const all_enriched_entries = $derived.by(() => {
97
- if (coords_entries.length === 0)
98
- return [];
99
- if (energy_mode !== `on-the-fly`)
100
- return coords_entries;
101
- const pts = coords_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }));
102
- const e_hulls = thermo.compute_e_above_hull_for_points(pts, hull_model);
103
- return coords_entries.map((e, idx) => ({ ...e, e_above_hull: e_hulls[idx] }));
104
- });
105
- // Auto threshold: show all for few entries, use default for many, interpolate between
106
- const max_hull_dist_in_data = $derived(helpers.calc_max_hull_dist_in_data(all_enriched_entries));
107
- const auto_default_threshold = $derived(helpers.compute_auto_hull_dist_threshold(all_enriched_entries.length, max_hull_dist_in_data, DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases));
108
- // Initialize threshold to auto value on first load
109
- let initialized = $state(false);
110
- $effect(() => {
252
+ })
253
+
254
+ // Cached hull model for e_above_hull queries; recompute only when faces change
255
+ let hull_model = $derived.by(() => thermo.build_lower_hull_model(hull_faces))
256
+
257
+ // Enrich coords with e_above_hull from cached hull model (before filtering)
258
+ const all_enriched_entries = $derived.by(() => {
259
+ if (coords_entries.length === 0) return []
260
+ if (energy_mode !== `on-the-fly`) return coords_entries
261
+ const pts = coords_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }))
262
+ const raw_dists = thermo.compute_e_above_hull_for_points(pts, hull_model)
263
+ return coords_entries.map((entry, idx) => ({
264
+ ...entry, ...compute_hull_stability(raw_dists[idx], entry.exclude_from_hull),
265
+ }))
266
+ })
267
+
268
+ // Auto threshold: show all for few entries, use default for many, interpolate between
269
+ const max_hull_dist_in_data = $derived(
270
+ helpers.calc_max_hull_dist_in_data(all_enriched_entries),
271
+ )
272
+ const auto_default_threshold = $derived(helpers.compute_auto_hull_dist_threshold(
273
+ all_enriched_entries.length,
274
+ max_hull_dist_in_data,
275
+ DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases,
276
+ ))
277
+
278
+ // Initialize threshold to auto value on first load
279
+ let initialized = $state(false)
280
+ $effect(() => {
111
281
  if (!initialized && all_enriched_entries.length > 0) {
112
- initialized = true;
113
- max_hull_dist_show_phases = auto_default_threshold;
282
+ initialized = true
283
+ max_hull_dist_show_phases = auto_default_threshold
114
284
  }
115
- });
116
- // Filter by threshold and compute visibility
117
- const plot_entries = $derived(all_enriched_entries
118
- .filter((e) => (e.e_above_hull ?? 0) <= max_hull_dist_show_phases)
119
- .map((e) => ({
120
- ...e,
121
- visible: ((e.is_stable || e.e_above_hull === 0) && show_stable) ||
122
- (!(e.is_stable || e.e_above_hull === 0) && show_unstable),
123
- })));
124
- $effect(() => {
125
- stable_entries = plot_entries.filter((entry) => entry.is_stable || entry.e_above_hull === 0);
126
- unstable_entries = plot_entries.filter((entry) => typeof entry.e_above_hull === `number` && entry.e_above_hull > 0 &&
127
- !entry.is_stable);
128
- });
129
- // Canvas rendering
130
- let canvas;
131
- let ctx = null;
132
- // Performance optimization
133
- let frame_id = 0;
134
- let pulse_frame_id = 0;
135
- const camera_default = {
285
+ })
286
+
287
+ // Filter by threshold and compute visibility
288
+ const plot_entries = $derived(
289
+ all_enriched_entries
290
+ .filter((e) => (e.e_above_hull ?? 0) <= max_hull_dist_show_phases)
291
+ .map((e) => ({
292
+ ...e,
293
+ visible: ((e.is_stable || e.e_above_hull === 0) && show_stable) ||
294
+ (!(e.is_stable || e.e_above_hull === 0) && show_unstable),
295
+ })),
296
+ )
297
+
298
+ $effect(() => {
299
+ stable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
300
+ entry.is_stable || entry.e_above_hull === 0
301
+ )
302
+ unstable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
303
+ typeof entry.e_above_hull === `number` && entry.e_above_hull > 0 &&
304
+ !entry.is_stable
305
+ )
306
+ })
307
+
308
+ // Canvas rendering
309
+ let canvas: HTMLCanvasElement
310
+ let ctx: CanvasRenderingContext2D | null = null
311
+
312
+ // Performance optimization
313
+ let frame_id = 0
314
+ let pulse_frame_id = 0
315
+
316
+ const camera_default = {
136
317
  elevation: DEFAULTS.convex_hull.ternary.camera_elevation,
137
318
  azimuth: DEFAULTS.convex_hull.ternary.camera_azimuth,
138
319
  zoom: DEFAULTS.convex_hull.ternary.camera_zoom,
139
320
  center_x: 0,
140
321
  center_y: -50, // Shift up to better show the formation energy funnel
141
- };
142
- let camera = $state({ ...camera_default });
143
- // === Gizmo state & coordinate mapping ===
144
- // ConvexHull3D uses Rz(azimuth) then Rx(-elevation), viewing along -z_rotated.
145
- // These helpers convert between that system and Three.js camera position/up.
146
- const GIZMO_CAM_DIST = 5;
147
- const MIN_ELEV_FOR_Z_AXIS = 5; // degrees — below this, z-axis ticks collapse to a point
148
- let gizmo_cam_ref = $state();
149
- let gizmo_orbit_ref = $state(undefined);
150
- let gizmo_active = $state(false);
151
- // Convert elevation/azimuth (degrees) to Three.js camera position + up vector.
152
- function gizmo_camera(elev_deg, azim_deg) {
153
- const [elev, azim] = [to_radians(elev_deg), to_radians(azim_deg)];
322
+ }
323
+ let camera = $state({ ...camera_default })
324
+
325
+ // === Gizmo state & coordinate mapping ===
326
+ // ConvexHull3D uses Rz(azimuth) then Rx(-elevation), viewing along -z_rotated.
327
+ // These helpers convert between that system and Three.js camera position/up.
328
+ const GIZMO_CAM_DIST = 5
329
+ const MIN_ELEV_FOR_Z_AXIS = 5 // degrees — below this, z-axis ticks collapse to a point
330
+ let gizmo_cam_ref = $state<PerspectiveCamera>()
331
+ let gizmo_orbit_ref = $state<OrbitControls | undefined>(undefined)
332
+ let gizmo_active = $state(false)
333
+
334
+ // Convert elevation/azimuth (degrees) to Three.js camera position + up vector.
335
+ function gizmo_camera(
336
+ elev_deg: number,
337
+ azim_deg: number,
338
+ ): { position: Vec3; up: Vec3 } {
339
+ const [elev, azim] = [to_radians(elev_deg), to_radians(azim_deg)]
154
340
  const [se, ce, sa, ca] = [
155
- Math.sin(elev),
156
- Math.cos(elev),
157
- Math.sin(azim),
158
- Math.cos(azim),
159
- ];
341
+ Math.sin(elev),
342
+ Math.cos(elev),
343
+ Math.sin(azim),
344
+ Math.cos(azim),
345
+ ]
160
346
  return {
161
- position: [
162
- -sa * se * GIZMO_CAM_DIST,
163
- -ca * se * GIZMO_CAM_DIST,
164
- ce * GIZMO_CAM_DIST,
165
- ],
166
- up: [sa * ce, ca * ce, se],
167
- };
168
- }
169
- // Derived gizmo camera state, avoids recomputing in the template
170
- const gizmo_cam_state = $derived(gizmo_camera(camera.elevation, camera.azimuth));
171
- // Center camera on the triangle's visual center for a given elevation.
172
- // The centroid (rotation center) sits at 1/3 height while the bbox
173
- // center is at 1/2 height a difference of sqrt(3)/12 in data units.
174
- // Scale by cos(elevation) so offset only applies in near-top-down views.
175
- function center_camera(elev_deg) {
176
- camera.center_x = 0;
347
+ position: [
348
+ -sa * se * GIZMO_CAM_DIST,
349
+ -ca * se * GIZMO_CAM_DIST,
350
+ ce * GIZMO_CAM_DIST,
351
+ ],
352
+ up: [sa * ce, ca * ce, se],
353
+ }
354
+ }
355
+
356
+ // Derived gizmo camera state, avoids recomputing in the template
357
+ const gizmo_cam_state = $derived(gizmo_camera(camera.elevation, camera.azimuth))
358
+
359
+ // Center camera on the triangle's visual center for a given elevation.
360
+ // The centroid (rotation center) sits at 1/3 height while the bbox
361
+ // center is at 1/2 height — a difference of sqrt(3)/12 in data units.
362
+ // Scale by cos(elevation) so offset only applies in near-top-down views.
363
+ function center_camera(elev_deg: number): void {
364
+ camera.center_x = 0
177
365
  // 0.6 matches the draw_data_points() scale factor that maps data coords to canvas pixels
178
- const scale = Math.min(canvas_dims.width, canvas_dims.height) * 0.6 * camera.zoom;
179
- camera.center_y = Math.sqrt(3) / 12 * scale * Math.cos(to_radians(elev_deg));
180
- }
181
- // Sync: ConvexHull3D → Three.js gizmo camera (on main canvas drag)
182
- $effect(() => {
183
- if (gizmo_active)
184
- return;
185
- const cam = gizmo_cam_ref;
186
- if (!cam)
187
- return;
188
- const { position, up } = gizmo_camera(camera.elevation, camera.azimuth);
189
- cam.position.set(...position);
190
- cam.up.set(...up);
191
- cam.lookAt(0, 0, 0);
192
- gizmo_orbit_ref?.update?.();
193
- });
194
- // Sync: gizmo → ConvexHull3D (during and after gizmo animation)
195
- function sync_gizmo_to_camera() {
196
- const cam = gizmo_cam_ref;
197
- if (!cam)
198
- return;
199
- const { x: cx, y: cy, z: cz } = cam.position;
200
- const dist = Math.sqrt(cx * cx + cy * cy + cz * cz);
201
- if (dist < 1e-6)
202
- return;
203
- const elev_rad = Math.acos(Math.max(-1, Math.min(1, cz / dist)));
204
- const sin_elev = Math.sin(elev_rad);
366
+ const scale = Math.min(canvas_dims.width, canvas_dims.height) * 0.6 * camera.zoom
367
+ camera.center_y = Math.sqrt(3) / 12 * scale * Math.cos(to_radians(elev_deg))
368
+ }
369
+
370
+ // Sync: ConvexHull3D → Three.js gizmo camera (on main canvas drag)
371
+ $effect(() => {
372
+ if (gizmo_active) return
373
+ const cam = gizmo_cam_ref
374
+ if (!cam) return
375
+ const { position, up } = gizmo_camera(camera.elevation, camera.azimuth)
376
+ cam.position.set(...position)
377
+ cam.up.set(...up)
378
+ cam.lookAt(0, 0, 0)
379
+ gizmo_orbit_ref?.update?.()
380
+ })
381
+
382
+ // Sync: gizmo → ConvexHull3D (during and after gizmo animation)
383
+ function sync_gizmo_to_camera(): void {
384
+ const cam = gizmo_cam_ref
385
+ if (!cam) return
386
+ const { x: cx, y: cy, z: cz } = cam.position
387
+ const dist = Math.sqrt(cx * cx + cy * cy + cz * cz)
388
+ if (dist < 1e-6) return
389
+ const elev_rad = Math.acos(Math.max(-1, Math.min(1, cz / dist)))
390
+ const sin_elev = Math.sin(elev_rad)
205
391
  const azim_deg = Math.abs(sin_elev) > 1e-6
206
- ? Math.atan2(-cx / (dist * sin_elev), -cy / (dist * sin_elev)) * 180 / Math.PI
207
- : 0;
208
- const elev_deg = elev_rad * 180 / Math.PI;
209
- camera.elevation = elev_deg;
210
- camera.azimuth = azim_deg;
211
- center_camera(elev_deg);
212
- }
213
- // Gizmo axis colors (constant — AXIS_COLORS/NEG_AXIS_COLORS never change)
214
- const gizmo_axis_options = Object.fromEntries([...AXIS_COLORS, ...NEG_AXIS_COLORS].map(([axis, color, hover_color]) => [axis, {
215
- color,
216
- labelColor: `#111`,
217
- opacity: 0.85,
218
- hover: { color: hover_color, labelColor: `#222`, opacity: 1 },
219
- }]));
220
- // Extract placement from gizmo options (not a Threlte Gizmo prop)
221
- const gizmo_placement = $derived(typeof gizmo === `object` && gizmo?.placement ? gizmo.placement : `top-right`);
222
- // Merge constant axis options with consumer overrides (exclude our custom placement)
223
- const gizmo_props = $derived.by(() => {
392
+ ? Math.atan2(-cx / (dist * sin_elev), -cy / (dist * sin_elev)) * 180 / Math.PI
393
+ : 0
394
+ const elev_deg = elev_rad * 180 / Math.PI
395
+ camera.elevation = elev_deg
396
+ camera.azimuth = azim_deg
397
+ center_camera(elev_deg)
398
+ }
399
+
400
+ // Gizmo axis colors (constant — AXIS_COLORS/NEG_AXIS_COLORS never change)
401
+ const gizmo_axis_options = Object.fromEntries(
402
+ [...AXIS_COLORS, ...NEG_AXIS_COLORS].map((
403
+ [axis, color, hover_color],
404
+ ) => [axis, {
405
+ color,
406
+ labelColor: `#111`,
407
+ opacity: 0.85,
408
+ hover: { color: hover_color, labelColor: `#222`, opacity: 1 },
409
+ }]),
410
+ )
411
+
412
+ // Extract placement from gizmo options (not a Threlte Gizmo prop)
413
+ const gizmo_placement = $derived(
414
+ typeof gizmo === `object` && gizmo?.placement ? gizmo.placement : `top-right`,
415
+ )
416
+
417
+ // Merge constant axis options with consumer overrides (exclude our custom placement)
418
+ const gizmo_props = $derived.by(() => {
224
419
  if (typeof gizmo !== `object` || !gizmo) {
225
- return { background: { enabled: false }, size: 80, ...gizmo_axis_options };
420
+ return { background: { enabled: false }, size: 80, ...gizmo_axis_options }
226
421
  }
227
- const { placement: _, ...threlte_opts } = gizmo;
422
+ const { placement: _, ...threlte_opts } = gizmo
228
423
  return {
229
- background: { enabled: false },
230
- size: 80,
231
- ...gizmo_axis_options,
232
- ...threlte_opts,
233
- };
234
- });
235
- // Interaction state
236
- let is_dragging = $state(false);
237
- let drag_started = $state(false);
238
- let last_mouse = $state({ x: 0, y: 0 });
239
- let hover_data = $state(null);
240
- let copy_feedback = $state({ visible: false, position: { x: 0, y: 0 } });
241
- // Drag and drop state
242
- let drag_over = $state(false);
243
- // Structure popup state
244
- let modal_open = $state(false);
245
- let selected_structure = $state(null);
246
- let modal_place_right = $state(true);
247
- // Hull face color (customizable via controls)
248
- let hull_face_color = $state(`#4caf50`);
249
- // Pulsating highlight for selected point
250
- let pulse_time = $state(0);
251
- let pulse_opacity = $derived(0.3 + 0.4 * Math.sin(pulse_time * 4));
252
- // Merge highlight style with defaults
253
- const merged_highlight_style = $derived(helpers.merge_highlight_style(highlight_style));
254
- // Helper to check if entry is highlighted
255
- const is_highlighted = (entry) => helpers.is_entry_highlighted(entry, highlighted_entries);
256
- $effect(() => {
257
- if (!selected_entry && !highlighted_entries.length)
258
- return;
424
+ background: { enabled: false },
425
+ size: 80,
426
+ ...gizmo_axis_options,
427
+ ...threlte_opts,
428
+ }
429
+ })
430
+
431
+ // Interaction state
432
+ let is_dragging = $state(false)
433
+ let drag_started = $state(false)
434
+ let last_mouse = $state({ x: 0, y: 0 })
435
+ let hover_data = $state<HoverData3D<ConvexHullEntry> | null>(null)
436
+ let copy_feedback = $state({ visible: false, position: { x: 0, y: 0 } })
437
+
438
+ // Drag and drop state
439
+ let drag_over = $state(false)
440
+
441
+ // Structure popup state
442
+ let modal_open = $state(false)
443
+ let selected_structure = $state<AnyStructure | null>(null)
444
+ let modal_place_right = $state(true)
445
+
446
+ // Hull face color (customizable via controls)
447
+ let hull_face_color = $state(`#4caf50`)
448
+
449
+ // Pulsating highlight for selected point
450
+ let pulse_time = $state(0)
451
+ let pulse_opacity = $derived(0.3 + 0.4 * Math.sin(pulse_time * 4))
452
+
453
+ // Merge highlight style with defaults
454
+ const merged_highlight_style = $derived(
455
+ helpers.merge_highlight_style(highlight_style),
456
+ )
457
+
458
+ // Helper to check if entry is highlighted
459
+ const is_highlighted = (entry: ConvexHullEntry): boolean =>
460
+ helpers.is_entry_highlighted(entry, highlighted_entries)
461
+
462
+ $effect(() => {
463
+ if (!selected_entry && !highlighted_entries.length) return
259
464
  const animate = () => {
260
- pulse_time += 0.02;
261
- render_once();
262
- pulse_frame_id = requestAnimationFrame(animate);
263
- };
264
- pulse_frame_id = requestAnimationFrame(animate);
465
+ pulse_time += 0.02
466
+ render_once()
467
+ pulse_frame_id = requestAnimationFrame(animate)
468
+ }
469
+ pulse_frame_id = requestAnimationFrame(animate)
265
470
  return () => {
266
- if (pulse_frame_id)
267
- cancelAnimationFrame(pulse_frame_id);
268
- };
269
- });
270
- // Re-render when important state changes
271
- $effect(() => {
272
- // deno-fmt-ignore
273
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
274
- [show_hull_faces, color_mode, color_scale, show_stable_labels, show_unstable_labels, max_hull_dist_show_labels, camera.elevation, camera.azimuth, camera.zoom, camera.center_x, camera.center_y, plot_entries, hull_face_color, hull_face_opacity, hull_face_color_mode, element_colors, highlighted_entries, text_color];
275
- render_once();
276
- });
277
- // Function to extract structure data from a convex hull entry
278
- function extract_structure_from_entry(entry) {
279
- const orig_entry = entries.find((ent) => ent.entry_id === entry.entry_id);
280
- return orig_entry?.structure || null;
281
- }
282
- const reset_camera = () => Object.assign(camera, camera_default);
283
- function reset_all() {
284
- reset_camera();
285
- fullscreen = DEFAULTS.convex_hull.ternary.fullscreen;
286
- info_pane_open = DEFAULTS.convex_hull.ternary.info_pane_open;
287
- legend_pane_open = DEFAULTS.convex_hull.ternary.legend_pane_open;
288
- color_mode = DEFAULTS.convex_hull.ternary.color_mode;
289
- color_scale = DEFAULTS.convex_hull.ternary.color_scale;
290
- show_stable = DEFAULTS.convex_hull.ternary.show_stable;
291
- show_unstable = DEFAULTS.convex_hull.ternary.show_unstable;
292
- show_stable_labels = DEFAULTS.convex_hull.ternary.show_stable_labels;
293
- show_unstable_labels = DEFAULTS.convex_hull.ternary.show_unstable_labels;
294
- max_hull_dist_show_labels = DEFAULTS.convex_hull.ternary.max_hull_dist_show_labels;
471
+ if (pulse_frame_id) cancelAnimationFrame(pulse_frame_id)
472
+ }
473
+ })
474
+
475
+ // Re-render when important state changes
476
+ $effect(() => {
477
+ // oxfmt-ignore
478
+ void [show_hull_faces, color_mode, color_scale, show_stable_labels, show_unstable_labels, max_hull_dist_show_labels, camera.elevation, camera.azimuth, camera.zoom, camera.center_x, camera.center_y, plot_entries, hull_face_color, hull_face_opacity, hull_face_color_mode, element_colors, highlighted_entries, text_color] // track reactively
479
+
480
+ render_once()
481
+ })
482
+
483
+ // Function to extract structure data from a convex hull entry
484
+ function extract_structure_from_entry(
485
+ entry: ConvexHullEntry,
486
+ ): AnyStructure | null {
487
+ const orig_entry = entries.find((ent) => ent.entry_id === entry.entry_id)
488
+ return orig_entry?.structure as AnyStructure || null
489
+ }
490
+
491
+ const reset_camera = () => Object.assign(camera, camera_default)
492
+ function reset_all() {
493
+ reset_camera()
494
+ fullscreen = DEFAULTS.convex_hull.ternary.fullscreen
495
+ info_pane_open = DEFAULTS.convex_hull.ternary.info_pane_open
496
+ legend_pane_open = DEFAULTS.convex_hull.ternary.legend_pane_open
497
+ color_mode = DEFAULTS.convex_hull.ternary.color_mode
498
+ color_scale = DEFAULTS.convex_hull.ternary.color_scale as D3InterpolateName
499
+ show_stable = DEFAULTS.convex_hull.ternary.show_stable
500
+ show_unstable = DEFAULTS.convex_hull.ternary.show_unstable
501
+ show_stable_labels = DEFAULTS.convex_hull.ternary.show_stable_labels
502
+ show_unstable_labels = DEFAULTS.convex_hull.ternary.show_unstable_labels
503
+ max_hull_dist_show_labels = DEFAULTS.convex_hull.ternary.max_hull_dist_show_labels
295
504
  // Use auto-computed threshold based on entry count instead of static default
296
- max_hull_dist_show_phases = auto_default_threshold;
297
- show_hull_faces = DEFAULTS.convex_hull.ternary.show_hull_faces;
298
- hull_face_color = DEFAULTS.convex_hull.ternary.hull_face_color;
299
- hull_face_opacity = DEFAULTS.convex_hull.ternary.hull_face_opacity;
505
+ max_hull_dist_show_phases = auto_default_threshold
506
+ show_hull_faces = DEFAULTS.convex_hull.ternary.show_hull_faces
507
+ hull_face_color = DEFAULTS.convex_hull.ternary.hull_face_color
508
+ hull_face_opacity = DEFAULTS.convex_hull.ternary.hull_face_opacity
300
509
  hull_face_color_mode = DEFAULTS.convex_hull.ternary
301
- .hull_face_color_mode;
302
- }
303
- const handle_keydown = (event) => {
304
- const target = event.target;
305
- if (target.tagName.match(/INPUT|TEXTAREA/))
306
- return;
510
+ .hull_face_color_mode as HullFaceColorMode
511
+ }
512
+
513
+ const handle_keydown = (event: KeyboardEvent) => {
514
+ const target = event.target as HTMLElement
515
+ if (target.tagName.match(/INPUT|TEXTAREA/)) return
516
+
307
517
  // Stop propagation if event came from canvas to prevent wrapper's handler
308
518
  // from running again (both have onkeydown, causing duplicate handling)
309
519
  if (target === canvas) {
310
- event.stopPropagation();
520
+ event.stopPropagation()
311
521
  }
522
+
312
523
  if (event.key === `Escape` && modal_open) {
313
- close_structure_popup();
314
- return;
524
+ close_structure_popup()
525
+ return
315
526
  }
527
+
316
528
  // Handle Enter for keyboard accessibility - select hovered entry
317
529
  if (event.key === `Enter`) {
318
- const entry = hover_data?.entry;
319
- if (entry) {
320
- on_point_click?.(entry);
321
- if (enable_click_selection) {
322
- selected_entry = entry;
323
- if (enable_structure_preview) {
324
- const structure = extract_structure_from_entry(entry);
325
- if (structure) {
326
- selected_structure = structure;
327
- modal_place_right = helpers.calculate_modal_side(wrapper);
328
- modal_open = true;
329
- }
330
- }
530
+ const entry = hover_data?.entry
531
+ if (entry) {
532
+ on_point_click?.(entry)
533
+ if (enable_click_selection) {
534
+ selected_entry = entry
535
+ if (enable_structure_preview) {
536
+ const structure = extract_structure_from_entry(entry)
537
+ if (structure) {
538
+ selected_structure = structure
539
+ modal_place_right = helpers.calculate_modal_side(wrapper)
540
+ modal_open = true
331
541
  }
542
+ }
332
543
  }
333
- else if (modal_open) {
334
- close_structure_popup();
335
- }
336
- return;
544
+ } else if (modal_open) {
545
+ close_structure_popup()
546
+ }
547
+ return
548
+ }
549
+
550
+ const actions: Record<string, () => void> = {
551
+ r: reset_camera,
552
+ t: () => {
553
+ camera.elevation = 0
554
+ camera.azimuth = 0
555
+ center_camera(0)
556
+ },
557
+ b: () => color_mode = color_mode === `stability` ? `energy` : `stability`,
558
+ s: () => show_stable = !show_stable,
559
+ u: () => show_unstable = !show_unstable,
560
+ h: () => show_hull_faces = !show_hull_faces,
561
+ l: () => show_stable_labels = !show_stable_labels,
337
562
  }
338
- const actions = {
339
- r: reset_camera,
340
- t: () => {
341
- camera.elevation = 0;
342
- camera.azimuth = 0;
343
- center_camera(0);
344
- },
345
- b: () => color_mode = color_mode === `stability` ? `energy` : `stability`,
346
- s: () => show_stable = !show_stable,
347
- u: () => show_unstable = !show_unstable,
348
- h: () => show_hull_faces = !show_hull_faces,
349
- l: () => show_stable_labels = !show_stable_labels,
350
- };
351
- actions[event.key.toLowerCase()]?.();
352
- };
353
- async function handle_file_drop(event) {
354
- drag_over = false;
355
- const data = await helpers.parse_hull_entries_from_drop(event);
356
- if (data)
357
- on_file_drop?.(data);
358
- }
359
- async function copy_entry_data(entry, position) {
563
+ actions[event.key.toLowerCase()]?.()
564
+ }
565
+
566
+ async function handle_file_drop(event: DragEvent): Promise<void> {
567
+ drag_over = false
568
+ const data = await helpers.parse_hull_entries_from_drop(event)
569
+ if (data) on_file_drop?.(data)
570
+ }
571
+
572
+ async function copy_entry_data(
573
+ entry: ConvexHullEntry,
574
+ position: { x: number; y: number },
575
+ ) {
360
576
  await helpers.copy_entry_to_clipboard(entry, position, (visible, pos) => {
361
- copy_feedback.visible = visible;
362
- copy_feedback.position = pos;
363
- });
364
- }
365
- const get_point_color = (entry) => helpers.get_point_color_for_entry(entry, color_mode, merged_config.colors, energy_color_scale);
366
- // Cache energy color scale per frame/setting
367
- const energy_color_scale = $derived.by(() => helpers.get_energy_color_scale(color_mode, color_scale, plot_entries));
368
- // Convex hull statistics - compute internally and expose via bindable prop
369
- $effect(() => {
370
- phase_stats = thermo.get_convex_hull_stats(plot_entries, elements, 3);
371
- });
372
- // 3D to 2D projection for ternary diagrams
373
- function project_3d_point(x, y, z) {
374
- if (!canvas)
375
- return { x: 0, y: 0, depth: 0 };
577
+ copy_feedback.visible = visible
578
+ copy_feedback.position = pos
579
+ })
580
+ }
581
+
582
+ const get_point_color = (entry: ConvexHullEntry): string =>
583
+ helpers.get_point_color_for_entry(
584
+ entry,
585
+ color_mode,
586
+ merged_config.colors,
587
+ energy_color_scale,
588
+ )
589
+
590
+ // Cache energy color scale per frame/setting
591
+ const energy_color_scale = $derived.by(() =>
592
+ helpers.get_energy_color_scale(color_mode, color_scale, plot_entries)
593
+ )
594
+
595
+ // Convex hull statistics - compute internally and expose via bindable prop
596
+ $effect(() => {
597
+ phase_stats = thermo.get_convex_hull_stats(plot_entries, elements, 3)
598
+ })
599
+
600
+ // 3D to 2D projection for ternary diagrams
601
+ function project_3d_point(
602
+ x: number,
603
+ y: number,
604
+ z: number,
605
+ ): { x: number; y: number; depth: number } {
606
+ if (!canvas) return { x: 0, y: 0, depth: 0 }
607
+
376
608
  const [elev, azim] = [
377
- (camera.elevation * Math.PI) / 180,
378
- (camera.azimuth * Math.PI) / 180,
379
- ];
609
+ (camera.elevation * Math.PI) / 180,
610
+ (camera.azimuth * Math.PI) / 180,
611
+ ]
380
612
  const [cos_az, sin_az, cos_el, sin_el] = [
381
- Math.cos(azim),
382
- Math.sin(azim),
383
- Math.cos(-elev),
384
- Math.sin(-elev),
385
- ];
386
- const centroid = get_triangle_centroid();
387
- const { center: e_ctr, z_scale } = energy_range;
388
- const [dx, dy, dz] = [x - centroid.x, y - centroid.y, (z - e_ctr) * z_scale];
389
- const [x1, y1] = [dx * cos_az - dy * sin_az, dx * sin_az + dy * cos_az];
390
- const [y2, z2] = [y1 * cos_el - dz * sin_el, y1 * sin_el + dz * cos_el];
613
+ Math.cos(azim),
614
+ Math.sin(azim),
615
+ Math.cos(-elev),
616
+ Math.sin(-elev),
617
+ ]
618
+ const centroid = get_triangle_centroid()
619
+ const { center: e_ctr, z_scale } = energy_range
620
+
621
+ const [dx, dy, dz] = [x - centroid.x, y - centroid.y, (z - e_ctr) * z_scale]
622
+ const [x1, y1] = [dx * cos_az - dy * sin_az, dx * sin_az + dy * cos_az]
623
+ const [y2, z2] = [y1 * cos_el - dz * sin_el, y1 * sin_el + dz * cos_el]
624
+
391
625
  // Use Math.min for consistent scaling with cached canvas dimensions
392
- const scale = Math.min(canvas_dims.width, canvas_dims.height) * 0.6 * camera.zoom;
626
+ const scale = Math.min(canvas_dims.width, canvas_dims.height) * 0.6 * camera.zoom
393
627
  return {
394
- x: canvas_dims.width / 2 + camera.center_x + x1 * scale,
395
- y: canvas_dims.height / 2 + camera.center_y - y2 * scale,
396
- depth: z2,
397
- };
398
- }
399
- function draw_structure_outline() {
400
- if (!ctx)
401
- return;
628
+ x: canvas_dims.width / 2 + camera.center_x + x1 * scale,
629
+ y: canvas_dims.height / 2 + camera.center_y - y2 * scale,
630
+ depth: z2,
631
+ }
632
+ }
633
+
634
+ function draw_structure_outline(): void {
635
+ if (!ctx) return
636
+
402
637
  // Set consistent style for all triangle structure lines
403
- ctx.strokeStyle = CONVEX_HULL_STYLE.structure_line.color;
404
- ctx.lineWidth = CONVEX_HULL_STYLE.structure_line.line_width;
405
- ctx.setLineDash(CONVEX_HULL_STYLE.structure_line.dash); // Dashed lines for all structure lines
638
+ ctx.strokeStyle = CONVEX_HULL_STYLE.structure_line.color
639
+ ctx.lineWidth = CONVEX_HULL_STYLE.structure_line.line_width
640
+ ctx.setLineDash(CONVEX_HULL_STYLE.structure_line.dash) // Dashed lines for all structure lines
641
+
406
642
  // Draw triangle base and vertical edges
407
- draw_triangle_structure();
408
- }
409
- function draw_triangle_structure() {
410
- if (!ctx)
411
- return;
643
+ draw_triangle_structure()
644
+ }
645
+
646
+ function draw_triangle_structure(): void {
647
+ if (!ctx) return
648
+
412
649
  // Get formation energy range for vertical edges
413
- const formation_energies = plot_entries.map((e) => e.e_form_per_atom ?? 0);
414
- const e_form_min = Math.min(0, ...formation_energies); // Include 0 for elemental references
415
- const e_form_max = Math.max(0, ...formation_energies); // Include 0 for elemental references
650
+ const formation_energies = plot_entries.map((e) => e.e_form_per_atom ?? 0)
651
+ const e_form_min = Math.min(0, ...formation_energies) // Include 0 for elemental references
652
+ const e_form_max = Math.max(0, ...formation_energies) // Include 0 for elemental references
653
+
416
654
  // Draw base triangle edges (top triangle at formation energy = 0)
417
- const triangle_edges = get_triangle_edges();
418
- ctx.beginPath();
655
+ const triangle_edges = get_triangle_edges()
656
+ ctx.beginPath()
419
657
  for (const [v1, v2] of triangle_edges) {
420
- const proj1 = project_3d_point(v1.x, v1.y, 0); // Base triangle at formation energy = 0
421
- const proj2 = project_3d_point(v2.x, v2.y, 0);
422
- ctx.moveTo(proj1.x, proj1.y);
423
- ctx.lineTo(proj2.x, proj2.y);
658
+ const proj1 = project_3d_point(v1.x, v1.y, 0) // Base triangle at formation energy = 0
659
+ const proj2 = project_3d_point(v2.x, v2.y, 0)
660
+
661
+ ctx.moveTo(proj1.x, proj1.y)
662
+ ctx.lineTo(proj2.x, proj2.y)
424
663
  }
425
- ctx.stroke();
664
+ ctx.stroke()
665
+
426
666
  // Draw vertical edges from corners (from most negative to 0 formation energy)
427
- const vertical_edges = get_triangle_vertical_edges(e_form_min, e_form_max);
428
- ctx.beginPath();
667
+ const vertical_edges = get_triangle_vertical_edges(
668
+ e_form_min,
669
+ e_form_max,
670
+ )
671
+ ctx.beginPath()
429
672
  for (const [v1, v2] of vertical_edges) {
430
- const proj1 = project_3d_point(v1.x, v1.y, v1.z);
431
- const proj2 = project_3d_point(v2.x, v2.y, v2.z);
432
- ctx.moveTo(proj1.x, proj1.y);
433
- ctx.lineTo(proj2.x, proj2.y);
673
+ const proj1 = project_3d_point(v1.x, v1.y, v1.z)
674
+ const proj2 = project_3d_point(v2.x, v2.y, v2.z)
675
+
676
+ ctx.moveTo(proj1.x, proj1.y)
677
+ ctx.lineTo(proj2.x, proj2.y)
434
678
  }
435
- ctx.stroke();
679
+ ctx.stroke()
680
+
436
681
  // Draw bottom triangle (connecting the bottom tips of vertical lines)
437
- const bottom_triangle_edges = get_triangle_edges();
438
- ctx.beginPath();
682
+ const bottom_triangle_edges = get_triangle_edges()
683
+ ctx.beginPath()
439
684
  for (const [v1, v2] of bottom_triangle_edges) {
440
- const proj1 = project_3d_point(v1.x, v1.y, e_form_min); // Bottom triangle at most negative energy
441
- const proj2 = project_3d_point(v2.x, v2.y, e_form_min);
442
- ctx.moveTo(proj1.x, proj1.y);
443
- ctx.lineTo(proj2.x, proj2.y);
685
+ const proj1 = project_3d_point(v1.x, v1.y, e_form_min) // Bottom triangle at most negative energy
686
+ const proj2 = project_3d_point(v2.x, v2.y, e_form_min)
687
+
688
+ ctx.moveTo(proj1.x, proj1.y)
689
+ ctx.lineTo(proj2.x, proj2.y)
444
690
  }
445
- ctx.stroke();
691
+ ctx.stroke()
692
+
446
693
  // Reset stroke style to default for other elements
447
- const styles = getComputedStyle(canvas);
448
- ctx.strokeStyle = styles.getPropertyValue(`--hull-edge-color`) || `#212121`;
449
- ctx.setLineDash([]); // Reset line dash for other drawing operations
450
- }
451
- function draw_element_labels() {
452
- if (!ctx || elements.length !== 3)
453
- return;
454
- ctx.save();
694
+ const styles = getComputedStyle(canvas)
695
+ ctx.strokeStyle = styles.getPropertyValue(`--hull-edge-color`) || `#212121`
696
+ ctx.setLineDash([]) // Reset line dash for other drawing operations
697
+ }
698
+
699
+ function draw_element_labels(): void {
700
+ if (!ctx || elements.length !== 3) return
701
+
702
+ ctx.save()
703
+
455
704
  // Draw element labels outside triangle corners
456
- const centroid = get_triangle_centroid();
457
- ctx.fillStyle = text_color;
458
- ctx.font = `bold 16px Arial`;
459
- ctx.textAlign = `center`;
460
- ctx.textBaseline = `middle`;
461
- for (let idx = 0; idx < TRIANGLE_VERTICES.length && idx < elements.length; idx++) {
462
- const [x, y] = TRIANGLE_VERTICES[idx];
463
- const dx = x - centroid.x;
464
- const dy = y - centroid.y;
465
- const length = Math.sqrt(dx * dx + dy * dy);
466
- const distance = 0.05;
467
- const label_pos = {
468
- x: x + (dx / length) * distance,
469
- y: y + (dy / length) * distance,
470
- z: 0,
471
- };
472
- const proj = project_3d_point(label_pos.x, label_pos.y, label_pos.z);
473
- ctx.fillText(elements[idx], proj.x, proj.y);
705
+ const centroid = get_triangle_centroid()
706
+ ctx.fillStyle = text_color
707
+ ctx.font = `bold 16px Arial`
708
+ ctx.textAlign = `center`
709
+ ctx.textBaseline = `middle`
710
+
711
+ for (
712
+ let idx = 0;
713
+ idx < TRIANGLE_VERTICES.length && idx < elements.length;
714
+ idx++
715
+ ) {
716
+ const [x, y] = TRIANGLE_VERTICES[idx]
717
+ const dx = x - centroid.x
718
+ const dy = y - centroid.y
719
+ const length = Math.sqrt(dx * dx + dy * dy)
720
+ const distance = 0.05
721
+
722
+ const label_pos = {
723
+ x: x + (dx / length) * distance,
724
+ y: y + (dy / length) * distance,
725
+ z: 0,
726
+ }
727
+
728
+ const proj = project_3d_point(label_pos.x, label_pos.y, label_pos.z)
729
+ ctx.fillText(elements[idx], proj.x, proj.y)
474
730
  }
475
- ctx.restore();
476
- }
477
- function draw_z_axis_ticks() {
478
- if (!ctx || elements.length !== 3)
479
- return;
731
+
732
+ ctx.restore()
733
+ }
734
+
735
+ function draw_z_axis_ticks(): void {
736
+ if (!ctx || elements.length !== 3) return
480
737
  // Hide z-axis in near-top-down views where ticks collapse to a point
481
- if (Math.abs(camera.elevation) < MIN_ELEV_FOR_Z_AXIS)
482
- return;
483
- const { min: e_min, max: e_max, center: e_mid } = energy_range;
484
- if (Math.abs(e_max - e_min) < 1e-6)
485
- return;
738
+ if (Math.abs(camera.elevation) < MIN_ELEV_FOR_Z_AXIS) return
739
+
740
+ const { min: e_min, max: e_max, center: e_mid } = energy_range
741
+ if (Math.abs(e_max - e_min) < 1e-6) return
742
+
486
743
  // Find the vertex that projects to the leftmost x-position (changes with rotation)
487
- const projected_vertices = TRIANGLE_VERTICES.map(([vx, vy]) => project_3d_point(vx, vy, e_mid));
488
- const leftmost_idx = projected_vertices.reduce((min_idx, proj, idx) => (proj.x < projected_vertices[min_idx].x ? idx : min_idx), 0);
489
- const [axis_x, axis_y] = TRIANGLE_VERTICES[leftmost_idx];
490
- const tick_len = 6 * canvas_dims.scale;
491
- ctx.save();
492
- ctx.fillStyle = text_color;
493
- ctx.textAlign = `right`;
494
- ctx.textBaseline = `middle`;
495
- ctx.strokeStyle = CONVEX_HULL_STYLE.structure_line.color;
496
- ctx.font = `${merged_config.font_size}px Arial`;
744
+ const projected_vertices = TRIANGLE_VERTICES.map(([vx, vy]) =>
745
+ project_3d_point(vx, vy, e_mid)
746
+ )
747
+ const leftmost_idx = projected_vertices.reduce(
748
+ (
749
+ min_idx,
750
+ proj,
751
+ idx,
752
+ ) => (proj.x < projected_vertices[min_idx].x ? idx : min_idx),
753
+ 0,
754
+ )
755
+ const [axis_x, axis_y] = TRIANGLE_VERTICES[leftmost_idx]
756
+ const tick_len = 6 * canvas_dims.scale
757
+
758
+ ctx.save()
759
+ ctx.fillStyle = text_color
760
+ ctx.textAlign = `right`
761
+ ctx.textBaseline = `middle`
762
+ ctx.strokeStyle = CONVEX_HULL_STYLE.structure_line.color
763
+ ctx.font = `${merged_config.font_size}px Arial`
764
+
497
765
  for (const tick of ticks(e_min, e_max, 5)) {
498
- const { x, y } = project_3d_point(axis_x, axis_y, tick);
499
- ctx.beginPath();
500
- ctx.moveTo(x - tick_len, y);
501
- ctx.lineTo(x, y);
502
- ctx.stroke();
503
- ctx.fillText(format_num(tick, `.2~`), x - tick_len - 4, y);
766
+ const { x, y } = project_3d_point(axis_x, axis_y, tick)
767
+ ctx.beginPath()
768
+ ctx.moveTo(x - tick_len, y)
769
+ ctx.lineTo(x, y)
770
+ ctx.stroke()
771
+ ctx.fillText(format_num(tick, `.2~`), x - tick_len - 4, y)
504
772
  }
773
+
505
774
  // Rotated axis label: Eform (eV/atom) with "form" as subscript
506
- const { x: lx, y: ly } = project_3d_point(axis_x, axis_y, e_mid);
507
- const fs = merged_config.font_size ?? 12;
508
- const sub_fs = Math.round(fs * 0.75);
509
- ctx.translate(lx - 50 * canvas_dims.scale, ly);
510
- ctx.rotate(-Math.PI / 2);
511
- ctx.textAlign = `left`;
775
+ const { x: lx, y: ly } = project_3d_point(axis_x, axis_y, e_mid)
776
+ const fs = merged_config.font_size ?? 12
777
+ const sub_fs = Math.round(fs * 0.75)
778
+ ctx.translate(lx - 50 * canvas_dims.scale, ly)
779
+ ctx.rotate(-Math.PI / 2)
780
+ ctx.textAlign = `left`
512
781
  // Measure widths in each font, then draw — reordered to minimize font switches
513
- ctx.font = `bold ${fs}px Arial`;
514
- const e_width = ctx.measureText(`E`).width;
515
- const suffix_width = ctx.measureText(` (eV/atom)`).width;
516
- ctx.font = `${sub_fs}px Arial`;
517
- const form_width = ctx.measureText(`form`).width;
518
- const offset = -(e_width + form_width + suffix_width) / 2;
782
+ ctx.font = `bold ${fs}px Arial`
783
+ const e_width = ctx.measureText(`E`).width
784
+ const suffix_width = ctx.measureText(` (eV/atom)`).width
785
+ ctx.font = `${sub_fs}px Arial`
786
+ const form_width = ctx.measureText(`form`).width
787
+ const offset = -(e_width + form_width + suffix_width) / 2
519
788
  // Draw subscript while sub-font is still active
520
- ctx.fillText(`form`, offset + e_width, fs * 0.3);
521
- ctx.font = `bold ${fs}px Arial`;
522
- ctx.fillText(`E`, offset, 0);
523
- ctx.fillText(` (eV/atom)`, offset + e_width + form_width, 0);
524
- ctx.restore();
525
- }
526
- function draw_convex_hull_faces() {
527
- if (!ctx || !show_hull_faces || hull_faces.length === 0)
528
- return;
789
+ ctx.fillText(`form`, offset + e_width, fs * 0.3)
790
+ ctx.font = `bold ${fs}px Arial`
791
+ ctx.fillText(`E`, offset, 0)
792
+ ctx.fillText(` (eV/atom)`, offset + e_width + form_width, 0)
793
+ ctx.restore()
794
+ }
795
+
796
+ function draw_convex_hull_faces(): void {
797
+ if (!ctx || !show_hull_faces || hull_faces.length === 0) return
798
+
529
799
  // Lazy computation for uniform mode: normalize alpha by formation energy
530
- let norm_alpha = null;
800
+ let norm_alpha: ((z: number) => number) | null = null
531
801
  if (hull_face_color_mode === `uniform`) {
532
- const formation_energies = plot_entries.map((e) => e.e_form_per_atom ?? 0);
533
- const min_fe = Math.min(0, ...formation_energies);
534
- norm_alpha = (z) => {
535
- const t = Math.max(0, Math.min(1, (0 - z) / Math.max(1e-6, 0 - min_fe)));
536
- return t * hull_face_opacity;
537
- };
802
+ const formation_energies = plot_entries.map((e) => e.e_form_per_atom ?? 0)
803
+ const min_fe = Math.min(0, ...formation_energies)
804
+ norm_alpha = (z: number) => {
805
+ const t = Math.max(0, Math.min(1, (0 - z) / Math.max(1e-6, 0 - min_fe)))
806
+ return t * hull_face_opacity
807
+ }
538
808
  }
809
+
539
810
  // Lazy computation for formation_energy mode
540
- let energy_face_scale = null;
541
- let min_z = 0;
811
+ let energy_face_scale: ((val: number) => string) | null = null
812
+ let min_z = 0
542
813
  if (hull_face_color_mode === `formation_energy`) {
543
- const all_z = hull_faces.flatMap((tri) => tri.vertices.map((v) => v.z));
544
- min_z = Math.min(...all_z);
545
- energy_face_scale = helpers.get_energy_color_scale(`energy`, color_scale, all_z.map((z) => ({ e_above_hull: z - min_z })));
814
+ const all_z = hull_faces.flatMap((tri) => tri.vertices.map((v) => v.z))
815
+ min_z = Math.min(...all_z)
816
+ energy_face_scale = helpers.get_energy_color_scale(
817
+ `energy`,
818
+ color_scale,
819
+ all_z.map((z) => ({ e_above_hull: z - min_z })), // Normalize to 0-based
820
+ )
546
821
  }
822
+
547
823
  // Helper to get face color based on mode
548
- const get_face_color = (tri, tri_idx) => {
549
- if (hull_face_color_mode === `uniform`) {
550
- return hull_face_color;
551
- }
552
- if (hull_face_color_mode === `formation_energy`) {
553
- const avg_z = (tri.vertices[0].z + tri.vertices[1].z + tri.vertices[2].z) / 3;
554
- return energy_face_scale(avg_z - min_z);
555
- }
556
- if (hull_face_color_mode === `dominant_element`) {
557
- // Find element vertex closest to face centroid in 2D ternary space
558
- const { x: cx, y: cy } = tri.centroid;
559
- const dists = TRIANGLE_VERTICES.map(([tx, ty]) => Math.hypot(cx - tx, cy - ty));
560
- const el = elements[dists.indexOf(Math.min(...dists))];
561
- return element_colors[el] ?? `#888888`;
562
- }
563
- if (hull_face_color_mode === `facet_index`) {
564
- return PLOT_COLORS[tri_idx % PLOT_COLORS.length];
565
- }
566
- return hull_face_color;
567
- };
824
+ const get_face_color = (
825
+ tri: typeof hull_faces[0],
826
+ tri_idx: number,
827
+ ): string => {
828
+ if (hull_face_color_mode === `uniform`) {
829
+ return hull_face_color
830
+ }
831
+ if (hull_face_color_mode === `formation_energy`) {
832
+ const avg_z = (tri.vertices[0].z + tri.vertices[1].z + tri.vertices[2].z) / 3
833
+ return energy_face_scale?.(avg_z - min_z) ?? hull_face_color
834
+ }
835
+ if (hull_face_color_mode === `dominant_element`) {
836
+ // Find element vertex closest to face centroid in 2D ternary space
837
+ const { x: cx, y: cy } = tri.centroid
838
+ const dists = TRIANGLE_VERTICES.map(([tx, ty]) =>
839
+ Math.hypot(cx - tx, cy - ty)
840
+ )
841
+ const el = elements[dists.indexOf(Math.min(...dists))]
842
+ return element_colors[el] ?? `#888888`
843
+ }
844
+ if (hull_face_color_mode === `facet_index`) {
845
+ return PLOT_COLORS[tri_idx % PLOT_COLORS.length]
846
+ }
847
+ return hull_face_color
848
+ }
849
+
568
850
  // Sort faces by depth for proper rendering
569
851
  const faces_with_depth = hull_faces.map((tri, tri_idx) => {
570
- const centroid_proj = project_3d_point(tri.centroid.x, tri.centroid.y, tri.centroid.z);
571
- return { tri, tri_idx, depth: centroid_proj.depth };
572
- });
573
- faces_with_depth.sort((a, b) => a.depth - b.depth); // Back to front
852
+ const centroid_proj = project_3d_point(
853
+ tri.centroid.x,
854
+ tri.centroid.y,
855
+ tri.centroid.z,
856
+ )
857
+ return { tri, tri_idx, depth: centroid_proj.depth }
858
+ })
859
+
860
+ faces_with_depth.sort((a, b) => a.depth - b.depth) // Back to front
861
+
574
862
  // Draw each face (lower hull only)
575
863
  for (const { tri, tri_idx } of faces_with_depth) {
576
- const [p1, p2, p3] = tri.vertices;
577
- const proj1 = project_3d_point(p1.x, p1.y, p1.z);
578
- const proj2 = project_3d_point(p2.x, p2.y, p2.z);
579
- const proj3 = project_3d_point(p3.x, p3.y, p3.z);
580
- const face_color = get_face_color(tri, tri_idx);
581
- // For uniform mode, use gradient with variable opacity
582
- // For other modes, use fixed opacity
583
- if (hull_face_color_mode === `uniform`) {
584
- // Build per-face linear gradient in screen space matching linear variation of formation energy
585
- const a1 = norm_alpha(p1.z);
586
- const a2 = norm_alpha(p2.z);
587
- const a3 = norm_alpha(p3.z);
588
- // Solve a*x + b*y + c = alpha at the three projected vertices
589
- const x1 = proj1.x, y1 = proj1.y;
590
- const x2 = proj2.x, y2 = proj2.y;
591
- const x3 = proj3.x, y3 = proj3.y;
592
- const det = x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2);
593
- let coef_a = 0, coef_b = 0, coef_c = (a1 + a2 + a3) / 3;
594
- if (Math.abs(det) > 1e-9) {
595
- coef_a = (a1 * (y2 - y3) + a2 * (y3 - y1) + a3 * (y1 - y2)) / det;
596
- coef_b = (a1 * (x3 - x2) + a2 * (x1 - x3) + a3 * (x2 - x1)) / det;
597
- coef_c = (a1 * (x2 * y3 - x3 * y2) + a2 * (x3 * y1 - x1 * y3) +
598
- a3 * (x1 * y2 - x2 * y1)) /
599
- det;
600
- }
601
- // Helper to draw filled+stroked triangle (ctx guaranteed non-null by early return)
602
- const draw_tri = (fill, stroke_alpha) => {
603
- ctx.save();
604
- ctx.beginPath();
605
- ctx.moveTo(proj1.x, proj1.y);
606
- ctx.lineTo(proj2.x, proj2.y);
607
- ctx.lineTo(proj3.x, proj3.y);
608
- ctx.closePath();
609
- ctx.fillStyle = fill;
610
- ctx.fill();
611
- ctx.strokeStyle = add_alpha(face_color, Math.min(0.6, stroke_alpha));
612
- ctx.lineWidth = 1;
613
- ctx.stroke();
614
- ctx.restore();
615
- };
616
- // Gradient direction is the screen-space gradient of alpha
617
- const mag = Math.hypot(coef_a, coef_b);
618
- if (mag < 1e-9) {
619
- // Fallback: uniform fill if nearly flat
620
- const avg_alpha = (a1 + a2 + a3) / 3;
621
- draw_tri(add_alpha(face_color, avg_alpha), avg_alpha * 3);
622
- }
623
- else {
624
- const vx = coef_a / mag;
625
- const vy = coef_b / mag;
626
- const cx = (x1 + x2 + x3) / 3;
627
- const cy = (y1 + y2 + y3) / 3;
628
- const alpha_c = coef_a * cx + coef_b * cy + coef_c;
629
- const alpha_min = Math.min(a1, a2, a3);
630
- const alpha_max = Math.max(a1, a2, a3);
631
- const s_min = (alpha_min - alpha_c) / mag;
632
- const s_max = (alpha_max - alpha_c) / mag;
633
- const grad = ctx.createLinearGradient(cx + vx * s_min, cy + vy * s_min, cx + vx * s_max, cy + vy * s_max);
634
- grad.addColorStop(0, add_alpha(face_color, alpha_min));
635
- grad.addColorStop(1, add_alpha(face_color, alpha_max));
636
- draw_tri(grad, alpha_max * 3);
637
- }
864
+ const [p1, p2, p3] = tri.vertices
865
+
866
+ const proj1 = project_3d_point(p1.x, p1.y, p1.z)
867
+ const proj2 = project_3d_point(p2.x, p2.y, p2.z)
868
+ const proj3 = project_3d_point(p3.x, p3.y, p3.z)
869
+
870
+ const face_color = get_face_color(tri, tri_idx)
871
+
872
+ // For uniform mode, use gradient with variable opacity
873
+ // For other modes, use fixed opacity
874
+ if (hull_face_color_mode === `uniform`) {
875
+ // Build per-face linear gradient in screen space matching linear variation of formation energy
876
+ const a1 = norm_alpha?.(p1.z) ?? 0
877
+ const a2 = norm_alpha?.(p2.z) ?? 0
878
+ const a3 = norm_alpha?.(p3.z) ?? 0
879
+
880
+ // Solve a*x + b*y + c = alpha at the three projected vertices
881
+ const x1 = proj1.x, y1 = proj1.y
882
+ const x2 = proj2.x, y2 = proj2.y
883
+ const x3 = proj3.x, y3 = proj3.y
884
+ const det = x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)
885
+ let coef_a = 0, coef_b = 0, coef_c = (a1 + a2 + a3) / 3
886
+ if (Math.abs(det) > 1e-9) {
887
+ coef_a = (a1 * (y2 - y3) + a2 * (y3 - y1) + a3 * (y1 - y2)) / det
888
+ coef_b = (a1 * (x3 - x2) + a2 * (x1 - x3) + a3 * (x2 - x1)) / det
889
+ coef_c = (a1 * (x2 * y3 - x3 * y2) + a2 * (x3 * y1 - x1 * y3) +
890
+ a3 * (x1 * y2 - x2 * y1)) /
891
+ det
892
+ }
893
+
894
+ // Helper to draw filled+stroked triangle
895
+ const draw_tri = (fill: string | CanvasGradient, stroke_alpha: number) => {
896
+ if (!ctx) return
897
+ ctx.save()
898
+ ctx.beginPath()
899
+ ctx.moveTo(proj1.x, proj1.y)
900
+ ctx.lineTo(proj2.x, proj2.y)
901
+ ctx.lineTo(proj3.x, proj3.y)
902
+ ctx.closePath()
903
+ ctx.fillStyle = fill
904
+ ctx.fill()
905
+ ctx.strokeStyle = add_alpha(face_color, Math.min(0.6, stroke_alpha))
906
+ ctx.lineWidth = 1
907
+ ctx.stroke()
908
+ ctx.restore()
638
909
  }
639
- else {
640
- // Non-uniform modes: solid color with fixed opacity
641
- ctx.save();
642
- ctx.beginPath();
643
- ctx.moveTo(proj1.x, proj1.y);
644
- ctx.lineTo(proj2.x, proj2.y);
645
- ctx.lineTo(proj3.x, proj3.y);
646
- ctx.closePath();
647
- ctx.fillStyle = add_alpha(face_color, hull_face_opacity);
648
- ctx.fill();
649
- ctx.strokeStyle = add_alpha(face_color, Math.min(0.6, hull_face_opacity * 3));
650
- ctx.lineWidth = 1;
651
- ctx.stroke();
652
- ctx.restore();
910
+
911
+ // Gradient direction is the screen-space gradient of alpha
912
+ const mag = Math.hypot(coef_a, coef_b)
913
+ if (mag < 1e-9) {
914
+ // Fallback: uniform fill if nearly flat
915
+ const avg_alpha = (a1 + a2 + a3) / 3
916
+ draw_tri(add_alpha(face_color, avg_alpha), avg_alpha * 3)
917
+ } else {
918
+ const vx = coef_a / mag
919
+ const vy = coef_b / mag
920
+ const cx = (x1 + x2 + x3) / 3
921
+ const cy = (y1 + y2 + y3) / 3
922
+ const alpha_c = coef_a * cx + coef_b * cy + coef_c
923
+ const alpha_min = Math.min(a1, a2, a3)
924
+ const alpha_max = Math.max(a1, a2, a3)
925
+ const s_min = (alpha_min - alpha_c) / mag
926
+ const s_max = (alpha_max - alpha_c) / mag
927
+
928
+ const grad = ctx.createLinearGradient(
929
+ cx + vx * s_min,
930
+ cy + vy * s_min,
931
+ cx + vx * s_max,
932
+ cy + vy * s_max,
933
+ )
934
+ grad.addColorStop(0, add_alpha(face_color, alpha_min))
935
+ grad.addColorStop(1, add_alpha(face_color, alpha_max))
936
+ draw_tri(grad, alpha_max * 3)
653
937
  }
938
+ } else {
939
+ // Non-uniform modes: solid color with fixed opacity
940
+ ctx.save()
941
+ ctx.beginPath()
942
+ ctx.moveTo(proj1.x, proj1.y)
943
+ ctx.lineTo(proj2.x, proj2.y)
944
+ ctx.lineTo(proj3.x, proj3.y)
945
+ ctx.closePath()
946
+ ctx.fillStyle = add_alpha(face_color, hull_face_opacity)
947
+ ctx.fill()
948
+ ctx.strokeStyle = add_alpha(face_color, Math.min(0.6, hull_face_opacity * 3))
949
+ ctx.lineWidth = 1
950
+ ctx.stroke()
951
+ ctx.restore()
952
+ }
654
953
  }
655
- }
656
- // Formation energy color bar helpers
657
- const e_form_range = $derived.by(() => {
658
- const energies = plot_entries.map((e) => e.e_form_per_atom ?? 0);
659
- const min_fe = energies.length ? Math.min(0, ...energies) : -1;
660
- return [min_fe, 0];
661
- });
662
- const e_form_color_scale_fn = $derived.by(() => {
663
- const [min_fe, max_fe] = e_form_range;
664
- const denom = Math.max(1e-6, max_fe - min_fe);
665
- return (value) => {
666
- // alpha 0 at 0 eV, goes to hull_face_opacity at most negative energy
667
- const t = Math.max(0, Math.min(1, (value - min_fe) / denom));
668
- const alpha = (1 - t) * hull_face_opacity;
669
- return add_alpha(hull_face_color, alpha);
670
- };
671
- });
672
- function draw_data_points() {
673
- if (!ctx || sorted_points_cache.length === 0)
674
- return;
954
+ }
955
+
956
+ // Formation energy color bar helpers
957
+ const e_form_range = $derived.by((): [number, number] => {
958
+ const energies = plot_entries.map((e) => e.e_form_per_atom ?? 0)
959
+ const min_fe = energies.length ? Math.min(0, ...energies) : -1
960
+ return [min_fe, 0]
961
+ })
962
+
963
+ const e_form_color_scale_fn = $derived.by(() => {
964
+ const [min_fe, max_fe] = e_form_range
965
+ const denom = Math.max(1e-6, max_fe - min_fe)
966
+ return (value: number) => {
967
+ // alpha 0 at 0 eV, goes to hull_face_opacity at most negative energy
968
+ const t = Math.max(0, Math.min(1, (value - min_fe) / denom))
969
+ const alpha = (1 - t) * hull_face_opacity
970
+ return add_alpha(hull_face_color, alpha)
971
+ }
972
+ })
973
+
974
+ function draw_data_points(): void {
975
+ if (!ctx || sorted_points_cache.length === 0) return
976
+
675
977
  for (const { entry, projected } of sorted_points_cache) {
676
- const is_stable = entry.is_stable || entry.e_above_hull === 0;
677
- const is_entry_highlighted = is_highlighted(entry);
678
- const color = get_point_color(entry);
679
- const size = (entry.size || (is_stable ? 6 : 4)) * canvas_dims.scale;
680
- const marker = entry.marker || `circle`;
681
- // Shadow
682
- const shadow_offset = Math.abs(entry.z) * 0.1 * canvas_dims.scale;
683
- ctx.fillStyle = `rgba(0, 0, 0, 0.2)`;
684
- const shadow_path = helpers.create_marker_path(size * 0.8, marker);
685
- ctx.save();
686
- ctx.translate(projected.x + shadow_offset, projected.y + shadow_offset);
687
- ctx.fill(shadow_path);
688
- ctx.restore();
689
- // Highlights
690
- if (selected_entry && entry.entry_id === selected_entry.entry_id) {
691
- helpers.draw_selection_highlight(ctx, projected, size, canvas_dims.scale, pulse_time, pulse_opacity);
692
- }
693
- if (is_entry_highlighted) {
694
- helpers.draw_highlight_effect(ctx, projected, size, canvas_dims.scale, pulse_time, merged_highlight_style);
695
- }
696
- // Main point with marker symbol
697
- ctx.fillStyle =
698
- is_entry_highlighted && merged_highlight_style.effect === `color`
699
- ? merged_highlight_style.color
700
- : color;
701
- ctx.strokeStyle = is_stable ? `#ffffff` : `#000000`;
702
- ctx.lineWidth = 0.5 * canvas_dims.scale;
703
- const marker_path = helpers.create_marker_path(size, marker);
704
- ctx.save();
705
- ctx.translate(projected.x, projected.y);
706
- ctx.fill(marker_path);
707
- ctx.stroke(marker_path);
708
- ctx.restore();
978
+ const is_stable = entry.is_stable || entry.e_above_hull === 0
979
+ const is_entry_highlighted = is_highlighted(entry)
980
+ const color = get_point_color(entry)
981
+ const size = (entry.size || (is_stable ? 6 : 4)) * canvas_dims.scale
982
+ const marker = entry.marker || `circle`
983
+
984
+ // Shadow
985
+ const shadow_offset = Math.abs(entry.z) * 0.1 * canvas_dims.scale
986
+ ctx.fillStyle = `rgba(0, 0, 0, 0.2)`
987
+ const shadow_path = helpers.create_marker_path(size * 0.8, marker)
988
+ ctx.save()
989
+ ctx.translate(projected.x + shadow_offset, projected.y + shadow_offset)
990
+ ctx.fill(shadow_path)
991
+ ctx.restore()
992
+
993
+ // Highlights
994
+ if (selected_entry && entry.entry_id === selected_entry.entry_id) {
995
+ helpers.draw_selection_highlight(
996
+ ctx,
997
+ projected,
998
+ size,
999
+ canvas_dims.scale,
1000
+ pulse_time,
1001
+ pulse_opacity,
1002
+ )
1003
+ }
1004
+ if (is_entry_highlighted) {
1005
+ helpers.draw_highlight_effect(
1006
+ ctx,
1007
+ projected,
1008
+ size,
1009
+ canvas_dims.scale,
1010
+ pulse_time,
1011
+ merged_highlight_style,
1012
+ )
1013
+ }
1014
+
1015
+ // Main point with marker symbol
1016
+ ctx.fillStyle =
1017
+ is_entry_highlighted && merged_highlight_style.effect === `color`
1018
+ ? merged_highlight_style.color
1019
+ : color
1020
+ ctx.strokeStyle = is_stable ? `#ffffff` : `#000000`
1021
+ ctx.lineWidth = 0.5 * canvas_dims.scale
1022
+ const marker_path = helpers.create_marker_path(size, marker)
1023
+ ctx.save()
1024
+ ctx.translate(projected.x, projected.y)
1025
+ ctx.fill(marker_path)
1026
+ ctx.stroke(marker_path)
1027
+ ctx.restore()
709
1028
  }
710
- }
711
- function draw_hull_labels() {
712
- if (!ctx || !merged_config.show_labels)
713
- return;
714
- const composition_map = new SvelteMap();
715
- for (const entry of plot_entries) {
716
- if (!entry.visible || entry.is_element)
717
- continue;
718
- const comp_key = Object.entries(entry.composition)
719
- .filter(([, amt]) => amt > 0)
720
- .sort(([a], [b]) => a.localeCompare(b))
721
- .map(([el, amt]) => `${el}${amt.toFixed(3)}`)
722
- .join(``);
723
- const existing = composition_map.get(comp_key);
724
- if (!existing || (entry.e_form_per_atom ?? 0) < (existing.e_form_per_atom ?? 0)) {
725
- composition_map.set(comp_key, entry);
726
- }
1029
+ }
1030
+
1031
+ const hull_label_font_size = 12
1032
+ const hull_label_subscript_font_size = 11
1033
+ const hull_label_font = `${hull_label_font_size}px Arial`
1034
+ const hull_label_subscript_font = `${hull_label_subscript_font_size}px Arial`
1035
+
1036
+ function label_priority_energy(entry: ConvexHullEntry): number {
1037
+ for (const value of [
1038
+ entry.e_form_per_atom,
1039
+ entry.z,
1040
+ entry.energy_per_atom,
1041
+ entry.energy,
1042
+ entry.e_above_hull,
1043
+ ]) {
1044
+ if (typeof value === `number` && Number.isFinite(value)) return value
1045
+ }
1046
+ return Number.POSITIVE_INFINITY
1047
+ }
1048
+
1049
+ function get_label_placements(
1050
+ projected: { x: number; y: number },
1051
+ point_size: number,
1052
+ text_width: number,
1053
+ text_height: number,
1054
+ ): LabelPlacement[] {
1055
+ const padding = Math.max(1, 2 * canvas_dims.scale)
1056
+ const gap = point_size + 4 * canvas_dims.scale
1057
+ const side_gap = point_size + 5 * canvas_dims.scale
1058
+ const placements = [
1059
+ { x: projected.x, y: projected.y + gap },
1060
+ { x: projected.x, y: projected.y - gap - text_height },
1061
+ { x: projected.x + side_gap + text_width / 2, y: projected.y - text_height / 2 },
1062
+ { x: projected.x - side_gap - text_width / 2, y: projected.y - text_height / 2 },
1063
+ { x: projected.x + side_gap + text_width / 2, y: projected.y + gap },
1064
+ { x: projected.x - side_gap - text_width / 2, y: projected.y + gap },
1065
+ { x: projected.x + side_gap + text_width / 2, y: projected.y - gap - text_height },
1066
+ { x: projected.x - side_gap - text_width / 2, y: projected.y - gap - text_height },
1067
+ ]
1068
+
1069
+ return placements.map((placement) => ({
1070
+ ...placement,
1071
+ rect: pad_rect(
1072
+ centered_rect(placement.x, placement.y, text_width, text_height),
1073
+ padding,
1074
+ ),
1075
+ }))
1076
+ }
1077
+
1078
+ function measure_formula_segments(
1079
+ context: CanvasRenderingContext2D,
1080
+ segments: FormulaLabelSegment[],
1081
+ ): number {
1082
+ context.save()
1083
+ const width = segments.reduce((sum, segment) => {
1084
+ context.font = segment.subscript ? hull_label_subscript_font : hull_label_font
1085
+ return sum + context.measureText(segment.text).width
1086
+ }, 0)
1087
+ context.restore()
1088
+ return width
1089
+ }
1090
+
1091
+ function draw_formula_segments(
1092
+ context: CanvasRenderingContext2D,
1093
+ segments: FormulaLabelSegment[],
1094
+ center_x: number,
1095
+ top_y: number,
1096
+ text_width: number,
1097
+ ): void {
1098
+ const subscript_offset = hull_label_font_size * 0.28
1099
+ let text_x = center_x - text_width / 2
1100
+
1101
+ context.save()
1102
+ context.textAlign = `left`
1103
+ context.textBaseline = `top`
1104
+ for (const segment of segments) {
1105
+ context.font = segment.subscript ? hull_label_subscript_font : hull_label_font
1106
+ context.fillText(
1107
+ segment.text,
1108
+ text_x,
1109
+ top_y + (segment.subscript ? subscript_offset : 0),
1110
+ )
1111
+ text_x += context.measureText(segment.text).width
1112
+ }
1113
+ context.restore()
1114
+ }
1115
+
1116
+ function draw_hull_labels(): void {
1117
+ if (!ctx || !merged_config.show_labels) return
1118
+
1119
+ ctx.fillStyle = text_color
1120
+ ctx.font = hull_label_font
1121
+ ctx.textAlign = `center`
1122
+ ctx.textBaseline = `top`
1123
+ const label_height = hull_label_font_size + 2
1124
+
1125
+ const label_entries = helpers.get_composition_label_entries(
1126
+ plot_entries.filter((entry) => {
1127
+ if (!entry.visible || entry.is_element) return false
1128
+ const is_stable_point = entry.is_stable || (entry.e_above_hull ?? 0) <= 1e-6
1129
+ return (is_stable_point && show_stable_labels) ||
1130
+ (!is_stable_point && show_unstable_labels &&
1131
+ (entry.e_above_hull ?? 0) <= max_hull_dist_show_labels)
1132
+ }),
1133
+ )
1134
+ .sort((entry_1, entry_2) => {
1135
+ const energy_diff = label_priority_energy(entry_1) -
1136
+ label_priority_energy(entry_2)
1137
+ if (energy_diff !== 0) return energy_diff
1138
+ return (entry_1.e_above_hull ?? 0) - (entry_2.e_above_hull ?? 0)
1139
+ })
1140
+
1141
+ const occupied_rects: Rect[] = []
1142
+ const canvas_rect: Rect = {
1143
+ x: 0,
1144
+ y: 0,
1145
+ width: canvas_dims.width,
1146
+ height: canvas_dims.height,
727
1147
  }
728
- ctx.fillStyle = text_color;
729
- ctx.font = `12px Arial`;
730
- ctx.textAlign = `center`;
731
- ctx.textBaseline = `top`;
732
- for (const entry of composition_map.values()) {
733
- const is_stable_point = entry.is_stable || (entry.e_above_hull ?? 0) <= 1e-6;
734
- const can_label = (is_stable_point && show_stable_labels) ||
735
- (!is_stable_point && show_unstable_labels &&
736
- (entry.e_above_hull ?? 0) <= max_hull_dist_show_labels);
737
- if (!can_label)
738
- continue;
739
- const projected = project_3d_point(entry.x, entry.y, entry.z);
740
- const formula = helpers.get_entry_label(entry, elements);
741
- ctx.fillText(formula, projected.x, projected.y + 16 * canvas_dims.scale);
1148
+ for (const entry of label_entries) {
1149
+ const projected = project_3d_point(entry.x, entry.y, entry.z)
1150
+ const formula_segments = get_formula_label_segments(
1151
+ helpers.get_entry_label(entry, elements),
1152
+ )
1153
+ const is_stable_point = entry.is_stable || entry.e_above_hull === 0
1154
+ const point_size = (entry.size || (is_stable_point ? 6 : 4)) * canvas_dims.scale
1155
+ const text_width = measure_formula_segments(ctx, formula_segments)
1156
+ const placements = get_label_placements(
1157
+ projected,
1158
+ point_size,
1159
+ text_width,
1160
+ label_height,
1161
+ )
1162
+ const placement = placements.find((candidate) =>
1163
+ rect_within_rect(candidate.rect, canvas_rect) &&
1164
+ !occupied_rects.some((occupied_rect) =>
1165
+ rects_overlap(candidate.rect, occupied_rect)
1166
+ )
1167
+ )
1168
+ if (!placement) continue
1169
+
1170
+ occupied_rects.push(placement.rect)
1171
+ draw_formula_segments(ctx, formula_segments, placement.x, placement.y, text_width)
742
1172
  }
743
- }
744
- function render_frame() {
745
- if (!ctx || !canvas)
746
- return;
1173
+ }
1174
+
1175
+ function render_frame(): void {
1176
+ if (!ctx || !canvas) return
1177
+
747
1178
  // Use CSS dimensions for rendering
748
- const display_width = canvas.clientWidth || 600;
749
- const display_height = canvas.clientHeight || 600;
1179
+ const display_width = canvas.clientWidth || 600
1180
+ const display_height = canvas.clientHeight || 600
1181
+
750
1182
  // Clear canvas
751
- ctx.clearRect(0, 0, display_width, display_height);
1183
+ ctx.clearRect(0, 0, display_width, display_height)
1184
+
752
1185
  // Set background - use transparent to inherit from container
753
- ctx.fillStyle = `transparent`;
754
- ctx.fillRect(0, 0, display_width, display_height);
1186
+ ctx.fillStyle = `transparent`
1187
+ ctx.fillRect(0, 0, display_width, display_height)
1188
+
755
1189
  if (elements.length !== 3) {
756
- if (elements.length > 0) {
757
- ctx.fillStyle = text_color;
758
- ctx.font = `16px Arial`;
759
- ctx.textAlign = `center`;
760
- ctx.textBaseline = `middle`;
761
- ctx.fillText(`Ternary convex hull requires exactly 3 elements (got ${elements.length})`, display_width / 2, display_height / 2);
762
- }
763
- return;
1190
+ if (elements.length > 0) {
1191
+ ctx.fillStyle = text_color
1192
+ ctx.font = `16px Arial`
1193
+ ctx.textAlign = `center`
1194
+ ctx.textBaseline = `middle`
1195
+ ctx.fillText(
1196
+ `Ternary convex hull requires exactly 3 elements (got ${elements.length})`,
1197
+ display_width / 2,
1198
+ display_height / 2,
1199
+ )
1200
+ }
1201
+ return
764
1202
  }
765
- draw_structure_outline();
766
- draw_convex_hull_faces(); // behind points
767
- draw_z_axis_ticks(); // after faces for visibility at high opacity
768
- draw_data_points();
769
- draw_hull_labels();
770
- draw_element_labels();
771
- }
772
- function handle_mouse_down(event) {
773
- is_dragging = true;
774
- drag_started = false;
775
- hover_data = null;
776
- on_point_hover?.(null);
777
- last_mouse = { x: event.clientX, y: event.clientY };
778
- }
779
- const handle_mouse_move = (event) => {
780
- if (!is_dragging)
781
- return;
782
- const [dx, dy] = [event.clientX - last_mouse.x, event.clientY - last_mouse.y];
1203
+
1204
+ draw_structure_outline()
1205
+ draw_convex_hull_faces() // behind points
1206
+ draw_z_axis_ticks() // after faces for visibility at high opacity
1207
+ draw_data_points()
1208
+ draw_hull_labels()
1209
+ draw_element_labels()
1210
+ }
1211
+
1212
+ function handle_mouse_down(event: MouseEvent) {
1213
+ is_dragging = true
1214
+ drag_started = false
1215
+ hover_data = null
1216
+ on_point_hover?.(null)
1217
+ last_mouse = { x: event.clientX, y: event.clientY }
1218
+ }
1219
+
1220
+ const handle_mouse_move = (event: MouseEvent) => {
1221
+ if (!is_dragging) return
1222
+ const [dx, dy] = [event.clientX - last_mouse.x, event.clientY - last_mouse.y]
1223
+
783
1224
  // Mark as drag if any movement occurred
784
- if (dx !== 0 || dy !== 0)
785
- drag_started = true;
1225
+ if (dx !== 0 || dy !== 0) drag_started = true
1226
+
786
1227
  // With Cmd/Ctrl held: pan the view instead of rotating
787
1228
  if (event.metaKey || event.ctrlKey) {
788
- camera.center_x += dx;
789
- camera.center_y += dy;
790
- }
791
- else {
792
- // Horizontal drag -> azimuth rotation around z-axis
793
- camera.azimuth += dx * 0.3; // Positive dx (drag right) rotates clockwise
794
- // Vertical drag -> elevation angle (full range)
795
- camera.elevation -= dy * 0.3; // Positive dy (drag down) tilts view down
1229
+ camera.center_x += dx
1230
+ camera.center_y += dy
1231
+ } else {
1232
+ // Horizontal drag -> azimuth rotation around z-axis
1233
+ camera.azimuth += dx * 0.3 // Positive dx (drag right) rotates clockwise
1234
+
1235
+ // Vertical drag -> elevation angle (full range)
1236
+ camera.elevation -= dy * 0.3 // Positive dy (drag down) tilts view down
796
1237
  }
797
- last_mouse = { x: event.clientX, y: event.clientY };
798
- };
799
- const handle_wheel = (event) => {
800
- event.preventDefault();
801
- camera.zoom = Math.max(0.5, Math.min(10, camera.zoom * (event.deltaY > 0 ? 0.98 : 1.02)));
802
- };
803
- const handle_hover = (event) => {
804
- if (is_dragging)
805
- return;
806
- const entry = find_entry_at_mouse(event);
1238
+
1239
+ last_mouse = { x: event.clientX, y: event.clientY }
1240
+ }
1241
+
1242
+ const handle_wheel = (event: WheelEvent) => {
1243
+ event.preventDefault()
1244
+ camera.zoom = Math.max(
1245
+ 0.5,
1246
+ Math.min(10, camera.zoom * (event.deltaY > 0 ? 0.98 : 1.02)),
1247
+ )
1248
+ }
1249
+
1250
+ const handle_hover = (event: MouseEvent) => {
1251
+ if (is_dragging) return
1252
+ const entry = find_entry_at_mouse(event)
807
1253
  hover_data = entry
808
- ? { entry, position: { x: event.clientX, y: event.clientY } }
809
- : null;
810
- on_point_hover?.(hover_data);
811
- };
812
- const find_entry_at_mouse = (event) => helpers.find_hull_entry_at_mouse(canvas, event, plot_entries, (x, y, z) => {
813
- const pt = project_3d_point(x, y, z);
814
- return { x: pt.x, y: pt.y };
815
- });
816
- const handle_click = (event) => {
817
- event.stopPropagation();
1254
+ ? { entry, position: { x: event.clientX, y: event.clientY } }
1255
+ : null
1256
+ on_point_hover?.(hover_data)
1257
+ }
1258
+
1259
+ const find_entry_at_mouse = (event: MouseEvent): ConvexHullEntry | null =>
1260
+ helpers.find_hull_entry_at_mouse(
1261
+ canvas,
1262
+ event,
1263
+ plot_entries,
1264
+ (x: number, y: number, z: number) => {
1265
+ const pt = project_3d_point(x, y, z)
1266
+ return { x: pt.x, y: pt.y }
1267
+ },
1268
+ )
1269
+
1270
+ const handle_click = (event: MouseEvent) => {
1271
+ event.stopPropagation()
818
1272
  // Check if this was a drag operation (any mouse movement during drag)
819
- const was_drag = drag_started;
820
- drag_started = false; // Reset for next interaction
821
- if (was_drag)
822
- return; // Don't trigger click if this was a drag
823
- const entry = find_entry_at_mouse(event);
1273
+ const was_drag = drag_started
1274
+ drag_started = false // Reset for next interaction
1275
+ if (was_drag) return // Don't trigger click if this was a drag
1276
+
1277
+ const entry = find_entry_at_mouse(event)
824
1278
  if (!entry) {
825
- if (modal_open)
826
- close_structure_popup();
827
- return;
1279
+ if (modal_open) close_structure_popup()
1280
+ return
828
1281
  }
829
- on_point_click?.(entry);
1282
+
1283
+ on_point_click?.(entry)
1284
+
830
1285
  if (enable_click_selection) {
831
- selected_entry = entry;
832
- if (enable_structure_preview) {
833
- const structure = extract_structure_from_entry(entry);
834
- if (structure) {
835
- selected_structure = structure;
836
- modal_place_right = helpers.calculate_modal_side(wrapper);
837
- modal_open = true;
838
- }
1286
+ selected_entry = entry
1287
+ if (enable_structure_preview) {
1288
+ const structure = extract_structure_from_entry(entry)
1289
+ if (structure) {
1290
+ selected_structure = structure
1291
+ modal_place_right = helpers.calculate_modal_side(wrapper)
1292
+ modal_open = true
839
1293
  }
1294
+ }
840
1295
  }
841
- };
842
- function close_structure_popup() {
843
- modal_open = false;
844
- selected_structure = null;
845
- selected_entry = null;
846
- }
847
- const handle_double_click = (event) => {
848
- const entry = find_entry_at_mouse(event);
1296
+ }
1297
+
1298
+ function close_structure_popup() {
1299
+ modal_open = false
1300
+ selected_structure = null
1301
+ selected_entry = null
1302
+ }
1303
+
1304
+ const handle_double_click = (event: MouseEvent) => {
1305
+ const entry = find_entry_at_mouse(event)
849
1306
  if (entry) {
850
- copy_entry_data(entry, {
851
- x: event.clientX,
852
- y: event.clientY,
853
- });
1307
+ copy_entry_data(entry, {
1308
+ x: event.clientX,
1309
+ y: event.clientY,
1310
+ })
854
1311
  }
855
- };
856
- const render_once = () => {
1312
+ }
1313
+
1314
+ const render_once = () => {
857
1315
  if (!frame_id) {
858
- frame_id = requestAnimationFrame(() => {
859
- render_frame();
860
- frame_id = 0;
861
- });
1316
+ frame_id = requestAnimationFrame(() => {
1317
+ render_frame()
1318
+ frame_id = 0
1319
+ })
862
1320
  }
863
- };
864
- function update_canvas_size() {
865
- if (!canvas)
866
- return;
867
- const dpr = globalThis.devicePixelRatio || 1;
868
- const container = canvas.parentElement;
869
- const rect = container?.getBoundingClientRect();
870
- const [w, h] = rect ? [rect.width, rect.height] : [400, 400];
1321
+ }
1322
+
1323
+ function update_canvas_size() {
1324
+ if (!canvas) return
1325
+ const dpr = globalThis.devicePixelRatio || 1
1326
+ const container = canvas.parentElement
1327
+ const rect = container?.getBoundingClientRect()
1328
+ const [w, h] = rect ? [rect.width, rect.height] : [400, 400]
1329
+
871
1330
  // Only update canvas dimensions if they actually changed
872
1331
  // (assigning canvas.width/height clears the canvas even if values are the same)
873
- const new_width = Math.max(0, Math.round(w * dpr));
874
- const new_height = Math.max(0, Math.round(h * dpr));
1332
+ const new_width = Math.max(0, Math.round(w * dpr))
1333
+ const new_height = Math.max(0, Math.round(h * dpr))
875
1334
  if (!ctx || canvas.width !== new_width || canvas.height !== new_height) {
876
- canvas.width = new_width;
877
- canvas.height = new_height;
878
- ctx = canvas.getContext(`2d`);
879
- if (ctx) {
880
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
881
- ctx.imageSmoothingEnabled = true;
882
- ctx.imageSmoothingQuality = `high`;
883
- }
1335
+ canvas.width = new_width
1336
+ canvas.height = new_height
1337
+ ctx = canvas.getContext(`2d`)
1338
+ if (ctx) {
1339
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
1340
+ ctx.imageSmoothingEnabled = true
1341
+ ctx.imageSmoothingQuality = `high`
1342
+ }
884
1343
  }
885
- canvas_dims = { width: w, height: h, scale: Math.min(w, h) / 600 };
886
- render_once();
887
- }
888
- // Reactive dark mode detection for canvas text color
889
- let dark_mode = $state(is_dark_mode());
890
- $effect(() => watch_dark_mode((dark) => dark_mode = dark));
891
- const text_color = $derived(helpers.get_canvas_text_color(dark_mode));
892
- $effect(() => {
893
- if (!canvas)
894
- return;
1344
+ canvas_dims = { width: w, height: h, scale: Math.min(w, h) / 600 }
1345
+ render_once()
1346
+ }
1347
+
1348
+ // Reactive dark mode detection for canvas text color
1349
+ let dark_mode = $state(is_dark_mode())
1350
+ $effect(() => watch_dark_mode((dark) => dark_mode = dark))
1351
+ const text_color = $derived(helpers.get_canvas_text_color(dark_mode))
1352
+
1353
+ $effect(() => {
1354
+ if (!canvas) return
1355
+
895
1356
  // Initial setup
896
- update_canvas_size();
1357
+ update_canvas_size()
1358
+
897
1359
  // Watch for resize events - only update canvas, don't reset camera
898
- const resize_observer = new ResizeObserver(update_canvas_size);
899
- const container = canvas.parentElement;
1360
+ const resize_observer = new ResizeObserver(update_canvas_size)
1361
+
1362
+ const container = canvas.parentElement
900
1363
  if (container) {
901
- resize_observer.observe(container);
1364
+ resize_observer.observe(container)
902
1365
  }
903
- return () => {
904
- if (frame_id)
905
- cancelAnimationFrame(frame_id);
906
- if (pulse_frame_id)
907
- cancelAnimationFrame(pulse_frame_id);
908
- resize_observer.disconnect();
909
- };
910
- });
911
- // Fullscreen handling with camera reset
912
- let was_fullscreen = $state(fullscreen);
913
- $effect(() => {
1366
+
1367
+ return () => { // Cleanup on unmount
1368
+ if (frame_id) cancelAnimationFrame(frame_id)
1369
+ if (pulse_frame_id) cancelAnimationFrame(pulse_frame_id)
1370
+ resize_observer.disconnect()
1371
+ }
1372
+ })
1373
+
1374
+ // Fullscreen handling with camera reset
1375
+ let was_fullscreen = $state(fullscreen)
1376
+ $effect(() => {
914
1377
  setup_fullscreen_effect(fullscreen, wrapper, (entering_fullscreen) => {
915
- if (entering_fullscreen !== was_fullscreen) {
916
- camera.center_x = 0;
917
- camera.center_y = -50;
918
- was_fullscreen = entering_fullscreen;
919
- }
920
- });
921
- set_fullscreen_bg(wrapper, fullscreen, `--hull-3d-bg-fullscreen`);
922
- });
923
- // Performance: Cache canvas dimensions and formation energy range
924
- let canvas_dims = $state({ width: 600, height: 600, scale: 1 });
925
- const energy_range = $derived.by(() => {
926
- const energies = plot_entries.map((e) => e.e_form_per_atom ?? 0);
927
- const [min, max] = [Math.min(0, ...energies), Math.max(0, ...energies)];
928
- const z_scale = 0.75 / Math.max(max - min, 0.001);
929
- return { min, max, center: (min + max) / 2, z_scale };
930
- });
931
- // Performance: Pre-compute and cache all point projections + depth sorting
932
- const sorted_points_cache = $derived.by(() => {
933
- if (!canvas || plot_entries.length === 0)
934
- return [];
1378
+ if (entering_fullscreen !== was_fullscreen) {
1379
+ camera.center_x = 0
1380
+ camera.center_y = -50
1381
+ was_fullscreen = entering_fullscreen
1382
+ }
1383
+ })
1384
+ set_fullscreen_bg(wrapper, fullscreen, `--hull-3d-bg-fullscreen`)
1385
+ })
1386
+
1387
+ // Performance: Cache canvas dimensions and formation energy range
1388
+ let canvas_dims = $state({ width: 600, height: 600, scale: 1 })
1389
+ const energy_range = $derived.by(() => {
1390
+ const energies = plot_entries.map((e) => e.e_form_per_atom ?? 0)
1391
+ const [min, max] = [Math.min(0, ...energies), Math.max(0, ...energies)]
1392
+ const z_scale = 0.75 / Math.max(max - min, 0.001)
1393
+ return { min, max, center: (min + max) / 2, z_scale }
1394
+ })
1395
+
1396
+ // Performance: Pre-compute and cache all point projections + depth sorting
1397
+ const sorted_points_cache = $derived.by(() => {
1398
+ if (!canvas || plot_entries.length === 0) return []
935
1399
  return plot_entries
936
- .filter((entry) => entry.visible)
937
- .map((entry) => ({
1400
+ .filter((entry) => entry.visible)
1401
+ .map((entry) => ({
938
1402
  entry,
939
1403
  projected: project_3d_point(entry.x, entry.y, entry.z),
940
- }))
941
- .sort((a, b) => a.projected.depth - b.projected.depth);
942
- });
943
- let style = $derived(`--hull-stable-color:${merged_config.colors?.stable || `#0072B2`};
1404
+ }))
1405
+ .sort((a, b) => a.projected.depth - b.projected.depth)
1406
+ })
1407
+
1408
+ let style = $derived(
1409
+ `--hull-stable-color:${merged_config.colors?.stable || `#0072B2`};
944
1410
  --hull-unstable-color:${merged_config.colors?.unstable || `#E69F00`};
945
1411
  --hull-edge-color:${merged_config.colors?.edge || `var(--text-color, #212121)`};
946
- --hull-text-color:${merged_config.colors?.annotation || `var(--text-color, #212121)`}`);
1412
+ --hull-text-color:${
1413
+ merged_config.colors?.annotation || `var(--text-color, #212121)`
1414
+ }`,
1415
+ )
947
1416
  </script>
948
1417
 
949
1418
  <svelte:document
@@ -984,7 +1453,7 @@ let style = $derived(`--hull-stable-color:${merged_config.colors?.stable || `#00
984
1453
  selected_entry,
985
1454
  })}
986
1455
  <h3 style="position: absolute; left: 1em; top: 1ex; margin: 0; font-weight: 500">
987
- {@html merged_controls.title || phase_stats?.chemical_system || ``}
1456
+ {@html sanitize_html(merged_controls.title || phase_stats?.chemical_system || ``)}
988
1457
  </h3>
989
1458
  <canvas
990
1459
  bind:this={canvas}