matterviz 0.3.1 → 0.3.2

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 (257) hide show
  1. package/dist/FilePicker.svelte +37 -20
  2. package/dist/Icon.svelte +2 -2
  3. package/dist/app.css +29 -0
  4. package/dist/brillouin/BrillouinZone.svelte +19 -61
  5. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  6. package/dist/brillouin/BrillouinZoneExportPane.svelte +12 -20
  7. package/dist/brillouin/BrillouinZoneScene.svelte +2 -2
  8. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  9. package/dist/chempot-diagram/ChemPotDiagram.svelte +192 -0
  10. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  11. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +677 -0
  12. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  13. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2688 -0
  14. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  15. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -0
  16. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  17. package/dist/chempot-diagram/color.d.ts +10 -0
  18. package/dist/chempot-diagram/color.js +33 -0
  19. package/dist/chempot-diagram/compute.d.ts +38 -0
  20. package/dist/chempot-diagram/compute.js +650 -0
  21. package/dist/chempot-diagram/index.d.ts +5 -0
  22. package/dist/chempot-diagram/index.js +5 -0
  23. package/dist/chempot-diagram/pointer.d.ts +16 -0
  24. package/dist/chempot-diagram/pointer.js +40 -0
  25. package/dist/chempot-diagram/temperature.d.ts +15 -0
  26. package/dist/chempot-diagram/temperature.js +37 -0
  27. package/dist/chempot-diagram/types.d.ts +83 -0
  28. package/dist/chempot-diagram/types.js +27 -0
  29. package/dist/colors/index.d.ts +3 -1
  30. package/dist/colors/index.js +4 -0
  31. package/dist/composition/BarChart.svelte +13 -22
  32. package/dist/composition/BubbleChart.svelte +5 -3
  33. package/dist/composition/FormulaFilter.svelte +586 -94
  34. package/dist/composition/FormulaFilter.svelte.d.ts +35 -1
  35. package/dist/composition/PieChart.svelte +43 -18
  36. package/dist/composition/PieChart.svelte.d.ts +1 -1
  37. package/dist/convex-hull/ConvexHull.svelte +4 -2
  38. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  39. package/dist/convex-hull/ConvexHull2D.svelte +13 -44
  40. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  41. package/dist/convex-hull/ConvexHull3D.svelte +16 -7
  42. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  43. package/dist/convex-hull/ConvexHull4D.svelte +17 -7
  44. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  45. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +1 -1
  46. package/dist/convex-hull/ConvexHullStats.svelte +701 -226
  47. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  48. package/dist/convex-hull/ConvexHullTooltip.svelte +1 -0
  49. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  50. package/dist/convex-hull/demo-temperature.js +36 -0
  51. package/dist/convex-hull/helpers.d.ts +1 -1
  52. package/dist/convex-hull/helpers.js +2 -4
  53. package/dist/convex-hull/index.d.ts +1 -0
  54. package/dist/convex-hull/index.js +1 -0
  55. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  56. package/dist/convex-hull/thermodynamics.js +106 -17
  57. package/dist/convex-hull/types.d.ts +5 -0
  58. package/dist/convex-hull/types.js +5 -0
  59. package/dist/coordination/CoordinationBarPlot.svelte +29 -46
  60. package/dist/element/BohrAtom.svelte +1 -1
  61. package/dist/element/data.js +2 -14
  62. package/dist/element/data.json.gz +0 -0
  63. package/dist/element/types.d.ts +1 -0
  64. package/dist/fermi-surface/FermiSurface.svelte +20 -64
  65. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  66. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  67. package/dist/fermi-surface/FermiSurfaceScene.svelte +1 -1
  68. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  69. package/dist/fermi-surface/parse.js +16 -22
  70. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1273 -0
  71. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  72. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +171 -0
  73. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +31 -0
  74. package/dist/heatmap-matrix/index.d.ts +53 -0
  75. package/dist/heatmap-matrix/index.js +100 -0
  76. package/dist/heatmap-matrix/shared.d.ts +2 -0
  77. package/dist/heatmap-matrix/shared.js +4 -0
  78. package/dist/icons.d.ts +111 -0
  79. package/dist/icons.js +111 -0
  80. package/dist/index.d.ts +3 -1
  81. package/dist/index.js +3 -1
  82. package/dist/io/export.js +15 -3
  83. package/dist/io/file-drop.d.ts +7 -0
  84. package/dist/io/file-drop.js +43 -0
  85. package/dist/io/index.d.ts +2 -2
  86. package/dist/io/index.js +2 -112
  87. package/dist/io/types.d.ts +1 -0
  88. package/dist/io/url-drop.d.ts +2 -0
  89. package/dist/io/url-drop.js +118 -0
  90. package/dist/isosurface/Isosurface.svelte +101 -45
  91. package/dist/isosurface/IsosurfaceControls.svelte +19 -0
  92. package/dist/isosurface/parse.js +73 -30
  93. package/dist/isosurface/slice.d.ts +2 -1
  94. package/dist/isosurface/slice.js +3 -3
  95. package/dist/isosurface/types.d.ts +13 -1
  96. package/dist/isosurface/types.js +98 -0
  97. package/dist/labels.d.ts +2 -1
  98. package/dist/labels.js +1 -0
  99. package/dist/layout/InfoTag.svelte +62 -62
  100. package/dist/layout/SubpageGrid.svelte +74 -0
  101. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  102. package/dist/layout/index.d.ts +1 -0
  103. package/dist/layout/index.js +1 -0
  104. package/dist/layout/json-tree/JsonNode.svelte +83 -85
  105. package/dist/layout/json-tree/JsonTree.svelte +20 -19
  106. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  107. package/dist/layout/json-tree/JsonValue.svelte +196 -116
  108. package/dist/layout/json-tree/types.d.ts +10 -2
  109. package/dist/layout/json-tree/utils.d.ts +2 -0
  110. package/dist/layout/json-tree/utils.js +33 -0
  111. package/dist/math.d.ts +7 -0
  112. package/dist/math.js +358 -7
  113. package/dist/overlays/ContextMenu.svelte +3 -2
  114. package/dist/overlays/DraggablePane.svelte +163 -58
  115. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  116. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +232 -77
  117. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  118. package/dist/phase-diagram/PhaseDiagramControls.svelte +32 -11
  119. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  120. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +103 -0
  121. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  122. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +102 -95
  123. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +7 -0
  124. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +100 -26
  125. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  126. package/dist/phase-diagram/index.d.ts +2 -0
  127. package/dist/phase-diagram/index.js +2 -0
  128. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  129. package/dist/phase-diagram/svg-to-diagram.js +865 -0
  130. package/dist/phase-diagram/types.d.ts +10 -0
  131. package/dist/phase-diagram/utils.d.ts +7 -4
  132. package/dist/phase-diagram/utils.js +149 -59
  133. package/dist/plot/AxisLabel.svelte +26 -0
  134. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  135. package/dist/plot/BarPlot.svelte +473 -228
  136. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  137. package/dist/plot/BarPlotControls.svelte +3 -2
  138. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  139. package/dist/plot/ColorBar.svelte +54 -54
  140. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  141. package/dist/plot/ColorScaleSelect.svelte +1 -1
  142. package/dist/plot/ElementScatter.svelte +3 -2
  143. package/dist/plot/FillArea.svelte +4 -1
  144. package/dist/plot/Histogram.svelte +320 -230
  145. package/dist/plot/Histogram.svelte.d.ts +2 -2
  146. package/dist/plot/HistogramControls.svelte +29 -10
  147. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  148. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +2 -2
  149. package/dist/plot/PlotControls.svelte +109 -27
  150. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  151. package/dist/plot/PlotLegend.svelte +1 -1
  152. package/dist/plot/PortalSelect.svelte +2 -1
  153. package/dist/plot/ReferenceLine.svelte +2 -1
  154. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  155. package/dist/plot/ReferencePlane.svelte +1 -3
  156. package/dist/plot/ScatterPlot.svelte +343 -209
  157. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  158. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  159. package/dist/plot/ScatterPlot3DControls.svelte +203 -250
  160. package/dist/plot/ScatterPlot3DScene.svelte +4 -7
  161. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  162. package/dist/plot/ScatterPlotControls.svelte +95 -55
  163. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  164. package/dist/plot/ZeroLines.svelte +44 -0
  165. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  166. package/dist/plot/ZoomRect.svelte +21 -0
  167. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  168. package/dist/plot/axis-utils.d.ts +1 -1
  169. package/dist/plot/index.d.ts +6 -2
  170. package/dist/plot/index.js +6 -2
  171. package/dist/plot/interactions.d.ts +8 -10
  172. package/dist/plot/interactions.js +2 -3
  173. package/dist/plot/layout.d.ts +7 -1
  174. package/dist/plot/layout.js +12 -4
  175. package/dist/plot/reference-line.d.ts +4 -21
  176. package/dist/plot/reference-line.js +7 -81
  177. package/dist/plot/types.d.ts +42 -17
  178. package/dist/plot/types.js +10 -0
  179. package/dist/plot/utils/label-placement.js +13 -10
  180. package/dist/plot/utils.d.ts +1 -0
  181. package/dist/plot/utils.js +14 -0
  182. package/dist/rdf/RdfPlot.svelte +55 -66
  183. package/dist/settings.d.ts +3 -0
  184. package/dist/settings.js +17 -3
  185. package/dist/spectral/Bands.svelte +515 -143
  186. package/dist/spectral/Bands.svelte.d.ts +22 -2
  187. package/dist/spectral/helpers.d.ts +23 -1
  188. package/dist/spectral/helpers.js +65 -9
  189. package/dist/spectral/types.d.ts +2 -0
  190. package/dist/structure/AtomLegend.svelte +29 -8
  191. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  192. package/dist/structure/CellSelect.svelte +92 -22
  193. package/dist/structure/Structure.svelte +108 -118
  194. package/dist/structure/Structure.svelte.d.ts +1 -1
  195. package/dist/structure/StructureControls.svelte +25 -22
  196. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  197. package/dist/structure/StructureInfoPane.svelte +7 -1
  198. package/dist/structure/StructureScene.svelte +104 -66
  199. package/dist/structure/StructureScene.svelte.d.ts +2 -1
  200. package/dist/structure/atom-properties.d.ts +6 -2
  201. package/dist/structure/atom-properties.js +38 -25
  202. package/dist/structure/export.js +10 -7
  203. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  204. package/dist/structure/ferrox-wasm-types.js +0 -3
  205. package/dist/structure/ferrox-wasm.d.ts +3 -2
  206. package/dist/structure/ferrox-wasm.js +1 -2
  207. package/dist/structure/index.d.ts +6 -0
  208. package/dist/structure/index.js +22 -0
  209. package/dist/structure/parse.js +19 -16
  210. package/dist/structure/partial-occupancy.d.ts +25 -0
  211. package/dist/structure/partial-occupancy.js +102 -0
  212. package/dist/structure/validation.js +6 -3
  213. package/dist/symmetry/SymmetryStats.svelte +18 -4
  214. package/dist/symmetry/WyckoffTable.svelte +18 -10
  215. package/dist/symmetry/index.d.ts +7 -4
  216. package/dist/symmetry/index.js +83 -18
  217. package/dist/table/HeatmapTable.svelte +425 -65
  218. package/dist/table/HeatmapTable.svelte.d.ts +12 -1
  219. package/dist/table/ToggleMenu.svelte +2 -0
  220. package/dist/table/index.d.ts +2 -0
  221. package/dist/trajectory/Trajectory.svelte +147 -145
  222. package/dist/trajectory/TrajectoryExportPane.svelte +13 -9
  223. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  224. package/dist/trajectory/constants.d.ts +6 -0
  225. package/dist/trajectory/constants.js +7 -0
  226. package/dist/trajectory/extract.js +3 -5
  227. package/dist/trajectory/format-detect.d.ts +9 -0
  228. package/dist/trajectory/format-detect.js +76 -0
  229. package/dist/trajectory/frame-reader.d.ts +17 -0
  230. package/dist/trajectory/frame-reader.js +339 -0
  231. package/dist/trajectory/helpers.d.ts +15 -0
  232. package/dist/trajectory/helpers.js +187 -0
  233. package/dist/trajectory/index.d.ts +1 -0
  234. package/dist/trajectory/index.js +11 -4
  235. package/dist/trajectory/parse/ase.d.ts +2 -0
  236. package/dist/trajectory/parse/ase.js +76 -0
  237. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  238. package/dist/trajectory/parse/hdf5.js +121 -0
  239. package/dist/trajectory/parse/index.d.ts +12 -0
  240. package/dist/trajectory/parse/index.js +304 -0
  241. package/dist/trajectory/parse/lammps.d.ts +5 -0
  242. package/dist/trajectory/parse/lammps.js +169 -0
  243. package/dist/trajectory/parse/vasp.d.ts +2 -0
  244. package/dist/trajectory/parse/vasp.js +65 -0
  245. package/dist/trajectory/parse/xyz.d.ts +2 -0
  246. package/dist/trajectory/parse/xyz.js +109 -0
  247. package/dist/trajectory/types.d.ts +11 -0
  248. package/dist/trajectory/types.js +1 -0
  249. package/dist/utils.d.ts +2 -0
  250. package/dist/utils.js +4 -0
  251. package/dist/xrd/XrdPlot.svelte +6 -4
  252. package/dist/xrd/calc-xrd.js +0 -1
  253. package/package.json +30 -24
  254. package/readme.md +4 -4
  255. package/dist/trajectory/parse.d.ts +0 -42
  256. package/dist/trajectory/parse.js +0 -1267
  257. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
