matterviz 0.3.5 → 0.3.7

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 (229) hide show
  1. package/dist/MillerIndexInput.svelte +5 -5
  2. package/dist/api/optimade.js +3 -3
  3. package/dist/brillouin/BrillouinZone.svelte +5 -2
  4. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  5. package/dist/brillouin/BrillouinZoneExportPane.svelte +1 -3
  6. package/dist/brillouin/BrillouinZoneInfoPane.svelte +1 -1
  7. package/dist/brillouin/BrillouinZoneScene.svelte +5 -5
  8. package/dist/brillouin/compute.js +21 -21
  9. package/dist/brillouin/index.d.ts +1 -1
  10. package/dist/brillouin/index.js +0 -1
  11. package/dist/brillouin/types.d.ts +8 -13
  12. package/dist/chempot-diagram/ChemPotDiagram.svelte +3 -3
  13. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +3 -4
  14. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +33 -34
  15. package/dist/chempot-diagram/compute.js +1 -7
  16. package/dist/chempot-diagram/temperature.d.ts +1 -1
  17. package/dist/chempot-diagram/temperature.js +1 -3
  18. package/dist/chempot-diagram/types.d.ts +4 -9
  19. package/dist/colors/index.js +5 -5
  20. package/dist/composition/Composition.svelte +2 -1
  21. package/dist/composition/Formula.svelte +7 -4
  22. package/dist/composition/FormulaFilter.svelte +1 -3
  23. package/dist/composition/format.js +4 -4
  24. package/dist/composition/parse.d.ts +2 -1
  25. package/dist/composition/parse.js +61 -46
  26. package/dist/convex-hull/ConvexHull2D.svelte +62 -51
  27. package/dist/convex-hull/ConvexHull3D.svelte +101 -90
  28. package/dist/convex-hull/ConvexHull4D.svelte +70 -58
  29. package/dist/convex-hull/ConvexHullControls.svelte +24 -35
  30. package/dist/convex-hull/ConvexHullInfoPane.svelte +8 -5
  31. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +2 -0
  32. package/dist/convex-hull/ConvexHullStats.svelte +9 -2
  33. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +2 -0
  34. package/dist/convex-hull/GasPressureControls.svelte +7 -7
  35. package/dist/convex-hull/StructurePopup.svelte +65 -30
  36. package/dist/convex-hull/StructurePopup.svelte.d.ts +6 -6
  37. package/dist/convex-hull/TemperatureSlider.svelte +8 -5
  38. package/dist/convex-hull/barycentric-coords.d.ts +2 -2
  39. package/dist/convex-hull/barycentric-coords.js +2 -2
  40. package/dist/convex-hull/gas-thermodynamics.js +2 -4
  41. package/dist/convex-hull/helpers.d.ts +13 -2
  42. package/dist/convex-hull/helpers.js +37 -16
  43. package/dist/convex-hull/index.d.ts +1 -0
  44. package/dist/convex-hull/index.js +1 -0
  45. package/dist/convex-hull/thermodynamics.d.ts +2 -1
  46. package/dist/convex-hull/thermodynamics.js +7 -7
  47. package/dist/convex-hull/types.d.ts +15 -15
  48. package/dist/effects.svelte.d.ts +12 -0
  49. package/dist/effects.svelte.js +37 -0
  50. package/dist/element/BohrAtom.svelte +4 -4
  51. package/dist/element/data.json.gz.d.ts +3 -1
  52. package/dist/element/index.d.ts +1 -1
  53. package/dist/element/index.js +0 -1
  54. package/dist/fermi-surface/FermiSurface.svelte +4 -4
  55. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  56. package/dist/fermi-surface/FermiSurfaceControls.svelte +15 -19
  57. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  58. package/dist/fermi-surface/FermiSurfaceScene.svelte +8 -6
  59. package/dist/fermi-surface/compute.js +2 -2
  60. package/dist/fermi-surface/export.js +13 -26
  61. package/dist/fermi-surface/parse.js +8 -12
  62. package/dist/fermi-surface/types.d.ts +2 -5
  63. package/dist/heatmap-matrix/HeatmapMatrix.svelte +21 -3
  64. package/dist/heatmap-matrix/index.js +6 -6
  65. package/dist/io/decompress.d.ts +2 -1
  66. package/dist/io/decompress.js +1 -1
  67. package/dist/io/export.js +1 -1
  68. package/dist/io/index.d.ts +1 -1
  69. package/dist/io/index.js +0 -1
  70. package/dist/io/url-drop.js +7 -1
  71. package/dist/isosurface/IsosurfaceControls.svelte +11 -25
  72. package/dist/isosurface/slice.js +1 -1
  73. package/dist/isosurface/types.js +12 -12
  74. package/dist/labels.d.ts +1 -1
  75. package/dist/labels.js +14 -11
  76. package/dist/layout/InfoTag.svelte +6 -4
  77. package/dist/layout/PropertyFilter.svelte +4 -2
  78. package/dist/layout/json-tree/JsonTree.svelte +22 -14
  79. package/dist/layout/json-tree/JsonValue.svelte +2 -2
  80. package/dist/layout/json-tree/types.d.ts +3 -2
  81. package/dist/layout/json-tree/types.js +0 -1
  82. package/dist/layout/json-tree/utils.d.ts +4 -4
  83. package/dist/layout/json-tree/utils.js +12 -20
  84. package/dist/marching-cubes.js +13 -15
  85. package/dist/math.d.ts +11 -1
  86. package/dist/math.js +15 -6
  87. package/dist/overlays/DragControlTab.svelte +98 -0
  88. package/dist/overlays/DragControlTab.svelte.d.ts +8 -0
  89. package/dist/overlays/DraggablePane.svelte +7 -84
  90. package/dist/overlays/index.d.ts +1 -0
  91. package/dist/overlays/index.js +1 -0
  92. package/dist/periodic-table/PeriodicTable.svelte +11 -11
  93. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +4 -2
  94. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  95. package/dist/phase-diagram/PhaseDiagramControls.svelte +4 -9
  96. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  97. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +2 -10
  98. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +2 -3
  99. package/dist/phase-diagram/TdbInfoPanel.svelte +3 -3
  100. package/dist/phase-diagram/build-diagram.js +11 -18
  101. package/dist/phase-diagram/diagram-input.d.ts +5 -9
  102. package/dist/phase-diagram/index.d.ts +2 -2
  103. package/dist/phase-diagram/index.js +0 -2
  104. package/dist/phase-diagram/parse.d.ts +2 -2
  105. package/dist/phase-diagram/parse.js +6 -10
  106. package/dist/phase-diagram/svg-to-diagram.js +15 -15
  107. package/dist/phase-diagram/types.d.ts +5 -11
  108. package/dist/phase-diagram/utils.d.ts +2 -2
  109. package/dist/phase-diagram/utils.js +9 -11
  110. package/dist/plot/BarPlot.svelte +162 -314
  111. package/dist/plot/BarPlot.svelte.d.ts +5 -4
  112. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  113. package/dist/plot/BinnedScatterPlot.svelte +1114 -0
  114. package/dist/plot/BinnedScatterPlot.svelte.d.ts +66 -0
  115. package/dist/plot/ColorBar.svelte +19 -17
  116. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  117. package/dist/plot/FillArea.svelte +2 -4
  118. package/dist/plot/FillArea.svelte.d.ts +1 -1
  119. package/dist/plot/Histogram.svelte +167 -281
  120. package/dist/plot/Histogram.svelte.d.ts +1 -1
  121. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  122. package/dist/plot/InteractiveAxisLabel.svelte +5 -3
  123. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  124. package/dist/plot/PlotAxis.svelte +169 -0
  125. package/dist/plot/PlotAxis.svelte.d.ts +24 -0
  126. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  127. package/dist/plot/ReferenceLine3D.svelte +53 -51
  128. package/dist/plot/ReferencePlane.svelte +39 -42
  129. package/dist/plot/ScatterPlot.svelte +300 -367
  130. package/dist/plot/ScatterPlot.svelte.d.ts +8 -5
  131. package/dist/plot/ScatterPlot3D.svelte +33 -6
  132. package/dist/plot/ScatterPlot3D.svelte.d.ts +3 -2
  133. package/dist/plot/ScatterPlot3DControls.svelte +9 -9
  134. package/dist/plot/ScatterPlotControls.svelte +3 -4
  135. package/dist/plot/ScatterPoint.svelte +18 -27
  136. package/dist/plot/ScatterPoint.svelte.d.ts +4 -3
  137. package/dist/plot/Surface3D.svelte +4 -7
  138. package/dist/plot/ZeroLines.svelte +2 -1
  139. package/dist/plot/ZeroLines.svelte.d.ts +2 -1
  140. package/dist/plot/ZoomRect.svelte +2 -2
  141. package/dist/plot/ZoomRect.svelte.d.ts +3 -3
  142. package/dist/plot/adaptive-density.d.ts +69 -0
  143. package/dist/plot/adaptive-density.js +191 -0
  144. package/dist/plot/auto-place.d.ts +43 -0
  145. package/dist/plot/auto-place.js +122 -0
  146. package/dist/plot/axis-utils.js +3 -5
  147. package/dist/plot/binned-scatter-types.d.ts +59 -0
  148. package/dist/plot/binned-scatter-types.js +1 -0
  149. package/dist/plot/data-cleaning.js +1 -1
  150. package/dist/plot/data-transform.js +1 -1
  151. package/dist/plot/fill-utils.d.ts +4 -9
  152. package/dist/plot/fill-utils.js +29 -44
  153. package/dist/plot/index.d.ts +4 -0
  154. package/dist/plot/index.js +2 -0
  155. package/dist/plot/interactions.d.ts +4 -4
  156. package/dist/plot/interactions.js +4 -3
  157. package/dist/plot/layout.d.ts +20 -2
  158. package/dist/plot/layout.js +59 -16
  159. package/dist/plot/reference-line.d.ts +1 -1
  160. package/dist/plot/reference-line.js +9 -11
  161. package/dist/plot/scales.d.ts +1 -1
  162. package/dist/plot/scales.js +20 -23
  163. package/dist/plot/types.d.ts +30 -58
  164. package/dist/plot/types.js +2 -6
  165. package/dist/plot/utils/label-placement.d.ts +24 -3
  166. package/dist/plot/utils/label-placement.js +82 -12
  167. package/dist/plot/utils/series-visibility.d.ts +8 -2
  168. package/dist/plot/utils/series-visibility.js +23 -5
  169. package/dist/rdf/RdfPlot.svelte +5 -5
  170. package/dist/rdf/calc-rdf.js +3 -3
  171. package/dist/sanitize.d.ts +2 -0
  172. package/dist/sanitize.js +2 -0
  173. package/dist/spectral/Bands.svelte +1 -1
  174. package/dist/spectral/BandsAndDos.svelte +22 -16
  175. package/dist/spectral/BrillouinBandsDos.svelte +20 -16
  176. package/dist/spectral/Dos.svelte +1 -1
  177. package/dist/spectral/helpers.d.ts +4 -2
  178. package/dist/spectral/helpers.js +44 -35
  179. package/dist/spectral/index.d.ts +1 -1
  180. package/dist/spectral/index.js +0 -1
  181. package/dist/structure/AtomLegend.svelte +23 -6
  182. package/dist/structure/AtomLegend.svelte.d.ts +1 -0
  183. package/dist/structure/CanvasTooltip.svelte +9 -9
  184. package/dist/structure/CanvasTooltip.svelte.d.ts +1 -1
  185. package/dist/structure/CellSelect.svelte +14 -16
  186. package/dist/structure/Structure.svelte +317 -68
  187. package/dist/structure/Structure.svelte.d.ts +4 -2
  188. package/dist/structure/StructureControls.svelte +20 -45
  189. package/dist/structure/StructureExportPane.svelte +2 -1
  190. package/dist/structure/StructureInfoPane.svelte +10 -8
  191. package/dist/structure/StructureScene.svelte +527 -177
  192. package/dist/structure/StructureScene.svelte.d.ts +5 -2
  193. package/dist/structure/atom-properties.js +4 -4
  194. package/dist/structure/bond-order-perception.js +115 -98
  195. package/dist/structure/bonding.d.ts +27 -1
  196. package/dist/structure/bonding.js +187 -16
  197. package/dist/structure/export.js +1 -1
  198. package/dist/structure/index.d.ts +3 -2
  199. package/dist/structure/index.js +0 -2
  200. package/dist/structure/parse.js +88 -59
  201. package/dist/symmetry/WyckoffTable.svelte +7 -0
  202. package/dist/symmetry/index.js +13 -14
  203. package/dist/table/HeatmapTable.svelte +45 -66
  204. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  205. package/dist/table/ToggleMenu.svelte +19 -10
  206. package/dist/theme/themes.mjs +12 -0
  207. package/dist/tooltip/index.d.ts +1 -1
  208. package/dist/tooltip/index.js +0 -1
  209. package/dist/trajectory/Trajectory.svelte +43 -15
  210. package/dist/trajectory/TrajectoryInfoPane.svelte +2 -2
  211. package/dist/trajectory/extract.js +1 -1
  212. package/dist/trajectory/frame-reader.js +4 -4
  213. package/dist/trajectory/helpers.d.ts +5 -4
  214. package/dist/trajectory/helpers.js +9 -17
  215. package/dist/trajectory/index.d.ts +2 -2
  216. package/dist/trajectory/index.js +2 -2
  217. package/dist/trajectory/parse/ase.js +4 -4
  218. package/dist/trajectory/parse/hdf5.js +1 -1
  219. package/dist/trajectory/parse/index.js +2 -3
  220. package/dist/trajectory/parse/lammps.js +1 -1
  221. package/dist/trajectory/parse/vasp.js +1 -1
  222. package/dist/trajectory/plotting.d.ts +1 -1
  223. package/dist/trajectory/plotting.js +38 -38
  224. package/dist/trajectory/types.d.ts +1 -1
  225. package/dist/utils.d.ts +1 -0
  226. package/dist/utils.js +9 -0
  227. package/dist/xrd/calc-xrd.js +3 -4
  228. package/dist/xrd/parse.js +1 -1
  229. package/package.json +42 -22
