matterviz 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/dist/EmptyState.svelte +10 -2
  2. package/dist/FilePicker.svelte +154 -96
  3. package/dist/Icon.svelte +20 -14
  4. package/dist/MillerIndexInput.svelte +27 -21
  5. package/dist/api/optimade.js +6 -6
  6. package/dist/app.css +216 -178
  7. package/dist/brillouin/BrillouinZone.svelte +299 -198
  8. package/dist/brillouin/BrillouinZone.svelte.d.ts +1 -1
  9. package/dist/brillouin/BrillouinZoneControls.svelte +32 -5
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte +74 -55
  11. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte +99 -68
  13. package/dist/brillouin/BrillouinZoneScene.svelte +277 -165
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +1 -1
  15. package/dist/brillouin/BrillouinZoneTooltip.svelte +17 -7
  16. package/dist/brillouin/compute.js +11 -6
  17. package/dist/chempot-diagram/ChemPotDiagram.svelte +327 -0
  18. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +13 -0
  19. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +847 -0
  20. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +16 -0
  21. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +3194 -0
  22. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +16 -0
  23. package/dist/chempot-diagram/ChemPotScene3D.svelte +11 -0
  24. package/dist/chempot-diagram/ChemPotScene3D.svelte.d.ts +7 -0
  25. package/dist/chempot-diagram/async-compute.svelte.d.ts +3 -0
  26. package/dist/chempot-diagram/async-compute.svelte.js +77 -0
  27. package/dist/chempot-diagram/chempot-worker.d.ts +1 -0
  28. package/dist/chempot-diagram/chempot-worker.js +11 -0
  29. package/dist/chempot-diagram/color.d.ts +10 -0
  30. package/dist/chempot-diagram/color.js +32 -0
  31. package/dist/chempot-diagram/compute.d.ts +48 -0
  32. package/dist/chempot-diagram/compute.js +812 -0
  33. package/dist/chempot-diagram/index.d.ts +6 -0
  34. package/dist/chempot-diagram/index.js +6 -0
  35. package/dist/chempot-diagram/pointer.d.ts +16 -0
  36. package/dist/chempot-diagram/pointer.js +40 -0
  37. package/dist/chempot-diagram/temperature.d.ts +15 -0
  38. package/dist/chempot-diagram/temperature.js +36 -0
  39. package/dist/chempot-diagram/types.d.ts +86 -0
  40. package/dist/chempot-diagram/types.js +28 -0
  41. package/dist/colors/index.d.ts +3 -1
  42. package/dist/colors/index.js +9 -3
  43. package/dist/composition/BarChart.svelte +141 -77
  44. package/dist/composition/BubbleChart.svelte +107 -52
  45. package/dist/composition/Composition.svelte +100 -79
  46. package/dist/composition/Formula.svelte +108 -62
  47. package/dist/composition/FormulaFilter.svelte +973 -353
  48. package/dist/composition/FormulaFilter.svelte.d.ts +35 -1
  49. package/dist/composition/PieChart.svelte +199 -99
  50. package/dist/composition/PieChart.svelte.d.ts +1 -1
  51. package/dist/composition/format.d.ts +5 -0
  52. package/dist/composition/format.js +20 -3
  53. package/dist/composition/parse.js +14 -9
  54. package/dist/convex-hull/ConvexHull.svelte +93 -38
  55. package/dist/convex-hull/ConvexHull2D.svelte +551 -393
  56. package/dist/convex-hull/ConvexHull3D.svelte +1303 -825
  57. package/dist/convex-hull/ConvexHull4D.svelte +1012 -686
  58. package/dist/convex-hull/ConvexHullControls.svelte +115 -28
  59. package/dist/convex-hull/ConvexHullInfoPane.svelte +29 -3
  60. package/dist/convex-hull/ConvexHullStats.svelte +821 -249
  61. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +6 -1
  62. package/dist/convex-hull/ConvexHullTooltip.svelte +41 -16
  63. package/dist/convex-hull/GasPressureControls.svelte +104 -61
  64. package/dist/convex-hull/StructurePopup.svelte +25 -4
  65. package/dist/convex-hull/TemperatureSlider.svelte +45 -25
  66. package/dist/convex-hull/barycentric-coords.js +13 -7
  67. package/dist/convex-hull/demo-temperature.d.ts +6 -0
  68. package/dist/convex-hull/demo-temperature.js +40 -0
  69. package/dist/convex-hull/gas-thermodynamics.js +17 -12
  70. package/dist/convex-hull/helpers.d.ts +10 -1
  71. package/dist/convex-hull/helpers.js +79 -38
  72. package/dist/convex-hull/index.d.ts +1 -0
  73. package/dist/convex-hull/index.js +1 -0
  74. package/dist/convex-hull/thermodynamics.d.ts +8 -21
  75. package/dist/convex-hull/thermodynamics.js +163 -69
  76. package/dist/convex-hull/types.d.ts +12 -12
  77. package/dist/convex-hull/types.js +0 -12
  78. package/dist/coordination/CoordinationBarPlot.svelte +232 -176
  79. package/dist/element/BohrAtom.svelte +56 -13
  80. package/dist/element/ElementHeading.svelte +7 -2
  81. package/dist/element/ElementPhoto.svelte +15 -9
  82. package/dist/element/ElementStats.svelte +10 -4
  83. package/dist/element/ElementTile.svelte +137 -73
  84. package/dist/element/Nucleus.svelte +39 -11
  85. package/dist/element/data.js +2 -14
  86. package/dist/element/data.json.gz +0 -0
  87. package/dist/element/types.d.ts +1 -0
  88. package/dist/feedback/ClickFeedback.svelte +16 -5
  89. package/dist/feedback/DragOverlay.svelte +10 -2
  90. package/dist/feedback/Spinner.svelte +4 -2
  91. package/dist/feedback/StatusMessage.svelte +8 -2
  92. package/dist/fermi-surface/FermiSlice.svelte +118 -88
  93. package/dist/fermi-surface/FermiSurface.svelte +336 -239
  94. package/dist/fermi-surface/FermiSurface.svelte.d.ts +1 -1
  95. package/dist/fermi-surface/FermiSurfaceControls.svelte +113 -46
  96. package/dist/fermi-surface/FermiSurfaceScene.svelte +536 -343
  97. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +1 -1
  98. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +14 -5
  99. package/dist/fermi-surface/compute.js +16 -20
  100. package/dist/fermi-surface/parse.js +37 -33
  101. package/dist/fermi-surface/symmetry.js +2 -7
  102. package/dist/fermi-surface/types.d.ts +3 -5
  103. package/dist/heatmap-matrix/HeatmapMatrix.svelte +1527 -0
  104. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +110 -0
  105. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +225 -0
  106. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +30 -0
  107. package/dist/heatmap-matrix/index.d.ts +53 -0
  108. package/dist/heatmap-matrix/index.js +100 -0
  109. package/dist/heatmap-matrix/shared.d.ts +2 -0
  110. package/dist/heatmap-matrix/shared.js +4 -0
  111. package/dist/icons.d.ts +111 -0
  112. package/dist/icons.js +158 -0
  113. package/dist/index.d.ts +5 -2
  114. package/dist/index.js +5 -2
  115. package/dist/io/decompress.js +1 -1
  116. package/dist/io/export.d.ts +3 -0
  117. package/dist/io/export.js +138 -140
  118. package/dist/io/file-drop.d.ts +7 -0
  119. package/dist/io/file-drop.js +43 -0
  120. package/dist/io/index.d.ts +2 -2
  121. package/dist/io/index.js +2 -112
  122. package/dist/io/is-binary.js +2 -3
  123. package/dist/io/types.d.ts +1 -0
  124. package/dist/io/url-drop.d.ts +2 -0
  125. package/dist/io/url-drop.js +117 -0
  126. package/dist/isosurface/Isosurface.svelte +220 -110
  127. package/dist/isosurface/IsosurfaceControls.svelte +65 -28
  128. package/dist/isosurface/parse.js +104 -56
  129. package/dist/isosurface/slice.d.ts +2 -1
  130. package/dist/isosurface/slice.js +8 -13
  131. package/dist/isosurface/types.d.ts +14 -1
  132. package/dist/isosurface/types.js +152 -5
  133. package/dist/labels.d.ts +2 -1
  134. package/dist/labels.js +12 -8
  135. package/dist/layout/FullscreenToggle.svelte +11 -2
  136. package/dist/layout/InfoCard.svelte +38 -6
  137. package/dist/layout/InfoTag.svelte +125 -94
  138. package/dist/layout/PropertyFilter.svelte +82 -37
  139. package/dist/layout/SettingsSection.svelte +85 -55
  140. package/dist/layout/SubpageGrid.svelte +82 -0
  141. package/dist/layout/SubpageGrid.svelte.d.ts +14 -0
  142. package/dist/layout/index.d.ts +1 -0
  143. package/dist/layout/index.js +1 -0
  144. package/dist/layout/json-tree/JsonNode.svelte +266 -223
  145. package/dist/layout/json-tree/JsonTree.svelte +516 -429
  146. package/dist/layout/json-tree/JsonTree.svelte.d.ts +1 -1
  147. package/dist/layout/json-tree/JsonValue.svelte +281 -173
  148. package/dist/layout/json-tree/types.d.ts +10 -2
  149. package/dist/layout/json-tree/utils.d.ts +2 -0
  150. package/dist/layout/json-tree/utils.js +37 -2
  151. package/dist/marching-cubes.js +25 -2
  152. package/dist/math.d.ts +20 -17
  153. package/dist/math.js +474 -57
  154. package/dist/overlays/ContextMenu.svelte +66 -40
  155. package/dist/overlays/DraggablePane.svelte +331 -154
  156. package/dist/overlays/DraggablePane.svelte.d.ts +2 -0
  157. package/dist/periodic-table/PeriodicTable.svelte +278 -145
  158. package/dist/periodic-table/PeriodicTableControls.svelte +178 -128
  159. package/dist/periodic-table/PropertySelect.svelte +25 -7
  160. package/dist/periodic-table/TableInset.svelte +8 -3
  161. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +559 -267
  162. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -2
  163. package/dist/phase-diagram/PhaseDiagramControls.svelte +131 -51
  164. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +3 -2
  165. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +126 -0
  166. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +15 -0
  167. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +160 -110
  168. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +8 -1
  169. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +217 -86
  170. package/dist/phase-diagram/PhaseDiagramTooltip.svelte.d.ts +6 -3
  171. package/dist/phase-diagram/TdbInfoPanel.svelte +28 -4
  172. package/dist/phase-diagram/build-diagram.js +9 -9
  173. package/dist/phase-diagram/colors.js +1 -3
  174. package/dist/phase-diagram/index.d.ts +2 -0
  175. package/dist/phase-diagram/index.js +2 -0
  176. package/dist/phase-diagram/parse.js +10 -9
  177. package/dist/phase-diagram/svg-to-diagram.d.ts +2 -0
  178. package/dist/phase-diagram/svg-to-diagram.js +869 -0
  179. package/dist/phase-diagram/types.d.ts +10 -0
  180. package/dist/phase-diagram/utils.d.ts +8 -4
  181. package/dist/phase-diagram/utils.js +219 -74
  182. package/dist/plot/AxisLabel.svelte +51 -0
  183. package/dist/plot/AxisLabel.svelte.d.ts +16 -0
  184. package/dist/plot/BarPlot.svelte +1461 -768
  185. package/dist/plot/BarPlot.svelte.d.ts +3 -3
  186. package/dist/plot/BarPlotControls.svelte +33 -6
  187. package/dist/plot/BarPlotControls.svelte.d.ts +1 -1
  188. package/dist/plot/ColorBar.svelte +533 -383
  189. package/dist/plot/ColorBar.svelte.d.ts +1 -1
  190. package/dist/plot/ColorScaleSelect.svelte +28 -7
  191. package/dist/plot/ElementScatter.svelte +38 -16
  192. package/dist/plot/FillArea.svelte +152 -92
  193. package/dist/plot/Histogram.svelte +1162 -709
  194. package/dist/plot/Histogram.svelte.d.ts +1 -1
  195. package/dist/plot/HistogramControls.svelte +81 -18
  196. package/dist/plot/HistogramControls.svelte.d.ts +6 -2
  197. package/dist/plot/InteractiveAxisLabel.svelte +34 -11
  198. package/dist/plot/InteractiveAxisLabel.svelte.d.ts +1 -1
  199. package/dist/plot/Line.svelte +63 -28
  200. package/dist/plot/PlotControls.svelte +221 -96
  201. package/dist/plot/PlotControls.svelte.d.ts +1 -1
  202. package/dist/plot/PlotLegend.svelte +174 -91
  203. package/dist/plot/PlotTooltip.svelte +45 -6
  204. package/dist/plot/PortalSelect.svelte +175 -146
  205. package/dist/plot/ReferenceLine.svelte +77 -22
  206. package/dist/plot/ReferenceLine.svelte.d.ts +1 -0
  207. package/dist/plot/ReferenceLine3D.svelte +132 -107
  208. package/dist/plot/ReferencePlane.svelte +146 -123
  209. package/dist/plot/ScatterPlot.svelte +1880 -1156
  210. package/dist/plot/ScatterPlot.svelte.d.ts +3 -3
  211. package/dist/plot/ScatterPlot3D.svelte +256 -131
  212. package/dist/plot/ScatterPlot3D.svelte.d.ts +2 -2
  213. package/dist/plot/ScatterPlot3DControls.svelte +300 -297
  214. package/dist/plot/ScatterPlot3DControls.svelte.d.ts +2 -1
  215. package/dist/plot/ScatterPlot3DScene.svelte +608 -406
  216. package/dist/plot/ScatterPlot3DScene.svelte.d.ts +2 -2
  217. package/dist/plot/ScatterPlotControls.svelte +150 -70
  218. package/dist/plot/ScatterPlotControls.svelte.d.ts +1 -1
  219. package/dist/plot/ScatterPoint.svelte +98 -26
  220. package/dist/plot/ScatterPoint.svelte.d.ts +1 -0
  221. package/dist/plot/SpacegroupBarPlot.svelte +142 -85
  222. package/dist/plot/Surface3D.svelte +159 -108
  223. package/dist/plot/ZeroLines.svelte +96 -0
  224. package/dist/plot/ZeroLines.svelte.d.ts +32 -0
  225. package/dist/plot/ZoomRect.svelte +23 -0
  226. package/dist/plot/ZoomRect.svelte.d.ts +8 -0
  227. package/dist/plot/axis-utils.d.ts +1 -1
  228. package/dist/plot/axis-utils.js +1 -3
  229. package/dist/plot/data-cleaning.js +12 -28
  230. package/dist/plot/data-transform.js +2 -1
  231. package/dist/plot/fill-utils.js +2 -0
  232. package/dist/plot/index.d.ts +6 -2
  233. package/dist/plot/index.js +6 -2
  234. package/dist/plot/interactions.d.ts +8 -10
  235. package/dist/plot/interactions.js +2 -3
  236. package/dist/plot/layout.d.ts +11 -2
  237. package/dist/plot/layout.js +44 -17
  238. package/dist/plot/reference-line.d.ts +5 -22
  239. package/dist/plot/reference-line.js +12 -84
  240. package/dist/plot/scales.js +24 -36
  241. package/dist/plot/types.d.ts +53 -40
  242. package/dist/plot/types.js +12 -7
  243. package/dist/plot/utils/label-placement.d.ts +32 -15
  244. package/dist/plot/utils/label-placement.js +227 -63
  245. package/dist/plot/utils/series-visibility.js +2 -3
  246. package/dist/plot/utils.d.ts +1 -0
  247. package/dist/plot/utils.js +14 -0
  248. package/dist/rdf/RdfPlot.svelte +173 -132
  249. package/dist/rdf/calc-rdf.js +4 -5
  250. package/dist/sanitize.d.ts +4 -0
  251. package/dist/sanitize.js +107 -0
  252. package/dist/settings.d.ts +21 -6
  253. package/dist/settings.js +63 -19
  254. package/dist/spectral/Bands.svelte +963 -412
  255. package/dist/spectral/Bands.svelte.d.ts +22 -2
  256. package/dist/spectral/BandsAndDos.svelte +90 -49
  257. package/dist/spectral/BrillouinBandsDos.svelte +151 -93
  258. package/dist/spectral/Dos.svelte +389 -258
  259. package/dist/spectral/helpers.d.ts +23 -1
  260. package/dist/spectral/helpers.js +119 -51
  261. package/dist/spectral/types.d.ts +2 -0
  262. package/dist/state.svelte.d.ts +1 -1
  263. package/dist/state.svelte.js +3 -2
  264. package/dist/structure/Arrow.svelte +59 -20
  265. package/dist/structure/AtomLegend.svelte +231 -129
  266. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  267. package/dist/structure/Bond.svelte +73 -47
  268. package/dist/structure/CanvasTooltip.svelte +10 -2
  269. package/dist/structure/CellSelect.svelte +148 -51
  270. package/dist/structure/Cylinder.svelte +33 -17
  271. package/dist/structure/Lattice.svelte +88 -33
  272. package/dist/structure/Structure.svelte +1077 -821
  273. package/dist/structure/Structure.svelte.d.ts +1 -1
  274. package/dist/structure/StructureControls.svelte +373 -139
  275. package/dist/structure/StructureControls.svelte.d.ts +1 -1
  276. package/dist/structure/StructureExportPane.svelte +124 -89
  277. package/dist/structure/StructureExportPane.svelte.d.ts +1 -1
  278. package/dist/structure/StructureInfoPane.svelte +304 -231
  279. package/dist/structure/StructureScene.svelte +919 -445
  280. package/dist/structure/StructureScene.svelte.d.ts +16 -7
  281. package/dist/structure/atom-properties.d.ts +6 -2
  282. package/dist/structure/atom-properties.js +42 -29
  283. package/dist/structure/bonding.js +6 -7
  284. package/dist/structure/export.js +22 -34
  285. package/dist/structure/ferrox-wasm-types.d.ts +3 -2
  286. package/dist/structure/ferrox-wasm-types.js +0 -3
  287. package/dist/structure/ferrox-wasm.d.ts +3 -2
  288. package/dist/structure/ferrox-wasm.js +2 -3
  289. package/dist/structure/index.d.ts +16 -0
  290. package/dist/structure/index.js +88 -6
  291. package/dist/structure/measure.d.ts +2 -2
  292. package/dist/structure/measure.js +4 -44
  293. package/dist/structure/parse.js +130 -155
  294. package/dist/structure/partial-occupancy.d.ts +25 -0
  295. package/dist/structure/partial-occupancy.js +99 -0
  296. package/dist/structure/pbc.d.ts +1 -0
  297. package/dist/structure/pbc.js +16 -6
  298. package/dist/structure/supercell.d.ts +2 -2
  299. package/dist/structure/supercell.js +12 -22
  300. package/dist/structure/validation.js +5 -3
  301. package/dist/symmetry/SymmetryStats.svelte +94 -37
  302. package/dist/symmetry/WyckoffTable.svelte +42 -14
  303. package/dist/symmetry/cell-transform.js +5 -3
  304. package/dist/symmetry/index.d.ts +7 -4
  305. package/dist/symmetry/index.js +87 -21
  306. package/dist/symmetry/spacegroups.js +148 -148
  307. package/dist/table/HeatmapTable.svelte +1112 -516
  308. package/dist/table/HeatmapTable.svelte.d.ts +12 -1
  309. package/dist/table/ToggleMenu.svelte +125 -90
  310. package/dist/table/index.d.ts +2 -0
  311. package/dist/table/index.js +2 -4
  312. package/dist/theme/ThemeControl.svelte +21 -12
  313. package/dist/time.js +4 -1
  314. package/dist/tooltip/TooltipContent.svelte +33 -8
  315. package/dist/trajectory/Trajectory.svelte +889 -687
  316. package/dist/trajectory/TrajectoryError.svelte +14 -3
  317. package/dist/trajectory/TrajectoryExportPane.svelte +148 -90
  318. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +1 -1
  319. package/dist/trajectory/TrajectoryInfoPane.svelte +272 -143
  320. package/dist/trajectory/constants.d.ts +6 -0
  321. package/dist/trajectory/constants.js +7 -0
  322. package/dist/trajectory/extract.js +13 -31
  323. package/dist/trajectory/format-detect.d.ts +9 -0
  324. package/dist/trajectory/format-detect.js +76 -0
  325. package/dist/trajectory/frame-reader.d.ts +17 -0
  326. package/dist/trajectory/frame-reader.js +332 -0
  327. package/dist/trajectory/helpers.d.ts +14 -0
  328. package/dist/trajectory/helpers.js +172 -0
  329. package/dist/trajectory/index.d.ts +1 -0
  330. package/dist/trajectory/index.js +23 -14
  331. package/dist/trajectory/parse/ase.d.ts +2 -0
  332. package/dist/trajectory/parse/ase.js +77 -0
  333. package/dist/trajectory/parse/hdf5.d.ts +2 -0
  334. package/dist/trajectory/parse/hdf5.js +129 -0
  335. package/dist/trajectory/parse/index.d.ts +12 -0
  336. package/dist/trajectory/parse/index.js +299 -0
  337. package/dist/trajectory/parse/lammps.d.ts +5 -0
  338. package/dist/trajectory/parse/lammps.js +179 -0
  339. package/dist/trajectory/parse/vasp.d.ts +2 -0
  340. package/dist/trajectory/parse/vasp.js +68 -0
  341. package/dist/trajectory/parse/xyz.d.ts +2 -0
  342. package/dist/trajectory/parse/xyz.js +110 -0
  343. package/dist/trajectory/plotting.js +13 -8
  344. package/dist/trajectory/types.d.ts +11 -0
  345. package/dist/trajectory/types.js +1 -0
  346. package/dist/utils.d.ts +3 -0
  347. package/dist/utils.js +17 -0
  348. package/dist/xrd/XrdPlot.svelte +337 -245
  349. package/dist/xrd/broadening.js +14 -9
  350. package/dist/xrd/calc-xrd.js +12 -19
  351. package/dist/xrd/parse.d.ts +1 -1
  352. package/dist/xrd/parse.js +17 -17
  353. package/package.json +103 -101
  354. package/readme.md +4 -4
  355. package/dist/trajectory/parse.d.ts +0 -42
  356. package/dist/trajectory/parse.js +0 -1267
  357. /package/dist/element/{data.json.d.ts → data.json.gz.d.ts} +0 -0
  358. /package/dist/theme/{themes.js → themes.mjs} +0 -0