@@ -1,12 +1,16 @@
1
- <script lang="ts">import { get_alphabetical_formula } from '../composition/format';
1
+ <script lang="ts">import { get_alphabetical_formula, get_electro_neg_formula, get_reduced_formula, } from '../composition';
2
2
  import Icon from '../Icon.svelte';
3
3
  import { format_num } from '../labels';
4
4
  import Histogram from '../plot/Histogram.svelte';
5
5
  import HeatmapTable from '../table/HeatmapTable.svelte';
6
- import { SvelteSet } from 'svelte/reactivity';
7
- let { phase_stats, stable_entries, unstable_entries, ...rest } = $props();
6
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
7
+ import { get_arity, is_on_hull } from './types';
8
+ let { phase_stats, stable_entries, unstable_entries, layout = `toggle`, on_entry_click, highlighted_entry_id, min_n_elements = $bindable(1), entry_href, ...rest } = $props();
8
9
  let copied_items = new SvelteSet();
9
10
  let view_mode = $state(`stats`);
11
+ // Formula filter: when set, table shows only entries with this reduced formula
12
+ let formula_filter = $state(``);
13
+ let show_export_dropdown = $state(false);
10
14
  async function copy_to_clipboard(label, value, key) {
11
15
  try {
12
16
  await navigator.clipboard.writeText(`${label}: ${value}`);
@@ -17,258 +21,595 @@ async function copy_to_clipboard(label, value, key) {
17
21
  console.error(`Failed to copy to clipboard:`, error);
18
22
  }
19
23
  }
24
+ function handle_copy_keydown(event, label, value, key) {
25
+ if (event.key !== `Enter` && event.key !== ` `)
26
+ return;
27
+ event.preventDefault();
28
+ copy_to_clipboard(label, value, key);
29
+ }
20
30
  // Shared concatenation of stable + unstable for histograms
21
31
  let all_entries = $derived([...stable_entries, ...unstable_entries]);
32
+ // Static arity labels for phase breakdown display
33
+ const arity_types = [
34
+ [`Unary`, `unary`, 1],
35
+ [`Binary`, `binary`, 2],
36
+ [`Ternary`, `ternary`, 3],
37
+ [`Quaternary`, `quaternary`, 4],
38
+ [`Quinary+`, `quinary_plus`, 5],
39
+ ];
40
+ const histogram_props = {
41
+ bins: 50,
42
+ y_axis: { label: ``, ticks: 3 },
43
+ show_legend: false,
44
+ show_controls: false,
45
+ padding: { t: 5, b: 22, l: 35, r: 5 },
46
+ style: `height: 100px; --histogram-min-height: 100px`,
47
+ };
22
48
  // Prepare histogram data for formation energies and hull distances
23
- let e_form_data = $derived.by(() => {
24
- const energies = all_entries
25
- .map((entry) => entry.e_form_per_atom ?? entry.energy_per_atom)
26
- .filter((val) => val !== undefined && isFinite(val));
27
- return [{
28
- x: [],
29
- y: energies,
30
- label: `Formation Energy`,
31
- line_style: { stroke: `steelblue` },
32
- }];
33
- });
34
- let hull_distance_data = $derived.by(() => {
35
- const distances = all_entries
36
- .map((entry) => entry.e_above_hull)
37
- .filter((val) => val !== undefined && isFinite(val));
38
- return [{
39
- x: [],
40
- y: distances,
41
- label: `E above hull`,
42
- line_style: { stroke: `coral` },
43
- }];
44
- });
49
+ let e_form_data = $derived([{
50
+ x: [],
51
+ y: all_entries
52
+ .map((entry) => entry.e_form_per_atom ?? entry.energy_per_atom)
53
+ .filter((val) => val !== undefined && isFinite(val)),
54
+ label: `Formation Energy`,
55
+ }]);
56
+ let hull_distance_data = $derived([{
57
+ x: [],
58
+ y: all_entries
59
+ .map((entry) => entry.e_above_hull)
60
+ .filter((val) => val !== undefined && isFinite(val)),
61
+ label: `E above hull`,
62
+ }]);
45
63
  let pane_data = $derived.by(() => {
46
64
  if (!phase_stats)
47
65
  return [];
48
- const sections = [];
49
- // Determine system dimensionality from chemical_system string (count elements)
50
- const num_elements = phase_stats.chemical_system.split(`-`).length;
51
- const max_arity = Math.max(num_elements, phase_stats.quaternary > 0
52
- ? 4
53
- : phase_stats.ternary > 0
54
- ? 3
55
- : phase_stats.binary > 0
56
- ? 2
57
- : 1);
58
- const phase_items = [
66
+ const pct = (count) => phase_stats.total > 0 ? format_num(count / phase_stats.total, `.1~%`) : `0%`;
67
+ return [
59
68
  {
60
- label: `Total entries in ${phase_stats.chemical_system}`,
61
- value: format_num(phase_stats.total),
62
- key: `total-entries`,
69
+ title: ``,
70
+ items: [
71
+ {
72
+ label: `Total entries in ${phase_stats.chemical_system}`,
73
+ value: format_num(phase_stats.total),
74
+ key: `total-entries`,
75
+ },
76
+ // Only show phase types that exist or are within the max_arity
77
+ // used when computing stats (respects zeroed-out counts)
78
+ ...arity_types
79
+ .filter(([, field, arity]) => phase_stats[field] > 0 || phase_stats.max_arity >= arity)
80
+ .map(([display, field]) => ({
81
+ label: `${display} phases`,
82
+ value: `${format_num(phase_stats[field])} (${pct(phase_stats[field])})`,
83
+ key: `${field}-phases`,
84
+ })),
85
+ ],
86
+ },
87
+ {
88
+ title: `Stability`,
89
+ items: [
90
+ {
91
+ label: `Stable phases`,
92
+ value: `${format_num(phase_stats.stable)} (${pct(phase_stats.stable)})`,
93
+ key: `stable-phases`,
94
+ },
95
+ {
96
+ label: `Unstable phases`,
97
+ value: `${format_num(phase_stats.unstable)} (${pct(phase_stats.unstable)})`,
98
+ key: `unstable-phases`,
99
+ },
100
+ ],
101
+ },
102
+ {
103
+ title: `E<sub>form</sub> distribution`,
104
+ items: [{
105
+ label: `Min / avg / max (eV/atom)`,
106
+ value: [
107
+ phase_stats.energy_range.min,
108
+ phase_stats.energy_range.avg,
109
+ phase_stats.energy_range.max,
110
+ ]
111
+ .map((val) => format_num(val, `.3f`)).join(` / `),
112
+ key: `formation-energy`,
113
+ }],
114
+ },
115
+ {
116
+ title: `E<sub>above hull</sub> distribution`,
117
+ items: [{
118
+ label: `Max / avg (eV/atom)`,
119
+ value: [phase_stats.hull_distance.max, phase_stats.hull_distance.avg]
120
+ .map((val) => format_num(val, `.3f`)).join(` / `),
121
+ key: `hull-distance`,
122
+ }],
63
123
  },
64
124
  ];
65
- // Only show phase types that exist or are within expected dimensionality
66
- const arity_types = [
67
- [`Unary`, `unary`, 1],
68
- [`Binary`, `binary`, 2],
69
- [`Ternary`, `ternary`, 3],
70
- [`Quaternary`, `quaternary`, 4],
71
- ];
72
- for (const [display, field, min_arity] of arity_types) {
73
- const count = phase_stats[field];
74
- if (count > 0 || max_arity >= min_arity) {
75
- phase_items.push({
76
- label: `${display} phases`,
77
- value: `${format_num(count)} (${format_num(count / phase_stats.total, `.1~%`)})`,
78
- key: `${field}-phases`,
79
- });
125
+ });
126
+ // Subsystem coverage: count entries per element pair for the stats pane
127
+ let subsystem_coverage = $derived.by(() => {
128
+ if (!phase_stats)
129
+ return null;
130
+ const elements = phase_stats.chemical_system.split(`-`);
131
+ if (elements.length < 3 || elements.length > 10)
132
+ return null;
133
+ // Count entries containing each pair
134
+ const pair_counts = new SvelteMap();
135
+ for (const entry of all_entries) {
136
+ const active = Object.keys(entry.composition)
137
+ .filter((el) => (entry.composition[el] ?? 0) > 0);
138
+ // Count all pairs present in this entry
139
+ for (let idx_a = 0; idx_a < active.length; idx_a++) {
140
+ for (let idx_b = idx_a + 1; idx_b < active.length; idx_b++) {
141
+ const key = [active[idx_a], active[idx_b]].sort().join(`-`);
142
+ pair_counts.set(key, (pair_counts.get(key) ?? 0) + 1);
143
+ }
80
144
  }
81
145
  }
82
- sections.push({ title: ``, items: phase_items });
83
- // Stability
84
- const stable_item = {
85
- label: `Stable phases`,
86
- value: `${format_num(phase_stats.stable)} (${format_num(phase_stats.stable / phase_stats.total, `.1~%`)})`,
87
- key: `stable-phases`,
88
- };
89
- const unstable_item = {
90
- label: `Unstable phases`,
91
- value: `${format_num(phase_stats.unstable)} (${format_num(phase_stats.unstable / phase_stats.total, `.1~%`)})`,
92
- key: `unstable-phases`,
93
- };
94
- sections.push({ title: `Stability`, items: [stable_item, unstable_item] });
95
- // Energy Statistics
96
- const energy_item = {
97
- label: `Min / avg / max (eV/atom)`,
98
- value: `${format_num(phase_stats.energy_range.min, `.3f`)} / ${format_num(phase_stats.energy_range.avg, `.3f`)} / ${format_num(phase_stats.energy_range.max, `.3f`)}`,
99
- key: `formation-energy`,
100
- };
101
- sections.push({
102
- title: `E<sub>form</sub> distribution`,
103
- items: [energy_item],
104
- });
105
- // Hull Distance
106
- const hull_distance_item = {
107
- label: `Max / avg (eV/atom)`,
108
- value: `${format_num(phase_stats.hull_distance.max, `.3f`)} / ${format_num(phase_stats.hull_distance.avg, `.3f`)}`,
109
- key: `hull-distance`,
110
- };
111
- sections.push({
112
- title: `E<sub>above hull</sub> distribution`,
113
- items: [hull_distance_item],
114
- });
115
- return sections;
146
+ // Build pairs list sorted by element order in chemical_system
147
+ return elements.flatMap((el_a, idx_a) => elements.slice(idx_a + 1).map((el_b) => {
148
+ const key = [el_a, el_b].sort().join(`-`);
149
+ return { pair: key, count: pair_counts.get(key) ?? 0 };
150
+ }));
116
151
  });
117
- // Table view: visible entries and feature flags
118
- let visible_entries = $derived(all_entries.filter((entry) => entry.visible));
152
+ let subsystem_coverage_summary = $derived(subsystem_coverage?.map(({ pair, count }) => `${pair}: ${count}`).join(` | `) ??
153
+ null);
154
+ // Table view: visible entries filtered by min element count and formula
155
+ let visible_entries = $derived(all_entries.filter((entry) => {
156
+ if (!entry.visible)
157
+ return false;
158
+ if (min_n_elements > 1 && get_arity(entry) < min_n_elements)
159
+ return false;
160
+ if (active_formula_filter &&
161
+ composition_key(entry.composition) !== active_formula_filter)
162
+ return false;
163
+ return true;
164
+ }));
119
165
  let has_raw = $derived(visible_entries.some((entry) => entry.energy_per_atom !== undefined));
120
166
  let has_ids = $derived(visible_entries.some((entry) => entry.entry_id));
121
- let table_data = $derived(visible_entries.map((entry) => {
122
- const counts = Object.values(entry.composition);
123
- const n_atoms = counts.reduce((sum, count) => sum + count, 0);
124
- const row = {
125
- Formula: entry.reduced_formula ?? entry.name ??
126
- get_alphabetical_formula(entry.composition, true, ``),
127
- 'E<sub>hull</sub>': entry.e_above_hull ?? null,
128
- 'E<sub>form</sub>': entry.e_form_per_atom ?? entry.energy_per_atom ?? null,
129
- };
130
- if (has_raw)
131
- row[`E<sub>raw</sub>`] = entry.energy_per_atom;
132
- if (has_ids)
133
- row.ID = entry.entry_id;
134
- row[`N<sub>el</sub>`] = counts.filter((count) => count > 0).length;
135
- row[`N<sub>at</sub>`] = n_atoms;
136
- return row;
137
- }));
138
- let table_columns = $derived.by(() => {
139
- const cols = [
140
- { label: `Formula`, color_scale: null },
141
- {
142
- label: `E<sub>hull</sub>`,
143
- better: `lower`,
144
- color_scale: `interpolateRdYlGn`,
145
- format: `.4f`,
146
- description: `Energy above convex hull (eV/atom)`,
147
- },
148
- {
149
- label: `E<sub>form</sub>`,
150
- better: `lower`,
151
- color_scale: `interpolateBlues`,
152
- format: `.4f`,
153
- description: `Formation energy (eV/atom)`,
154
- },
155
- ];
156
- if (has_raw) {
157
- cols.push({
158
- label: `E<sub>raw</sub>`,
159
- color_scale: `interpolateCool`,
160
- format: `.4f`,
161
- description: `Raw energy per atom (eV/atom)`,
162
- });
167
+ let max_n_el = $derived(all_entries.reduce((max, entry) => Math.max(max, get_arity(entry)), 1));
168
+ // Sortable HTML cell with a hidden data-sort-value for HeatmapTable sorting
169
+ const sort_span = (sort_val, display, attrs = ``) => `<span data-sort-value="${sort_val}"${attrs ? ` ${attrs}` : ``}>${display}</span>`;
170
+ // Escape HTML special chars to prevent XSS when rendering user-supplied strings via {@html}
171
+ const escape_html = (str) => str
172
+ .replace(/&/g, `&amp;`)
173
+ .replace(/</g, `&lt;`)
174
+ .replace(/>/g, `&gt;`)
175
+ .replace(/"/g, `&quot;`)
176
+ .replace(/'/g, `&#39;`);
177
+ const unescape_html = (str, max_rounds = 5) => {
178
+ let decoded = str;
179
+ for (let round_idx = 0; round_idx < max_rounds; round_idx++) {
180
+ const next_decoded = decoded
181
+ .replace(/&amp;/g, `&`)
182
+ .replace(/&lt;/g, `<`)
183
+ .replace(/&gt;/g, `>`)
184
+ .replace(/&quot;/g, `"`)
185
+ .replace(/&#39;/g, `'`);
186
+ if (next_decoded === decoded)
187
+ break;
188
+ decoded = next_decoded;
163
189
  }
164
- if (has_ids) {
165
- cols.push({ label: `ID`, color_scale: null, description: `Entry identifier` });
190
+ return decoded;
191
+ };
192
+ // Convert legacy/html formula strings like Fe<sub>2</sub>O<sub>3</sub> back to plain
193
+ // stoichiometric input before parsing/reordering.
194
+ const normalize_formula_markup = (formula) => unescape_html(formula)
195
+ .replaceAll(/<sub>\s*([^<]+?)\s*<\/sub>/gi, `$1`)
196
+ .replaceAll(/<[^>]+>/g, ``)
197
+ .replaceAll(/\s+/g, ``);
198
+ const sanitize_href = (href) => {
199
+ const trimmed_href = href?.trim();
200
+ if (!trimmed_href)
201
+ return null;
202
+ const lower_href = trimmed_href.toLowerCase();
203
+ const blocked_schemes = [`javascript:`, `data:`, `vbscript:`];
204
+ if (blocked_schemes.some((scheme) => lower_href.startsWith(scheme)))
205
+ return null;
206
+ return trimmed_href;
207
+ };
208
+ // Serialize reduced composition to a stable string key for polymorph counting
209
+ const composition_key = (comp) => get_alphabetical_formula(get_reduced_formula(comp), true, ``);
210
+ // Count polymorphs per reduced formula across all entries
211
+ let polymorph_counts = $derived.by(() => {
212
+ const counts = new SvelteMap();
213
+ for (const entry of all_entries) {
214
+ const key = composition_key(entry.composition);
215
+ counts.set(key, (counts.get(key) ?? 0) + 1);
166
216
  }
167
- cols.push({
217
+ return counts;
218
+ });
219
+ let poly_formulas = $derived([...polymorph_counts.entries()]
220
+ .filter(([, count]) => count > 1)
221
+ .sort(([, count_a], [, count_b]) => count_b - count_a));
222
+ let has_polymorphs = $derived(poly_formulas.length > 0);
223
+ let active_formula_filter = $derived.by(() => {
224
+ if (!formula_filter || !has_polymorphs)
225
+ return ``;
226
+ return poly_formulas.some(([formula]) => formula === formula_filter)
227
+ ? formula_filter
228
+ : ``;
229
+ });
230
+ $effect(() => {
231
+ if (formula_filter && formula_filter !== active_formula_filter) {
232
+ formula_filter = ``;
233
+ }
234
+ });
235
+ // Build table rows and a WeakMap from row→entry for the click handler
236
+ let { table_data, entry_by_row } = $derived.by(() => {
237
+ const map = new WeakMap();
238
+ const rows = visible_entries.map((entry, idx) => {
239
+ const n_atoms = Object.values(entry.composition).reduce((sum, count) => sum + count, 0);
240
+ const on_hull = is_on_hull(entry);
241
+ const formula_source = entry.reduced_formula ?? entry.name ??
242
+ get_alphabetical_formula(entry.composition, true, ``);
243
+ const normalized_formula = normalize_formula_markup(formula_source);
244
+ const formatted_formula = get_electro_neg_formula(normalized_formula);
245
+ const formula_html = formatted_formula || escape_html(normalized_formula);
246
+ // Match by entry_id or common data fields (mat_id, structure_id)
247
+ // since entry_id may be wrapped in HTML (e.g. <a> tags)
248
+ const entry_data = entry.data;
249
+ const is_highlighted = !!(highlighted_entry_id && (entry.entry_id === highlighted_entry_id ||
250
+ entry_data?.mat_id === highlighted_entry_id ||
251
+ entry_data?.structure_id === highlighted_entry_id));
252
+ const row = {
253
+ '#': sort_span(idx + 1, `${idx + 1}`),
254
+ Formula: on_hull ? `<strong>${formula_html}</strong>` : formula_html,
255
+ 'E<sub>hull</sub>': entry.e_above_hull ?? null,
256
+ 'E<sub>form</sub>': entry.e_form_per_atom ?? entry.energy_per_atom ?? null,
257
+ };
258
+ if (has_raw)
259
+ row[`E<sub>raw</sub>`] = entry.energy_per_atom;
260
+ if (has_ids) {
261
+ const safe_href = sanitize_href(entry_href?.(entry));
262
+ const safe_id = entry.entry_id ? escape_html(entry.entry_id) : undefined;
263
+ row.ID = safe_href && safe_id
264
+ ? `<a href="${escape_html(safe_href)}" target="_blank" rel="noopener">${safe_id}</a>`
265
+ : safe_id;
266
+ }
267
+ if (has_polymorphs) {
268
+ const comp_key = composition_key(entry.composition);
269
+ const poly_count = polymorph_counts.get(comp_key) ?? 1;
270
+ row.Poly = poly_count;
271
+ }
272
+ row[`N<sub>el</sub>`] = get_arity(entry);
273
+ row[`N<sub>at</sub>`] = n_atoms;
274
+ // Highlight row for current material
275
+ if (is_highlighted) {
276
+ row.style =
277
+ `background: color-mix(in srgb, var(--hull-stable-color, #22c55e) 15%, transparent)`;
278
+ }
279
+ map.set(row, entry);
280
+ return row;
281
+ });
282
+ return { table_data: rows, entry_by_row: map };
283
+ });
284
+ function handle_row_click(_event, row) {
285
+ const entry = entry_by_row.get(row);
286
+ if (entry)
287
+ on_entry_click?.(entry);
288
+ }
289
+ let table_columns = $derived([
290
+ { label: `#`, color_scale: null, description: `Row number` },
291
+ { label: `Formula`, color_scale: null },
292
+ {
293
+ label: `E<sub>hull</sub>`,
294
+ better: `lower`,
295
+ color_scale: `interpolateRdYlGn`,
296
+ format: `.4f`,
297
+ description: `Energy above convex hull (eV/atom)`,
298
+ },
299
+ {
300
+ label: `E<sub>form</sub>`,
301
+ better: `lower`,
302
+ color_scale: `interpolateBlues`,
303
+ format: `.4f`,
304
+ description: `Formation energy (eV/atom)`,
305
+ },
306
+ ...(has_raw
307
+ ? [{
308
+ label: `E<sub>raw</sub>`,
309
+ color_scale: `interpolateCool`,
310
+ format: `.4f`,
311
+ description: `Raw energy per atom (eV/atom)`,
312
+ }]
313
+ : []),
314
+ ...(has_ids
315
+ ? [{ label: `ID`, color_scale: null, description: `Entry identifier` }]
316
+ : []),
317
+ ...(has_polymorphs
318
+ ? [{
319
+ label: `Poly`,
320
+ color_scale: null,
321
+ description: `Number of polymorphs (same reduced formula)`,
322
+ }]
323
+ : []),
324
+ {
168
325
  label: `N<sub>el</sub>`,
169
326
  color_scale: null,
170
327
  description: `Number of elements`,
171
- }, {
328
+ },
329
+ {
172
330
  label: `N<sub>at</sub>`,
173
331
  color_scale: null,
174
332
  format: `d`,
175
333
  description: `Number of atoms in unit cell`,
176
- });
177
- return cols;
178
- });
334
+ },
335
+ ]);
336
+ const html_to_text = (val) => {
337
+ if (val == null)
338
+ return ``;
339
+ if (typeof val !== `string`)
340
+ return String(val);
341
+ const temp_el = document.createElement(`div`);
342
+ temp_el.innerHTML = val;
343
+ return temp_el.textContent?.trim() ?? ``;
344
+ };
345
+ const csv_escape = (val) => /[",\n]/.test(val) ? `"${val.replaceAll(`"`, `""`)}"` : val;
346
+ const get_export_filename = (format) => {
347
+ const system = (phase_stats?.chemical_system ?? `convex-hull-stats`)
348
+ .toLowerCase()
349
+ .replaceAll(/\s+/g, `-`);
350
+ return `${system}.${format}`;
351
+ };
352
+ const build_export_rows = () => {
353
+ const column_labels = table_columns.map((col) => col.label);
354
+ return table_data.map((row) => Object.fromEntries(column_labels.map((label) => [html_to_text(label), html_to_text(row[label])])));
355
+ };
356
+ const download_file = (content, filename, mime_type) => {
357
+ const blob = new Blob([content], { type: mime_type });
358
+ const object_url = URL.createObjectURL(blob);
359
+ const link_el = document.createElement(`a`);
360
+ link_el.href = object_url;
361
+ link_el.download = filename;
362
+ document.body.append(link_el);
363
+ link_el.click();
364
+ link_el.remove();
365
+ URL.revokeObjectURL(object_url);
366
+ };
367
+ function export_table(format) {
368
+ const rows = build_export_rows();
369
+ if (format === `json`) {
370
+ download_file(JSON.stringify(rows, null, 2), get_export_filename(`json`), `application/json;charset=utf-8`);
371
+ return;
372
+ }
373
+ const headers = rows.length > 0 ? Object.keys(rows[0]) : [];
374
+ const csv_lines = [
375
+ headers.map(csv_escape).join(`,`),
376
+ ...rows.map((row) => headers.map((header) => csv_escape(row[header] ?? ``)).join(`,`)),
377
+ ];
378
+ download_file(csv_lines.join(`\n`), get_export_filename(`csv`), `text/csv;charset=utf-8`);
379
+ }
179
380
  </script>
180
381
 
181
- <div {...rest} class="convex-hull-stats {rest.class ?? ``}">
182
- <div class="view-toggle">
183
- <button class:active={view_mode === `stats`} onclick={() => view_mode = `stats`}>
184
- Stats
185
- </button>
186
- <button class:active={view_mode === `table`} onclick={() => view_mode = `table`}>
187
- Table
188
- </button>
189
- </div>
190
- {#if view_mode === `stats`}
191
- {#each pane_data as section, sec_idx (sec_idx)}
192
- {#if sec_idx > 0}<hr />{/if}
193
- <section>
194
- {#if section.title}
195
- <h5>{@html section.title}</h5>
196
- {/if}
197
- {#each section.items as item (item.key ?? item.label)}
198
- {@const { key, label, value } = item}
199
- <div
200
- class="clickable stat-item"
201
- data-testid={key ? `pd-${key}` : undefined}
202
- title="Click to copy: {label}: {value}"
203
- onclick={() => copy_to_clipboard(item.label, String(item.value), key ?? item.label)}
204
- role="button"
205
- tabindex="0"
206
- onkeydown={(event) => {
207
- if (event.key === `Enter` || event.key === ` `) {
208
- event.preventDefault()
209
- copy_to_clipboard(item.label, String(item.value), key ?? item.label)
210
- }
382
+ {#snippet stats_panel()}
383
+ {#each pane_data as section, sec_idx (sec_idx)}
384
+ {#if sec_idx > 0}<hr />{/if}
385
+ <section>
386
+ {#if section.title}
387
+ <h5>{@html section.title}</h5>
388
+ {/if}
389
+ {#each section.items as item (item.key ?? item.label)}
390
+ {@const { key, label, value } = item}
391
+ <div
392
+ class="clickable stat-item"
393
+ data-testid={key ? `pd-${key}` : undefined}
394
+ title="Click to copy: {label}: {value}"
395
+ onclick={() => copy_to_clipboard(item.label, String(item.value), key ?? item.label)}
396
+ role="button"
397
+ tabindex="0"
398
+ onkeydown={(event) =>
399
+ handle_copy_keydown(
400
+ event,
401
+ item.label,
402
+ String(item.value),
403
+ key ?? item.label,
404
+ )}
405
+ >
406
+ <span>{@html label}:</span>
407
+ <span>{@html value}</span>
408
+ {#if key && copied_items.has(key)}
409
+ <Icon
410
+ icon="Check"
411
+ style="color: var(--success-color, #10b981); width: 12px; height: 12px"
412
+ class="copy-checkmark"
413
+ />
414
+ {/if}
415
+ </div>
416
+ {/each}
417
+
418
+ {#if sec_idx === 0 && subsystem_coverage}
419
+ <div
420
+ class="clickable stat-item subsystem-coverage-row"
421
+ data-testid="pd-binary-subsystem-coverage"
422
+ title="Click to copy: Binary subsystem coverage: {subsystem_coverage_summary ?? ``}"
423
+ onclick={() =>
424
+ copy_to_clipboard(
425
+ `Binary subsystem coverage`,
426
+ subsystem_coverage_summary ?? ``,
427
+ `binary-subsystem-coverage`,
428
+ )}
429
+ role="button"
430
+ tabindex="0"
431
+ onkeydown={(event) =>
432
+ handle_copy_keydown(
433
+ event,
434
+ `Binary subsystem coverage`,
435
+ subsystem_coverage_summary ?? ``,
436
+ `binary-subsystem-coverage`,
437
+ )}
438
+ >
439
+ <span class="subsystem-label"
440
+ >Binary subsystem coverage ({subsystem_coverage.length} pairs)</span>
441
+ <span class="subsystem-chips">
442
+ {#each subsystem_coverage as { pair, count } (pair)}
443
+ <span class="subsystem-chip" class:has-entries={count > 0}>
444
+ <span class="pair">{pair}</span>
445
+ <span class="count">{count}</span>
446
+ </span>
447
+ {/each}
448
+ </span>
449
+ {#if copied_items.has(`binary-subsystem-coverage`)}
450
+ <Icon
451
+ icon="Check"
452
+ style="color: var(--success-color, #10b981); width: 12px; height: 12px"
453
+ class="copy-checkmark"
454
+ />
455
+ {/if}
456
+ </div>
457
+ {/if}
458
+
459
+ {#if section.title === `E<sub>form</sub> distribution` &&
460
+ e_form_data[0].y.length > 0}
461
+ <Histogram
462
+ {...histogram_props}
463
+ series={e_form_data}
464
+ x_axis={{ label: ``, format: `.2f` }}
465
+ bar={{ color: `steelblue`, opacity: 0.7 }}
466
+ />
467
+ {/if}
468
+
469
+ {#if section.title === `E<sub>above hull</sub> distribution` &&
470
+ hull_distance_data[0].y.length > 0}
471
+ <Histogram
472
+ {...histogram_props}
473
+ series={hull_distance_data}
474
+ x_axis={{ label: ``, format: `.2f`, range: [0, null] }}
475
+ bar={{ color: `coral`, opacity: 0.7 }}
476
+ />
477
+ {/if}
478
+ </section>
479
+ {/each}
480
+ {/snippet}
481
+
482
+ {#snippet table_panel()}
483
+ <div class="table-filters">
484
+ {#if max_n_el > 2}
485
+ <label>
486
+ Min N<sub>el</sub>:
487
+ <select bind:value={min_n_elements}>
488
+ {#each Array.from({ length: max_n_el }, (_, idx) => idx + 1) as nel (nel)}
489
+ <option value={nel}>{nel}{nel === 1 ? ` (all)` : ``}</option>
490
+ {/each}
491
+ </select>
492
+ </label>
493
+ {/if}
494
+ {#if has_polymorphs}
495
+ <label>
496
+ Polymorphs:
497
+ <select bind:value={formula_filter}>
498
+ <option value="">all</option>
499
+ {#each poly_formulas as [formula, count] (formula)}
500
+ <option value={formula}>{formula} ({count})</option>
501
+ {/each}
502
+ </select>
503
+ </label>
504
+ {/if}
505
+ <span class="filter-count">{visible_entries.length} entries</span>
506
+ <span class="filter-spacer"></span>
507
+ <div class="export-actions">
508
+ <button
509
+ class="icon-btn"
510
+ class:active={show_export_dropdown}
511
+ title="Export"
512
+ onclick={() => show_export_dropdown = !show_export_dropdown}
513
+ >
514
+ <Icon icon="Export" style="width: 14px" />
515
+ </button>
516
+ {#if show_export_dropdown}
517
+ <div class="export-dropdown">
518
+ <button
519
+ class="dropdown-option"
520
+ onclick={() => {
521
+ export_table(`csv`)
522
+ show_export_dropdown = false
211
523
  }}
212
524
  >
213
- <span>{@html label}:</span>
214
- <span>{@html value}</span>
215
- {#if key && copied_items.has(key)}
216
- <Icon
217
- icon="Check"
218
- style="color: var(--success-color, #10b981); width: 12px; height: 12px"
219
- class="copy-checkmark"
220
- />
221
- {/if}
222
- </div>
223
- {/each}
224
-
225
- {#if section.title === `E<sub>form</sub> distribution` &&
226
- e_form_data[0].y.length > 0}
227
- <Histogram
228
- series={e_form_data}
229
- bins={50}
230
- x_axis={{ label: ``, format: `.2f` }}
231
- y_axis={{ label: ``, ticks: 3 }}
232
- show_legend={false}
233
- show_controls={false}
234
- padding={{ t: 5, b: 22, l: 35, r: 5 }}
235
- style="height: 100px; --histogram-min-height: 100px"
236
- bar={{ color: `steelblue`, opacity: 0.7 }}
237
- />
238
- {/if}
525
+ <Icon icon="Download" style="width: 12px" /> CSV
526
+ </button>
527
+ <button
528
+ class="dropdown-option"
529
+ onclick={() => {
530
+ export_table(`json`)
531
+ show_export_dropdown = false
532
+ }}
533
+ >
534
+ <Icon icon="Download" style="width: 12px" /> JSON
535
+ </button>
536
+ </div>
537
+ {/if}
538
+ </div>
539
+ </div>
540
+ <HeatmapTable
541
+ data={table_data}
542
+ columns={table_columns}
543
+ initial_sort={{ column: `E<sub>hull</sub>`, direction: `asc` }}
544
+ scroll_style={layout === `side-by-side`
545
+ ? `flex: 1 1 0; max-width: 100%; overflow: auto`
546
+ : `max-height: var(--hull-stats-max-height, 500px)`}
547
+ style="width: 100%"
548
+ root_style={layout === `side-by-side`
549
+ ? `flex: 1 1 0; min-height: 0; margin-inline: 0`
550
+ : undefined}
551
+ onrowclick={on_entry_click ? handle_row_click : undefined}
552
+ export_data={false}
553
+ />
554
+ {/snippet}
239
555
 
240
- {#if section.title === `E<sub>above hull</sub> distribution` &&
241
- hull_distance_data[0].y.length > 0}
242
- <Histogram
243
- series={hull_distance_data}
244
- bins={50}
245
- x_axis={{ label: ``, format: `.2f`, range: [0, null] }}
246
- y_axis={{ label: ``, ticks: 3 }}
247
- show_legend={false}
248
- show_controls={false}
249
- padding={{ t: 5, b: 22, l: 35, r: 5 }}
250
- style="height: 100px; --histogram-min-height: 100px"
251
- bar={{ color: `coral`, opacity: 0.7 }}
252
- />
253
- {/if}
254
- </section>
255
- {/each}
256
- {:else}
257
- <HeatmapTable
258
- data={table_data}
259
- columns={table_columns}
260
- initial_sort={{ column: `E<sub>hull</sub>`, direction: `asc` }}
261
- scroll_style="max-height: var(--hull-stats-max-height, 500px)"
262
- style="width: 100%"
263
- />
264
- {/if}
265
- </div>
556
+ {#if layout === `side-by-side`}
557
+ <div {...rest} class="convex-hull-stats side-by-side {rest.class ?? ``}">
558
+ <div class="stats-pane">
559
+ {@render stats_panel()}
560
+ </div>
561
+ <div class="table-pane">
562
+ {@render table_panel()}
563
+ </div>
564
+ </div>
565
+ {:else}
566
+ <div {...rest} class="convex-hull-stats {rest.class ?? ``}">
567
+ <div class="view-toggle">
568
+ <button class:active={view_mode === `stats`} onclick={() => view_mode = `stats`}>
569
+ Stats
570
+ </button>
571
+ <button class:active={view_mode === `table`} onclick={() => view_mode = `table`}>
572
+ Table
573
+ </button>
574
+ </div>
575
+ {#if view_mode === `stats`}
576
+ {@render stats_panel()}
577
+ {:else}
578
+ {@render table_panel()}
579
+ {/if}
580
+ </div>
581
+ {/if}
266
582
 
267
583
  <style>
268
584
  .convex-hull-stats {
269
585
  background: var(--hull-stats-bg, var(--hull-bg));
270
586
  border-radius: var(--hull-border-radius, var(--border-radius, 3pt));
271
- padding: 1em;
587
+ padding: var(--hull-stats-padding, 1em);
588
+ }
589
+ .convex-hull-stats.side-by-side {
590
+ display: flex;
591
+ gap: var(--hull-stats-gap, 1.5em);
592
+ align-items: stretch;
593
+ width: fit-content;
594
+ max-width: 100%;
595
+ margin-inline: auto;
596
+ }
597
+ .stats-pane {
598
+ flex: 0 0 auto;
599
+ width: fit-content;
600
+ min-width: var(--hull-stats-pane-min-width, 200px);
601
+ max-width: var(--hull-stats-pane-max-width, 320px);
602
+ }
603
+ .table-pane {
604
+ flex: 1 1 0;
605
+ max-width: 100%;
606
+ min-width: 0;
607
+ overflow: auto;
608
+ display: flex;
609
+ flex-direction: column;
610
+ }
611
+ .convex-hull-stats :global(tbody tr[onclick]) {
612
+ cursor: pointer;
272
613
  }
273
614
  section div {
274
615
  display: flex;
@@ -301,7 +642,7 @@ let table_columns = $derived.by(() => {
301
642
  }
302
643
  }
303
644
  .stat-item span:first-child {
304
- color: var(--text-color-muted, #666);
645
+ color: var(--text-color-muted, light-dark(#666, #bbb));
305
646
  }
306
647
  section h5 {
307
648
  margin: 0 0 6px 0;
@@ -312,8 +653,9 @@ let table_columns = $derived.by(() => {
312
653
  }
313
654
  .view-toggle button {
314
655
  flex: 1;
315
- padding: 4pt 8pt;
316
- border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
656
+ padding: 2pt 8pt;
657
+ border: 1px solid
658
+ var(--hull-stats-border-color, color-mix(in srgb, currentColor 20%, transparent));
317
659
  background: transparent;
318
660
  color: inherit;
319
661
  cursor: pointer;
@@ -327,7 +669,140 @@ let table_columns = $derived.by(() => {
327
669
  border-left: none;
328
670
  }
329
671
  .view-toggle button.active {
330
- background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.15));
672
+ background: var(
673
+ --hull-stats-toggle-active-bg,
674
+ light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.15))
675
+ );
331
676
  font-weight: 500;
332
677
  }
678
+ .table-filters {
679
+ display: flex;
680
+ align-items: center;
681
+ flex-wrap: wrap;
682
+ gap: 0.75em;
683
+ margin-bottom: 6pt;
684
+ font-size: 0.85em;
685
+ label {
686
+ display: flex;
687
+ align-items: center;
688
+ gap: 0.4em;
689
+ sub {
690
+ margin-left: -0.2em;
691
+ font-size: 0.72em;
692
+ line-height: 0;
693
+ vertical-align: baseline;
694
+ position: relative;
695
+ top: 0.33em;
696
+ }
697
+ }
698
+ select {
699
+ padding: 2pt 4pt;
700
+ border: 1px solid
701
+ var(--hull-stats-border-color, color-mix(in srgb, currentColor 20%, transparent));
702
+ border-radius: 3pt;
703
+ background: transparent;
704
+ color: inherit;
705
+ font-size: inherit;
706
+ }
707
+ }
708
+ .filter-spacer {
709
+ flex: 1 1 auto;
710
+ }
711
+ .export-actions {
712
+ position: relative;
713
+ .icon-btn {
714
+ padding: 2pt 6pt;
715
+ border: 1px solid
716
+ var(--hull-stats-border-color, color-mix(in srgb, currentColor 20%, transparent));
717
+ border-radius: 3pt;
718
+ background: transparent;
719
+ color: inherit;
720
+ cursor: pointer;
721
+ display: inline-flex;
722
+ align-items: center;
723
+ justify-content: center;
724
+ }
725
+ .icon-btn:hover {
726
+ background: color-mix(in srgb, currentColor 8%, transparent);
727
+ }
728
+ .icon-btn.active {
729
+ background: color-mix(in srgb, currentColor 12%, transparent);
730
+ }
731
+ }
732
+ .export-dropdown {
733
+ position: absolute;
734
+ right: 0;
735
+ top: calc(100% + 4px);
736
+ display: flex;
737
+ flex-direction: column;
738
+ min-width: 88px;
739
+ padding: 3pt;
740
+ border: 1px solid
741
+ var(--hull-stats-border-color, color-mix(in srgb, currentColor 20%, transparent));
742
+ border-radius: 4pt;
743
+ background: var(--page-bg, Canvas);
744
+ z-index: 4;
745
+ box-shadow: 0 2px 8px color-mix(in srgb, black 20%, transparent);
746
+ .dropdown-option {
747
+ display: inline-flex;
748
+ align-items: center;
749
+ gap: 5px;
750
+ border: none;
751
+ border-radius: 3pt;
752
+ background: transparent;
753
+ color: inherit;
754
+ cursor: pointer;
755
+ text-align: left;
756
+ padding: 3pt 6pt;
757
+ }
758
+ .dropdown-option:hover {
759
+ background: color-mix(in srgb, currentColor 8%, transparent);
760
+ }
761
+ }
762
+ .table-pane :global(.control-buttons) {
763
+ display: none;
764
+ margin: 0;
765
+ }
766
+ .filter-count {
767
+ color: var(--text-color-muted, light-dark(#666, #bbb));
768
+ font-size: 0.9em;
769
+ }
770
+ .subsystem-coverage-row {
771
+ flex-wrap: wrap;
772
+ gap: 4pt 1em;
773
+ justify-content: flex-start;
774
+ .subsystem-label {
775
+ color: var(--text-color-muted, light-dark(#666, #bbb));
776
+ font-size: 0.9em;
777
+ }
778
+ .subsystem-chips {
779
+ display: flex;
780
+ flex-wrap: wrap;
781
+ gap: 4pt;
782
+ }
783
+ }
784
+ .subsystem-chip {
785
+ display: inline-flex;
786
+ align-items: center;
787
+ gap: 0;
788
+ padding: 1pt 5pt;
789
+ border-radius: 3pt;
790
+ font-size: 0.78em;
791
+ line-height: 1.2;
792
+ background: color-mix(in srgb, currentColor 5%, transparent);
793
+ color: var(--text-color-muted, light-dark(#666, #bbb));
794
+ .pair {
795
+ font-weight: 500;
796
+ }
797
+ .count {
798
+ margin-left: 3pt;
799
+ font-size: 0.9em;
800
+ font-weight: 600;
801
+ color: color-mix(in srgb, currentColor 70%, transparent);
802
+ }
803
+ }
804
+ .subsystem-chip.has-entries {
805
+ background: color-mix(in srgb, var(--hull-stable-color, #22c55e) 15%, transparent);
806
+ color: inherit;
807
+ }
333
808
  </style>