@@ -9,10 +9,8 @@
9
9
  vesta_hex,
10
10
  watch_dark_mode,
11
11
  } from '../colors'
12
- import {
13
- get_formula_label_segments,
14
- type FormulaLabelSegment,
15
- } from '../composition/format'
12
+ import { get_formula_label_segments } from '../composition/format'
13
+ import type { FormulaLabelSegment } from '../composition/format'
16
14
  import { normalize_show_controls } from '../controls'
17
15
  import { sanitize_html } from '../sanitize'
18
16
  import { ClickFeedback, DragOverlay, Spinner } from '../feedback'
@@ -23,15 +21,11 @@
23
21
  setup_fullscreen_effect,
24
22
  toggle_fullscreen,
25
23
  } from '../layout'
26
- import { to_radians, type Vec3 } from '../math'
24
+ import { to_radians, type Point3D, type Vec3 } from '../math'
27
25
  import { ColorBar, PlotTooltip } from '../plot'
28
- import {
29
- centered_rect,
30
- pad_rect,
31
- rects_overlap,
32
- rect_within_rect,
33
- type Rect,
34
- } from '../plot/layout'
26
+ import { centered_rect, pad_rect, rects_overlap, rect_within_rect } from '../plot/layout'
27
+ import type { Rect } from '../plot/layout'
28
+ import { create_pulse_animation } from '../effects.svelte'
35
29
  import { DEFAULTS } from '../settings'
