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.
- package/dist/EmptyState.svelte +10 -2
- package/dist/FilePicker.svelte +123 -82
- package/dist/Icon.svelte +18 -12
- package/dist/MillerIndexInput.svelte +27 -21
- package/dist/api/optimade.js +6 -6
- package/dist/app.css +216 -207
- package/dist/brillouin/BrillouinZone.svelte +292 -149
- package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
- package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
- package/dist/brillouin/BrillouinZoneExportPane.svelte +69 -42
- package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
- package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
- package/dist/brillouin/BrillouinZoneScene.svelte +275 -163
- package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
- package/dist/brillouin/BrillouinZoneTooltip.svelte +17 -7
- package/dist/brillouin/compute.js +11 -6
- package/dist/chempot-diagram/ChemPotDiagram.svelte +162 -27
- package/dist/chempot-diagram/ChemPotDiagram2D.svelte +451 -281
- package/dist/chempot-diagram/ChemPotDiagram3D.svelte +2148 -1642
- package/dist/chempot-diagram/ChemPotScene3D.svelte +8 -5
- package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
- package/dist/chempot-diagram/async-compute.svelte.js +77 -0
- package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
- package/dist/chempot-diagram/chempot-worker.js +11 -0
- package/dist/chempot-diagram/color.js +1 -2
- package/dist/chempot-diagram/compute.d.ts +10 -0
- package/dist/chempot-diagram/compute.js +250 -88
- package/dist/chempot-diagram/index.d.ts +2 -1
- package/dist/chempot-diagram/index.js +2 -1
- package/dist/chempot-diagram/temperature.js +8 -9
- package/dist/chempot-diagram/types.d.ts +3 -0
- package/dist/chempot-diagram/types.js +1 -0
- package/dist/colors/index.d.ts +1 -1
- package/dist/colors/index.js +5 -3
- package/dist/composition/BarChart.svelte +128 -55
- package/dist/composition/BubbleChart.svelte +102 -49
- package/dist/composition/Composition.svelte +100 -79
- package/dist/composition/Formula.svelte +108 -62
- package/dist/composition/FormulaFilter.svelte +665 -537
- package/dist/composition/PieChart.svelte +183 -108
- package/dist/composition/format.d.ts +5 -0
- package/dist/composition/format.js +20 -3
- package/dist/composition/parse.js +14 -9
- package/dist/convex-hull/ConvexHull.svelte +93 -40
- package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull2D.svelte +549 -360
- package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull3D.svelte +1296 -827
- package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHull4D.svelte +1004 -688
- package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHullControls.svelte +115 -28
- package/dist/convex-hull/ConvexHullControls.svelte.d.ts +1 -1
- package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
- package/dist/convex-hull/ConvexHullStats.svelte +425 -328
- package/dist/convex-hull/ConvexHullTooltip.svelte +40 -16
- package/dist/convex-hull/GasPressureControls.svelte +104 -61
- package/dist/convex-hull/StructurePopup.svelte +25 -4
- package/dist/convex-hull/TemperatureSlider.svelte +45 -25
- package/dist/convex-hull/barycentric-coords.js +13 -7
- package/dist/convex-hull/demo-temperature.js +8 -4
- package/dist/convex-hull/gas-thermodynamics.js +17 -12
- package/dist/convex-hull/helpers.d.ts +9 -0
- package/dist/convex-hull/helpers.js +77 -34
- package/dist/convex-hull/thermodynamics.js +61 -56
- package/dist/convex-hull/types.d.ts +9 -14
- package/dist/convex-hull/types.js +0 -17
- package/dist/coordination/CoordinationBarPlot.svelte +227 -154
- package/dist/element/BohrAtom.svelte +55 -12
- package/dist/element/ElementHeading.svelte +7 -2
- package/dist/element/ElementPhoto.svelte +15 -9
- package/dist/element/ElementStats.svelte +10 -4
- package/dist/element/ElementTile.svelte +137 -73
- package/dist/element/Nucleus.svelte +39 -11
- package/dist/feedback/ClickFeedback.svelte +16 -5
- package/dist/feedback/DragOverlay.svelte +10 -2
- package/dist/feedback/Spinner.svelte +4 -2
- package/dist/feedback/StatusMessage.svelte +8 -2
- package/dist/fermi-surface/FermiSlice.svelte +118 -88
- package/dist/fermi-surface/FermiSurface.svelte +328 -187
- package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
- package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
- package/dist/fermi-surface/FermiSurfaceControls.svelte.d.ts +1 -1
- package/dist/fermi-surface/FermiSurfaceScene.svelte +535 -342
- package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
- package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
- package/dist/fermi-surface/compute.js +16 -20
- package/dist/fermi-surface/parse.js +24 -14
- package/dist/fermi-surface/symmetry.js +2 -7
- package/dist/fermi-surface/types.d.ts +3 -5
- package/dist/heatmap-matrix/HeatmapMatrix.svelte +1019 -765
- package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +1 -1
- package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +76 -22
- package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +2 -3
- package/dist/icons.js +47 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/io/decompress.js +1 -1
- package/dist/io/export.d.ts +3 -0
- package/dist/io/export.js +129 -143
- package/dist/io/is-binary.js +2 -3
- package/dist/io/url-drop.js +1 -2
- package/dist/isosurface/Isosurface.svelte +202 -148
- package/dist/isosurface/IsosurfaceControls.svelte +46 -28
- package/dist/isosurface/parse.js +34 -29
- package/dist/isosurface/slice.js +5 -10
- package/dist/isosurface/types.d.ts +2 -1
- package/dist/isosurface/types.js +61 -12
- package/dist/labels.js +11 -8
- package/dist/layout/FullscreenToggle.svelte +11 -2
- package/dist/layout/InfoCard.svelte +38 -6
- package/dist/layout/InfoTag.svelte +63 -32
- package/dist/layout/PropertyFilter.svelte +82 -37
- package/dist/layout/SettingsSection.svelte +85 -55
- package/dist/layout/SubpageGrid.svelte +10 -2
- package/dist/layout/json-tree/JsonNode.svelte +183 -138
- package/dist/layout/json-tree/JsonTree.svelte +499 -413
- package/dist/layout/json-tree/JsonValue.svelte +127 -99
- package/dist/layout/json-tree/utils.js +4 -2
- package/dist/marching-cubes.js +25 -2
- package/dist/math.d.ts +13 -17
- package/dist/math.js +133 -67
- package/dist/overlays/ContextMenu.svelte +65 -40
- package/dist/overlays/DraggablePane.svelte +211 -139
- package/dist/periodic-table/PeriodicTable.svelte +278 -145
- package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
- package/dist/periodic-table/PropertySelect.svelte +25 -7
- package/dist/periodic-table/TableInset.svelte +8 -3
- package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +446 -309
- package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +1 -1
- package/dist/phase-diagram/PhaseDiagramControls.svelte +102 -43
- package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +1 -1
- package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +63 -40
- package/dist/phase-diagram/PhaseDiagramExportPane.svelte +71 -28
- package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +1 -1
- package/dist/phase-diagram/PhaseDiagramTooltip.svelte +158 -101
- package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
- package/dist/phase-diagram/build-diagram.js +9 -9
- package/dist/phase-diagram/colors.js +1 -3
- package/dist/phase-diagram/parse.js +10 -9
- package/dist/phase-diagram/svg-to-diagram.js +53 -49
- package/dist/phase-diagram/utils.d.ts +1 -0
- package/dist/phase-diagram/utils.js +80 -25
- package/dist/plot/AxisLabel.svelte +28 -3
- package/dist/plot/BarPlot.svelte +1182 -734
- package/dist/plot/BarPlot.svelte.d.ts +2 -2
- package/dist/plot/BarPlotControls.svelte +31 -5
- package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
- package/dist/plot/ColorBar.svelte +479 -329
- package/dist/plot/ColorScaleSelect.svelte +27 -6
- package/dist/plot/ElementScatter.svelte +36 -15
- package/dist/plot/FillArea.svelte +152 -95
- package/dist/plot/Histogram.svelte +934 -571
- package/dist/plot/Histogram.svelte.d.ts +1 -1
- package/dist/plot/HistogramControls.svelte +53 -9
- package/dist/plot/HistogramControls.svelte.d.ts +1 -1
- package/dist/plot/InteractiveAxisLabel.svelte +34 -11
- package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
- package/dist/plot/Line.svelte +63 -28
- package/dist/plot/PlotControls.svelte +157 -114
- package/dist/plot/PlotControls.svelte.d.ts +1 -1
- package/dist/plot/PlotLegend.svelte +174 -91
- package/dist/plot/PlotTooltip.svelte +45 -6
- package/dist/plot/PortalSelect.svelte +175 -147
- package/dist/plot/ReferenceLine.svelte +76 -22
- package/dist/plot/ReferenceLine3D.svelte +132 -107
- package/dist/plot/ReferencePlane.svelte +146 -121
- package/dist/plot/ScatterPlot.svelte +1681 -1091
- package/dist/plot/ScatterPlot.svelte.d.ts +2 -2
- package/dist/plot/ScatterPlot3D.svelte +256 -131
- package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
- package/dist/plot/ScatterPlot3DControls.svelte +113 -63
- package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
- package/dist/plot/ScatterPlot3DScene.svelte +608 -403
- package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
- package/dist/plot/ScatterPlotControls.svelte +65 -25
- package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
- package/dist/plot/ScatterPoint.svelte +98 -26
- package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
- package/dist/plot/SpacegroupBarPlot.svelte +142 -85
- package/dist/plot/Surface3D.svelte +159 -108
- package/dist/plot/ZeroLines.svelte +55 -3
- package/dist/plot/ZoomRect.svelte +4 -2
- package/dist/plot/axis-utils.js +1 -3
- package/dist/plot/data-cleaning.js +12 -28
- package/dist/plot/data-transform.js +2 -1
- package/dist/plot/fill-utils.js +2 -0
- package/dist/plot/layout.d.ts +4 -1
- package/dist/plot/layout.js +33 -14
- package/dist/plot/reference-line.d.ts +2 -2
- package/dist/plot/reference-line.js +7 -5
- package/dist/plot/scales.js +24 -36
- package/dist/plot/types.d.ts +11 -23
- package/dist/plot/types.js +6 -11
- package/dist/plot/utils/label-placement.d.ts +32 -15
- package/dist/plot/utils/label-placement.js +227 -66
- package/dist/plot/utils/series-visibility.js +2 -3
- package/dist/rdf/RdfPlot.svelte +143 -91
- package/dist/rdf/calc-rdf.js +4 -5
- package/dist/sanitize.d.ts +4 -0
- package/dist/sanitize.js +107 -0
- package/dist/settings.d.ts +18 -6
- package/dist/settings.js +46 -16
- package/dist/spectral/Bands.svelte +632 -453
- package/dist/spectral/BandsAndDos.svelte +90 -49
- package/dist/spectral/BrillouinBandsDos.svelte +151 -93
- package/dist/spectral/Dos.svelte +389 -258
- package/dist/spectral/helpers.js +55 -43
- package/dist/state.svelte.d.ts +1 -1
- package/dist/state.svelte.js +3 -2
- package/dist/structure/Arrow.svelte +59 -20
- package/dist/structure/AtomLegend.svelte +215 -134
- package/dist/structure/Bond.svelte +73 -47
- package/dist/structure/CanvasTooltip.svelte +10 -2
- package/dist/structure/CellSelect.svelte +72 -45
- package/dist/structure/Cylinder.svelte +33 -17
- package/dist/structure/Lattice.svelte +88 -33
- package/dist/structure/Structure.svelte +1063 -797
- package/dist/structure/Structure.svelte.d.ts +1 -1
- package/dist/structure/StructureControls.svelte +349 -118
- package/dist/structure/StructureExportPane.svelte +124 -89
- package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
- package/dist/structure/StructureInfoPane.svelte +304 -237
- package/dist/structure/StructureScene.svelte +879 -443
- package/dist/structure/StructureScene.svelte.d.ts +15 -7
- package/dist/structure/atom-properties.js +8 -8
- package/dist/structure/bonding.js +6 -7
- package/dist/structure/export.js +14 -29
- package/dist/structure/ferrox-wasm.js +1 -1
- package/dist/structure/index.d.ts +13 -3
- package/dist/structure/index.js +83 -23
- package/dist/structure/measure.d.ts +2 -2
- package/dist/structure/measure.js +4 -44
- package/dist/structure/parse.js +113 -141
- package/dist/structure/partial-occupancy.js +7 -10
- package/dist/structure/pbc.d.ts +1 -0
- package/dist/structure/pbc.js +16 -6
- package/dist/structure/supercell.d.ts +2 -2
- package/dist/structure/supercell.js +12 -22
- package/dist/structure/validation.js +1 -2
- package/dist/symmetry/SymmetryStats.svelte +84 -41
- package/dist/symmetry/WyckoffTable.svelte +26 -6
- package/dist/symmetry/cell-transform.js +5 -3
- package/dist/symmetry/index.js +8 -7
- package/dist/symmetry/spacegroups.js +148 -148
- package/dist/table/HeatmapTable.svelte +790 -554
- package/dist/table/HeatmapTable.svelte.d.ts +1 -1
- package/dist/table/ToggleMenu.svelte +125 -92
- package/dist/table/index.js +2 -4
- package/dist/theme/ThemeControl.svelte +21 -12
- package/dist/time.js +4 -1
- package/dist/tooltip/TooltipContent.svelte +33 -8
- package/dist/trajectory/Trajectory.svelte +758 -558
- package/dist/trajectory/TrajectoryError.svelte +14 -3
- package/dist/trajectory/TrajectoryExportPane.svelte +137 -83
- package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
- package/dist/trajectory/extract.js +10 -26
- package/dist/trajectory/format-detect.js +5 -5
- package/dist/trajectory/frame-reader.d.ts +1 -1
- package/dist/trajectory/frame-reader.js +5 -12
- package/dist/trajectory/helpers.d.ts +0 -1
- package/dist/trajectory/helpers.js +2 -17
- package/dist/trajectory/index.js +14 -12
- package/dist/trajectory/parse/ase.js +5 -4
- package/dist/trajectory/parse/hdf5.js +26 -18
- package/dist/trajectory/parse/index.js +13 -18
- package/dist/trajectory/parse/lammps.js +17 -7
- package/dist/trajectory/parse/vasp.js +5 -2
- package/dist/trajectory/parse/xyz.js +8 -7
- package/dist/trajectory/plotting.js +13 -8
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +13 -0
- package/dist/xrd/XrdPlot.svelte +337 -247
- package/dist/xrd/broadening.js +14 -9
- package/dist/xrd/calc-xrd.js +12 -18
- package/dist/xrd/parse.d.ts +1 -1
- package/dist/xrd/parse.js +17 -17
- package/package.json +99 -103
- package/readme.md +1 -1
- /package/dist/theme/{themes.js → themes.mjs} +0 -0
|
@@ -1,93 +1,232 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Normalize
|
|
53
|
-
let
|
|
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(`<`) || // 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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
let
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let
|
|
90
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
$
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
277
|
+
column_order = col_ids
|
|
278
|
+
return
|
|
132
279
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
column_order
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
let
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
332
|
+
id = `row_${row_id_counter++}`
|
|
333
|
+
row_id_map.set(row, id)
|
|
174
334
|
}
|
|
175
|
-
return id
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
function reset_drag_state() {
|
|
188
|
-
drag_col_id = null
|
|
189
|
-
drag_over_col_id = null
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
event.dataTransfer
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
event.
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
369
|
+
event.dataTransfer.dropEffect = `none`
|
|
370
|
+
drag_over_col_id = null
|
|
371
|
+
return
|
|
209
372
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
388
|
+
reset_drag_state()
|
|
389
|
+
return
|
|
223
390
|
}
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
const
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
if (!sort_state.column && multi_sort.length === 0)
|
|
259
|
-
|
|
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) =>
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (typeof val !== `string`)
|
|
432
|
-
|
|
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(
|
|
631
|
+
const error_match = val.match(
|
|
632
|
+
/^([-+−]?(?:\d+\.?\d*|\d*\.\d+)(?:[eE][-+−]?\d+)?)\s*(?:±|\+[-−]|\()/,
|
|
633
|
+
)
|
|
438
634
|
if (error_match) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
656
|
+
return result
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
function calc_color(val: CellVal, col: Label) {
|
|
465
660
|
if (!show_heatmap || col.color_scale === null) {
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
const color = calc_cell_color(
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
if (hide_sort_indicator)
|
|
493
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
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
|
-
|
|
741
|
+
selected_rows = selected_rows.filter((_, i) => i !== idx)
|
|
742
|
+
} else {
|
|
743
|
+
selected_rows = [...selected_rows, row]
|
|
527
744
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
})
|
|
586
|
-
download_file(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
document.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
event.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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}
|