@@ -1,556 +1,880 @@
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 { calc_cell_color, strip_html } from './';
5
- import { tooltip } from 'svelte-multiselect/attachments';
6
- import { flip } from 'svelte/animate';
7
- import { SvelteMap } from 'svelte/reactivity';
8
- 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
9
- fixed_header = false, default_num_format = `.3`, show_heatmap = $bindable(true), heatmap_class = `heatmap`, 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, onsort = undefined, onsorterror = undefined, loading = $bindable(false), sort_data = true, heatmap_opacity = $bindable(1), ...rest } = $props();
10
- let container_el = $state();
11
- // Read --page-bg from computed style for text contrast calculation.
12
- // Recalculates on mount and when the theme changes (dark/light mode toggle).
13
- let page_bg_lum = $state(luminance(`white`));
14
- $effect(() => {
15
- if (!container_el)
16
- 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
17
135
  const read_page_bg = () => {
18
- const page_bg = getComputedStyle(container_el).getPropertyValue(`--page-bg`)
19
- .trim();
20
- page_bg_lum = luminance(page_bg || `white`);
21
- };
22
- read_page_bg();
23
- return watch_dark_mode(read_page_bg);
24
- });
25
- // Detect HTML to prevent setting raw HTML as data-sort-value. Simple string matching
26
- // suffices since false positives just skip setting the attr (sorting still works by inner data-sort-value).
27
- function is_html_str(val) {
28
- if (typeof val !== `string`)
29
- return false;
30
- return ((val.includes(`<`) && val.includes(`>`)) || // Has angle brackets
31
- val.startsWith(`&lt;`) || // Has HTML entity for <
32
- val.includes(`href=`) || // Has href attribute
33
- val.includes(`class=`) // Has class attribute
34
- );
35
- }
36
- // Normalize initial_sort config
37
- let initial_sort_config = $derived(initial_sort
38
- ? typeof initial_sort === `string`
39
- ? { column: initial_sort, direction: `asc` }
40
- : { direction: `asc`, ...initial_sort }
41
- : null);
42
- // Normalize pagination config
43
- let pagination_config = $derived(pagination
44
- ? { page_size: 25, ...(typeof pagination === `object` ? pagination : {}) }
45
- : null);
46
- // Normalize search config
47
- let search_config = $derived(search
48
- ? {
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
+ ? {
49
180
  placeholder: `Filter...`,
50
181
  expanded: false,
51
182
  ...(typeof search === `object` ? search : {}),
52
- }
53
- : null);
54
- const default_formats = [`csv`, `json`];
55
- let export_config = $derived(export_data
56
- ? {
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
+ ? {
57
193
  formats: default_formats,
58
194
  filename: `table-export`,
59
195
  ...(typeof export_data === `object` ? export_data : {}),
60
- }
61
- : null);
62
- // Derive sort_state from bindable prop, falling back to initial_sort if sort not yet set
63
- // This ensures immediate sorting on first render without waiting for effects
64
- 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>({
65
203
  column: sort.column || initial_sort_config?.column || ``,
66
204
  ascending: sort.column
67
- ? sort.dir !== `desc`
68
- : initial_sort_config?.direction !== `desc`,
69
- });
70
- // Multi-column sort state (for Shift+click)
71
- let multi_sort = $state([]);
72
- // Search/filter state
73
- let search_query = $state(``);
74
- let search_expanded = $derived(search_config?.expanded ?? false);
75
- // Pagination state
76
- let current_page = $state(1);
77
- // Dropdown states
78
- let show_column_dropdown = $state(false);
79
- let show_export_dropdown = $state(false);
80
- // Column resize state
81
- let resize_col_id = $state(null);
82
- let resize_start_x = $state(0);
83
- let resize_start_width = $state(0);
84
- let column_widths = $state({});
85
- // Helper to make column IDs (needed since column labels in different groups can be repeated)
86
- const get_col_id = (col) => col.group ? `${col.key ?? col.label} (${col.group})` : (col.key ?? col.label);
87
- // Sync column_order with columns: initialize if empty, remove stale IDs, append new IDs
88
- $effect(() => {
89
- if (columns.length === 0)
90
- return;
91
- const col_ids = columns.map(get_col_id);
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 = [
230
+ `interpolateViridis`,
231
+ `interpolatePlasma`,
232
+ `interpolateInferno`,
233
+ `interpolateCividis`,
234
+ `interpolateTurbo`,
235
+ `interpolateBlues`,
236
+ `interpolateGreens`,
237
+ `interpolateReds`,
238
+ `interpolateYlOrRd`,
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> = {}
258
+ for (const row of data.slice(0, 50)) {
259
+ for (const key of Object.keys(row)) {
260
+ if (key !== `style` && key !== `class`) seen[key] = true
261
+ }
262
+ }
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
+
92
275
  // Case 1: First render - initialize with default order
93
276
  if (column_order.length === 0) {
94
- column_order = col_ids;
95
- return;
277
+ column_order = col_ids
278
+ return
96
279
  }
97
- // Case 2: Already in sync - skip to avoid infinite effect loop
98
- const arrays_equal = column_order.length === col_ids.length &&
99
- column_order.every((id, idx) => id === col_ids[idx]);
100
- if (arrays_equal)
101
- return;
102
- // Case 3: Sync needed - keep valid IDs in their order, append any new ones
103
- const valid_ids = new Set(col_ids);
104
- const kept = column_order.filter((id) => valid_ids.has(id));
105
- const new_ids = col_ids.filter((id) => !kept.includes(id));
106
- column_order = [...kept, ...new_ids];
107
- });
108
- // Reorder columns based on column_order
109
- let ordered_columns = $derived.by(() => {
110
- if (column_order.length === 0)
111
- return columns;
112
- 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
+
113
303
  // Add columns in specified order, then any remaining columns that weren't in the order list
114
304
  const ordered = column_order
115
- .map((id) => col_map.get(id))
116
- .filter(Boolean);
117
- const ordered_ids = new Set(ordered.map(get_col_id));
118
- const remaining = columns.filter((col) => !ordered_ids.has(get_col_id(col)));
119
- return [...ordered, ...remaining];
120
- });
121
- let drag_col_id = $state(null);
122
- let drag_over_col_id = $state(null);
123
- // WeakMap to assign stable unique IDs to row objects for efficient comparison and keying
124
- // This avoids O(n) JSON.stringify calls and prevents unnecessary re-renders
125
- const row_id_map = new WeakMap();
126
- let row_id_counter = 0;
127
- function get_row_id(row) {
128
- 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)
129
331
  if (id === undefined) {
130
- id = `row_${row_id_counter++}`;
131
- row_id_map.set(row, id);
332
+ id = `row_${row_id_counter++}`
333
+ row_id_map.set(row, id)
132
334
  }
133
- return id;
134
- }
135
- // Returns 'left' or 'right' to indicate which side of target to insert dragged column
136
- function get_drag_side(target_col_id) {
137
- if (!drag_col_id)
138
- return null;
139
- const drag_idx = column_order.indexOf(drag_col_id);
140
- const target_idx = column_order.indexOf(target_col_id);
141
- if (drag_idx === -1 || target_idx === -1)
142
- return null;
143
- return drag_idx < target_idx ? `right` : `left`;
144
- }
145
- function reset_drag_state() {
146
- drag_col_id = null;
147
- drag_over_col_id = null;
148
- }
149
- const get_drag_col_group = () => ordered_columns.find((col) => get_col_id(col) === drag_col_id)?.group;
150
- function handle_drag_start(event, col) {
151
- if (!event.dataTransfer)
152
- return;
153
- drag_col_id = get_col_id(col);
154
- event.dataTransfer.effectAllowed = `move`;
155
- event.dataTransfer.setData(`text/html`, ``);
156
- }
157
- function handle_drag_over(event, col) {
158
- event.preventDefault();
159
- if (!event.dataTransfer)
160
- return;
161
- 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
+
162
367
  // Prevent cross-group drag-over to keep group headers contiguous
163
368
  if (get_drag_col_group() !== col.group) {
164
- event.dataTransfer.dropEffect = `none`;
165
- drag_over_col_id = null;
166
- return;
369
+ event.dataTransfer.dropEffect = `none`
370
+ drag_over_col_id = null
371
+ return
167
372
  }
168
- drag_over_col_id = get_col_id(col);
169
- }
170
- function handle_drop(event, target_col) {
171
- 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
+
172
380
  // Block cross-group (or group→ungroup) reorders to preserve group contiguity
173
381
  if (!drag_col_id || drag_col_id === get_col_id(target_col)) {
174
- reset_drag_state();
175
- return;
382
+ reset_drag_state()
383
+ return
176
384
  }
385
+
177
386
  // Block cross-group reorders to preserve group contiguity
178
387
  if (get_drag_col_group() !== target_col.group) {
179
- reset_drag_state();
180
- return;
388
+ reset_drag_state()
389
+ return
181
390
  }
182
- const target_col_id = get_col_id(target_col);
183
- const drag_idx = column_order.indexOf(drag_col_id);
184
- 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
+
185
396
  if (drag_idx === -1 || target_idx === -1) {
186
- reset_drag_state();
187
- return;
397
+ reset_drag_state()
398
+ return
188
399
  }
400
+
189
401
  // Reorder: remove dragged column, then insert at target position
190
402
  // When dragging left-to-right (drag_idx < target_idx), removing the dragged
191
403
  // element shifts all subsequent indices down by 1, so we must adjust target_idx
192
- const new_order = [...column_order];
193
- new_order.splice(drag_idx, 1);
194
- const adjusted_target = drag_idx < target_idx ? target_idx - 1 : target_idx;
195
- new_order.splice(adjusted_target, 0, drag_col_id);
196
- column_order = new_order;
197
- reset_drag_state();
198
- }
199
- // Filter data based on search query
200
- let filtered_data = $derived.by(() => {
201
- const base_data = data?.filter?.((row) => Object.values(row).some((val) => val !== undefined)) ?? [];
202
- if (!search_query.trim())
203
- return base_data;
204
- const query = search_query.toLowerCase().trim();
205
- return base_data.filter((row) => Object.values(row).some((val) => {
206
- if (val == null)
207
- return false;
208
- const clean_val = strip_html(String(val)).toLowerCase();
209
- return clean_val.includes(query);
210
- }));
211
- });
212
- 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(() => {
213
431
  // Skip client-side sorting when using async onsort callback or sort_data is false
214
- if (onsort || !sort_data)
215
- return filtered_data;
216
- if (!sort_state.column && multi_sort.length === 0)
217
- 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
+
218
436
  // Helper to check if value is invalid (null, undefined, NaN)
219
- 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
+
220
440
  // Get sort value from a cell (handles HTML data-sort-value and numbers with errors)
221
- const get_sort_val = (val) => {
222
- if (typeof val === `string`) {
223
- // Check for HTML data-sort-value attribute first
224
- const sort_attr_match = val.match(/data-sort-value="([^"]*)"/);
225
- if (sort_attr_match) {
226
- const num = Number(sort_attr_match[1]);
227
- return isNaN(num) ? sort_attr_match[1] : num;
228
- }
229
- // Handle numbers with error notation: "1.23 ± 0.05" or "1.23 +- 0.05" or "1.23(5)"
230
- // Extract the primary number before the ± or +- or (
231
- // Supports: ± (U+00B1), ASCII +-, Unicode minus − (U+2212), with optional whitespace
232
- const error_match = val.match(/^([+-−]?\d+\.?\d*(?:[eE][+-−]?\d+)?)\s*(?:[±\u00B1]|[+][−-]|\()/);
233
- if (error_match) {
234
- const num = Number(error_match[1]);
235
- if (!isNaN(num))
236
- return num;
237
- }
238
- // Try parsing as a plain number (handles "1.23" strings)
239
- const plain_num = Number(val);
240
- if (!isNaN(plain_num) && val.trim() !== ``)
241
- 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
242
458
  }
243
- return val;
244
- };
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
+
245
466
  // Build sort criteria: multi_sort takes precedence, fallback to single sort
246
467
  const sort_criteria = multi_sort.length > 0
247
- ? multi_sort
248
- : sort_state.column
249
- ? [sort_state]
250
- : [];
251
- if (sort_criteria.length === 0)
252
- 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
+
253
475
  return [...filtered_data].sort((row1, row2) => {
254
- for (const { column, ascending } of sort_criteria) {
255
- const matched_col = ordered_columns.find((c) => get_col_id(c) === column);
256
- if (!matched_col)
257
- continue;
258
- const col_id = get_col_id(matched_col);
259
- const val1 = row1[col_id];
260
- const val2 = row2[col_id];
261
- if (val1 === val2)
262
- continue;
263
- // Push invalid values to bottom
264
- if (is_invalid(val1) || is_invalid(val2)) {
265
- return +is_invalid(val1) - +is_invalid(val2);
266
- }
267
- const sort_val1 = get_sort_val(val1);
268
- const sort_val2 = get_sort_val(val2);
269
- const modifier = ascending ? 1 : -1;
270
- if (typeof sort_val1 === `string` && typeof sort_val2 === `string`) {
271
- const cmp = sort_val1.localeCompare(sort_val2, undefined, {
272
- numeric: true,
273
- sensitivity: `base`,
274
- });
275
- if (cmp !== 0)
276
- return cmp * modifier;
277
- }
278
- else {
279
- if (sort_val1 !== sort_val2) {
280
- return (sort_val1 ?? 0) < (sort_val2 ?? 0) ? -modifier : modifier;
281
- }
282
- }
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)
283
489
  }
284
- return 0;
285
- });
286
- });
287
- // Paginated data
288
- let paginated_data = $derived.by(() => {
289
- if (!pagination_config)
290
- return sorted_data;
291
- const start = (current_page - 1) * pagination_config.page_size;
292
- return sorted_data.slice(start, start + pagination_config.page_size);
293
- });
294
- let total_pages = $derived(Math.ceil(sorted_data.length / (pagination_config?.page_size ?? 25)));
295
- // Track previous values to detect actual changes
296
- let prev_search_query = $state(``);
297
- let prev_data_length = $state(0);
298
- // Track async sort requests to prevent race conditions
299
- let sort_request_id = 0;
300
- // Reset to page 1 when search query or data length actually changes
301
- $effect(() => {
302
- const query_changed = search_query !== prev_search_query;
303
- 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
+
304
534
  if (query_changed || data_changed) {
305
- current_page = 1;
306
- prev_search_query = search_query;
307
- 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
308
541
  }
309
- });
310
- 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
+ ) {
311
549
  // Find the column using both label and group if provided
312
- const col = ordered_columns.find((c) => c.label === column && c.group === group);
313
- if (!col)
314
- return; // Skip if column not found
315
- if (col.sortable === false)
316
- return; // Skip sorting if column marked as unsortable
317
- 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
+
318
559
  // Shift+click for multi-column sort
319
560
  if (event.shiftKey) {
320
- const existing_idx = multi_sort.findIndex((s) => s.column === col_id);
321
- if (existing_idx >= 0) {
322
- // Toggle direction or remove if clicked again
323
- const existing = multi_sort[existing_idx];
324
- if (existing.ascending === (col.better === `lower`)) {
325
- // Remove from multi-sort
326
- multi_sort = multi_sort.filter((_, idx) => idx !== existing_idx);
327
- }
328
- else {
329
- // Toggle direction
330
- multi_sort = multi_sort.map((s, idx) => idx === existing_idx ? { ...s, ascending: !s.ascending } : s);
331
- }
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
+ )
332
573
  }
333
- else {
334
- // Add to multi-sort
335
- multi_sort = [...multi_sort, {
336
- column: col_id,
337
- ascending: col.better === `lower`,
338
- }];
339
- }
340
- // Clear single sort when using multi-sort
341
- sort = { column: ``, dir: `asc` };
342
- }
343
- else {
344
- // Regular click - single column sort
345
- multi_sort = []; // Clear multi-sort
346
- // Use sort_state.column for comparison since it includes initial_sort fallback
347
- const new_dir = sort_state.column !== col_id
348
- ? (col.better === `lower` ? `asc` : `desc`)
349
- : (sort_state.ascending ? `desc` : `asc`);
350
- // Save previous sort state in case we need to revert on error
351
- const prev_sort = { ...sort };
352
- sort = { column: col_id, dir: new_dir };
353
- // If onsort callback provided, fetch new data from server
354
- if (onsort) {
355
- loading = true;
356
- const request_id = ++sort_request_id;
357
- try {
358
- const result = await onsort(col_id, new_dir);
359
- // Only update if this is still the most recent request (avoid race condition)
360
- if (request_id === sort_request_id) {
361
- data = result;
362
- }
363
- }
364
- catch (err) {
365
- console.error(`Sort callback failed:`, err);
366
- // Revert sort state on failure so UI doesn't show wrong direction
367
- if (request_id === sort_request_id) {
368
- sort = prev_sort;
369
- onsorterror?.(err, col_id, new_dir);
370
- }
371
- }
372
- finally {
373
- // Only clear loading if this is still the most recent request
374
- if (request_id === sort_request_id) {
375
- loading = false;
376
- }
377
- }
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
+ }
378
617
  }
