matterviz 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/dist/EmptyState.svelte +10 -2
  2. package/dist/FilePicker.svelte +123 -82
  3. package/dist/Icon.svelte +18 -12
  4. package/dist/MillerIndexInput.svelte +27 -21
  5. package/dist/api/optimade.js +6 -6
  6. package/dist/app.css +216 -207
  7. package/dist/brillouin/BrillouinZone.svelte +292 -149
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  9. package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +69 -42
  11. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte +275 -163
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  15. package/dist/brillouin/BrillouinZoneTooltip.svelte +17 -7
  16. package/dist/brillouin/compute.js +11 -6
  17. package/dist/chempot-diagram/ChemPotDiagram.svelte +162 -27
  18. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +451 -281
  19. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2148 -1642
  20. package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -5
  21. package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
  22. package/dist/chempot-diagram/async-compute.svelte.js +77 -0
  23. package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
  24. package/dist/chempot-diagram/chempot-worker.js +11 -0
  25. package/dist/chempot-diagram/color.js +1 -2
  26. package/dist/chempot-diagram/compute.d.ts +10 -0
  27. package/dist/chempot-diagram/compute.js +250 -88
  28. package/dist/chempot-diagram/index.d.ts +2 -1
  29. package/dist/chempot-diagram/index.js +2 -1
  30. package/dist/chempot-diagram/temperature.js +8 -9
  31. package/dist/chempot-diagram/types.d.ts +3 -0
  32. package/dist/chempot-diagram/types.js +1 -0
  33. package/dist/colors/index.d.ts +1 -1
  34. package/dist/colors/index.js +5 -3
  35. package/dist/composition/BarChart.svelte +128 -55
  36. package/dist/composition/BubbleChart.svelte +102 -49
  37. package/dist/composition/Composition.svelte +100 -79
  38. package/dist/composition/Formula.svelte +108 -62
  39. package/dist/composition/FormulaFilter.svelte +665 -537
  40. package/dist/composition/PieChart.svelte +183 -108
  41. package/dist/composition/format.d.ts +5 -0
  42. package/dist/composition/format.js +20 -3
  43. package/dist/composition/parse.js +14 -9
  44. package/dist/convex-hull/ConvexHull.svelte +93 -40
  45. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
  46. package/dist/convex-hull/ConvexHull2D.svelte +549 -360
  47. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  48. package/dist/convex-hull/ConvexHull3D.svelte +1296 -827
  49. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  50. package/dist/convex-hull/ConvexHull4D.svelte +1004 -688
  51. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  52. package/dist/convex-hull/ConvexHullControls.svelte +115 -28
  53. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +1 -1
  54. package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
  55. package/dist/convex-hull/ConvexHullStats.svelte +425 -328
  56. package/dist/convex-hull/ConvexHullTooltip.svelte +40 -16
  57. package/dist/convex-hull/GasPressureControls.svelte +104 -61
  58. package/dist/convex-hull/StructurePopup.svelte +25 -4
  59. package/dist/convex-hull/TemperatureSlider.svelte +45 -25
  60. package/dist/convex-hull/barycentric-coords.js +13 -7
  61. package/dist/convex-hull/demo-temperature.js +8 -4
  62. package/dist/convex-hull/gas-thermodynamics.js +17 -12
  63. package/dist/convex-hull/helpers.d.ts +9 -0
  64. package/dist/convex-hull/helpers.js +77 -34
  65. package/dist/convex-hull/thermodynamics.js +61 -56
  66. package/dist/convex-hull/types.d.ts +9 -14
  67. package/dist/convex-hull/types.js +0 -17
  68. package/dist/coordination/CoordinationBarPlot.svelte +227 -154
  69. package/dist/element/BohrAtom.svelte +55 -12
  70. package/dist/element/ElementHeading.svelte +7 -2
  71. package/dist/element/ElementPhoto.svelte +15 -9
  72. package/dist/element/ElementStats.svelte +10 -4
  73. package/dist/element/ElementTile.svelte +137 -73
  74. package/dist/element/Nucleus.svelte +39 -11
  75. package/dist/feedback/ClickFeedback.svelte +16 -5
  76. package/dist/feedback/DragOverlay.svelte +10 -2
  77. package/dist/feedback/Spinner.svelte +4 -2
  78. package/dist/feedback/StatusMessage.svelte +8 -2
  79. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  80. package/dist/fermi-surface/FermiSurface.svelte +328 -187
  81. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  82. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  83. package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
  84. package/dist/fermi-surface/FermiSurfaceScene.svelte +535 -342
  85. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  86. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  87. package/dist/fermi-surface/compute.js +16 -20
  88. package/dist/fermi-surface/parse.js +24 -14
  89. package/dist/fermi-surface/symmetry.js +2 -7
  90. package/dist/fermi-surface/types.d.ts +3 -5
  91. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1019 -765
  92. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +1 -1
  93. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +76 -22
  94. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +2 -3
  95. package/dist/icons.js +47 -0
  96. package/dist/index.d.ts +2 -1
  97. package/dist/index.js +2 -1
  98. package/dist/io/decompress.js +1 -1
  99. package/dist/io/export.d.ts +3 -0
  100. package/dist/io/export.js +129 -143
  101. package/dist/io/is-binary.js +2 -3
  102. package/dist/io/url-drop.js +1 -2
  103. package/dist/isosurface/Isosurface.svelte +202 -148
  104. package/dist/isosurface/IsosurfaceControls.svelte +46 -28
  105. package/dist/isosurface/parse.js +34 -29
  106. package/dist/isosurface/slice.js +5 -10
  107. package/dist/isosurface/types.d.ts +2 -1
  108. package/dist/isosurface/types.js +61 -12
  109. package/dist/labels.js +11 -8
  110. package/dist/layout/FullscreenToggle.svelte +11 -2
  111. package/dist/layout/InfoCard.svelte +38 -6
  112. package/dist/layout/InfoTag.svelte +63 -32
  113. package/dist/layout/PropertyFilter.svelte +82 -37
  114. package/dist/layout/SettingsSection.svelte +85 -55
  115. package/dist/layout/SubpageGrid.svelte +10 -2
  116. package/dist/layout/json-tree/JsonNode.svelte +183 -138
  117. package/dist/layout/json-tree/JsonTree.svelte +499 -413
  118. package/dist/layout/json-tree/JsonValue.svelte +127 -99
  119. package/dist/layout/json-tree/utils.js +4 -2
  120. package/dist/marching-cubes.js +25 -2
  121. package/dist/math.d.ts +13 -17
  122. package/dist/math.js +133 -67
  123. package/dist/overlays/ContextMenu.svelte +65 -40
  124. package/dist/overlays/DraggablePane.svelte +211 -139
  125. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  126. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  127. package/dist/periodic-table/PropertySelect.svelte +25 -7
  128. package/dist/periodic-table/TableInset.svelte +8 -3
  129. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +446 -309
  130. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
  131. package/dist/phase-diagram/PhaseDiagramControls.svelte +102 -43
  132. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
  133. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +63 -40
  134. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +71 -28
  135. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +1 -1
  136. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +158 -101
  137. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  138. package/dist/phase-diagram/build-diagram.js +9 -9
  139. package/dist/phase-diagram/colors.js +1 -3
  140. package/dist/phase-diagram/parse.js +10 -9
  141. package/dist/phase-diagram/svg-to-diagram.js +53 -49
  142. package/dist/phase-diagram/utils.d.ts +1 -0
  143. package/dist/phase-diagram/utils.js +80 -25
  144. package/dist/plot/AxisLabel.svelte +28 -3
  145. package/dist/plot/BarPlot.svelte +1182 -734
  146. package/dist/plot/BarPlot.svelte.d.ts +2 -2
  147. package/dist/plot/BarPlotControls.svelte +31 -5
  148. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  149. package/dist/plot/ColorBar.svelte +479 -329
  150. package/dist/plot/ColorScaleSelect.svelte +27 -6
  151. package/dist/plot/ElementScatter.svelte +36 -15
  152. package/dist/plot/FillArea.svelte +152 -95
  153. package/dist/plot/Histogram.svelte +934 -571
  154. package/dist/plot/Histogram.svelte.d.ts +1 -1
  155. package/dist/plot/HistogramControls.svelte +53 -9
  156. package/dist/plot/HistogramControls.svelte.d.ts +1 -1
  157. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  158. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  159. package/dist/plot/Line.svelte +63 -28
  160. package/dist/plot/PlotControls.svelte +157 -114
  161. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  162. package/dist/plot/PlotLegend.svelte +174 -91
  163. package/dist/plot/PlotTooltip.svelte +45 -6
  164. package/dist/plot/PortalSelect.svelte +175 -147
  165. package/dist/plot/ReferenceLine.svelte +76 -22
  166. package/dist/plot/ReferenceLine3D.svelte +132 -107
  167. package/dist/plot/ReferencePlane.svelte +146 -121
  168. package/dist/plot/ScatterPlot.svelte +1681 -1091
  169. package/dist/plot/ScatterPlot.svelte.d.ts +2 -2
  170. package/dist/plot/ScatterPlot3D.svelte +256 -131
  171. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  172. package/dist/plot/ScatterPlot3DControls.svelte +113 -63
  173. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  174. package/dist/plot/ScatterPlot3DScene.svelte +608 -403
  175. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  176. package/dist/plot/ScatterPlotControls.svelte +65 -25
  177. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  178. package/dist/plot/ScatterPoint.svelte +98 -26
  179. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  180. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  181. package/dist/plot/Surface3D.svelte +159 -108
  182. package/dist/plot/ZeroLines.svelte +55 -3
  183. package/dist/plot/ZoomRect.svelte +4 -2
  184. package/dist/plot/axis-utils.js +1 -3
  185. package/dist/plot/data-cleaning.js +12 -28
  186. package/dist/plot/data-transform.js +2 -1
  187. package/dist/plot/fill-utils.js +2 -0
  188. package/dist/plot/layout.d.ts +4 -1
  189. package/dist/plot/layout.js +33 -14
  190. package/dist/plot/reference-line.d.ts +2 -2
  191. package/dist/plot/reference-line.js +7 -5
  192. package/dist/plot/scales.js +24 -36
  193. package/dist/plot/types.d.ts +11 -23
  194. package/dist/plot/types.js +6 -11
  195. package/dist/plot/utils/label-placement.d.ts +32 -15
  196. package/dist/plot/utils/label-placement.js +227 -66
  197. package/dist/plot/utils/series-visibility.js +2 -3
  198. package/dist/rdf/RdfPlot.svelte +143 -91
  199. package/dist/rdf/calc-rdf.js +4 -5
  200. package/dist/sanitize.d.ts +4 -0
  201. package/dist/sanitize.js +107 -0
  202. package/dist/settings.d.ts +18 -6
  203. package/dist/settings.js +46 -16
  204. package/dist/spectral/Bands.svelte +632 -453
  205. package/dist/spectral/BandsAndDos.svelte +90 -49
  206. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  207. package/dist/spectral/Dos.svelte +389 -258
  208. package/dist/spectral/helpers.js +55 -43
  209. package/dist/state.svelte.d.ts +1 -1
  210. package/dist/state.svelte.js +3 -2
  211. package/dist/structure/Arrow.svelte +59 -20
  212. package/dist/structure/AtomLegend.svelte +215 -134
  213. package/dist/structure/Bond.svelte +73 -47
  214. package/dist/structure/CanvasTooltip.svelte +10 -2
  215. package/dist/structure/CellSelect.svelte +72 -45
  216. package/dist/structure/Cylinder.svelte +33 -17
  217. package/dist/structure/Lattice.svelte +88 -33
  218. package/dist/structure/Structure.svelte +1063 -797
  219. package/dist/structure/Structure.svelte.d.ts +1 -1
  220. package/dist/structure/StructureControls.svelte +349 -118
  221. package/dist/structure/StructureExportPane.svelte +124 -89
  222. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  223. package/dist/structure/StructureInfoPane.svelte +304 -237
  224. package/dist/structure/StructureScene.svelte +879 -443
  225. package/dist/structure/StructureScene.svelte.d.ts +15 -7
  226. package/dist/structure/atom-properties.js +8 -8
  227. package/dist/structure/bonding.js +6 -7
  228. package/dist/structure/export.js +14 -29
  229. package/dist/structure/ferrox-wasm.js +1 -1
  230. package/dist/structure/index.d.ts +13 -3
  231. package/dist/structure/index.js +83 -23
  232. package/dist/structure/measure.d.ts +2 -2
  233. package/dist/structure/measure.js +4 -44
  234. package/dist/structure/parse.js +113 -141
  235. package/dist/structure/partial-occupancy.js +7 -10
  236. package/dist/structure/pbc.d.ts +1 -0
  237. package/dist/structure/pbc.js +16 -6
  238. package/dist/structure/supercell.d.ts +2 -2
  239. package/dist/structure/supercell.js +12 -22
  240. package/dist/structure/validation.js +1 -2
  241. package/dist/symmetry/SymmetryStats.svelte +84 -41
  242. package/dist/symmetry/WyckoffTable.svelte +26 -6
  243. package/dist/symmetry/cell-transform.js +5 -3
  244. package/dist/symmetry/index.js +8 -7
  245. package/dist/symmetry/spacegroups.js +148 -148
  246. package/dist/table/HeatmapTable.svelte +790 -554
  247. package/dist/table/HeatmapTable.svelte.d.ts +1 -1
  248. package/dist/table/ToggleMenu.svelte +125 -92
  249. package/dist/table/index.js +2 -4
  250. package/dist/theme/ThemeControl.svelte +21 -12
  251. package/dist/time.js +4 -1
  252. package/dist/tooltip/TooltipContent.svelte +33 -8
  253. package/dist/trajectory/Trajectory.svelte +758 -558
  254. package/dist/trajectory/TrajectoryError.svelte +14 -3
  255. package/dist/trajectory/TrajectoryExportPane.svelte +137 -83
  256. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  257. package/dist/trajectory/extract.js +10 -26
  258. package/dist/trajectory/format-detect.js +5 -5
  259. package/dist/trajectory/frame-reader.d.ts +1 -1
  260. package/dist/trajectory/frame-reader.js +5 -12
  261. package/dist/trajectory/helpers.d.ts +0 -1
  262. package/dist/trajectory/helpers.js +2 -17
  263. package/dist/trajectory/index.js +14 -12
  264. package/dist/trajectory/parse/ase.js +5 -4
  265. package/dist/trajectory/parse/hdf5.js +26 -18
  266. package/dist/trajectory/parse/index.js +13 -18
  267. package/dist/trajectory/parse/lammps.js +17 -7
  268. package/dist/trajectory/parse/vasp.js +5 -2
  269. package/dist/trajectory/parse/xyz.js +8 -7
  270. package/dist/trajectory/plotting.js +13 -8
  271. package/dist/utils.d.ts +1 -0
  272. package/dist/utils.js +13 -0
  273. package/dist/xrd/XrdPlot.svelte +337 -247
  274. package/dist/xrd/broadening.js +14 -9
  275. package/dist/xrd/calc-xrd.js +12 -18
  276. package/dist/xrd/parse.d.ts +1 -1
  277. package/dist/xrd/parse.js +17 -17
  278. package/package.json +99 -103
  279. package/readme.md +1 -1
  280. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,93 +1,232 @@