36
30
  import type { AnyStructure } from '../structure'
37
31
  import { Canvas, T } from '@threlte/core'
@@ -63,7 +57,6 @@
63
57
  HoverData3D,
64
58
  HullFaceColorMode,
65
59
  LabelPlacement,
66
- Point3D,
67
60
  } from './types'
68
61
  import { compute_hull_stability } from './helpers'
69
62
 
@@ -240,9 +233,9 @@
240
233
  const hull_faces = $derived.by((): ConvexHullTriangle[] => {
241
234
  if (coords_entries.length === 0) return []
242
235
  // Excluded entries don't participate in hull construction
243
- const hull_entries = coords_entries.filter((e) => !e.exclude_from_hull)
236
+ const hull_entries = coords_entries.filter((entry) => !entry.exclude_from_hull)
244
237
  if (hull_entries.length === 0) return []
245
- const points = hull_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }))
238
+ const points = hull_entries.map(({ x, y, z }) => ({ x, y, z }))
246
239
  try {
247
240
  return thermo.compute_lower_hull_triangles(points)
248
241
  } catch (error) {
@@ -258,7 +251,7 @@
258
251
  const all_enriched_entries = $derived.by(() => {
259
252
  if (coords_entries.length === 0) return []
260
253
  if (energy_mode !== `on-the-fly`) return coords_entries
261
- const pts = coords_entries.map((e) => ({ x: e.x, y: e.y, z: e.z }))
254
+ const pts = coords_entries.map(({ x, y, z }) => ({ x, y, z }))
262
255
  const raw_dists = thermo.compute_e_above_hull_for_points(pts, hull_model)
263
256
  return coords_entries.map((entry, idx) => ({
264
257
  ...entry, ...compute_hull_stability(raw_dists[idx], entry.exclude_from_hull),
@@ -275,34 +268,32 @@
275
268
  DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases,
276
269
  ))
277
270
 
278
- // Initialize threshold to auto value on first load
279
- let initialized = $state(false)
271
+ const next_auto_threshold = helpers.auto_threshold_reset(
272
+ DEFAULTS.convex_hull.ternary.max_hull_dist_show_phases,
273
+ )
280
274
  $effect(() => {
281
- if (!initialized && all_enriched_entries.length > 0) {
282
- initialized = true
283
- max_hull_dist_show_phases = auto_default_threshold
284
- }
275
+ max_hull_dist_show_phases = next_auto_threshold(
276
+ entries,
277
+ max_hull_dist_show_phases,
278
+ auto_default_threshold,
279
+ ) ?? max_hull_dist_show_phases
285
280
  })
286
281
 
287
- // Filter by threshold and compute visibility
282
+ // Filter by threshold; visibility is a view predicate, not entry state.
288
283
  const plot_entries = $derived(
289
- all_enriched_entries
290
- .filter((e) => (e.e_above_hull ?? 0) <= max_hull_dist_show_phases)
291
- .map((e) => ({
292
- ...e,
293
- visible: ((e.is_stable || e.e_above_hull === 0) && show_stable) ||
294
- (!(e.is_stable || e.e_above_hull === 0) && show_unstable),
295
- })),
284
+ all_enriched_entries.filter((entry) =>
285
+ (entry.e_above_hull ?? 0) <= max_hull_dist_show_phases
286
+ ),
296
287
  )
288
+ const visible_entries = $derived(helpers.visible_entries(
289
+ plot_entries,
290
+ show_stable,
291
+ show_unstable,
292
+ ))
297
293
 
298
294
  $effect(() => {
299
- stable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
300
- entry.is_stable || entry.e_above_hull === 0
301
- )
302
- unstable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
303
- typeof entry.e_above_hull === `number` && entry.e_above_hull > 0 &&
304
- !entry.is_stable
305
- )
295
+ stable_entries = plot_entries.filter(helpers.entry_is_stable)
296
+ unstable_entries = plot_entries.filter(helpers.entry_is_unstable)
306
297
  })
307
298
 
308
299
  // Canvas rendering
@@ -311,7 +302,6 @@
311
302
 
312
303
  // Performance optimization
313
304
  let frame_id = 0
314
- let pulse_frame_id = 0
315
305
 
316
306
  const camera_default = {
317
307
  elevation: DEFAULTS.convex_hull.ternary.camera_elevation,
@@ -442,13 +432,39 @@
442
432
  let modal_open = $state(false)
443
433
  let selected_structure = $state<AnyStructure | null>(null)
444
434
  let modal_place_right = $state(true)
435
+ $effect(() => {
436
+ const current_selection = helpers.current_entry(selected_entry, plot_entries)
437
+ const stale_selection = selected_entry && !current_selection
438
+ if (stale_selection) selected_entry = null
439
+ else if (current_selection && current_selection !== selected_entry) {
440
+ selected_entry = current_selection
441
+ }
442
+ const current_hover = helpers.current_entry(hover_data?.entry, plot_entries)
443
+ if (hover_data?.entry && !current_hover) {
444
+ hover_data = null
445
+ on_point_hover?.(null)
446
+ } else if (hover_data && current_hover && current_hover !== hover_data.entry) {
447
+ hover_data = { ...hover_data, entry: current_hover }
448
+ }
449
+ if (modal_open) {
450
+ const structure = current_selection && extract_structure_from_entry(current_selection)
451
+ if (structure) selected_structure = structure
452
+ else {
453
+ modal_open = false
454
+ selected_structure = null
455
+ }
456
+ }
457
+ })
445
458
 
446
459
  // Hull face color (customizable via controls)
447
460
  let hull_face_color = $state(`#4caf50`)
448
461
 
449
462
  // Pulsating highlight for selected point
450
- let pulse_time = $state(0)
451
- let pulse_opacity = $derived(0.3 + 0.4 * Math.sin(pulse_time * 4))
463
+ const pulse = create_pulse_animation(
464
+ () => selected_entry !== null || highlighted_entries.length > 0,
465
+ { on_tick: render_once },
466
+ )
467
+ let pulse_opacity = $derived(0.3 + 0.4 * pulse.unit)
452
468
 
453
469
  // Merge highlight style with defaults
454
470
  const merged_highlight_style = $derived(
@@ -459,19 +475,6 @@
459
475
  const is_highlighted = (entry: ConvexHullEntry): boolean =>
460
476
  helpers.is_entry_highlighted(entry, highlighted_entries)
461
477
 
462
- $effect(() => {
463
- if (!selected_entry && !highlighted_entries.length) return
464
- const animate = () => {
465
- pulse_time += 0.02
466
- render_once()
467
- pulse_frame_id = requestAnimationFrame(animate)
468
- }
469
- pulse_frame_id = requestAnimationFrame(animate)
470
- return () => {
471
- if (pulse_frame_id) cancelAnimationFrame(pulse_frame_id)
472
- }
473
- })
474
-
475
478
  // Re-render when important state changes
476
479
  $effect(() => {
477
480
  // oxfmt-ignore
@@ -511,8 +514,8 @@
511
514
  }
512
515
 
513
516
  const handle_keydown = (event: KeyboardEvent) => {
514
- const target = event.target as HTMLElement
515
- if (target.tagName.match(/INPUT|TEXTAREA/)) return
517
+ const target = event.target
518
+ if (target instanceof HTMLElement && target.tagName.match(/INPUT|TEXTAREA/)) return
516
519
 
517
520
  // Stop propagation if event came from canvas to prevent wrapper's handler
518
521
  // from running again (both have onkeydown, causing duplicate handling)
@@ -796,25 +799,32 @@
796
799
  if (!ctx || !show_hull_faces || hull_faces.length === 0) return
797
800
 
798
801
  // Lazy computation for uniform mode: normalize alpha by formation energy
799
- let norm_alpha: ((z: number) => number) | null = null
802
+ let norm_alpha: ((e_form: number) => number) | null = null
800
803
  if (hull_face_color_mode === `uniform`) {
801
- const min_fe = energy_range.min
802
- norm_alpha = (z: number) => {
803
- const t = Math.max(0, Math.min(1, (0 - z) / Math.max(1e-6, 0 - min_fe)))
804
- return t * hull_face_opacity
804
+ const min_uniform_e_form = energy_range.min
805
+ norm_alpha = (e_form: number) => {
806
+ const alpha_fraction = Math.max(
807
+ 0,
808
+ Math.min(1, (0 - e_form) / Math.max(1e-6, 0 - min_uniform_e_form)),
809
+ )
810
+ return alpha_fraction * hull_face_opacity
805
811
  }
806
812
  }
807
813
 
808
814
  // Lazy computation for formation_energy mode
809
815
  let energy_face_scale: ((val: number) => string) | null = null
810
- let min_z = 0
816
+ let min_face_e_form = 0
811
817
  if (hull_face_color_mode === `formation_energy`) {
812
- const all_z = hull_faces.flatMap((tri) => tri.vertices.map((v) => v.z))
813
- min_z = Math.min(...all_z)
818
+ const all_e_form = hull_faces.flatMap((tri) =>
819
+ tri.vertices.map((vertex) => vertex.z)
820
+ )
821
+ min_face_e_form = Math.min(...all_e_form)
814
822
  energy_face_scale = helpers.get_energy_color_scale(
815
823
  `energy`,
816
824
  color_scale,
817
- all_z.map((z) => ({ e_above_hull: z - min_z })), // Normalize to 0-based
825
+ all_e_form.map((e_form) => ({
826
+ e_above_hull: e_form - min_face_e_form,
827
+ })), // Normalize to 0-based
818
828
  )
819
829
  }
820
830
 
@@ -827,8 +837,8 @@
827
837
  return hull_face_color
828
838
  }
829
839
  if (hull_face_color_mode === `formation_energy`) {
830
- const avg_z = (tri.vertices[0].z + tri.vertices[1].z + tri.vertices[2].z) / 3
831
- return energy_face_scale?.(avg_z - min_z) ?? hull_face_color
840
+ const avg_e_form = (tri.vertices[0].z + tri.vertices[1].z + tri.vertices[2].z) / 3
841
+ return energy_face_scale?.(avg_e_form - min_face_e_form) ?? hull_face_color
832
842
  }
833
843
  if (hull_face_color_mode === `dominant_element`) {
834
844
  // Find element vertex closest to face centroid in 2D ternary space
@@ -855,7 +865,7 @@
855
865
  return { tri, tri_idx, depth: centroid_proj.depth }
856
866
  })
857
867
 
858
- faces_with_depth.sort((a, b) => a.depth - b.depth) // Back to front
868
+ faces_with_depth.sort((left, right) => left.depth - right.depth) // Back to front
859
869
 
860
870
  // Draw each face (lower hull only)
861
871
  for (const { tri, tri_idx } of faces_with_depth) {
@@ -962,8 +972,8 @@
962
972
  const denom = Math.max(1e-6, max_fe - min_fe)
963
973
  return (value: number) => {
964
974
  // alpha 0 at 0 eV, goes to hull_face_opacity at most negative energy
965
- const t = Math.max(0, Math.min(1, (value - min_fe) / denom))
966
- const alpha = (1 - t) * hull_face_opacity
975
+ const energy_fraction = Math.max(0, Math.min(1, (value - min_fe) / denom))
976
+ const alpha = (1 - energy_fraction) * hull_face_opacity
967
977
  return add_alpha(hull_face_color, alpha)
968
978
  }
969
979
  })
@@ -972,7 +982,7 @@
972
982
  if (!ctx || sorted_points_cache.length === 0) return
973
983
 
974
984
  for (const { entry, projected } of sorted_points_cache) {
975
- const is_stable = entry.is_stable || entry.e_above_hull === 0
985
+ const is_stable = helpers.entry_is_stable(entry)
976
986
  const is_entry_highlighted = is_highlighted(entry)
977
987
  const color = get_point_color(entry)
978
988
  const size = (entry.size || (is_stable ? 6 : 4)) * canvas_dims.scale
@@ -994,7 +1004,7 @@
994
1004
  projected,
995
1005
  size,
996
1006
  canvas_dims.scale,
997
- pulse_time,
1007
+ pulse.time,
998
1008
  pulse_opacity,
999
1009
  )
1000
1010
  }
@@ -1004,7 +1014,7 @@
1004
1014
  projected,
1005
1015
  size,
1006
1016
  canvas_dims.scale,
1007
- pulse_time,
1017
+ pulse.time,
1008
1018
  merged_highlight_style,
1009
1019
  )
1010
1020
  }
@@ -1120,9 +1130,9 @@
1120
1130
  const label_height = hull_label_font_size + 2
1121
1131
 
1122
1132
  const label_entries = helpers.get_composition_label_entries(
1123
- plot_entries.filter((entry) => {
1124
- if (!entry.visible || entry.is_element) return false
1125
- const is_stable_point = entry.is_stable || (entry.e_above_hull ?? 0) <= 1e-6
1133
+ visible_entries.filter((entry) => {
1134
+ if (entry.is_element) return false
1135
+ const is_stable_point = helpers.entry_is_stable(entry)
1126
1136
  return (is_stable_point && show_stable_labels) ||
1127
1137
  (!is_stable_point && show_unstable_labels &&
1128
1138
  (entry.e_above_hull ?? 0) <= max_hull_dist_show_labels)
@@ -1147,7 +1157,7 @@
1147
1157
  const formula_segments = get_formula_label_segments(
1148
1158
  helpers.get_entry_label(entry, elements),
1149
1159
  )
1150
- const is_stable_point = entry.is_stable || entry.e_above_hull === 0
1160
+ const is_stable_point = helpers.entry_is_stable(entry)
1151
1161
  const point_size = (entry.size || (is_stable_point ? 6 : 4)) * canvas_dims.scale
1152
1162
  const text_width = measure_formula_segments(ctx, formula_segments)
1153
1163
  const placements = get_label_placements(
@@ -1257,7 +1267,7 @@
1257
1267
  helpers.find_hull_entry_at_mouse(
1258
1268
  canvas,
1259
1269
  event,
1260
- plot_entries,
1270
+ visible_entries,
1261
1271
  (x: number, y: number, z: number) => {
1262
1272
  const pt = project_3d_point(x, y, z)
1263
1273
  return { x: pt.x, y: pt.y }
@@ -1308,7 +1318,7 @@
1308
1318
  }
1309
1319
  }
1310
1320
 
1311
- const render_once = () => {
1321
+ function render_once() {
1312
1322
  if (!frame_id) {
1313
1323
  frame_id = requestAnimationFrame(() => {
1314
1324
  render_frame()
@@ -1363,7 +1373,6 @@
1363
1373
 
1364
1374
  return () => { // Cleanup on unmount
1365
1375
  if (frame_id) cancelAnimationFrame(frame_id)
1366
- if (pulse_frame_id) cancelAnimationFrame(pulse_frame_id)
1367
1376
  resize_observer.disconnect()
1368
1377
  }
1369
1378
  })
@@ -1386,7 +1395,7 @@
1386
1395
  const energy_range = $derived.by(() => {
1387
1396
  let min = 0
1388
1397
  let max = 0
1389
- for (const entry of plot_entries) {
1398
+ for (const entry of all_enriched_entries) {
1390
1399
  const energy = entry.e_form_per_atom ?? 0
1391
1400
  min = Math.min(min, energy)
1392
1401
  max = Math.max(max, energy)
@@ -1397,14 +1406,13 @@
1397
1406
 
1398
1407
  // Performance: Pre-compute and cache all point projections + depth sorting
1399
1408
  const sorted_points_cache = $derived.by(() => {
1400
- if (!canvas || plot_entries.length === 0) return []
1401
- return plot_entries
1402
- .filter((entry) => entry.visible)
1409
+ if (!canvas || visible_entries.length === 0) return []
1410
+ return visible_entries
1403
1411
  .map((entry) => ({
1404
1412
  entry,
1405
1413
  projected: project_3d_point(entry.x, entry.y, entry.z),
1406
1414
  }))
1407
- .sort((a, b) => a.projected.depth - b.projected.depth)
1415
+ .sort((left, right) => left.projected.depth - right.projected.depth)
1408
1416
  })
1409
1417
 
1410
1418
  let style = $derived(
@@ -1479,8 +1487,8 @@
1479
1487
  <!-- Formation Energy Color Bar (bottom-left corner) -->
1480
1488
  {#if color_mode === `energy` && plot_entries.length > 0}
1481
1489
  {@const hull_distances = plot_entries
1482
- .map((e) => e.e_above_hull)
1483
- .filter((v): v is number => typeof v === `number`)}
1490
+ .map((entry) => entry.e_above_hull)
1491
+ .filter((value): value is number => typeof value === `number`)}
1484
1492
  {@const min_energy = hull_distances.length > 0 ? Math.min(...hull_distances) : 0}
1485
1493
  {@const max_energy = hull_distances.length > 0 ? Math.max(...hull_distances, 0.1) : 0.1}
1486
1494
  <ColorBar
@@ -1529,6 +1537,8 @@
1529
1537
  {phase_stats}
1530
1538
  {stable_entries}
1531
1539
  {unstable_entries}
1540
+ {show_stable}
1541
+ {show_unstable}
1532
1542
  {max_hull_dist_show_phases}
1533
1543
  {max_hull_dist_show_labels}
1534
1544
  {label_threshold}
@@ -1566,13 +1576,14 @@
1566
1576
  {merged_controls}
1567
1577
  toggle_props={{ class: `legend-controls-btn` }}
1568
1578
  {show_hull_faces}
1569
- on_hull_faces_change={(value) => show_hull_faces = value}
1579
+ on_hull_faces_change={(value: boolean) => show_hull_faces = value}
1570
1580
  {hull_face_color}
1571
- on_hull_face_color_change={(value) => hull_face_color = value}
1581
+ on_hull_face_color_change={(value: string) => hull_face_color = value}
1572
1582
  {hull_face_opacity}
1573
- on_hull_face_opacity_change={(value) => hull_face_opacity = value}
1583
+ on_hull_face_opacity_change={(value: number) => hull_face_opacity = value}
1574
1584
  {hull_face_color_mode}
1575
- on_hull_face_color_mode_change={(value) => hull_face_color_mode = value}
1585
+ on_hull_face_color_mode_change={(value: HullFaceColorMode) =>
1586
+ hull_face_color_mode = value}
1576
1587
  bind:energy_source_mode
1577
1588
  {has_precomputed_e_form}
1578
1589
  {can_compute_e_form}
@@ -17,6 +17,7 @@
17
17
  toggle_fullscreen,
18
18
  } from '../layout'
19
19
  import { ColorBar, PlotTooltip } from '../plot'
20
+ import { create_pulse_animation } from '../effects.svelte'
20
21
  import { DEFAULTS } from '../settings'
21
22
  import type { AnyStructure } from '../structure'
22
23
  import {
@@ -276,39 +277,36 @@
276
277
  DEFAULTS.convex_hull.quaternary.max_hull_dist_show_phases,
277
278
  ))
278
279
 
279
- // Initialize threshold to auto value on first load
280
- let initialized = $state(false)
280
+ const next_auto_threshold = helpers.auto_threshold_reset(
281
+ DEFAULTS.convex_hull.quaternary.max_hull_dist_show_phases,
282
+ )
281
283
  $effect(() => {
282
- if (!initialized && all_enriched_entries.length > 0) {
283
- initialized = true
284
- max_hull_dist_show_phases = auto_default_threshold
285
- }
284
+ max_hull_dist_show_phases = next_auto_threshold(
285
+ entries,
286
+ max_hull_dist_show_phases,
287
+ auto_default_threshold,
288
+ ) ?? max_hull_dist_show_phases
286
289
  })
287
290
 
288
- // Filter by threshold and compute visibility
291
+ // Filter by threshold; visibility is a view predicate, not entry state.
289
292
  const plot_entries = $derived(
290
293
  all_enriched_entries.filter((entry) => {
291
294
  // Always include stable entries and elemental reference points
292
- if (entry.is_stable || (entry.e_above_hull ?? Infinity) <= 1e-6) return true
295
+ if (helpers.entry_is_stable(entry)) return true
293
296
  return typeof entry.e_above_hull === `number` &&
294
297
  entry.e_above_hull <= max_hull_dist_show_phases
295
- }).map((entry) => {
296
- const is_stable = entry.is_stable || entry.e_above_hull === 0
297
- return {
298
- ...entry,
299
- visible: (is_stable && show_stable) || (!is_stable && show_unstable),
300
- }
301
298
  }),
302
299
  )
300
+ const visible_entries = $derived(helpers.visible_entries(
301
+ plot_entries,
302
+ show_stable,
303
+ show_unstable,
304
+ ))
303
305
 
304
306
  // Stable and unstable entries exposed as bindable props
305
307
  $effect(() => {
306
- stable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
307
- entry.is_stable || entry.e_above_hull === 0
308
- )
309
- unstable_entries = plot_entries.filter((entry: ConvexHullEntry) =>
310
- (entry.e_above_hull ?? 0) > 0 && !entry.is_stable
311
- )
308
+ stable_entries = plot_entries.filter(helpers.entry_is_stable)
309
+ unstable_entries = plot_entries.filter(helpers.entry_is_unstable)
312
310
  })
313
311
 
314
312
  let canvas: HTMLCanvasElement | undefined = undefined
@@ -338,14 +336,39 @@
338
336
  let modal_open = $state(false)
339
337
  let selected_structure = $state<AnyStructure | null>(null)
340
338
  let modal_place_right = $state(true)
339
+ $effect(() => {
340
+ const current_selection = helpers.current_entry(selected_entry, plot_entries)
341
+ const stale_selection = selected_entry && !current_selection
342
+ if (stale_selection) selected_entry = null
343
+ else if (current_selection && current_selection !== selected_entry) {
344
+ selected_entry = current_selection
345
+ }
346
+ const current_hover = helpers.current_entry(hover_data?.entry, plot_entries)
347
+ if (hover_data?.entry && !current_hover) {
348
+ hover_data = null
349
+ on_point_hover?.(null)
350
+ } else if (hover_data && current_hover && current_hover !== hover_data.entry) {
351
+ hover_data = { ...hover_data, entry: current_hover }
352
+ }
353
+ if (modal_open) {
354
+ const structure = current_selection && extract_structure_from_entry(current_selection)
355
+ if (structure) selected_structure = structure
356
+ else {
357
+ modal_open = false
358
+ selected_structure = null
359
+ }
360
+ }
361
+ })
341
362
 
342
363
  // Hull face color (customizable via controls)
343
364
  let hull_face_color = $state(`#4caf50`)
344
365
 
345
366
  // Pulsating highlight for selected point and highlighted entries
346
- let pulse_time = $state(0)
347
- let pulse_opacity = $derived(0.3 + 0.4 * Math.sin(pulse_time * 4))
348
- let pulse_frame_id = 0
367
+ const pulse = create_pulse_animation(
368
+ () => selected_entry !== null || highlighted_entries.length > 0,
369
+ { on_tick: render_once },
370
+ )
371
+ let pulse_opacity = $derived(0.3 + 0.4 * pulse.unit)
349
372
 
350
373
  // Merge highlight style with defaults
351
374
  const merged_highlight_style = $derived(
@@ -356,21 +379,6 @@
356
379
  const is_highlighted = (entry: ConvexHullEntry): boolean =>
357
380
  helpers.is_entry_highlighted(entry, highlighted_entries)
358
381
 
359
- $effect(() => {
360
- if (!selected_entry && !highlighted_entries.length) return
361
- const reduce = globalThis.matchMedia?.(`(prefers-reduced-motion: reduce)`).matches
362
- if (reduce) return
363
- const animate = () => {
364
- pulse_time += 0.02
365
- render_once()
366
- pulse_frame_id = requestAnimationFrame(animate)
367
- }
368
- pulse_frame_id = requestAnimationFrame(animate)
369
- return () => {
370
- if (pulse_frame_id) cancelAnimationFrame(pulse_frame_id)
371
- }
372
- })
373
-
374
382
  // Re-render when important state changes
375
383
  $effect(() => {
376
384
  // oxfmt-ignore
@@ -432,11 +440,15 @@
432
440
  }
433
441
 
434
442
  const handle_keydown = (event: KeyboardEvent) => {
435
- const target = event.target as HTMLElement
443
+ const target = event.target
436
444
  // Skip if focus is on an interactive element that handles Enter natively
437
445
  const interactive_selector =
438
446
  `input,textarea,select,button,a,[contenteditable="true"],[role="button"],[tabindex]:not([tabindex="-1"])`
439
- if (target.matches(interactive_selector) && target !== canvas) return
447
+ if (
448
+ target instanceof HTMLElement &&
449
+ target.matches(interactive_selector) &&
450
+ target !== canvas
451
+ ) return
440
452
 
441
453
  // Prevent double handling from canvas + wrapper bubbling
442
454
  if (event.target !== event.currentTarget && event.currentTarget !== canvas) return
@@ -640,9 +652,7 @@
640
652
  if (!ctx || !show_hull_faces || hull_4d.length === 0) return
641
653
 
642
654
  // Get stable points to determine which hull facets to draw
643
- const stable_points = plot_entries.filter((entry) =>
644
- entry.is_stable || entry.e_above_hull === 0
645
- )
655
+ const stable_points = plot_entries.filter(helpers.entry_is_stable)
646
656
  if (stable_points.length === 0) return
647
657
 
648
658
  // Each tetrahedral facet has 4 triangular faces - we need to draw these
@@ -805,7 +815,7 @@
805
815
  if (!ctx || sorted_points_cache.length === 0) return
806
816
 
807
817
  for (const { entry, projected } of sorted_points_cache) {
808
- const is_stable = entry.is_stable || entry.e_above_hull === 0
818
+ const is_stable = helpers.entry_is_stable(entry)
809
819
  const is_entry_highlighted = is_highlighted(entry)
810
820
  const color = get_point_color(entry)
811
821
  const size = (entry.size || (is_stable ? 6 : 4)) * canvas_dims.scale
@@ -827,7 +837,7 @@
827
837
  projected,
828
838
  size,
829
839
  canvas_dims.scale,
830
- pulse_time,
840
+ pulse.time,
831
841
  pulse_opacity,
832
842
  )
833
843
  }
@@ -837,7 +847,7 @@
837
847
  projected,
838
848
  size,
839
849
  canvas_dims.scale,
840
- pulse_time,
850
+ pulse.time,
841
851
  merged_highlight_style,
842
852
  )
843
853
  }
@@ -864,7 +874,7 @@
864
874
  .map(({ entry }) => entry)
865
875
  .filter((entry) => {
866
876
  if (entry.is_element) return false
867
- const is_stable = entry.is_stable || entry.e_above_hull === 0
877
+ const is_stable = helpers.entry_is_stable(entry)
868
878
  return (is_stable && show_stable_labels) ||
869
879
  (!is_stable && show_unstable_labels &&
870
880
  (entry.e_above_hull ?? 0) <= max_hull_dist_show_labels)
@@ -877,7 +887,7 @@
877
887
  ctx.textBaseline = `middle`
878
888
 
879
889
  for (const entry of label_entries) {
880
- const is_stable = entry.is_stable || entry.e_above_hull === 0
890
+ const is_stable = helpers.entry_is_stable(entry)
881
891
  const size = (entry.size || (is_stable ? 6 : 4)) * canvas_dims.scale
882
892
  const projected = project_3d_point(entry.x, entry.y, entry.z)
883
893
  const label = helpers.get_entry_label(entry, elements)
@@ -969,7 +979,7 @@
969
979
  helpers.find_hull_entry_at_mouse(
970
980
  canvas,
971
981
  event,
972
- plot_entries,
982
+ visible_entries,
973
983
  (x: number, y: number, z: number) => {
974
984
  const projected = project_3d_point(x, y, z)
975
985
  return { x: projected.x, y: projected.y }
@@ -1017,7 +1027,7 @@
1017
1027
  }
1018
1028
  }
1019
1029
 
1020
- const render_once = () => {
1030
+ function render_once() {
1021
1031
  if (!frame_id) {
1022
1032
  frame_id = requestAnimationFrame(() => {
1023
1033
  render_frame()
@@ -1085,15 +1095,14 @@
1085
1095
  let canvas_dims = $state({ width: 600, height: 600, scale: 1 })
1086
1096
  const formation_energy_min = $derived.by(() => {
1087
1097
  let min_energy = 0
1088
- for (const entry of plot_entries) {
1098
+ for (const entry of all_enriched_entries) {
1089
1099
  min_energy = Math.min(min_energy, entry.e_form_per_atom ?? 0)
1090
1100
  }
1091
1101
  return min_energy
1092
1102
  })
1093
1103
  const sorted_points_cache = $derived.by(() => {
1094
- if (!canvas || plot_entries.length === 0) return []
1095
- return plot_entries
1096
- .filter((entry) => entry.visible)
1104
+ if (!canvas || visible_entries.length === 0) return []
1105
+ return visible_entries
1097
1106
  .map((entry) => ({
1098
1107
  entry,
1099
1108
  projected: project_3d_point(entry.x, entry.y, entry.z),
@@ -1210,6 +1219,8 @@
1210
1219
  {phase_stats}
1211
1220
  {stable_entries}
1212
1221
  {unstable_entries}
1222
+ {show_stable}
1223
+ {show_unstable}
1213
1224
  {max_hull_dist_show_phases}
1214
1225
  {max_hull_dist_show_labels}
1215
1226
  {label_threshold}
@@ -1247,13 +1258,14 @@
1247
1258
  {merged_controls}
1248
1259
  toggle_props={{ class: `legend-controls-btn` }}
1249
1260
  {show_hull_faces}
1250
- on_hull_faces_change={(value) => show_hull_faces = value}
1261
+ on_hull_faces_change={(value: boolean) => show_hull_faces = value}
1251
1262
  {hull_face_color}
1252
- on_hull_face_color_change={(value) => hull_face_color = value}
1263
+ on_hull_face_color_change={(value: string) => hull_face_color = value}
1253
1264
  {hull_face_opacity}
1254
- on_hull_face_opacity_change={(value) => hull_face_opacity = value}
1265
+ on_hull_face_opacity_change={(value: number) => hull_face_opacity = value}
1255
1266
  {hull_face_color_mode}
1256
- on_hull_face_color_mode_change={(value) => hull_face_color_mode = value}
1267
+ on_hull_face_color_mode_change={(value: HullFaceColorMode) =>
1268
+ hull_face_color_mode = value}
1257
1269
  bind:energy_source_mode
1258
1270
  {has_precomputed_e_form}
1259
1271
  {can_compute_e_form}