matterviz 0.3.1 → 0.3.3

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