1
- <script lang="ts">import { luminance, watch_dark_mode } from '../colors';
2
- import Icon from '../Icon.svelte';
3
- import { format_num } from '../labels';
4
- import { SettingsSection } from '../layout';
5
- import ContextMenu from '../overlays/ContextMenu.svelte';
6
- import DraggablePane from '../overlays/DraggablePane.svelte';
7
- import { calc_cell_color, strip_html } from './';
8
- import { normalize_unicode_minus } from '../utils';
9
- import { tooltip } from 'svelte-multiselect/attachments';
10
- import { flip } from 'svelte/animate';
11
- import { SvelteMap } from 'svelte/reactivity';
12
- let { data = $bindable([]), columns = [], sort_hint = undefined, cell, special_cells, controls, initial_sort = undefined, sort = $bindable({ column: ``, dir: `asc` }), // allows external control/sync of sorting
13
- fixed_header = false, default_num_format = `.3`, show_heatmap = $bindable(true), heatmap_class = `heatmap`, onrowclick, onrowdblclick, column_order = $bindable([]), export_data = false, show_column_toggle = false, search = false, show_row_select = false, pagination = false, selected_rows = $bindable([]), hidden_columns = $bindable([]), scroll_style, root_style, onsort = undefined, onsorterror = undefined, loading = $bindable(false), sort_data = true, heatmap_opacity = $bindable(1), empty_message = `No data`, show_row_numbers = false, allow_better_toggle = false, show_controls = $bindable(false), controls_open = $bindable(false), header_cell, footer, ...rest } = $props();
14
- let container_el = $state();
15
- // Read --page-bg from computed style for text contrast calculation.
16
- // Recalculates on mount and when the theme changes (dark/light mode toggle).
17
- let page_bg_lum = $state(luminance(`white`));
18
- $effect(() => {
19
- if (!container_el)
20
- return;
1
+ <script lang="ts">
2
+ import { luminance, watch_dark_mode } from '../colors'
3
+ import Icon from '../Icon.svelte'
4
+ import { format_num } from '../labels'
5
+ import { SettingsSection } from '../layout'
6
+ import ContextMenu from '../overlays/ContextMenu.svelte'
7
+ import DraggablePane from '../overlays/DraggablePane.svelte'
8
+ import type {
9
+ CellSnippet,
10
+ CellVal,
11
+ ExportData,
12
+ InitialSort,
13
+ Label,
14
+ MultiSortState,
15
+ Pagination,
16
+ RowData,
17
+ Search,
18
+ SortHint,
19
+ SortState,
20
+ SpecialCells,
21
+ } from './'
22
+ import { calc_cell_color, strip_html } from './'
23
+ import { sanitize_html } from '../sanitize'
24
+ import { normalize_unicode_minus } from '../utils'
25
+ import type { Snippet } from 'svelte'
26
+ import { tooltip } from 'svelte-multiselect/attachments'
27
+ import { flip } from 'svelte/animate'
28
+ import type { HTMLAttributes } from 'svelte/elements'
29
+ import { SvelteMap } from 'svelte/reactivity'
30
+
31
+ let {
32
+ data = $bindable([]),
33
+ columns = [],
34
+ sort_hint = undefined,
35
+ cell,
36
+ special_cells,
37
+ controls,
38
+ initial_sort = undefined,
39
+ sort = $bindable({ column: ``, dir: `asc` }), // allows external control/sync of sorting
40
+ fixed_header = false,
41
+ default_num_format = `.3`,
42
+ show_heatmap = $bindable(true),
43
+ heatmap_class = `heatmap`,
44
+ onrowclick,
45
+ onrowdblclick,
46
+ column_order = $bindable([]),
47
+ export_data = false,
48
+ show_column_toggle = false,
49
+ search = false,
50
+ show_row_select = false,
51
+ pagination = false,
52
+ selected_rows = $bindable([]),
53
+ hidden_columns = $bindable([]),
54
+ scroll_style,
55
+ root_style,
56
+ onsort = undefined,
57
+ onsorterror = undefined,
58
+ loading = $bindable(false),
59
+ sort_data = true,
60
+ heatmap_opacity = $bindable(1),
61
+ empty_message = `No data`,
62
+ show_row_numbers = false,
63
+ allow_better_toggle = false,
64
+ show_controls = $bindable(false),
65
+ controls_open = $bindable(false),
66
+ header_cell,
67
+ footer,
68
+ ...rest
69
+ }: HTMLAttributes<HTMLDivElement> & {
70
+ data: RowData[]
71
+ columns?: Label[]
72
+ sort_hint?: SortHint
73
+ cell?: CellSnippet
74
+ special_cells?: SpecialCells
75
+ controls?: Snippet
76
+ initial_sort?: InitialSort
77
+ sort?: { column: string; dir: `asc` | `desc` }
78
+ fixed_header?: boolean
79
+ default_num_format?: string
80
+ show_heatmap?: boolean
81
+ heatmap_class?: string
82
+ onrowclick?: (event: MouseEvent | KeyboardEvent, row: RowData) => void
83
+ onrowdblclick?: (event: MouseEvent, row: RowData) => void
84
+ // Array of column IDs to control display order. IDs are derived as:
85
+ // - Ungrouped columns: col.key ?? col.label
86
+ // - Grouped columns: `${col.key ?? col.label} (${col.group})`
87
+ // This allows persisting/restoring column order across sessions.
88
+ column_order?: string[]
89
+ export_data?: ExportData
90
+ show_column_toggle?: boolean
91
+ search?: Search
92
+ show_row_select?: boolean
93
+ pagination?: Pagination
94
+ selected_rows?: RowData[]
95
+ hidden_columns?: string[]
96
+ scroll_style?: string
97
+ // Inline styles for the root table container (merged with rest.style). Use instead of global CSS overrides.
98
+ root_style?: string
99
+ // Async callback for server-side sorting. When provided, client-side sorting is skipped
100
+ // and the callback is called with (column_id, direction) to fetch new data from server.
101
+ onsort?: (column: string, dir: `asc` | `desc`) => Promise<RowData[]>
102
+ // Callback when onsort fails, receives the error for parent handling (e.g. toast notification)
103
+ onsorterror?: (error: unknown, column: string, dir: `asc` | `desc`) => void
104
+ // Loading state during async sort operations
105
+ loading?: boolean
106
+ // Whether to sort data client-side. Set to false when parent handles sorting externally.
107
+ // When onsort is provided, sort_data behavior is implicitly false.
108
+ sort_data?: boolean
109
+ // Heatmap cell background opacity (0–1). Controls both the visual fade via CSS
110
+ // color-mix() and the JS text contrast correction. Default 1 (fully opaque).
111
+ heatmap_opacity?: number
112
+ // Message shown when the table has no data rows. Set to empty string to hide.
113
+ empty_message?: string
114
+ // Show a row number column as the first column
115
+ show_row_numbers?: boolean
116
+ // When true, show a toggle in colored column headers to cycle gradient direction
117
+ allow_better_toggle?: boolean
118
+ // Whether the gear icon for the controls pane is visible
119
+ show_controls?: boolean
120
+ // Whether the controls pane is expanded
121
+ controls_open?: boolean
122
+ // Custom snippet for rendering header cells. Falls back to {@html col.label}.
123
+ header_cell?: Snippet<[{ col: Label }]>
124
+ // Footer snippet rendered inside <tfoot> below the table body
125
+ footer?: Snippet
126
+ } = $props()
127
+
128
+ let container_el = $state<HTMLDivElement>()
129
+
130
+ // Read --page-bg from computed style for text contrast calculation.
131
+ // Recalculates on mount and when the theme changes (dark/light mode toggle).
132
+ let page_bg_lum = $state(luminance(`white`))
133
+ $effect(() => {
134
+ if (!container_el) return
21
135
  const read_page_bg = () => {
22
- const page_bg = getComputedStyle(container_el).getPropertyValue(`--page-bg`)
23
- .trim();
24
- page_bg_lum = luminance(page_bg || `white`);
25
- };
26
- read_page_bg();
27
- return watch_dark_mode(read_page_bg);
28
- });
29
- // Detect HTML to prevent setting raw HTML as data-sort-value. Simple string matching
30
- // suffices since false positives just skip setting the attr (sorting still works by inner data-sort-value).
31
- function is_html_str(val) {
32
- if (typeof val !== `string`)
33
- return false;
34
- return ((val.includes(`<`) && val.includes(`>`)) || // Has angle brackets
35
- val.startsWith(`&lt;`) || // Has HTML entity for <
36
- val.includes(`href=`) || // Has href attribute
37
- val.includes(`class=`) // Has class attribute
38
- );
39
- }
40
- // Normalize initial_sort config
41
- let initial_sort_config = $derived(initial_sort
42
- ? typeof initial_sort === `string`
43
- ? { column: initial_sort, direction: `asc` }
44
- : { direction: `asc`, ...initial_sort }
45
- : null);
46
- // Normalize pagination config
47
- let pagination_config = $derived(pagination
48
- ? { page_size: 25, ...(typeof pagination === `object` ? pagination : {}) }
49
- : null);
50
- // Mutable page size — writable $derived allows user to change via dropdown
51
- let effective_page_size = $derived(pagination_config?.page_size ?? 25);
52
- // Normalize search config
53
- let search_config = $derived(search
54
- ? {
136
+ if (!container_el) return
137
+ const page_bg = getComputedStyle(container_el).getPropertyValue(`--page-bg`)
138
+ .trim()
139
+ page_bg_lum = luminance(page_bg || `white`)
140
+ }
141
+ read_page_bg()
142
+ return watch_dark_mode(read_page_bg)
143
+ })
144
+
145
+ // Detect HTML to prevent setting raw HTML as data-sort-value. Simple string matching
146
+ // suffices since false positives just skip setting the attr (sorting still works by inner data-sort-value).
147
+ function is_html_str(val: unknown): boolean {
148
+ if (typeof val !== `string`) return false
149
+ return (
150
+ (val.includes(`<`) && val.includes(`>`)) || // Has angle brackets
151
+ val.startsWith(`&lt;`) || // Has HTML entity for <
152
+ val.includes(`href=`) || // Has href attribute
153
+ val.includes(`class=`) // Has class attribute
154
+ )
155
+ }
156
+
157
+ // Normalize initial_sort config
158
+ let initial_sort_config = $derived(
159
+ initial_sort
160
+ ? typeof initial_sort === `string`
161
+ ? { column: initial_sort, direction: `asc` as const }
162
+ : { direction: `asc` as const, ...initial_sort }
163
+ : null,
164
+ )
165
+
166
+ // Normalize pagination config
167
+ let pagination_config = $derived(
168
+ pagination
169
+ ? { page_size: 25, ...(typeof pagination === `object` ? pagination : {}) }
170
+ : null,
171
+ )
172
+
173
+ // Mutable page size — writable $derived allows user to change via dropdown
174
+ let effective_page_size = $derived(pagination_config?.page_size ?? 25)
175
+
176
+ // Normalize search config
177
+ let search_config = $derived(
178
+ search
179
+ ? {
55
180
  placeholder: `Filter...`,
56
181
  expanded: false,
57
182
  ...(typeof search === `object` ? search : {}),
58
- }
59
- : null);
60
- const default_formats = [`csv`, `json`];
61
- let export_config = $derived(export_data
62
- ? {
183
+ }
184
+ : null,
185
+ )
186
+
187
+ // Normalize export_data config
188
+ type ExportFormat = `csv` | `json`
189
+ const default_formats: ExportFormat[] = [`csv`, `json`]
190
+ let export_config = $derived(
191
+ export_data
192
+ ? {
63
193
  formats: default_formats,
64
194
  filename: `table-export`,
65
195
  ...(typeof export_data === `object` ? export_data : {}),
66
- }
67
- : null);
68
- // Derive sort_state from bindable prop, falling back to initial_sort if sort not yet set
69
- // This ensures immediate sorting on first render without waiting for effects
70
- let sort_state = $derived({
196
+ }
197
+ : null,
198
+ )
199
+
200
+ // Derive sort_state from bindable prop, falling back to initial_sort if sort not yet set
201
+ // This ensures immediate sorting on first render without waiting for effects
202
+ let sort_state = $derived<SortState>({
71
203
  column: sort.column || initial_sort_config?.column || ``,
72
204
  ascending: sort.column
73
- ? sort.dir !== `desc`
74
- : initial_sort_config?.direction !== `desc`,
75
- });
76
- // Multi-column sort state (for Shift+click)
77
- let multi_sort = $state([]);
78
- // Search/filter state
79
- let search_query = $state(``);
80
- let search_expanded = $derived(search_config?.expanded ?? false);
81
- // Pagination state
82
- let current_page = $state(1);
83
- // Dropdown states
84
- let show_column_dropdown = $state(false);
85
- let show_export_dropdown = $state(false);
86
- // Per-column gradient direction overrides (user-toggled via header)
87
- let better_overrides = new SvelteMap();
88
- // Per-column color scale overrides
89
- let color_scale_overrides = new SvelteMap();
90
- const color_scale_options = [
205
+ ? sort.dir !== `desc`
206
+ : initial_sort_config?.direction !== `desc`,
207
+ })
208
+
209
+ // Multi-column sort state (for Shift+click)
210
+ let multi_sort = $state<MultiSortState>([])
211
+
212
+ // Search/filter state
213
+ let search_query = $state(``)
214
+ let search_expanded = $derived(search_config?.expanded ?? false)
215
+
216
+ // Pagination state
217
+ let current_page = $state(1)
218
+
219
+ // Dropdown states
220
+ let show_column_dropdown = $state(false)
221
+ let show_export_dropdown = $state(false)
222
+
223
+ // Per-column gradient direction overrides (user-toggled via header)
224
+ let better_overrides = new SvelteMap<string, `higher` | `lower`>()
225
+
226
+ // Per-column color scale overrides
227
+ let color_scale_overrides = new SvelteMap<string, string>()
228
+
229
+ const color_scale_options = [
91
230
  `interpolateViridis`,
92
231
  `interpolatePlasma`,
93
232
  `interpolateInferno`,
@@ -97,548 +236,645 @@ const color_scale_options = [
97
236
  `interpolateGreens`,
98
237
  `interpolateReds`,
99
238
  `interpolateYlOrRd`,
100
- ];
101
- // Columns that have a color gradient
102
- let colored_columns = $derived(columns.filter((col) => col.color_scale !== null && col.color_scale !== undefined));
103
- // Column resize state
104
- let resize_col_id = $state(null);
105
- let resize_start_x = $state(0);
106
- let resize_start_width = $state(0);
107
- let column_widths = $state({});
108
- // Auto-discover columns from data keys when none are provided
109
- $effect.pre(() => {
110
- if (columns.length > 0 || data.length === 0)
111
- return;
112
- const seen = {};
239
+ ] as const
240
+
241
+ // Columns that have a color gradient
242
+ let colored_columns = $derived(
243
+ columns.filter((col) =>
244
+ col.color_scale !== null && col.color_scale !== undefined
245
+ ),
246
+ )
247
+
248
+ // Column resize state
249
+ let resize_col_id = $state<string | null>(null)
250
+ let resize_start_x = $state(0)
251
+ let resize_start_width = $state(0)
252
+ let column_widths = $state<Record<string, number>>({})
253
+
254
+ // Auto-discover columns from data keys when none are provided
255
+ $effect.pre(() => {
256
+ if (columns.length > 0 || data.length === 0) return
257
+ const seen: Record<string, true> = {}
113
258
  for (const row of data.slice(0, 50)) {
114
- for (const key of Object.keys(row)) {
115
- if (key !== `style` && key !== `class`)
116
- seen[key] = true;
117
- }
259
+ for (const key of Object.keys(row)) {
260
+ if (key !== `style` && key !== `class`) seen[key] = true
261
+ }
118
262
  }
119
- columns = Object.keys(seen).map((key) => ({ label: key }));
120
- });
121
- // Helper to make column IDs (needed since column labels in different groups can be repeated)
122
- const get_col_id = (col) => col.group ? `${col.key ?? col.label} (${col.group})` : (col.key ?? col.label);
123
- // Sync column_order with columns: initialize if empty, remove stale IDs, append new IDs
124
- $effect(() => {
125
- if (columns.length === 0)
126
- return;
127
- const col_ids = columns.map(get_col_id);
263
+ columns = Object.keys(seen).map((key) => ({ label: key }))
264
+ })
265
+
266
+ // Helper to make column IDs (needed since column labels in different groups can be repeated)
267
+ const get_col_id = (col: Label) =>
268
+ col.group ? `${col.key ?? col.label} (${col.group})` : (col.key ?? col.label)
269
+
270
+ // Sync column_order with columns: initialize if empty, remove stale IDs, append new IDs
271
+ $effect(() => {
272
+ if (columns.length === 0) return
273
+ const col_ids = columns.map(get_col_id)
274
+
128
275
  // Case 1: First render - initialize with default order
129
276
  if (column_order.length === 0) {
130
- column_order = col_ids;
131
- return;
277
+ column_order = col_ids
278
+ return
132
279
  }
133
- // Case 2: Already in sync - skip to avoid infinite effect loop
134
- const arrays_equal = column_order.length === col_ids.length &&
135
- column_order.every((id, idx) => id === col_ids[idx]);
136
- if (arrays_equal)
137
- return;
138
- // Case 3: Sync needed - keep valid IDs in their order, append any new ones
139
- const valid_ids = new Set(col_ids);
140
- const kept = column_order.filter((id) => valid_ids.has(id));
141
- const new_ids = col_ids.filter((id) => !kept.includes(id));
142
- column_order = [...kept, ...new_ids];
143
- });
144
- // Reorder columns based on column_order
145
- let ordered_columns = $derived.by(() => {
146
- if (column_order.length === 0)
147
- return columns;
148
- const col_map = new SvelteMap(columns.map((col) => [get_col_id(col), col]));
280
+
281
+ // Case 2: Sync needed - keep valid IDs in their order, append any new ones
282
+ const valid_ids = new Set(col_ids)
283
+ const kept = column_order.filter((id) => valid_ids.has(id))
284
+ const new_ids = col_ids.filter((id) => !kept.includes(id))
285
+ const new_order = [...kept, ...new_ids]
286
+
287
+ // Skip assignment if content is unchanged to prevent infinite effect loop.
288
+ // After drag reorder, column_order differs from col_ids (default order) but the
289
+ // computed new_order equals the current column_order assigning a new array
290
+ // reference would re-trigger this effect endlessly.
291
+ if (new_order.length === column_order.length &&
292
+ new_order.every((id, idx) => id === column_order[idx])) return
293
+
294
+ column_order = new_order
295
+ })
296
+
297
+ // Reorder columns based on column_order
298
+ let ordered_columns = $derived.by(() => {
299
+ if (column_order.length === 0) return columns
300
+
301
+ const col_map = new SvelteMap(columns.map((col) => [get_col_id(col), col]))
302
+
149
303
  // Add columns in specified order, then any remaining columns that weren't in the order list
150
304
  const ordered = column_order
151
- .map((id) => col_map.get(id))
152
- .filter((col) => col != null);
153
- const ordered_ids = new Set(ordered.map(get_col_id));
154
- const remaining = columns.filter((col) => !ordered_ids.has(get_col_id(col)));
155
- return [...ordered, ...remaining];
156
- });
157
- let drag_col_id = $state(null);
158
- let drag_over_col_id = $state(null);
159
- // Merge root_style with rest.style for root div; omit style from rest to avoid duplicate
160
- let rest_props = $derived.by(() => {
161
- const { style: rest_style, ...other_props } = rest;
162
- const merged = [rest_style, root_style].filter(Boolean).join(`; `);
163
- return { ...other_props, ...(merged ? { style: merged } : {}) };
164
- });
165
- // WeakMap to assign stable unique IDs to row objects for efficient comparison and keying
166
- // This avoids O(n) JSON.stringify calls and prevents unnecessary re-renders
167
- const row_id_map = new WeakMap();
168
- let row_id_counter = 0;
169
- function get_row_id(row) {
170
- let id = row_id_map.get(row);
305
+ .map((id) => col_map.get(id))
306
+ .filter((col): col is Label => col != null)
307
+
308
+ const ordered_ids = new Set(ordered.map(get_col_id))
309
+ const remaining = columns.filter((col) => !ordered_ids.has(get_col_id(col)))
310
+
311
+ return [...ordered, ...remaining]
312
+ })
313
+
314
+ let drag_col_id = $state<string | null>(null)
315
+ let drag_over_col_id = $state<string | null>(null)
316
+
317
+ // Merge root_style with rest.style for root div; omit style from rest to avoid duplicate
318
+ let rest_props = $derived.by(() => {
319
+ const { style: rest_style, ...other_props } = rest
320
+ const merged = [rest_style, root_style].filter(Boolean).join(`; `)
321
+ return { ...other_props, ...(merged ? { style: merged } : {}) }
322
+ })
323
+
324
+ // WeakMap to assign stable unique IDs to row objects for efficient comparison and keying
325
+ // This avoids O(n) JSON.stringify calls and prevents unnecessary re-renders
326
+ const row_id_map = new WeakMap<RowData, string>()
327
+ let row_id_counter = 0
328
+
329
+ function get_row_id(row: RowData): string {
330
+ let id = row_id_map.get(row)
171
331
  if (id === undefined) {
172
- id = `row_${row_id_counter++}`;
173
- row_id_map.set(row, id);
332
+ id = `row_${row_id_counter++}`
333
+ row_id_map.set(row, id)
174
334
  }
175
- return id;
176
- }
177
- // Returns 'left' or 'right' to indicate which side of target to insert dragged column
178
- function get_drag_side(target_col_id) {
179
- if (!drag_col_id)
180
- return null;
181
- const drag_idx = column_order.indexOf(drag_col_id);
182
- const target_idx = column_order.indexOf(target_col_id);
183
- if (drag_idx === -1 || target_idx === -1)
184
- return null;
185
- return drag_idx < target_idx ? `right` : `left`;
186
- }
187
- function reset_drag_state() {
188
- drag_col_id = null;
189
- drag_over_col_id = null;
190
- }
191
- const get_drag_col_group = () => ordered_columns.find((col) => get_col_id(col) === drag_col_id)?.group;
192
- function handle_drag_start(event, col) {
193
- if (!event.dataTransfer)
194
- return;
195
- drag_col_id = get_col_id(col);
196
- event.dataTransfer.effectAllowed = `move`;
197
- event.dataTransfer.setData(`text/html`, ``);
198
- }
199
- function handle_drag_over(event, col) {
200
- event.preventDefault();
201
- if (!event.dataTransfer)
202
- return;
203
- event.dataTransfer.dropEffect = `move`;
335
+ return id
336
+ }
337
+
338
+ // Returns 'left' or 'right' to indicate which side of target to insert dragged column
339
+ function get_drag_side(target_col_id: string): `left` | `right` | null {
340
+ if (!drag_col_id) return null
341
+ const drag_idx = column_order.indexOf(drag_col_id)
342
+ const target_idx = column_order.indexOf(target_col_id)
343
+ if (drag_idx === -1 || target_idx === -1) return null
344
+ return drag_idx < target_idx ? `right` : `left`
345
+ }
346
+
347
+ function reset_drag_state() {
348
+ drag_col_id = null
349
+ drag_over_col_id = null
350
+ }
351
+
352
+ const get_drag_col_group = () =>
353
+ ordered_columns.find((col) => get_col_id(col) === drag_col_id)?.group
354
+
355
+ function handle_drag_start(event: DragEvent, col: Label) {
356
+ if (!event.dataTransfer) return
357
+ drag_col_id = get_col_id(col)
358
+ event.dataTransfer.effectAllowed = `move`
359
+ event.dataTransfer.setData(`text/html`, ``)
360
+ }
361
+
362
+ function handle_drag_over(event: DragEvent, col: Label) {
363
+ event.preventDefault()
364
+ if (!event.dataTransfer) return
365
+ event.dataTransfer.dropEffect = `move`
366
+
204
367
  // Prevent cross-group drag-over to keep group headers contiguous
205
368
  if (get_drag_col_group() !== col.group) {
206
- event.dataTransfer.dropEffect = `none`;
207
- drag_over_col_id = null;
208
- return;
369
+ event.dataTransfer.dropEffect = `none`
370
+ drag_over_col_id = null
371
+ return
209
372
  }
210
- drag_over_col_id = get_col_id(col);
211
- }
212
- function handle_drop(event, target_col) {
213
- event.preventDefault();
373
+
374
+ drag_over_col_id = get_col_id(col)
375
+ }
376
+
377
+ function handle_drop(event: DragEvent, target_col: Label) {
378
+ event.preventDefault()
379
+
214
380
  // Block cross-group (or group→ungroup) reorders to preserve group contiguity
215
381
  if (!drag_col_id || drag_col_id === get_col_id(target_col)) {
216
- reset_drag_state();
217
- return;
382
+ reset_drag_state()
383
+ return
218
384
  }
385
+
219
386
  // Block cross-group reorders to preserve group contiguity
220
387
  if (get_drag_col_group() !== target_col.group) {
221
- reset_drag_state();
222
- return;
388
+ reset_drag_state()
389
+ return
223
390
  }
224
- const target_col_id = get_col_id(target_col);
225
- const drag_idx = column_order.indexOf(drag_col_id);
226
- const target_idx = column_order.indexOf(target_col_id);
391
+
392
+ const target_col_id = get_col_id(target_col)
393
+ const drag_idx = column_order.indexOf(drag_col_id)
394
+ const target_idx = column_order.indexOf(target_col_id)
395
+
227
396
  if (drag_idx === -1 || target_idx === -1) {
228
- reset_drag_state();
229
- return;
397
+ reset_drag_state()
398
+ return
230
399
  }
400
+
231
401
  // Reorder: remove dragged column, then insert at target position
232
402
  // When dragging left-to-right (drag_idx < target_idx), removing the dragged
233
403
  // element shifts all subsequent indices down by 1, so we must adjust target_idx
234
- const new_order = [...column_order];
235
- new_order.splice(drag_idx, 1);
236
- const adjusted_target = drag_idx < target_idx ? target_idx - 1 : target_idx;
237
- new_order.splice(adjusted_target, 0, drag_col_id);
238
- column_order = new_order;
239
- reset_drag_state();
240
- }
241
- // Filter data based on search query
242
- let filtered_data = $derived.by(() => {
243
- const base_data = data?.filter?.((row) => Object.values(row).some((val) => val !== undefined)) ?? [];
244
- if (!search_query.trim())
245
- return base_data;
246
- const query = search_query.toLowerCase().trim();
247
- return base_data.filter((row) => Object.values(row).some((val) => {
248
- if (val == null)
249
- return false;
250
- const clean_val = strip_html(String(val)).toLowerCase();
251
- return clean_val.includes(query);
252
- }));
253
- });
254
- let sorted_data = $derived.by(() => {
404
+ const new_order = [...column_order]
405
+ new_order.splice(drag_idx, 1)
406
+ const adjusted_target = drag_idx < target_idx ? target_idx - 1 : target_idx
407
+ new_order.splice(adjusted_target, 0, drag_col_id)
408
+ column_order = new_order
409
+ reset_drag_state()
410
+ }
411
+
412
+ // Filter data based on search query
413
+ let filtered_data = $derived.by(() => {
414
+ const base_data = data?.filter?.((row) =>
415
+ Object.values(row).some((val) => val !== undefined)
416
+ ) ?? []
417
+
418
+ if (!search_query.trim()) return base_data
419
+
420
+ const query = search_query.toLowerCase().trim()
421
+ return base_data.filter((row) =>
422
+ Object.values(row).some((val) => {
423
+ if (val == null) return false
424
+ const clean_val = strip_html(String(val)).toLowerCase()
425
+ return clean_val.includes(query)
426
+ })
427
+ )
428
+ })
429
+
430
+ let sorted_data = $derived.by(() => {
255
431
  // Skip client-side sorting when using async onsort callback or sort_data is false
256
- if (onsort || !sort_data)
257
- return filtered_data;
258
- if (!sort_state.column && multi_sort.length === 0)
259
- return filtered_data;
432
+ if (onsort || !sort_data) return filtered_data
433
+
434
+ if (!sort_state.column && multi_sort.length === 0) return filtered_data
435
+
260
436
  // Helper to check if value is invalid (null, undefined, NaN)
261
- const is_invalid = (val) => val == null || (typeof val === `number` && Number.isNaN(val));
437
+ const is_invalid = (val: unknown) =>
438
+ val == null || (typeof val === `number` && Number.isNaN(val))
439
+
262
440
  // Get sort value from a cell (handles HTML data-sort-value and numbers with errors)
263
- const get_sort_val = (val) => {
264
- if (typeof val === `string`) {
265
- // Check for HTML data-sort-value attribute first
266
- const sort_attr_match = val.match(/data-sort-value="([^"]*)"/);
267
- if (sort_attr_match) {
268
- const num = Number(sort_attr_match[1]);
269
- return isNaN(num) ? sort_attr_match[1] : num;
270
- }
271
- // Handle numbers with error notation: "1.23 ± 0.05" or "1.23 +- 0.05" or "1.23(5)"
272
- // Extract the primary number before the ± or +- or (
273
- // Supports: ± (U+00B1), ASCII +-, Unicode minus − (U+2212), with optional whitespace
274
- const error_match = val.match(/^([+-−]?\d+\.?\d*(?:[eE][+-−]?\d+)?)\s*(?:[±\u00B1]|[+][−-]|\()/);
275
- if (error_match) {
276
- const num = Number(error_match[1]);
277
- if (!isNaN(num))
278
- return num;
279
- }
280
- // Try parsing as a plain number (handles "1.23" strings)
281
- const plain_num = Number(val);
282
- if (!isNaN(plain_num) && val.trim() !== ``)
283
- return plain_num;
441
+ const get_sort_val = (val: CellVal): string | number => {
442
+ if (typeof val === `string`) {
443
+ // Check for HTML data-sort-value attribute first
444
+ const sort_attr_match = val.match(/data-sort-value="([^"]*)"/)
445
+ if (sort_attr_match) {
446
+ const num = Number(sort_attr_match[1])
447
+ return isNaN(num) ? sort_attr_match[1] : num
448
+ }
449
+ // Handle numbers with error notation: "1.23 ± 0.05" or "1.23 +- 0.05" or "1.23(5)"
450
+ // Extract the primary number before the ± or +- or (
451
+ // Supports: ± (U+00B1), ASCII +-, Unicode minus − (U+2212), with optional whitespace
452
+ const error_match = val.match(
453
+ /^([+-−]?\d+\.?\d*(?:[eE][+-−]?\d+)?)\s*(?:[±\u00B1]|[+][−-]|\()/,
454
+ )
455
+ if (error_match) {
456
+ const num = Number(error_match[1])
457
+ if (!isNaN(num)) return num
284
458
  }
285
- return val;
286
- };
459
+ // Try parsing as a plain number (handles "1.23" strings)
460
+ const plain_num = Number(val)
461
+ if (!isNaN(plain_num) && val.trim() !== ``) return plain_num
462
+ }
463
+ return val as string | number
464
+ }
465
+
287
466
  // Build sort criteria: multi_sort takes precedence, fallback to single sort
288
467
  const sort_criteria = multi_sort.length > 0
289
- ? multi_sort
290
- : sort_state.column
291
- ? [sort_state]
292
- : [];
293
- if (sort_criteria.length === 0)
294
- return filtered_data;
468
+ ? multi_sort
469
+ : sort_state.column
470
+ ? [sort_state]
471
+ : []
472
+
473
+ if (sort_criteria.length === 0) return filtered_data
474
+
295
475
  return [...filtered_data].sort((row1, row2) => {
296
- for (const { column, ascending } of sort_criteria) {
297
- const matched_col = ordered_columns.find((c) => get_col_id(c) === column);
298
- if (!matched_col)
299
- continue;
300
- const col_id = get_col_id(matched_col);
301
- const val1 = row1[col_id];
302
- const val2 = row2[col_id];
303
- if (val1 === val2)
304
- continue;
305
- // Push invalid values to bottom
306
- if (is_invalid(val1) || is_invalid(val2)) {
307
- return +is_invalid(val1) - +is_invalid(val2);
308
- }
309
- const sort_val1 = get_sort_val(val1);
310
- const sort_val2 = get_sort_val(val2);
311
- const modifier = ascending ? 1 : -1;
312
- if (typeof sort_val1 === `string` && typeof sort_val2 === `string`) {
313
- const cmp = sort_val1.localeCompare(sort_val2, undefined, {
314
- numeric: true,
315
- sensitivity: `base`,
316
- });
317
- if (cmp !== 0)
318
- return cmp * modifier;
319
- }
320
- else {
321
- if (sort_val1 !== sort_val2) {
322
- return (sort_val1 ?? 0) < (sort_val2 ?? 0) ? -modifier : modifier;
323
- }
324
- }
476
+ for (const { column, ascending } of sort_criteria) {
477
+ const matched_col = ordered_columns.find((c) => get_col_id(c) === column)
478
+ if (!matched_col) continue
479
+
480
+ const col_id = get_col_id(matched_col)
481
+ const val1 = row1[col_id]
482
+ const val2 = row2[col_id]
483
+
484
+ if (val1 === val2) continue
485
+
486
+ // Push invalid values to bottom
487
+ if (is_invalid(val1) || is_invalid(val2)) {
488
+ return +is_invalid(val1) - +is_invalid(val2)
325
489
  }
326
- return 0;
327
- });
328
- });
329
- // Paginated data
330
- let paginated_data = $derived.by(() => {
331
- if (!pagination_config)
332
- return sorted_data;
333
- const start = (current_page - 1) * effective_page_size;
334
- return sorted_data.slice(start, start + effective_page_size);
335
- });
336
- let total_pages = $derived(Math.ceil(sorted_data.length / effective_page_size));
337
- // Track previous values to detect actual changes
338
- let prev_search_query = $state(``);
339
- let prev_data_length = $state(0);
340
- // Track async sort requests to prevent race conditions
341
- let sort_request_id = 0;
342
- // Reset to page 1 when search query or data length actually changes
343
- $effect(() => {
344
- const query_changed = search_query !== prev_search_query;
345
- const data_changed = sorted_data.length !== prev_data_length;
490
+
491
+ const sort_val1 = get_sort_val(val1)
492
+ const sort_val2 = get_sort_val(val2)
493
+ const modifier = ascending ? 1 : -1
494
+
495
+ if (typeof sort_val1 === `string` && typeof sort_val2 === `string`) {
496
+ const cmp = sort_val1.localeCompare(sort_val2, undefined, {
497
+ numeric: true,
498
+ sensitivity: `base`,
499
+ })
500
+ if (cmp !== 0) return cmp * modifier
501
+ } else {
502
+ if (sort_val1 !== sort_val2) {
503
+ return (sort_val1 ?? 0) < (sort_val2 ?? 0) ? -modifier : modifier
504
+ }
505
+ }
506
+ }
507
+ return 0
508
+ })
509
+ })
510
+
511
+ // Paginated data
512
+ let paginated_data = $derived.by(() => {
513
+ if (!pagination_config) return sorted_data
514
+ const start = (current_page - 1) * effective_page_size
515
+ return sorted_data.slice(start, start + effective_page_size)
516
+ })
517
+
518
+ let total_pages = $derived(
519
+ Math.ceil(sorted_data.length / effective_page_size),
520
+ )
521
+
522
+ // Track previous values to detect actual changes
523
+ let prev_search_query = $state(``)
524
+ let prev_data_length = $state(0)
525
+
526
+ // Track async sort requests to prevent race conditions
527
+ let sort_request_id = 0
528
+
529
+ // Reset to page 1 when search query or data length actually changes
530
+ $effect(() => {
531
+ const query_changed = search_query !== prev_search_query
532
+ const data_changed = sorted_data.length !== prev_data_length
533
+
346
534
  if (query_changed || data_changed) {
347
- current_page = 1;
348
- prev_search_query = search_query;
349
- prev_data_length = sorted_data.length;
535
+ current_page = 1
536
+ prev_search_query = search_query
537
+ prev_data_length = sorted_data.length
538
+ } else if (total_pages > 0 && current_page > total_pages) {
539
+ // Clamp when total pages decreases (e.g., page size increase)
540
+ current_page = total_pages
350
541
  }
351
- else if (total_pages > 0 && current_page > total_pages) {
352
- // Clamp when total pages decreases (e.g., page size increase)
353
- current_page = total_pages;
354
- }
355
- });
356
- async function sort_rows(column, group, event) {
542
+ })
543
+
544
+ async function sort_rows(
545
+ column: string,
546
+ group: string | undefined,
547
+ event: MouseEvent | KeyboardEvent,
548
+ ) {
357
549
  // Find the column using both label and group if provided
358
- const col = ordered_columns.find((c) => c.label === column && c.group === group);
359
- if (!col)
360
- return; // Skip if column not found
361
- if (col.sortable === false)
362
- return; // Skip sorting if column marked as unsortable
363
- const col_id = get_col_id(col);
550
+ const col = ordered_columns.find(
551
+ (c) => c.label === column && c.group === group,
552
+ )
553
+
554
+ if (!col) return // Skip if column not found
555
+ if (col.sortable === false) return // Skip sorting if column marked as unsortable
556
+
557
+ const col_id = get_col_id(col)
558
+
364
559
  // Shift+click for multi-column sort
365
560
  if (event.shiftKey) {
366
- const existing_idx = multi_sort.findIndex((s) => s.column === col_id);
367
- if (existing_idx >= 0) {
368
- // Toggle direction or remove if clicked again
369
- const existing = multi_sort[existing_idx];
370
- if (existing.ascending === (col.better === `lower`)) {
371
- // Remove from multi-sort
372
- multi_sort = multi_sort.filter((_, idx) => idx !== existing_idx);
373
- }
374
- else {
375
- // Toggle direction
376
- multi_sort = multi_sort.map((s, idx) => idx === existing_idx ? { ...s, ascending: !s.ascending } : s);
377
- }
378
- }
379
- else {
380
- // Add to multi-sort
381
- multi_sort = [...multi_sort, {
382
- column: col_id,
383
- ascending: col.better === `lower`,
384
- }];
561
+ const existing_idx = multi_sort.findIndex((s) => s.column === col_id)
562
+ if (existing_idx >= 0) {
563
+ // Toggle direction or remove if clicked again
564
+ const existing = multi_sort[existing_idx]
565
+ if (existing.ascending === (col.better === `lower`)) {
566
+ // Remove from multi-sort
567
+ multi_sort = multi_sort.filter((_, idx) => idx !== existing_idx)
568
+ } else {
569
+ // Toggle direction
570
+ multi_sort = multi_sort.map((s, idx) =>
571
+ idx === existing_idx ? { ...s, ascending: !s.ascending } : s
572
+ )
385
573
  }
386
- // Clear single sort when using multi-sort
387
- sort = { column: ``, dir: `asc` };
388
- }
389
- else {
390
- // Regular click - single column sort
391
- multi_sort = []; // Clear multi-sort
392
- // Use sort_state.column for comparison since it includes initial_sort fallback
393
- const new_dir = sort_state.column !== col_id
394
- ? (col.better === `lower` ? `asc` : `desc`)
395
- : (sort_state.ascending ? `desc` : `asc`);
396
- // Save previous sort state in case we need to revert on error
397
- const prev_sort = { ...sort };
398
- sort = { column: col_id, dir: new_dir };
399
- // If onsort callback provided, fetch new data from server
400
- if (onsort) {
401
- loading = true;
402
- const request_id = ++sort_request_id;
403
- try {
404
- const result = await onsort(col_id, new_dir);
405
- // Only update if this is still the most recent request (avoid race condition)
406
- if (request_id === sort_request_id) {
407
- data = result;
408
- }
409
- }
410
- catch (err) {
411
- console.error(`Sort callback failed:`, err);
412
- // Revert sort state on failure so UI doesn't show wrong direction
413
- if (request_id === sort_request_id) {
414
- sort = prev_sort;
415
- onsorterror?.(err, col_id, new_dir);
416
- }
417
- }
418
- finally {
419
- // Only clear loading if this is still the most recent request
420
- if (request_id === sort_request_id) {
421
- loading = false;
422
- }
423
- }
574
+ } else {
575
+ // Add to multi-sort
576
+ multi_sort = [...multi_sort, {
577
+ column: col_id,
578
+ ascending: col.better === `lower`,
579
+ }]
580
+ }
581
+ // Clear single sort when using multi-sort
582
+ sort = { column: ``, dir: `asc` }
583
+ } else {
584
+ // Regular click - single column sort
585
+ multi_sort = [] // Clear multi-sort
586
+ // Use sort_state.column for comparison since it includes initial_sort fallback
587
+ const new_dir = sort_state.column !== col_id
588
+ ? (col.better === `lower` ? `asc` : `desc`)
589
+ : (sort_state.ascending ? `desc` : `asc`)
590
+
591
+ // Save previous sort state in case we need to revert on error
592
+ const prev_sort = { ...sort }
593
+ sort = { column: col_id, dir: new_dir }
594
+
595
+ // If onsort callback provided, fetch new data from server
596
+ if (onsort) {
597
+ loading = true
598
+ const request_id = ++sort_request_id
599
+ try {
600
+ const result = await onsort(col_id, new_dir)
601
+ // Only update if this is still the most recent request (avoid race condition)
602
+ if (request_id === sort_request_id) {
603
+ data = result
604
+ }
605
+ } catch (err) {
606
+ console.error(`Sort callback failed:`, err)
607
+ // Revert sort state on failure so UI doesn't show wrong direction
608
+ if (request_id === sort_request_id) {
609
+ sort = prev_sort
610
+ onsorterror?.(err, col_id, new_dir)
611
+ }
612
+ } finally {
613
+ // Only clear loading if this is still the most recent request
614
+ if (request_id === sort_request_id) {
615
+ loading = false
616
+ }
424
617
  }
618
+ }
425
619
  }
426
- }
427
- // Extract numeric value from strings with uncertainty notation: "1.23 ± 0.05", "1.23 +- 0.05", "1.23(5)"
428
- function parse_numeric_val(val) {
429
- if (typeof val === `number`)
430
- return Number.isNaN(val) ? null : val;
431
- if (typeof val !== `string`)
432
- return null;
620
+ }
621
+
622
+ // Extract numeric value from strings with uncertainty notation: "1.23 ± 0.05", "1.23 +- 0.05", "1.23(5)"
623
+ function parse_numeric_val(val: CellVal): number | null {
624
+ if (typeof val === `number`) return Number.isNaN(val) ? null : val
625
+ if (typeof val !== `string`) return null
626
+
433
627
  // Handle numbers with error notation: "1.23 ± 0.05" or "1.23 +- 0.05" or "1.23(5)"
434
628
  // Supports: ± (U+00B1), ASCII +-, Unicode minus − (U+2212), with optional whitespace
435
629
  // Note: [-+−] has hyphen first to avoid regex range interpretation
436
630
  // Pattern allows leading decimals like .5 or -.5 via (?:\d+\.?\d*|\d*\.\d+)
437
- const error_match = val.match(/^([-+−]?(?:\d+\.?\d*|\d*\.\d+)(?:[eE][-+−]?\d+)?)\s*(?:±|\+[-−]|\()/);
631
+ const error_match = val.match(
632
+ /^([-+−]?(?:\d+\.?\d*|\d*\.\d+)(?:[eE][-+−]?\d+)?)\s*(?:±|\+[-−]|\()/,
633
+ )
438
634
  if (error_match) {
439
- // Normalize unicode minus (U+2212) to ASCII hyphen for Number()
440
- const normalized = normalize_unicode_minus(error_match[1]);
441
- const num = Number(normalized);
442
- if (!isNaN(num))
443
- return num;
635
+ // Normalize unicode minus (U+2212) to ASCII hyphen for Number()
636
+ const normalized = normalize_unicode_minus(error_match[1])
637
+ const num = Number(normalized)
638
+ if (!isNaN(num)) return num
444
639
  }
445
640
  // Try parsing as a plain number (handles "1.23" strings)
446
641
  // Also normalize unicode minus for plain numbers
447
- const normalized_val = normalize_unicode_minus(val);
448
- const plain_num = Number(normalized_val);
449
- if (!isNaN(plain_num) && val.trim() !== ``)
450
- return plain_num;
451
- return null;
452
- }
453
- // Memoize parsed column values to avoid O(N²) re-parsing in calc_color
454
- let parsed_column_values = $derived.by(() => {
455
- const result = new SvelteMap();
642
+ const normalized_val = normalize_unicode_minus(val)
643
+ const plain_num = Number(normalized_val)
644
+ if (!isNaN(plain_num) && val.trim() !== ``) return plain_num
645
+ return null
646
+ }
647
+
648
+ // Memoize parsed column values to avoid O(N²) re-parsing in calc_color
649
+ let parsed_column_values = $derived.by(() => {
650
+ const result = new SvelteMap<string, (number | null)[]>()
456
651
  for (const col of ordered_columns) {
457
- if (col.color_scale === null)
458
- continue;
459
- const col_id = get_col_id(col);
460
- result.set(col_id, sorted_data.map((row) => parse_numeric_val(row[col_id])));
652
+ if (col.color_scale === null) continue
653
+ const col_id = get_col_id(col)
654
+ result.set(col_id, sorted_data.map((row) => parse_numeric_val(row[col_id])))
461
655
  }
462
- return result;
463
- });
464
- function calc_color(val, col) {
656
+ return result
657
+ })
658
+
659
+ function calc_color(val: CellVal, col: Label) {
465
660
  if (!show_heatmap || col.color_scale === null) {
466
- return { bg: null, text: null };
661
+ return { bg: null, text: null }
467
662
  }
663
+
468
664
  // Parse numeric value from strings with uncertainty notation
469
- const numeric_val = parse_numeric_val(val);
470
- if (numeric_val === null)
471
- return { bg: null, text: null };
472
- const col_id = get_col_id(col);
665
+ const numeric_val = parse_numeric_val(val)
666
+ if (numeric_val === null) return { bg: null, text: null }
667
+
668
+ const col_id = get_col_id(col)
473
669
  // Use memoized parsed values for the column
474
- const numeric_vals = parsed_column_values.get(col_id) ?? [];
475
- const better = better_overrides.get(col_id) ?? col.better;
670
+ const numeric_vals = parsed_column_values.get(col_id) ?? []
671
+
672
+ const better = better_overrides.get(col_id) ?? col.better
476
673
  const scale = (color_scale_overrides.get(col_id) ?? col.color_scale ??
477
- `interpolateViridis`);
478
- const color = calc_cell_color(numeric_val, numeric_vals, better, scale, col.scale_type || `linear`);
674
+ `interpolateViridis`) as Parameters<typeof calc_cell_color>[3]
675
+ const color = calc_cell_color(
676
+ numeric_val,
677
+ numeric_vals,
678
+ better,
679
+ scale,
680
+ col.scale_type || `linear`,
681
+ )
682
+
479
683
  // Recompute text contrast against effective bg (cell bg blended with page bg by opacity).
480
684
  // Approximation: blend luminances directly; accurate enough for black/white text choice.
481
685
  if (color.bg && heatmap_opacity < 1) {
482
- const blended_lum = luminance(color.bg) * heatmap_opacity +
483
- page_bg_lum * (1 - heatmap_opacity);
484
- color.text = blended_lum > 0.7 ? `black` : `white`;
686
+ const blended_lum = luminance(color.bg) * heatmap_opacity +
687
+ page_bg_lum * (1 - heatmap_opacity)
688
+ color.text = blended_lum > 0.7 ? `black` : `white`
485
689
  }
486
- return color;
487
- }
488
- let visible_columns = $derived(ordered_columns.filter((col) => col.visible !== false && !hidden_columns.includes(get_col_id(col))));
489
- const sort_indicator = (col, sort_state) => {
690
+ return color
691
+ }
692
+
693
+ let visible_columns = $derived(
694
+ ordered_columns.filter((col) =>
695
+ col.visible !== false && !hidden_columns.includes(get_col_id(col))
696
+ ),
697
+ )
698
+
699
+ const sort_indicator = (col: Label, sort_state: SortState) => {
490
700
  const hide_sort_indicator = col.show_sort_indicator === false ||
491
- col.style?.includes(`--hide-sort-indicator`);
492
- if (hide_sort_indicator)
493
- return ``;
494
- const col_id = get_col_id(col);
701
+ col.style?.includes(`--hide-sort-indicator`)
702
+ if (hide_sort_indicator) return ``
703
+
704
+ const col_id = get_col_id(col)
705
+
495
706
  // Check multi-sort first
496
- const multi_idx = multi_sort.findIndex((s) => s.column === col_id);
707
+ const multi_idx = multi_sort.findIndex((s) => s.column === col_id)
497
708
  if (multi_idx >= 0) {
498
- const arrow = multi_sort[multi_idx].ascending ? `↓` : `↑`;
499
- const badge = multi_sort.length > 1 ? `<sup>${multi_idx + 1}</sup>` : ``;
500
- return `<span style="font-size: 0.8em;">${arrow}${badge}</span>`;
709
+ const arrow = multi_sort[multi_idx].ascending ? `↓` : `↑`
710
+ const badge = multi_sort.length > 1 ? `<sup>${multi_idx + 1}</sup>` : ``
711
+ return `<span style="font-size: 0.8em;">${arrow}${badge}</span>`
501
712
  }
502
- const is_sorted = sort_state.column === col_id;
503
- if (!is_sorted)
504
- return ``;
713
+
714
+ const is_sorted = sort_state.column === col_id
715
+ if (!is_sorted) return ``
505
716
  // Show indicator only for actively sorted columns.
506
- const arrow = sort_state.ascending ? `↓` : `↑`;
507
- return arrow ? `<span style="font-size: 0.8em;">${arrow}</span>` : ``;
508
- };
509
- // Context menu state for column header right-click
510
- let context_menu_col = $state(null);
511
- let context_menu_pos = $state({ x: 0, y: 0 });
512
- const better_sections = [
717
+ const arrow = sort_state.ascending ? `↓` : `↑`
718
+
719
+ return arrow ? `<span style="font-size: 0.8em;">${arrow}</span>` : ``
720
+ }
721
+
722
+ // Context menu state for column header right-click
723
+ let context_menu_col = $state<string | null>(null)
724
+ let context_menu_pos = $state({ x: 0, y: 0 })
725
+
726
+ const better_sections = [
513
727
  {
514
- title: `Gradient direction`,
515
- options: [
516
- { value: `higher`, label: `▲ Higher is better` },
517
- { value: `lower`, label: `▼ Lower is better` },
518
- ],
728
+ title: `Gradient direction`,
729
+ options: [
730
+ { value: `higher`, label: `▲ Higher is better` },
731
+ { value: `lower`, label: `▼ Lower is better` },
732
+ ],
519
733
  },
520
- ];
521
- // Row selection using WeakMap-based ID lookup instead of O(n) JSON.stringify comparison
522
- function toggle_row_select(row) {
523
- const row_id = get_row_id(row);
524
- const idx = selected_rows.findIndex((r) => get_row_id(r) === row_id);
734
+ ] as const
735
+
736
+ // Row selection using WeakMap-based ID lookup instead of O(n) JSON.stringify comparison
737
+ function toggle_row_select(row: RowData) {
738
+ const row_id = get_row_id(row)
739
+ const idx = selected_rows.findIndex((r) => get_row_id(r) === row_id)
525
740
  if (idx >= 0) {
526
- selected_rows = selected_rows.filter((_, i) => i !== idx);
741
+ selected_rows = selected_rows.filter((_, i) => i !== idx)
742
+ } else {
743
+ selected_rows = [...selected_rows, row]
527
744
  }
528
- else {
529
- selected_rows = [...selected_rows, row];
530
- }
531
- }
532
- function is_row_selected(row) {
533
- const row_id = get_row_id(row);
534
- return selected_rows.some((r) => get_row_id(r) === row_id);
535
- }
536
- // Select-all: checks if every row on the current page is selected
537
- let all_page_selected = $derived(paginated_data.length > 0 && paginated_data.every((row) => is_row_selected(row)));
538
- function toggle_select_all() {
745
+ }
746
+
747
+ function is_row_selected(row: RowData): boolean {
748
+ const row_id = get_row_id(row)
749
+ return selected_rows.some((r) => get_row_id(r) === row_id)
750
+ }
751
+
752
+ // Select-all: checks if every row on the current page is selected
753
+ let all_page_selected = $derived(
754
+ paginated_data.length > 0 && paginated_data.every((row) => is_row_selected(row)),
755
+ )
756
+
757
+ function toggle_select_all() {
539
758
  if (all_page_selected) {
540
- const page_ids = new Set(paginated_data.map(get_row_id));
541
- selected_rows = selected_rows.filter((row) => !page_ids.has(get_row_id(row)));
759
+ const page_ids = new Set(paginated_data.map(get_row_id))
760
+ selected_rows = selected_rows.filter((row) => !page_ids.has(get_row_id(row)))
761
+ } else {
762
+ const already = new Set(selected_rows.map(get_row_id))
763
+ const new_rows = paginated_data.filter((row) => !already.has(get_row_id(row)))
764
+ selected_rows = [...selected_rows, ...new_rows]
542
765
  }
543
- else {
544
- const already = new Set(selected_rows.map(get_row_id));
545
- const new_rows = paginated_data.filter((row) => !already.has(get_row_id(row)));
546
- selected_rows = [...selected_rows, ...new_rows];
766
+ }
767
+
768
+ // Data source for exports: selected rows when any are selected, otherwise all sorted data
769
+ let export_rows = $derived(
770
+ show_row_select && selected_rows.length > 0 ? selected_rows : sorted_data,
771
+ )
772
+
773
+ // Serialize table as delimited text (shared by CSV export and clipboard copy)
774
+ // Per RFC 4180, fields containing commas, double quotes, or newlines must be quoted
775
+ function serialize_table(delimiter: string, csv_quote = false): string {
776
+ const quote = (str: string) => {
777
+ if (!csv_quote) return str
778
+ if (str.includes(`,`) || str.includes(`"`) || str.includes(`\n`)) {
779
+ return `"${str.replace(/"/g, `""`)}"`
780
+ }
781
+ return str
547
782
  }
548
- }
549
- // Data source for exports: selected rows when any are selected, otherwise all sorted data
550
- let export_rows = $derived(show_row_select && selected_rows.length > 0 ? selected_rows : sorted_data);
551
- // Serialize table as delimited text (shared by CSV export and clipboard copy)
552
- // Per RFC 4180, fields containing commas, double quotes, or newlines must be quoted
553
- function serialize_table(delimiter, csv_quote = false) {
554
- const quote = (str) => {
555
- if (!csv_quote)
556
- return str;
557
- if (str.includes(`,`) || str.includes(`"`) || str.includes(`\n`)) {
558
- return `"${str.replace(/"/g, `""`)}"`;
559
- }
560
- return str;
561
- };
562
- const headers = visible_columns.map((col) => quote(strip_html(col.label)));
563
- const rows = export_rows.map((row) => visible_columns.map((col) => {
564
- const val = row[get_col_id(col)];
565
- if (val == null)
566
- return ``;
567
- return quote(strip_html(String(val)));
568
- }));
569
- return [headers.join(delimiter), ...rows.map((r) => r.join(delimiter))].join(`\n`);
570
- }
571
- function export_csv(filename = `table-export`) {
572
- download_file(serialize_table(`,`, true), `${filename}.csv`, `text/csv`);
573
- }
574
- function export_json(filename = `table-export`) {
783
+ const headers = visible_columns.map((col) => quote(strip_html(col.label)))
784
+ const rows = export_rows.map((row) =>
785
+ visible_columns.map((col) => {
786
+ const val = row[get_col_id(col)]
787
+ if (val == null) return ``
788
+ return quote(strip_html(String(val)))
789
+ })
790
+ )
791
+ return [headers.join(delimiter), ...rows.map((r) => r.join(delimiter))].join(`\n`)
792
+ }
793
+
794
+ function export_csv(filename = `table-export`) {
795
+ download_file(serialize_table(`,`, true), `${filename}.csv`, `text/csv`)
796
+ }
797
+
798
+ function export_json(filename = `table-export`) {
575
799
  const rows = export_rows.map((row) => {
576
- const clean_row = {};
577
- for (const col of visible_columns) {
578
- const col_id = get_col_id(col);
579
- const val = row[col_id];
580
- clean_row[strip_html(col.label)] = typeof val === `string`
581
- ? strip_html(val)
582
- : val;
583
- }
584
- return clean_row;
585
- });
586
- download_file(JSON.stringify(rows, null, 2), `${filename}.json`, `application/json`);
587
- }
588
- function download_file(content, filename, mime_type) {
589
- const blob = new Blob([content], { type: mime_type });
590
- const url = URL.createObjectURL(blob);
591
- const link = document.createElement(`a`);
592
- link.href = url;
593
- link.download = filename;
594
- document.body.appendChild(link);
595
- link.click();
596
- document.body.removeChild(link);
597
- URL.revokeObjectURL(url);
598
- }
599
- function copy_to_clipboard() {
600
- navigator.clipboard.writeText(serialize_table(`\t`));
601
- }
602
- // Column visibility toggle
603
- function toggle_column(col_id) {
800
+ const clean_row: Record<string, unknown> = {}
801
+ for (const col of visible_columns) {
802
+ const col_id = get_col_id(col)
803
+ const val = row[col_id]
804
+ clean_row[strip_html(col.label)] = typeof val === `string`
805
+ ? strip_html(val)
806
+ : val
807
+ }
808
+ return clean_row
809
+ })
810
+ download_file(
811
+ JSON.stringify(rows, null, 2),
812
+ `${filename}.json`,
813
+ `application/json`,
814
+ )
815
+ }
816
+
817
+ function download_file(content: string, filename: string, mime_type: string) {
818
+ const blob = new Blob([content], { type: mime_type })
819
+ const url = URL.createObjectURL(blob)
820
+ const link = document.createElement(`a`)
821
+ link.href = url
822
+ link.download = filename
823
+ document.body.appendChild(link)
824
+ link.click()
825
+ document.body.removeChild(link)
826
+ URL.revokeObjectURL(url)
827
+ }
828
+
829
+ function copy_to_clipboard() {
830
+ navigator.clipboard.writeText(serialize_table(`\t`))
831
+ }
832
+
833
+ // Column visibility toggle
834
+ function toggle_column(col_id: string) {
604
835
  if (hidden_columns.includes(col_id)) {
605
- hidden_columns = hidden_columns.filter((id) => id !== col_id);
606
- }
607
- else {
608
- hidden_columns = [...hidden_columns, col_id];
836
+ hidden_columns = hidden_columns.filter((id) => id !== col_id)
837
+ } else {
838
+ hidden_columns = [...hidden_columns, col_id]
609
839
  }
610
- }
611
- // Column resize handlers
612
- function start_resize(event, col) {
613
- event.preventDefault();
614
- event.stopPropagation();
615
- resize_col_id = get_col_id(col);
616
- resize_start_x = event.clientX;
617
- const th = event.target instanceof Element ? event.target.parentElement : null;
618
- resize_start_width = th?.offsetWidth ?? 100;
619
- document.addEventListener(`mousemove`, handle_resize);
620
- document.addEventListener(`mouseup`, stop_resize);
621
- }
622
- function handle_resize(event) {
623
- if (!resize_col_id)
624
- return;
625
- const delta = event.clientX - resize_start_x;
626
- const new_width = Math.min(500, Math.max(50, resize_start_width + delta));
627
- column_widths = { ...column_widths, [resize_col_id]: new_width };
628
- }
629
- function stop_resize() {
630
- resize_col_id = null;
631
- document.removeEventListener(`mousemove`, handle_resize);
632
- document.removeEventListener(`mouseup`, stop_resize);
633
- }
634
- // Normalize sort_hint to a config object with defaults
635
- let hint_config = $derived(sort_hint
636
- ? {
637
- position: `bottom`,
840
+ }
841
+
842
+ // Column resize handlers
843
+ function start_resize(event: MouseEvent, col: Label) {
844
+ event.preventDefault()
845
+ event.stopPropagation()
846
+ resize_col_id = get_col_id(col)
847
+ resize_start_x = event.clientX
848
+ const th = event.target instanceof Element ? event.target.parentElement : null
849
+ resize_start_width = th?.offsetWidth ?? 100
850
+
851
+ document.addEventListener(`mousemove`, handle_resize)
852
+ document.addEventListener(`mouseup`, stop_resize)
853
+ }
854
+
855
+ function handle_resize(event: MouseEvent) {
856
+ if (!resize_col_id) return
857
+ const delta = event.clientX - resize_start_x
858
+ const new_width = Math.min(500, Math.max(50, resize_start_width + delta))
859
+ column_widths = { ...column_widths, [resize_col_id]: new_width }
860
+ }
861
+
862
+ function stop_resize() {
863
+ resize_col_id = null
864
+ document.removeEventListener(`mousemove`, handle_resize)
865
+ document.removeEventListener(`mouseup`, stop_resize)
866
+ }
867
+
868
+ // Normalize sort_hint to a config object with defaults
869
+ let hint_config = $derived(
870
+ sort_hint
871
+ ? {
872
+ position: `bottom` as const,
638
873
  permanent: false,
639
874
  ...(typeof sort_hint === `string` ? { text: sort_hint } : sort_hint),
640
- }
641
- : null);
875
+ }
876
+ : null,
877
+ )
642
878
  </script>
643
879
 
644
880
  {#snippet sort_hint_element(pos: `top` | `bottom`)}
@@ -719,7 +955,7 @@ let hint_config = $derived(sort_hint
719
955
  checked={!hidden_columns.includes(col_id)}
720
956
  onchange={() => toggle_column(col_id)}
721
957
  />
722
- {@html col.label}
958
+ {@html sanitize_html(col.label)}
723
959
  </label>
724
960
  {/each}
725
961
  </div>
@@ -856,7 +1092,7 @@ let hint_config = $derived(sort_hint
856
1092
  {#each colored_columns as col (get_col_id(col))}
857
1093
  {@const col_id = get_col_id(col)}
858
1094
  <div class="col-color-row">
859
- <span class="col-color-label">{@html col.label}</span>
1095
+ <span class="col-color-label">{@html sanitize_html(col.label)}</span>
860
1096
  <select
861
1097
  value={color_scale_overrides.get(col_id) ?? col.color_scale ??
862
1098
  `interpolateViridis`}
@@ -928,7 +1164,7 @@ let hint_config = $derived(sort_hint
928
1164
  c.group === col.group && c.label === col.label
929
1165
  )}
930
1166
  <th title={col.description} colspan={group_cols.length}>
931
- {@html col.group}
1167
+ {@html sanitize_html(col.group)}
932
1168
  </th>
933
1169
  {/if}
934
1170
  {/if}
@@ -1024,9 +1260,9 @@ let hint_config = $derived(sort_hint
1024
1260
  {#if header_cell}
1025
1261
  {@render header_cell({ col })}
1026
1262
  {:else}
1027
- {@html col.label}
1263
+ {@html sanitize_html(col.label)}
1028
1264
  {/if}
1029
- {@html sort_indicator(col, sort_state)}
1265
+ {@html sanitize_html(sort_indicator(col, sort_state))}
1030
1266
  <!-- Column resize handle -->
1031
1267
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1032
1268
  <span
@@ -1111,7 +1347,7 @@ let hint_config = $derived(sort_hint
1111
1347
  n/a
1112
1348
  </span>
1113
1349
  {:else}
1114
- {@html val}
1350
+ {@html sanitize_html(val)}
1115
1351
  {/if}
1116
1352
  </td>
1117
1353
  {/each}