618
+ }
379
619
  }
380
- }
381
- // Extract numeric value from strings with uncertainty notation: "1.23 ± 0.05", "1.23 +- 0.05", "1.23(5)"
382
- function parse_numeric_val(val) {
383
- if (typeof val === `number`)
384
- return Number.isNaN(val) ? null : val;
385
- if (typeof val !== `string`)
386
- 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
+
387
627
  // Handle numbers with error notation: "1.23 ± 0.05" or "1.23 +- 0.05" or "1.23(5)"
388
628
  // Supports: ± (U+00B1), ASCII +-, Unicode minus − (U+2212), with optional whitespace
389
629
  // Note: [-+−] has hyphen first to avoid regex range interpretation
390
630
  // Pattern allows leading decimals like .5 or -.5 via (?:\d+\.?\d*|\d*\.\d+)
391
- 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
+ )
392
634
  if (error_match) {
393
- // Normalize unicode minus (U+2212) to ASCII hyphen for Number()
394
- const normalized = error_match[1].replace(/−/g, `-`);
395
- const num = Number(normalized);
396
- if (!isNaN(num))
397
- 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
398
639
  }
399
640
  // Try parsing as a plain number (handles "1.23" strings)
400
641
  // Also normalize unicode minus for plain numbers
401
- const normalized_val = val.replace(/−/g, `-`);
402
- const plain_num = Number(normalized_val);
403
- if (!isNaN(plain_num) && val.trim() !== ``)
404
- return plain_num;
405
- return null;
406
- }
407
- // Memoize parsed column values to avoid O(N²) re-parsing in calc_color
408
- let parsed_column_values = $derived.by(() => {
409
- 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)[]>()
410
651
  for (const col of ordered_columns) {
411
- if (col.color_scale === null)
412
- continue;
413
- const col_id = get_col_id(col);
414
- 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])))
415
655
  }
416
- return result;
417
- });
418
- function calc_color(val, col) {
656
+ return result
657
+ })
658
+
659
+ function calc_color(val: CellVal, col: Label) {
419
660
  if (!show_heatmap || col.color_scale === null) {
420
- return { bg: null, text: null };
661
+ return { bg: null, text: null }
421
662
  }
663
+
422
664
  // Parse numeric value from strings with uncertainty notation
423
- const numeric_val = parse_numeric_val(val);
424
- if (numeric_val === null)
425
- return { bg: null, text: null };
426
- 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)
427
669
  // Use memoized parsed values for the column
428
- const numeric_vals = parsed_column_values.get(col_id) ?? [];
429
- // calc_cell_color handles null/NaN filtering internally
430
- const color = calc_cell_color(numeric_val, numeric_vals, col.better, col.color_scale || `interpolateViridis`, col.scale_type || `linear`);
670
+ const numeric_vals = parsed_column_values.get(col_id) ?? []
671
+
672
+ const better = better_overrides.get(col_id) ?? col.better
673
+ const scale = (color_scale_overrides.get(col_id) ?? col.color_scale ??
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
+
431
683
  // Recompute text contrast against effective bg (cell bg blended with page bg by opacity).
432
684
  // Approximation: blend luminances directly; accurate enough for black/white text choice.
433
685
  if (color.bg && heatmap_opacity < 1) {
434
- const blended_lum = luminance(color.bg) * heatmap_opacity +
435
- page_bg_lum * (1 - heatmap_opacity);
436
- 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`
437
689
  }
438
- return color;
439
- }
440
- let visible_columns = $derived(ordered_columns.filter((col) => col.visible !== false && !hidden_columns.includes(get_col_id(col))));
441
- const sort_indicator = (col, sort_state) => {
442
- const col_id = get_col_id(col);
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) => {
700
+ const hide_sort_indicator = col.show_sort_indicator === false ||
701
+ col.style?.includes(`--hide-sort-indicator`)
702
+ if (hide_sort_indicator) return ``
703
+
704
+ const col_id = get_col_id(col)
705
+
443
706
  // Check multi-sort first
444
- const multi_idx = multi_sort.findIndex((s) => s.column === col_id);
707
+ const multi_idx = multi_sort.findIndex((s) => s.column === col_id)
445
708
  if (multi_idx >= 0) {
446
- const arrow = multi_sort[multi_idx].ascending ? `↓` : `↑`;
447
- const badge = multi_sort.length > 1 ? `<sup>${multi_idx + 1}</sup>` : ``;
448
- 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>`
449
712
  }
450
- const is_sorted = sort_state.column === col_id;
451
- // Show for ascending/↑ for descending when sorted
452
- // Show for higher-is-better/↓ for lower-is-better when not sorted
453
- const arrow = is_sorted
454
- ? (sort_state.ascending ? `↓` : `↑`)
455
- : (col.better === `higher` ? `↑` : col.better === `lower` ? `↓` : ``);
456
- return arrow ? `<span style="font-size: 0.8em;">${arrow}</span>` : ``;
457
- };
458
- // Row selection using WeakMap-based ID lookup instead of O(n) JSON.stringify comparison
459
- function toggle_row_select(row) {
460
- const row_id = get_row_id(row);
461
- const idx = selected_rows.findIndex((r) => get_row_id(r) === row_id);
713
+
714
+ const is_sorted = sort_state.column === col_id
715
+ if (!is_sorted) return ``
716
+ // Show indicator only for actively sorted columns.
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 = [
727
+ {
728
+ title: `Gradient direction`,
729
+ options: [
730
+ { value: `higher`, label: `▲ Higher is better` },
731
+ { value: `lower`, label: `▼ Lower is better` },
732
+ ],
733
+ },
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)
462
740
  if (idx >= 0) {
463
- 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]
464
744
  }
465
- else {
466
- selected_rows = [...selected_rows, row];
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() {
758
+ if (all_page_selected) {
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]
467
765
  }
468
- }
469
- function is_row_selected(row) {
470
- const row_id = get_row_id(row);
471
- return selected_rows.some((r) => get_row_id(r) === row_id);
472
- }
473
- // Export functions
474
- function export_csv(filename = `table-export`) {
475
- const headers = visible_columns.map((col) => col.label);
476
- const rows = sorted_data.map((row) => visible_columns.map((col) => {
477
- const val = row[get_col_id(col)];
478
- if (val == null)
479
- return ``;
480
- const str_val = strip_html(String(val));
481
- // Escape quotes and wrap in quotes if contains comma
482
- if (str_val.includes(`,`) || str_val.includes(`"`)) {
483
- return `"${str_val.replace(/"/g, `""`)}"`;
484
- }
485
- return str_val;
486
- }));
487
- const csv_content = [headers.join(`,`), ...rows.map((r) => r.join(`,`))].join(`\n`);
488
- download_file(csv_content, `${filename}.csv`, `text/csv`);
489
- }
490
- function export_json(filename = `table-export`) {
491
- const rows = sorted_data.map((row) => {
492
- const clean_row = {};
493
- for (const col of visible_columns) {
494
- const col_id = get_col_id(col);
495
- const val = row[col_id];
496
- clean_row[col.label] = typeof val === `string` ? strip_html(val) : val;
497
- }
498
- return clean_row;
499
- });
500
- const json_content = JSON.stringify(rows, null, 2);
501
- download_file(json_content, `${filename}.json`, `application/json`);
502
- }
503
- function download_file(content, filename, mime_type) {
504
- const blob = new Blob([content], { type: mime_type });
505
- const url = URL.createObjectURL(blob);
506
- const link = document.createElement(`a`);
507
- link.href = url;
508
- link.download = filename;
509
- document.body.appendChild(link);
510
- link.click();
511
- document.body.removeChild(link);
512
- URL.revokeObjectURL(url);
513
- }
514
- // Column visibility toggle
515
- function toggle_column(col_id) {
516
- if (hidden_columns.includes(col_id)) {
517
- hidden_columns = hidden_columns.filter((id) => id !== col_id);
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
518
782
  }
519
- else {
520
- hidden_columns = [...hidden_columns, col_id];
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`) {
799
+ const rows = export_rows.map((row) => {
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) {
835
+ if (hidden_columns.includes(col_id)) {
836
+ hidden_columns = hidden_columns.filter((id) => id !== col_id)
837
+ } else {
838
+ hidden_columns = [...hidden_columns, col_id]
521
839
  }
522
- }
523
- // Column resize handlers
524
- function start_resize(event, col) {
525
- event.preventDefault();
526
- event.stopPropagation();
527
- resize_col_id = get_col_id(col);
528
- resize_start_x = event.clientX;
529
- const th = event.target.parentElement;
530
- resize_start_width = th?.offsetWidth ?? 100;
531
- document.addEventListener(`mousemove`, handle_resize);
532
- document.addEventListener(`mouseup`, stop_resize);
533
- }
534
- function handle_resize(event) {
535
- if (!resize_col_id)
536
- return;
537
- const delta = event.clientX - resize_start_x;
538
- const new_width = Math.min(500, Math.max(50, resize_start_width + delta));
539
- column_widths = { ...column_widths, [resize_col_id]: new_width };
540
- }
541
- function stop_resize() {
542
- resize_col_id = null;
543
- document.removeEventListener(`mousemove`, handle_resize);
544
- document.removeEventListener(`mouseup`, stop_resize);
545
- }
546
- // Normalize sort_hint to a config object with defaults
547
- let hint_config = $derived(sort_hint
548
- ? {
549
- 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,
550
873
  permanent: false,
551
874
  ...(typeof sort_hint === `string` ? { text: sort_hint } : sort_hint),
552
- }
553
- : null);
875
+ }
876
+ : null,
877
+ )
554
878
  </script>
555
879
 
556
880
  {#snippet sort_hint_element(pos: `top` | `bottom`)}
@@ -567,11 +891,15 @@ let hint_config = $derived(sort_hint
567
891
 
568
892
  <div
569
893
  {@attach tooltip()}
570
- {...rest}
894
+ {...rest_props}
571
895
  bind:this={container_el}
572
- class="table-container {rest.class ?? ``}"
896
+ class="table-container {rest_props.class ?? ``}"
573
897
  style:--heatmap-opacity="{heatmap_opacity * 100}%"
574
- onmouseleave={() => [show_column_dropdown, show_export_dropdown] = [false, false]}
898
+ onmouseleave={() => {
899
+ show_column_dropdown = false
900
+ show_export_dropdown = false
901
+ context_menu_col = null
902
+ }}
575
903
  >
576
904
  <!-- Floating control buttons -->
577
905
  <section class="control-buttons">
@@ -592,12 +920,16 @@ let hint_config = $derived(sort_hint
592
920
  search_query = ``
593
921
  search_expanded = false
594
922
  }}
595
- title="Clear"
923
+ {@attach tooltip({ content: `Clear`, placement: `top` })}
596
924
  >
597
925
  <Icon icon="Cross" style="width: 10px" />
598
926
  </button>
599
927
  {:else}
600
- <button class="icon-btn" onclick={() => search_expanded = true} title="Search">
928
+ <button
929
+ class="icon-btn"
930
+ onclick={() => search_expanded = true}
931
+ {@attach tooltip({ content: `Search`, placement: `top` })}
932
+ >
601
933
  <Icon icon="Search" style="width: 14px" />
602
934
  </button>
603
935
  {/if}
@@ -609,7 +941,7 @@ let hint_config = $derived(sort_hint
609
941
  class="icon-btn"
610
942
  class:active={show_column_dropdown}
611
943
  onclick={() => show_column_dropdown = !show_column_dropdown}
612
- title="Columns"
944
+ {@attach tooltip({ content: `Columns`, placement: `top` })}
613
945
  >
614
946
  <Icon icon="Columns" style="width: 14px" />
615
947
  </button>
@@ -623,7 +955,7 @@ let hint_config = $derived(sort_hint
623
955
  checked={!hidden_columns.includes(col_id)}
624
956
  onchange={() => toggle_column(col_id)}
625
957
  />
626
- {@html col.label}
958
+ {@html sanitize_html(col.label)}
627
959
  </label>
628
960
  {/each}
629
961
  </div>
@@ -637,7 +969,7 @@ let hint_config = $derived(sort_hint
637
969
  class="icon-btn"
638
970
  class:active={show_export_dropdown}
639
971
  onclick={() => show_export_dropdown = !show_export_dropdown}
640
- title="Export"
972
+ {@attach tooltip({ content: `Export`, placement: `top` })}
641
973
  >
642
974
  <Icon icon="Export" style="width: 14px" />
643
975
  </button>
@@ -665,6 +997,15 @@ let hint_config = $derived(sort_hint
665
997
  <Icon icon="Download" style="width: 12px" /> JSON
666
998
  </button>
667
999
  {/if}
1000
+ <button
1001
+ class="dropdown-option"
1002
+ onclick={() => {
1003
+ copy_to_clipboard()
1004
+ show_export_dropdown = false
1005
+ }}
1006
+ >
1007
+ <Icon icon="Copy" style="width: 12px" /> Copy
1008
+ </button>
668
1009
  </div>
669
1010
  {/if}
670
1011
  </div>
@@ -686,6 +1027,106 @@ let hint_config = $derived(sort_hint
686
1027
  {/if}
687
1028
  </section>
688
1029
 
1030
+ {#if show_controls}
1031
+ <DraggablePane
1032
+ bind:show={controls_open}
1033
+ closed_icon="Settings"
1034
+ open_icon="Cross"
1035
+ toggle_props={{
1036
+ title: `${controls_open ? `Close` : `Open`} table controls`,
1037
+ style: `position: absolute; top: 5pt; right: 1ex; z-index: 10`,
1038
+ }}
1039
+ pane_props={{ style: `max-height: 60vh; overflow-y: auto; font-size: 0.85em` }}
1040
+ >
1041
+ <SettingsSection
1042
+ title="Heatmap"
1043
+ current_values={{ show_heatmap, heatmap_opacity }}
1044
+ on_reset={() => {
1045
+ show_heatmap = true
1046
+ heatmap_opacity = 1
1047
+ }}
1048
+ >
1049
+ <label><input type="checkbox" bind:checked={show_heatmap} /> Show heatmap</label>
1050
+ {#if show_heatmap}
1051
+ <label>
1052
+ Opacity
1053
+ <input
1054
+ type="range"
1055
+ min="0"
1056
+ max="1"
1057
+ step="0.05"
1058
+ bind:value={heatmap_opacity}
1059
+ />
1060
+ <input
1061
+ type="number"
1062
+ min="0"
1063
+ max="1"
1064
+ step="0.05"
1065
+ bind:value={heatmap_opacity}
1066
+ style="width: 3.5em"
1067
+ />
1068
+ </label>
1069
+ {/if}
1070
+ </SettingsSection>
1071
+
1072
+ <SettingsSection
1073
+ title="Display"
1074
+ current_values={{ show_row_numbers }}
1075
+ on_reset={() => {
1076
+ show_row_numbers = false
1077
+ }}
1078
+ >
1079
+ <label><input type="checkbox" bind:checked={show_row_numbers} /> Row
1080
+ numbers</label>
1081
+ </SettingsSection>
1082
+
1083
+ {#if colored_columns.length > 0}
1084
+ <SettingsSection
1085
+ title="Column Colors"
1086
+ current_values={Object.fromEntries([...better_overrides, ...color_scale_overrides])}
1087
+ on_reset={() => {
1088
+ better_overrides.clear()
1089
+ color_scale_overrides.clear()
1090
+ }}
1091
+ >
1092
+ {#each colored_columns as col (get_col_id(col))}
1093
+ {@const col_id = get_col_id(col)}
1094
+ <div class="col-color-row">
1095
+ <span class="col-color-label">{@html sanitize_html(col.label)}</span>
1096
+ <select
1097
+ value={color_scale_overrides.get(col_id) ?? col.color_scale ??
1098
+ `interpolateViridis`}
1099
+ onchange={(event) => {
1100
+ const val = event.currentTarget.value
1101
+ if (
1102
+ val === (col.color_scale ?? `interpolateViridis`)
1103
+ ) color_scale_overrides.delete(col_id)
1104
+ else color_scale_overrides.set(col_id, val)
1105
+ }}
1106
+ >
1107
+ {#each color_scale_options as scale (scale)}
1108
+ <option value={scale}>{scale.replace(`interpolate`, ``)}</option>
1109
+ {/each}
1110
+ </select>
1111
+ <select
1112
+ value={better_overrides.get(col_id) ?? col.better ?? ``}
1113
+ onchange={(event) => {
1114
+ const val = event.currentTarget.value
1115
+ if (!val) better_overrides.delete(col_id)
1116
+ else better_overrides.set(col_id, val as `higher` | `lower`)
1117
+ }}
1118
+ >
1119
+ <option value="">Default</option>
1120
+ <option value="higher">▲ High</option>
1121
+ <option value="lower">▼ Low</option>
1122
+ </select>
1123
+ </div>
1124
+ {/each}
1125
+ </SettingsSection>
1126
+ {/if}
1127
+ </DraggablePane>
1128
+ {/if}
1129
+
689
1130
  {@render sort_hint_element(`top`)}
690
1131
 
691
1132
  <div
@@ -707,20 +1148,24 @@ let hint_config = $derived(sort_hint
707
1148
  {#if show_row_select}
708
1149
  <th class="select-col"></th>
709
1150
  {/if}
710
- {#each visible_columns as
711
- { label, group, description, sticky }
712
- (label + group)
713
- }
714
- {#if !group}
715
- <th class:sticky-col={sticky}></th>
1151
+ {#if show_row_numbers}
1152
+ <th class="row-num-col"></th>
1153
+ {/if}
1154
+ {#each visible_columns as col (get_col_id(col))}
1155
+ {#if !col.group}
1156
+ <th class:sticky-col={col.sticky}></th>
716
1157
  {:else}
717
- {@const group_cols = visible_columns.filter((c) => c.group === group)}
1158
+ {@const group_cols = visible_columns.filter((c) =>
1159
+ c.group === col.group
1160
+ )}
718
1161
  <!-- Only render the group header once for each group by checking if this is the first column of this group -->
719
- {#if visible_columns.findIndex((c) => c.group === group) ===
1162
+ {#if visible_columns.findIndex((c) => c.group === col.group) ===
720
1163
  visible_columns.findIndex((c) =>
721
- c.group === group && c.label === label
1164
+ c.group === col.group && c.label === col.label
722
1165
  )}
723
- <th title={description} colspan={group_cols.length}>{@html group}</th>
1166
+ <th title={col.description} colspan={group_cols.length}>
1167
+ {@html sanitize_html(col.group)}
1168
+ </th>
724
1169
  {/if}
725
1170
  {/if}
726
1171
  {/each}
@@ -729,11 +1174,21 @@ let hint_config = $derived(sort_hint
729
1174
  <!-- Second level headers -->
730
1175
  <tr>
731
1176
  {#if show_row_select}
732
- <th class="select-col" title="Select rows">
733
- <Icon icon="Checkbox" style="width: 14px; opacity: 0.7" />
1177
+ <th
1178
+ class="select-col"
1179
+ title={all_page_selected ? `Deselect all` : `Select all on this page`}
1180
+ >
1181
+ <input
1182
+ type="checkbox"
1183
+ checked={all_page_selected}
1184
+ onchange={toggle_select_all}
1185
+ />
734
1186
  </th>
735
1187
  {/if}
736
- {#each visible_columns as col (col.label + col.group)}
1188
+ {#if show_row_numbers}
1189
+ <th class="row-num-col">#</th>
1190
+ {/if}
1191
+ {#each visible_columns as col (get_col_id(col))}
737
1192
  {@const col_id = get_col_id(col)}
738
1193
  {@const drag_side = drag_over_col_id === col_id
739
1194
  ? get_drag_side(col_id)
@@ -743,6 +1198,20 @@ let hint_config = $derived(sort_hint
743
1198
  title={col.description}
744
1199
  tabindex={col.sortable === false ? undefined : 0}
745
1200
  role={col.sortable === false ? undefined : `button`}
1201
+ oncontextmenu={(event) => {
1202
+ if (
1203
+ !allow_better_toggle || col.color_scale === null ||
1204
+ col.color_scale === undefined
1205
+ ) return
1206
+ event.preventDefault()
1207
+ event.stopPropagation()
1208
+ context_menu_col = col_id
1209
+ const rect = container_el?.getBoundingClientRect()
1210
+ context_menu_pos = {
1211
+ x: event.clientX - (rect?.left ?? 0),
1212
+ y: event.clientY - (rect?.top ?? 0),
1213
+ }
1214
+ }}
746
1215
  onclick={(event) => {
747
1216
  if (!drag_col_id && !resize_col_id) {
748
1217
  sort_rows(
@@ -788,8 +1257,12 @@ let hint_config = $derived(sort_hint
788
1257
  event.currentTarget.removeAttribute(`aria-grabbed`)
789
1258
  }}
790
1259
  >
791
- {@html col.label}
792
- {@html sort_indicator(col, sort_state)}
1260
+ {#if header_cell}
1261
+ {@render header_cell({ col })}
1262
+ {:else}
1263
+ {@html sanitize_html(col.label)}
1264
+ {/if}
1265
+ {@html sanitize_html(sort_indicator(col, sort_state))}
793
1266
  <!-- Column resize handle -->
794
1267
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
795
1268
  <span
@@ -806,14 +1279,32 @@ let hint_config = $derived(sort_hint
806
1279
  </tr>
807
1280
  </thead>
808
1281
  <tbody>
809
- {#each paginated_data as row (get_row_id(row))}
1282
+ {#each paginated_data as row, row_idx (get_row_id(row))}
810
1283
  {@const row_selected = show_row_select && is_row_selected(row)}
811
1284
  <tr
812
1285
  animate:flip={{ duration: 500 }}
813
1286
  style={row.style}
814
1287
  class={row.class ?? ``}
815
1288
  class:selected={row_selected}
1289
+ tabindex={onrowclick ? 0 : undefined}
1290
+ onclick={onrowclick ? (event) => onrowclick(event, row) : undefined}
816
1291
  ondblclick={onrowdblclick ? (event) => onrowdblclick(event, row) : undefined}
1292
+ onkeydown={onrowclick
1293
+ ? (event) => {
1294
+ if (event.key === `Enter` || event.key === ` `) {
1295
+ event.preventDefault()
1296
+ onrowclick(event, row)
1297
+ } else if (event.key === `ArrowDown`) {
1298
+ event.preventDefault()
1299
+ const next = event.currentTarget.nextElementSibling
1300
+ if (next instanceof HTMLElement) next.focus()
1301
+ } else if (event.key === `ArrowUp`) {
1302
+ event.preventDefault()
1303
+ const prev = event.currentTarget.previousElementSibling
1304
+ if (prev instanceof HTMLElement) prev.focus()
1305
+ }
1306
+ }
1307
+ : undefined}
817
1308
  >
818
1309
  {#if show_row_select}
819
1310
  <td class="select-col">
@@ -824,7 +1315,12 @@ let hint_config = $derived(sort_hint
824
1315
  />
825
1316
  </td>
826
1317
  {/if}
827
- {#each visible_columns as col (col.label + col.group)}
1318
+ {#if show_row_numbers}
1319
+ <td class="row-num-col">
1320
+ {(current_page - 1) * effective_page_size + row_idx + 1}
1321
+ </td>
1322
+ {/if}
1323
+ {#each visible_columns as col (get_col_id(col))}
828
1324
  {@const val = row[get_col_id(col)]}
829
1325
  {@const color = calc_color(val, col)}
830
1326
  {@const col_width = column_widths[get_col_id(col)]}
@@ -851,13 +1347,29 @@ let hint_config = $derived(sort_hint
851
1347
  n/a
852
1348
  </span>
853
1349
  {:else}
854
- {@html val}
1350
+ {@html sanitize_html(val)}
855
1351
  {/if}
856
1352
  </td>
857
1353
  {/each}
858
1354
  </tr>
1355
+ {:else}
1356
+ {#if empty_message}
1357
+ <tr class="empty-row">
1358
+ <td
1359
+ colspan={visible_columns.length + (show_row_select ? 1 : 0) +
1360
+ (show_row_numbers ? 1 : 0)}
1361
+ >
1362
+ {empty_message}
1363
+ </td>
1364
+ </tr>
1365
+ {/if}
859
1366
  {/each}
860
1367
  </tbody>
1368
+ {#if footer}
1369
+ <tfoot>
1370
+ {@render footer()}
1371
+ </tfoot>
1372
+ {/if}
861
1373
  </table>
862
1374
  </div>
863
1375
 
@@ -914,8 +1426,47 @@ let hint_config = $derived(sort_hint
914
1426
  >
915
1427
  »
916
1428
  </button>
1429
+ {#if pagination_config.page_sizes}
1430
+ <select
1431
+ class="page-size-select"
1432
+ onchange={(event) => {
1433
+ effective_page_size = parseInt(event.currentTarget.value, 10)
1434
+ current_page = 1
1435
+ }}
1436
+ >
1437
+ {#each pagination_config.page_sizes as size (size)}
1438
+ <option value={size} selected={size === effective_page_size}>
1439
+ {size} / page
1440
+ </option>
1441
+ {/each}
1442
+ </select>
1443
+ {/if}
917
1444
  </div>
918
1445
  {/if}
1446
+
1447
+ <ContextMenu
1448
+ sections={better_sections}
1449
+ selected_values={{ 'Gradient direction': better_overrides.get(context_menu_col ?? ``) ?? `` }}
1450
+ position={context_menu_pos}
1451
+ visible={context_menu_col !== null}
1452
+ on_close={() => context_menu_col = null}
1453
+ style={[
1454
+ `--surface-bg: light-dark(#fff, #1e1e1e)`,
1455
+ `--border-color: light-dark(rgba(0,0,0,0.15), rgba(255,255,255,0.15))`,
1456
+ `--text-color: light-dark(#333, #eee)`,
1457
+ `--text-color-muted: light-dark(#888, #999)`,
1458
+ `--surface-bg-hover: light-dark(rgba(0,0,0,0.06), rgba(255,255,255,0.1))`,
1459
+ `--accent-color: light-dark(rgba(0,0,0,0.1), rgba(255,255,255,0.15))`,
1460
+ `z-index: 200`,
1461
+ ].join(`; `)}
1462
+ on_select={(_, option) => {
1463
+ if (!context_menu_col) return
1464
+ const current = better_overrides.get(context_menu_col)
1465
+ if (current === option.value) better_overrides.delete(context_menu_col)
1466
+ else better_overrides.set(context_menu_col, option.value as `higher` | `lower`)
1467
+ context_menu_col = null
1468
+ }}
1469
+ />
919
1470
  </div>
920
1471
 
921
1472
  <style>
@@ -1001,6 +1552,13 @@ let hint_config = $derived(sort_hint
1001
1552
  tbody tr:hover {
1002
1553
  filter: var(--heatmap-row-hover-filter, brightness(1.1));
1003
1554
  }
1555
+ tbody tr[tabindex] {
1556
+ cursor: pointer;
1557
+ }
1558
+ tbody tr:focus-visible {
1559
+ outline: 2px solid var(--highlight, #4a9eff);
1560
+ outline-offset: -2px;
1561
+ }
1004
1562
  td[data-sort-value] {
1005
1563
  cursor: default;
1006
1564
  }
@@ -1018,7 +1576,7 @@ let hint_config = $derived(sort_hint
1018
1576
  justify-content: flex-end;
1019
1577
  align-items: center;
1020
1578
  gap: 2px;
1021
- margin-bottom: 4px;
1579
+ margin-bottom: 1px;
1022
1580
  opacity: 0;
1023
1581
  pointer-events: none;
1024
1582
  transition: opacity 0.15s;
@@ -1029,21 +1587,21 @@ let hint_config = $derived(sort_hint
1029
1587
  pointer-events: auto;
1030
1588
  }
1031
1589
  .icon-btn {
1032
- padding: 5px 8px;
1590
+ padding: 2px 4px;
1033
1591
  border: none;
1034
- border-radius: 4px;
1592
+ border-radius: 3px;
1035
1593
  background: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1));
1036
1594
  color: light-dark(#333, #ddd);
1037
1595
  cursor: pointer;
1038
1596
  display: flex;
1039
1597
  align-items: center;
1040
1598
  justify-content: center;
1041
- gap: 4px;
1042
- font-size: 0.95em;
1599
+ gap: 2px;
1600
+ font-size: 0.8em;
1043
1601
  }
1044
1602
  .icon-btn :global(svg) {
1045
- width: 16px;
1046
- height: 16px;
1603
+ width: 12px;
1604
+ height: 12px;
1047
1605
  }
1048
1606
  .icon-btn:hover {
1049
1607
  background: light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.2));
@@ -1105,13 +1663,13 @@ let hint_config = $derived(sort_hint
1105
1663
  gap: 6px;
1106
1664
  }
1107
1665
  .search-input {
1108
- padding: 5px 8px;
1666
+ padding: 2px 4px;
1109
1667
  border: 1px solid light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.2));
1110
- border-radius: 4px;
1668
+ border-radius: 3px;
1111
1669
  background: light-dark(rgba(255, 255, 255, 0.9), rgba(0, 0, 0, 0.3));
1112
1670
  color: light-dark(#333, #eee);
1113
- font-size: 0.95em;
1114
- width: 120px;
1671
+ font-size: 0.8em;
1672
+ width: 110px;
1115
1673
  box-sizing: border-box;
1116
1674
  }
1117
1675
  .search-input:focus {
@@ -1218,6 +1776,23 @@ let hint_config = $derived(sort_hint
1218
1776
  font-size: 0.85em;
1219
1777
  }
1220
1778
 
1779
+ .col-color-row {
1780
+ display: flex;
1781
+ align-items: center;
1782
+ gap: 4px;
1783
+ padding: 2px 0;
1784
+ select {
1785
+ font-size: 0.85em;
1786
+ padding: 1px 2px;
1787
+ }
1788
+ }
1789
+ .col-color-label {
1790
+ flex: 1;
1791
+ overflow: hidden;
1792
+ text-overflow: ellipsis;
1793
+ white-space: nowrap;
1794
+ min-width: 0;
1795
+ }
1221
1796
  /* Column resize */
1222
1797
  .resize-handle {
1223
1798
  position: absolute;
@@ -1255,4 +1830,25 @@ let hint_config = $derived(sort_hint
1255
1830
  transform: rotate(360deg);
1256
1831
  }
1257
1832
  }
1833
+ .empty-row td {
1834
+ text-align: center;
1835
+ padding: 2em !important;
1836
+ color: var(--text-muted, #888);
1837
+ font-style: italic;
1838
+ }
1839
+ .row-num-col {
1840
+ text-align: right;
1841
+ color: var(--text-muted, #888);
1842
+ font-size: 0.85em;
1843
+ width: 2em;
1844
+ padding-right: 8px !important;
1845
+ }
1846
+ .page-size-select {
1847
+ padding: 2px 4px;
1848
+ border: 1px solid light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2));
1849
+ border-radius: 3px;
1850
+ background: light-dark(#fff, #333);
1851
+ color: inherit;
1852
+ font-size: 0.9em;
1853
+ }
1258
1854
  </style>