matterviz 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (527) hide show
  1. package/dist/EmptyState.svelte +10 -4
  2. package/dist/FilePicker.svelte +12 -14
  3. package/dist/Icon.svelte +7 -4
  4. package/dist/MillerIndexInput.svelte +2 -2
  5. package/dist/app.css +53 -0
  6. package/dist/brillouin/BrillouinZone.svelte +132 -219
  7. package/dist/brillouin/BrillouinZone.svelte.d.ts +9 -20
  8. package/dist/brillouin/BrillouinZoneControls.svelte +3 -6
  9. package/dist/brillouin/BrillouinZoneExportPane.svelte +47 -96
  10. package/dist/brillouin/BrillouinZoneExportPane.svelte.d.ts +1 -1
  11. package/dist/brillouin/BrillouinZoneInfoPane.svelte +15 -42
  12. package/dist/brillouin/BrillouinZoneInfoPane.svelte.d.ts +2 -3
  13. package/dist/brillouin/BrillouinZoneScene.svelte +63 -230
  14. package/dist/brillouin/BrillouinZoneScene.svelte.d.ts +3 -23
  15. package/dist/brillouin/ReciprocalVectors.svelte +39 -0
  16. package/dist/brillouin/ReciprocalVectors.svelte.d.ts +9 -0
  17. package/dist/brillouin/compute.d.ts +2 -0
  18. package/dist/brillouin/compute.js +80 -77
  19. package/dist/brillouin/geometry.d.ts +8 -0
  20. package/dist/brillouin/geometry.js +57 -0
  21. package/dist/brillouin/index.d.ts +2 -0
  22. package/dist/brillouin/index.js +2 -0
  23. package/dist/brillouin/types.d.ts +2 -2
  24. package/dist/chempot-diagram/ChemPotDiagram.svelte +33 -38
  25. package/dist/chempot-diagram/ChemPotDiagram.svelte.d.ts +1 -1
  26. package/dist/chempot-diagram/ChemPotDiagram2D.svelte +143 -246
  27. package/dist/chempot-diagram/ChemPotDiagram2D.svelte.d.ts +4 -1
  28. package/dist/chempot-diagram/ChemPotDiagram3D.svelte +652 -976
  29. package/dist/chempot-diagram/ChemPotDiagram3D.svelte.d.ts +7 -1
  30. package/dist/chempot-diagram/async-compute.svelte.js +1 -1
  31. package/dist/chempot-diagram/color.d.ts +3 -6
  32. package/dist/chempot-diagram/color.js +5 -5
  33. package/dist/chempot-diagram/compute.d.ts +3 -3
  34. package/dist/chempot-diagram/compute.js +3 -1
  35. package/dist/chempot-diagram/controls-state.svelte.d.ts +10 -0
  36. package/dist/chempot-diagram/controls-state.svelte.js +42 -0
  37. package/dist/chempot-diagram/export.d.ts +47 -0
  38. package/dist/chempot-diagram/export.js +133 -0
  39. package/dist/chempot-diagram/index.d.ts +1 -0
  40. package/dist/chempot-diagram/index.js +1 -0
  41. package/dist/chempot-diagram/pointer.d.ts +0 -10
  42. package/dist/chempot-diagram/pointer.js +4 -4
  43. package/dist/chempot-diagram/types.d.ts +3 -3
  44. package/dist/colors/index.d.ts +0 -4
  45. package/dist/colors/index.js +21 -22
  46. package/dist/composition/BarChart.svelte +37 -31
  47. package/dist/composition/BubbleChart.svelte +17 -17
  48. package/dist/composition/Composition.svelte +7 -11
  49. package/dist/composition/Formula.svelte +18 -25
  50. package/dist/composition/FormulaFilter.svelte +96 -98
  51. package/dist/composition/PieChart.svelte +60 -46
  52. package/dist/composition/chem-sys.js +3 -2
  53. package/dist/composition/format.js +4 -3
  54. package/dist/composition/index.d.ts +2 -0
  55. package/dist/composition/index.js +14 -0
  56. package/dist/composition/parse.d.ts +2 -3
  57. package/dist/composition/parse.js +27 -28
  58. package/dist/constants.js +11 -9
  59. package/dist/controls.d.ts +1 -0
  60. package/dist/controls.js +0 -1
  61. package/dist/convex-hull/ConvexHull.svelte +20 -25
  62. package/dist/convex-hull/ConvexHull.svelte.d.ts +1 -4
  63. package/dist/convex-hull/ConvexHull2D.svelte +176 -256
  64. package/dist/convex-hull/ConvexHull2D.svelte.d.ts +1 -1
  65. package/dist/convex-hull/ConvexHull3D.svelte +269 -804
  66. package/dist/convex-hull/ConvexHull3D.svelte.d.ts +1 -1
  67. package/dist/convex-hull/ConvexHull4D.svelte +236 -755
  68. package/dist/convex-hull/ConvexHull4D.svelte.d.ts +1 -1
  69. package/dist/convex-hull/ConvexHullChrome.svelte +264 -0
  70. package/dist/convex-hull/ConvexHullChrome.svelte.d.ts +30 -0
  71. package/dist/convex-hull/ConvexHullControls.svelte +128 -49
  72. package/dist/convex-hull/ConvexHullControls.svelte.d.ts +7 -6
  73. package/dist/convex-hull/ConvexHullInfoPane.svelte +18 -5
  74. package/dist/convex-hull/ConvexHullInfoPane.svelte.d.ts +6 -5
  75. package/dist/convex-hull/ConvexHullStats.svelte +210 -351
  76. package/dist/convex-hull/ConvexHullStats.svelte.d.ts +3 -1
  77. package/dist/convex-hull/ConvexHullTooltip.svelte +16 -14
  78. package/dist/convex-hull/ConvexHullTooltip.svelte.d.ts +2 -1
  79. package/dist/convex-hull/GasPressureControls.svelte +5 -12
  80. package/dist/convex-hull/StructurePopup.svelte +17 -21
  81. package/dist/convex-hull/StructurePopup.svelte.d.ts +2 -0
  82. package/dist/convex-hull/TemperatureSlider.svelte +4 -10
  83. package/dist/convex-hull/barycentric-coords.d.ts +2 -4
  84. package/dist/convex-hull/barycentric-coords.js +6 -33
  85. package/dist/convex-hull/canvas-interactions.svelte.d.ts +79 -0
  86. package/dist/convex-hull/canvas-interactions.svelte.js +279 -0
  87. package/dist/convex-hull/helpers.d.ts +47 -17
  88. package/dist/convex-hull/helpers.js +186 -101
  89. package/dist/convex-hull/hull-state.svelte.d.ts +44 -0
  90. package/dist/convex-hull/hull-state.svelte.js +124 -0
  91. package/dist/convex-hull/index.d.ts +9 -7
  92. package/dist/convex-hull/index.js +7 -2
  93. package/dist/convex-hull/thermodynamics.js +109 -937
  94. package/dist/convex-hull/types.d.ts +12 -4
  95. package/dist/convex-hull/types.js +12 -0
  96. package/dist/coordination/CoordinationBarPlot.svelte +41 -43
  97. package/dist/coordination/CoordinationBarPlot.svelte.d.ts +1 -1
  98. package/dist/coordination/calc-coordination.d.ts +1 -1
  99. package/dist/coordination/calc-coordination.js +28 -25
  100. package/dist/element/BohrAtom.svelte +6 -8
  101. package/dist/element/ElementHeading.svelte +4 -1
  102. package/dist/element/ElementPhoto.svelte +11 -9
  103. package/dist/element/ElementStats.svelte +12 -10
  104. package/dist/element/ElementTile.svelte +24 -29
  105. package/dist/element/ElementTile.svelte.d.ts +1 -1
  106. package/dist/element/Nucleus.svelte +4 -2
  107. package/dist/element/data.d.ts +1 -2
  108. package/dist/element/helpers.d.ts +4 -0
  109. package/dist/element/helpers.js +18 -0
  110. package/dist/element/index.d.ts +1 -0
  111. package/dist/element/index.js +3 -0
  112. package/dist/feedback/DragOverlay.svelte +5 -3
  113. package/dist/feedback/DragOverlay.svelte.d.ts +2 -1
  114. package/dist/feedback/StatusMessage.svelte +20 -7
  115. package/dist/fermi-surface/FermiSlice.svelte +21 -18
  116. package/dist/fermi-surface/FermiSurface.svelte +78 -165
  117. package/dist/fermi-surface/FermiSurface.svelte.d.ts +9 -20
  118. package/dist/fermi-surface/FermiSurfaceControls.svelte +6 -20
  119. package/dist/fermi-surface/FermiSurfaceScene.svelte +93 -272
  120. package/dist/fermi-surface/FermiSurfaceScene.svelte.d.ts +3 -23
  121. package/dist/fermi-surface/FermiSurfaceTooltip.svelte +9 -6
  122. package/dist/fermi-surface/compute.js +14 -13
  123. package/dist/fermi-surface/export.js +4 -15
  124. package/dist/fermi-surface/index.d.ts +0 -1
  125. package/dist/fermi-surface/index.js +0 -1
  126. package/dist/fermi-surface/parse.d.ts +1 -1
  127. package/dist/fermi-surface/parse.js +84 -97
  128. package/dist/fermi-surface/types.d.ts +2 -2
  129. package/dist/heatmap-matrix/HeatmapMatrix.svelte +161 -185
  130. package/dist/heatmap-matrix/HeatmapMatrix.svelte.d.ts +6 -5
  131. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte +12 -9
  132. package/dist/heatmap-matrix/HeatmapMatrixControls.svelte.d.ts +5 -5
  133. package/dist/heatmap-matrix/index.d.ts +8 -2
  134. package/dist/icons.d.ts +4 -0
  135. package/dist/icons.js +4 -0
  136. package/dist/index.d.ts +1 -0
  137. package/dist/index.js +1 -0
  138. package/dist/io/ExportPane.svelte +170 -0
  139. package/dist/io/ExportPane.svelte.d.ts +17 -0
  140. package/dist/io/decompress.d.ts +2 -1
  141. package/dist/io/decompress.js +22 -36
  142. package/dist/io/export.d.ts +9 -1
  143. package/dist/io/export.js +37 -29
  144. package/dist/io/fetch.d.ts +2 -4
  145. package/dist/io/fetch.js +0 -11
  146. package/dist/io/file-drop.d.ts +7 -0
  147. package/dist/io/file-drop.js +13 -0
  148. package/dist/io/index.d.ts +1 -0
  149. package/dist/io/index.js +3 -0
  150. package/dist/io/is-binary.d.ts +9 -0
  151. package/dist/io/is-binary.js +45 -1
  152. package/dist/io/types.d.ts +13 -0
  153. package/dist/io/url-drop.js +13 -25
  154. package/dist/isosurface/Isosurface.svelte +17 -23
  155. package/dist/isosurface/IsosurfaceControls.svelte +37 -42
  156. package/dist/isosurface/parse-vaspwave.d.ts +3 -0
  157. package/dist/isosurface/parse-vaspwave.js +138 -0
  158. package/dist/isosurface/parse.js +50 -48
  159. package/dist/labels.d.ts +6 -2
  160. package/dist/labels.js +44 -14
  161. package/dist/layout/FullscreenButton.svelte +34 -0
  162. package/dist/layout/FullscreenButton.svelte.d.ts +10 -0
  163. package/dist/layout/FullscreenToggle.svelte +12 -12
  164. package/dist/layout/InfoCard.svelte +7 -18
  165. package/dist/layout/InfoTag.svelte +1 -3
  166. package/dist/layout/NumberRangeInput.svelte +46 -0
  167. package/dist/layout/NumberRangeInput.svelte.d.ts +12 -0
  168. package/dist/layout/PropertyFilter.svelte +2 -6
  169. package/dist/layout/SettingsSection.svelte +21 -17
  170. package/dist/layout/SubpageGrid.svelte +3 -1
  171. package/dist/layout/ViewerChrome.svelte +115 -0
  172. package/dist/layout/ViewerChrome.svelte.d.ts +18 -0
  173. package/dist/layout/fullscreen.d.ts +4 -0
  174. package/dist/layout/fullscreen.js +3 -5
  175. package/dist/layout/fullscreen.svelte.d.ts +8 -0
  176. package/dist/layout/fullscreen.svelte.js +36 -0
  177. package/dist/layout/index.d.ts +4 -0
  178. package/dist/layout/index.js +4 -0
  179. package/dist/layout/json-tree/JsonNode.svelte +8 -11
  180. package/dist/layout/json-tree/JsonTree.svelte +53 -77
  181. package/dist/layout/json-tree/JsonValue.svelte +7 -15
  182. package/dist/layout/json-tree/utils.js +14 -23
  183. package/dist/math.d.ts +9 -3
  184. package/dist/math.js +37 -21
  185. package/dist/overlays/ContextMenu.svelte +4 -2
  186. package/dist/overlays/CopyButton.svelte +11 -5
  187. package/dist/overlays/DragControlTab.svelte +6 -1
  188. package/dist/overlays/DraggablePane.svelte +81 -26
  189. package/dist/overlays/DraggablePane.svelte.d.ts +1 -0
  190. package/dist/overlays/GlassChip.svelte +30 -0
  191. package/dist/overlays/GlassChip.svelte.d.ts +8 -0
  192. package/dist/overlays/InfoPaneCards.svelte +11 -23
  193. package/dist/overlays/index.d.ts +11 -0
  194. package/dist/overlays/index.js +26 -0
  195. package/dist/overlays/portal.d.ts +2 -0
  196. package/dist/overlays/portal.js +14 -0
  197. package/dist/periodic-table/PeriodicTable.svelte +115 -146
  198. package/dist/periodic-table/PeriodicTable.svelte.d.ts +5 -3
  199. package/dist/periodic-table/PeriodicTableControls.svelte +11 -63
  200. package/dist/periodic-table/TableInset.svelte +9 -4
  201. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte +50 -71
  202. package/dist/phase-diagram/IsobaricBinaryPhaseDiagram.svelte.d.ts +6 -8
  203. package/dist/phase-diagram/PhaseDiagramControls.svelte +73 -96
  204. package/dist/phase-diagram/PhaseDiagramControls.svelte.d.ts +4 -3
  205. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte +4 -2
  206. package/dist/phase-diagram/PhaseDiagramEditorPane.svelte.d.ts +2 -3
  207. package/dist/phase-diagram/PhaseDiagramExportPane.svelte +49 -134
  208. package/dist/phase-diagram/PhaseDiagramExportPane.svelte.d.ts +3 -4
  209. package/dist/phase-diagram/PhaseDiagramTooltip.svelte +54 -58
  210. package/dist/phase-diagram/TdbInfoPanel.svelte +21 -15
  211. package/dist/phase-diagram/build-diagram.js +3 -3
  212. package/dist/phase-diagram/colors.js +1 -1
  213. package/dist/phase-diagram/diagram-input.d.ts +1 -2
  214. package/dist/phase-diagram/parse.d.ts +2 -1
  215. package/dist/phase-diagram/parse.js +15 -15
  216. package/dist/phase-diagram/svg-to-diagram.js +21 -15
  217. package/dist/phase-diagram/utils.js +2 -3
  218. package/dist/plot/bar/BarPlot.svelte +494 -692
  219. package/dist/plot/bar/BarPlot.svelte.d.ts +9 -15
  220. package/dist/plot/bar/BarPlotControls.svelte +1 -3
  221. package/dist/plot/bar/BarPlotControls.svelte.d.ts +5 -7
  222. package/dist/plot/bar/SpacegroupBarPlot.svelte +31 -37
  223. package/dist/plot/bar/data.d.ts +5 -4
  224. package/dist/plot/bar/data.js +2 -1
  225. package/dist/plot/box/BoxPlot.svelte +361 -439
  226. package/dist/plot/box/BoxPlot.svelte.d.ts +6 -3
  227. package/dist/plot/box/BoxPlotControls.svelte.d.ts +1 -1
  228. package/dist/plot/box/Violin.svelte.d.ts +2 -1
  229. package/dist/plot/box/box-plot.d.ts +3 -2
  230. package/dist/plot/box/box-plot.js +6 -3
  231. package/dist/plot/box/kde.d.ts +2 -1
  232. package/dist/plot/box/kde.js +4 -4
  233. package/dist/plot/core/auto-place.d.ts +1 -1
  234. package/dist/plot/core/auto-place.js +5 -2
  235. package/dist/plot/core/axis-utils.d.ts +12 -7
  236. package/dist/plot/core/axis-utils.js +48 -48
  237. package/dist/plot/core/components/AxisLabel.svelte +82 -20
  238. package/dist/plot/core/components/AxisLabel.svelte.d.ts +1 -0
  239. package/dist/plot/core/components/ColorBar.svelte +106 -111
  240. package/dist/plot/core/components/ColorBar.svelte.d.ts +5 -4
  241. package/dist/plot/core/components/ColorScaleSelect.svelte +1 -1
  242. package/dist/plot/core/components/FillArea.svelte +10 -17
  243. package/dist/plot/core/components/HierarchyControls.svelte +249 -0
  244. package/dist/plot/{sunburst/SunburstControls.svelte.d.ts → core/components/HierarchyControls.svelte.d.ts} +12 -8
  245. package/dist/plot/core/components/InteractiveAxisLabel.svelte +1 -1
  246. package/dist/plot/core/components/InteractiveAxisLabel.svelte.d.ts +1 -1
  247. package/dist/plot/core/components/Line.svelte +21 -11
  248. package/dist/plot/core/components/Line.svelte.d.ts +5 -2
  249. package/dist/plot/core/components/PlotAxis.svelte +42 -18
  250. package/dist/plot/core/components/PlotAxis.svelte.d.ts +2 -1
  251. package/dist/plot/core/components/PlotControls.svelte +65 -41
  252. package/dist/plot/core/components/PlotControls.svelte.d.ts +1 -1
  253. package/dist/plot/core/components/PlotLegend.svelte +31 -35
  254. package/dist/plot/core/components/PlotMarginals.svelte +616 -0
  255. package/dist/plot/core/components/PlotMarginals.svelte.d.ts +15 -0
  256. package/dist/plot/core/components/PlotTooltip.svelte +1 -1
  257. package/dist/plot/core/components/PortalSelect.svelte +4 -8
  258. package/dist/plot/core/components/ReferenceLine.svelte +27 -24
  259. package/dist/plot/core/components/ReferenceLine3D.svelte +56 -59
  260. package/dist/plot/core/components/ReferenceLine3D.svelte.d.ts +4 -4
  261. package/dist/plot/core/components/ReferencePlane.svelte +9 -10
  262. package/dist/plot/core/components/ReferencePlane.svelte.d.ts +4 -4
  263. package/dist/plot/core/components/ZeroLines.svelte +4 -8
  264. package/dist/plot/core/components/ZoomRect.svelte +1 -2
  265. package/dist/plot/core/components/index.d.ts +1 -0
  266. package/dist/plot/core/components/index.js +1 -0
  267. package/dist/plot/core/data-cleaning-signal.d.ts +46 -0
  268. package/dist/plot/core/data-cleaning-signal.js +467 -0
  269. package/dist/plot/core/data-cleaning.d.ts +2 -42
  270. package/dist/plot/core/data-cleaning.js +29 -476
  271. package/dist/plot/core/data-transform.js +9 -5
  272. package/dist/plot/core/fill-utils.d.ts +7 -4
  273. package/dist/plot/core/fill-utils.js +34 -9
  274. package/dist/plot/core/index.d.ts +1 -0
  275. package/dist/plot/core/index.js +1 -0
  276. package/dist/plot/core/interactions.d.ts +5 -1
  277. package/dist/plot/core/interactions.js +14 -2
  278. package/dist/plot/core/layout.d.ts +3 -1
  279. package/dist/plot/core/layout.js +22 -13
  280. package/dist/plot/core/marginals.d.ts +170 -0
  281. package/dist/plot/core/marginals.js +436 -0
  282. package/dist/plot/core/pan-zoom.svelte.d.ts +35 -0
  283. package/dist/plot/core/pan-zoom.svelte.js +221 -0
  284. package/dist/plot/core/placed-tween.svelte.d.ts +21 -0
  285. package/dist/plot/core/placed-tween.svelte.js +68 -0
  286. package/dist/plot/core/reference-line.d.ts +10 -10
  287. package/dist/plot/core/reference-line.js +7 -7
  288. package/dist/plot/core/scales.d.ts +40 -25
  289. package/dist/plot/core/scales.js +49 -21
  290. package/dist/plot/core/svg.d.ts +2 -1
  291. package/dist/plot/core/types/fills.d.ts +101 -0
  292. package/dist/plot/core/types/fills.js +15 -0
  293. package/dist/plot/core/types/plot-3d.d.ts +87 -0
  294. package/dist/plot/core/types/plot-3d.js +4 -0
  295. package/dist/plot/core/types/reference-lines.d.ts +140 -0
  296. package/dist/plot/core/types/reference-lines.js +9 -0
  297. package/dist/plot/core/types.d.ts +47 -331
  298. package/dist/plot/core/types.js +23 -20
  299. package/dist/plot/core/utils/hierarchy-chart.d.ts +51 -0
  300. package/dist/plot/core/utils/hierarchy-chart.js +179 -0
  301. package/dist/plot/core/utils/hierarchy-labels.d.ts +20 -0
  302. package/dist/plot/core/utils/hierarchy-labels.js +99 -0
  303. package/dist/plot/core/utils/label-placement.d.ts +1 -1
  304. package/dist/plot/core/utils/label-placement.js +5 -5
  305. package/dist/plot/core/utils.d.ts +2 -1
  306. package/dist/plot/histogram/Histogram.svelte +373 -631
  307. package/dist/plot/histogram/Histogram.svelte.d.ts +7 -7
  308. package/dist/plot/histogram/HistogramControls.svelte +30 -28
  309. package/dist/plot/histogram/HistogramControls.svelte.d.ts +1 -1
  310. package/dist/plot/histogram/histogram.d.ts +31 -0
  311. package/dist/plot/histogram/histogram.js +70 -0
  312. package/dist/plot/histogram/index.d.ts +1 -0
  313. package/dist/plot/histogram/index.js +1 -0
  314. package/dist/plot/index.d.ts +1 -0
  315. package/dist/plot/index.js +1 -0
  316. package/dist/plot/sankey/Sankey.svelte +79 -73
  317. package/dist/plot/sankey/Sankey.svelte.d.ts +3 -3
  318. package/dist/plot/sankey/SankeyControls.svelte +22 -16
  319. package/dist/plot/sankey/sankey.js +3 -1
  320. package/dist/plot/scatter/BinnedScatterPlot.svelte +264 -112
  321. package/dist/plot/scatter/BinnedScatterPlot.svelte.d.ts +11 -4
  322. package/dist/plot/scatter/ElementScatter.svelte +4 -1
  323. package/dist/plot/scatter/ScatterPlot.svelte +623 -821
  324. package/dist/plot/scatter/ScatterPlot.svelte.d.ts +9 -15
  325. package/dist/plot/scatter/ScatterPlotControls.svelte +18 -19
  326. package/dist/plot/scatter/ScatterPlotControls.svelte.d.ts +1 -1
  327. package/dist/plot/scatter/ScatterPoint.svelte +16 -9
  328. package/dist/plot/scatter/binned-scatter-types.d.ts +6 -12
  329. package/dist/plot/scatter/binned-scatter-types.js +6 -1
  330. package/dist/plot/scatter/index.d.ts +1 -1
  331. package/dist/plot/scatter/scatter-data.d.ts +1 -1
  332. package/dist/plot/scatter/scatter-data.js +8 -7
  333. package/dist/plot/scatter-3d/ScatterPlot3D.svelte +40 -60
  334. package/dist/plot/scatter-3d/ScatterPlot3D.svelte.d.ts +6 -14
  335. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte +17 -15
  336. package/dist/plot/scatter-3d/ScatterPlot3DControls.svelte.d.ts +5 -5
  337. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte +232 -234
  338. package/dist/plot/scatter-3d/ScatterPlot3DScene.svelte.d.ts +5 -14
  339. package/dist/plot/scatter-3d/Surface3D.svelte +9 -17
  340. package/dist/plot/scatter-3d/Surface3D.svelte.d.ts +4 -3
  341. package/dist/plot/sunburst/Sunburst.svelte +256 -338
  342. package/dist/plot/sunburst/Sunburst.svelte.d.ts +4 -3
  343. package/dist/plot/sunburst/index.d.ts +1 -1
  344. package/dist/plot/sunburst/index.js +2 -1
  345. package/dist/plot/sunburst/render.d.ts +2 -2
  346. package/dist/plot/sunburst/render.js +47 -22
  347. package/dist/plot/sunburst/sunburst.d.ts +5 -1
  348. package/dist/plot/sunburst/sunburst.js +14 -4
  349. package/dist/plot/treemap/Treemap.svelte +907 -0
  350. package/dist/plot/treemap/Treemap.svelte.d.ts +84 -0
  351. package/dist/plot/treemap/index.d.ts +3 -0
  352. package/dist/plot/treemap/index.js +4 -0
  353. package/dist/plot/treemap/treemap.d.ts +26 -0
  354. package/dist/plot/treemap/treemap.js +99 -0
  355. package/dist/rdf/RdfPlot.svelte +22 -26
  356. package/dist/rdf/RdfPlot.svelte.d.ts +1 -1
  357. package/dist/rdf/calc-rdf.js +4 -8
  358. package/dist/sanitize.js +14 -3
  359. package/dist/scene/SceneCamera.svelte +62 -0
  360. package/dist/scene/SceneCamera.svelte.d.ts +19 -0
  361. package/dist/scene/bind-renderer.svelte.d.ts +2 -0
  362. package/dist/scene/bind-renderer.svelte.js +22 -0
  363. package/dist/scene/index.d.ts +4 -0
  364. package/dist/scene/index.js +5 -0
  365. package/dist/scene/props.js +52 -0
  366. package/dist/scene/types.d.ts +26 -0
  367. package/dist/scene/types.js +1 -0
  368. package/dist/settings.d.ts +33 -11
  369. package/dist/settings.js +136 -51
  370. package/dist/spectral/Bands.svelte +231 -237
  371. package/dist/spectral/Bands.svelte.d.ts +3 -2
  372. package/dist/spectral/BandsAndDos.svelte +29 -29
  373. package/dist/spectral/BrillouinBandsDos.svelte +21 -29
  374. package/dist/spectral/Dos.svelte +75 -77
  375. package/dist/spectral/Dos.svelte.d.ts +2 -1
  376. package/dist/spectral/helpers.d.ts +9 -9
  377. package/dist/spectral/helpers.js +66 -70
  378. package/dist/state.svelte.d.ts +0 -7
  379. package/dist/state.svelte.js +5 -24
  380. package/dist/structure/Arrow.svelte +6 -21
  381. package/dist/structure/ArrowInstances.svelte +160 -0
  382. package/dist/structure/ArrowInstances.svelte.d.ts +16 -0
  383. package/dist/structure/AtomLegend.svelte +115 -75
  384. package/dist/structure/AtomLegend.svelte.d.ts +1 -1
  385. package/dist/structure/Bond.svelte +31 -23
  386. package/dist/structure/CanvasTooltip.svelte +16 -3
  387. package/dist/structure/CellSelect.svelte +22 -29
  388. package/dist/structure/CellSelect.svelte.d.ts +2 -1
  389. package/dist/structure/Cylinder.svelte +24 -40
  390. package/dist/structure/InstancedAtoms.svelte +114 -0
  391. package/dist/structure/InstancedAtoms.svelte.d.ts +15 -0
  392. package/dist/structure/Lattice.svelte +85 -149
  393. package/dist/structure/SiteLabels.svelte +122 -0
  394. package/dist/structure/SiteLabels.svelte.d.ts +18 -0
  395. package/dist/structure/Structure.svelte +725 -735
  396. package/dist/structure/Structure.svelte.d.ts +8 -16
  397. package/dist/structure/StructureCarousel.svelte +630 -0
  398. package/dist/structure/StructureCarousel.svelte.d.ts +19 -0
  399. package/dist/structure/StructureControls.svelte +298 -325
  400. package/dist/structure/StructureControls.svelte.d.ts +5 -3
  401. package/dist/structure/StructureExportPane.svelte +62 -168
  402. package/dist/structure/StructureExportPane.svelte.d.ts +4 -5
  403. package/dist/structure/StructureInfoPane.svelte +77 -69
  404. package/dist/structure/StructureInfoPane.svelte.d.ts +6 -6
  405. package/dist/structure/StructureScene.svelte +758 -600
  406. package/dist/structure/StructureScene.svelte.d.ts +24 -20
  407. package/dist/structure/StructureViewport.svelte +386 -0
  408. package/dist/structure/StructureViewport.svelte.d.ts +63 -0
  409. package/dist/structure/atom-label-placement.d.ts +18 -0
  410. package/dist/structure/atom-label-placement.js +115 -0
  411. package/dist/structure/atom-properties.d.ts +3 -1
  412. package/dist/structure/atom-properties.js +115 -54
  413. package/dist/structure/bond-order-perception.js +2 -4
  414. package/dist/structure/bonding.d.ts +12 -2
  415. package/dist/structure/bonding.js +172 -73
  416. package/dist/structure/edit-history.d.ts +6 -0
  417. package/dist/structure/edit-history.js +27 -0
  418. package/dist/structure/export.d.ts +25 -4
  419. package/dist/structure/export.js +103 -153
  420. package/dist/structure/format-detect.d.ts +1 -0
  421. package/dist/structure/format-detect.js +22 -0
  422. package/dist/structure/geometry.d.ts +9 -0
  423. package/dist/structure/geometry.js +27 -0
  424. package/dist/structure/index.d.ts +18 -1
  425. package/dist/structure/index.js +15 -2
  426. package/dist/structure/measure.d.ts +1 -1
  427. package/dist/structure/measure.js +6 -12
  428. package/dist/structure/parse.d.ts +5 -3
  429. package/dist/structure/parse.js +551 -567
  430. package/dist/structure/partial-occupancy.d.ts +0 -1
  431. package/dist/structure/partial-occupancy.js +5 -3
  432. package/dist/structure/pbc.d.ts +1 -1
  433. package/dist/structure/pbc.js +204 -51
  434. package/dist/structure/polyhedra.d.ts +40 -0
  435. package/dist/structure/polyhedra.js +587 -0
  436. package/dist/structure/site.d.ts +4 -0
  437. package/dist/structure/site.js +1 -0
  438. package/dist/structure/supercell.d.ts +1 -2
  439. package/dist/structure/supercell.js +10 -15
  440. package/dist/structure/validation.js +5 -6
  441. package/dist/symmetry/SymmetryElementControls.svelte +69 -0
  442. package/dist/symmetry/SymmetryElementControls.svelte.d.ts +9 -0
  443. package/dist/symmetry/SymmetryElements.svelte +350 -0
  444. package/dist/symmetry/SymmetryElements.svelte.d.ts +24 -0
  445. package/dist/symmetry/SymmetryStats.svelte +142 -49
  446. package/dist/symmetry/WyckoffTable.svelte +58 -16
  447. package/dist/symmetry/WyckoffTable.svelte.d.ts +3 -0
  448. package/dist/symmetry/cell-transform.js +8 -14
  449. package/dist/symmetry/index.d.ts +15 -4
  450. package/dist/symmetry/index.js +308 -81
  451. package/dist/symmetry/spacegroups.d.ts +5 -1
  452. package/dist/symmetry/spacegroups.js +18 -3
  453. package/dist/symmetry/symmetry-elements.d.ts +33 -0
  454. package/dist/symmetry/symmetry-elements.js +521 -0
  455. package/dist/symmetry/wyckoff-db.d.ts +9 -0
  456. package/dist/symmetry/wyckoff-db.js +87 -0
  457. package/dist/table/HeatmapTable.svelte +1104 -378
  458. package/dist/table/HeatmapTable.svelte.d.ts +12 -2
  459. package/dist/table/ToggleMenu.svelte +5 -12
  460. package/dist/table/index.d.ts +21 -6
  461. package/dist/table/index.js +34 -21
  462. package/dist/theme/ThemeControl.svelte +16 -8
  463. package/dist/theme/index.d.ts +1 -5
  464. package/dist/theme/index.js +4 -10
  465. package/dist/theme/themes.mjs +14 -7
  466. package/dist/tooltip/KCoords.svelte +4 -1
  467. package/dist/tooltip/TooltipContent.svelte +2 -5
  468. package/dist/trajectory/Trajectory.svelte +556 -352
  469. package/dist/trajectory/Trajectory.svelte.d.ts +10 -22
  470. package/dist/trajectory/TrajectoryError.svelte +11 -8
  471. package/dist/trajectory/TrajectoryExportPane.svelte +28 -54
  472. package/dist/trajectory/TrajectoryExportPane.svelte.d.ts +4 -5
  473. package/dist/trajectory/TrajectoryInfoPane.svelte +88 -99
  474. package/dist/trajectory/TrajectoryInfoPane.svelte.d.ts +3 -2
  475. package/dist/trajectory/constants.js +6 -2
  476. package/dist/trajectory/extract.js +73 -71
  477. package/dist/trajectory/format-detect.d.ts +0 -1
  478. package/dist/trajectory/format-detect.js +13 -17
  479. package/dist/trajectory/frame-reader.d.ts +2 -1
  480. package/dist/trajectory/frame-reader.js +74 -136
  481. package/dist/trajectory/helpers.d.ts +12 -2
  482. package/dist/trajectory/helpers.js +68 -37
  483. package/dist/trajectory/index.d.ts +1 -0
  484. package/dist/trajectory/index.js +3 -0
  485. package/dist/trajectory/parse/ase.d.ts +9 -1
  486. package/dist/trajectory/parse/ase.js +47 -32
  487. package/dist/trajectory/parse/diagnostics.d.ts +3 -0
  488. package/dist/trajectory/parse/diagnostics.js +14 -0
  489. package/dist/trajectory/parse/h5-utils.d.ts +13 -0
  490. package/dist/trajectory/parse/h5-utils.js +109 -0
  491. package/dist/trajectory/parse/hdf5.d.ts +1 -1
  492. package/dist/trajectory/parse/hdf5.js +116 -106
  493. package/dist/trajectory/parse/index.d.ts +3 -4
  494. package/dist/trajectory/parse/index.js +68 -119
  495. package/dist/trajectory/parse/lammps.d.ts +0 -2
  496. package/dist/trajectory/parse/lammps.js +14 -16
  497. package/dist/trajectory/parse/pymatgen.d.ts +2 -0
  498. package/dist/trajectory/parse/pymatgen.js +74 -0
  499. package/dist/trajectory/parse/vasp.js +13 -9
  500. package/dist/trajectory/parse/vaspout-electronic.d.ts +13 -0
  501. package/dist/trajectory/parse/vaspout-electronic.js +207 -0
  502. package/dist/trajectory/parse/vaspout-h5.d.ts +5 -0
  503. package/dist/trajectory/parse/vaspout-h5.js +284 -0
  504. package/dist/trajectory/parse/xyz.d.ts +9 -21
  505. package/dist/trajectory/parse/xyz.js +38 -39
  506. package/dist/trajectory/plotting.d.ts +8 -10
  507. package/dist/trajectory/plotting.js +102 -127
  508. package/dist/utils.d.ts +4 -0
  509. package/dist/utils.js +8 -1
  510. package/dist/xrd/XrdPlot.svelte +56 -85
  511. package/dist/xrd/broadening.d.ts +2 -1
  512. package/dist/xrd/broadening.js +2 -1
  513. package/dist/xrd/calc-xrd.js +8 -15
  514. package/dist/xrd/index.d.ts +2 -2
  515. package/dist/xrd/parse.d.ts +0 -1
  516. package/dist/xrd/parse.js +56 -73
  517. package/package.json +77 -36
  518. package/dist/api/mp.d.ts +0 -6
  519. package/dist/api/mp.js +0 -22
  520. package/dist/element/data.json +0 -11864
  521. package/dist/fermi-surface/marching-cubes.d.ts +0 -2
  522. package/dist/fermi-surface/marching-cubes.js +0 -2
  523. package/dist/plot/core/hover-lock.svelte.d.ts +0 -14
  524. package/dist/plot/core/hover-lock.svelte.js +0 -45
  525. package/dist/plot/sunburst/SunburstControls.svelte +0 -200
  526. package/dist/structure/label-placement.d.ts +0 -14
  527. package/dist/structure/label-placement.js +0 -72
@@ -1,25 +1,30 @@
1
1
  <script lang="ts">
2
2
  import { luminance, watch_dark_mode } from '../colors'
3
3
  import Icon from '../Icon.svelte'
4
+ import { download } from '../io/fetch'
4
5
  import { format_num } from '../labels'
5
6
  import { SettingsSection } from '../layout'
6
7
  import ContextMenu from '../overlays/ContextMenu.svelte'
7
8
  import DraggablePane from '../overlays/DraggablePane.svelte'
9
+ import { portal } from '../overlays/portal'
8
10
  import type {
11
+ CellColor,
9
12
  CellSnippet,
10
13
  CellVal,
14
+ DateTimeFormatMode,
11
15
  ExportData,
12
16
  InitialSort,
13
17
  Label,
14
18
  MultiSortState,
15
19
  Pagination,
20
+ VirtualScroll,
16
21
  RowData,
17
22
  Search,
18
23
  SortHint,
19
24
  SortState,
20
25
  SpecialCells,
21
26
  } from './'
22
- import { calc_cell_color, strip_html } from './'
27
+ import { make_cell_color_scale, strip_html } from './'
23
28
  import { sanitize_html } from '../sanitize'
24
29
  import { normalize_unicode_minus } from '../utils'
25
30
  import type { Snippet } from 'svelte'
@@ -83,8 +88,32 @@
83
88
  }
84
89
  }
85
90
 
91
+ const close_datetime_select_on_outside_pointerdown = (event: PointerEvent) => {
92
+ if (datetime_select_open_col_id === null) return
93
+ if (event.target instanceof Element && event.target.closest(`.datetime-format-control`))
94
+ return
95
+ datetime_select_open_col_id = null
96
+ }
97
+
86
98
  const NUMERIC_WITH_ERROR_RE =
87
- /^([-+−]?(?:\d+\.?\d*|\d*\.\d+)(?:[eE][-+−]?\d+)?)\s*(?:±|\+[-−]|\()/
99
+ /^(?<numeric>[-+−]?(?:\d+\.?\d*|\d*\.\d+)(?:[eE][-+−]?\d+)?)\s*(?:±|\+[-−]|\()/
100
+ const DATA_SORT_VALUE_RE = /data-sort-value="(?<value>[^"]*)"/
101
+ const DATE_ONLY_RE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/
102
+ const DATE_TIME_RE =
103
+ /^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
104
+ type DateTimeColumnKind = `date` | `time` | `datetime`
105
+ const datetime_format_modes_by_kind: Record<DateTimeColumnKind, DateTimeFormatMode[]> = {
106
+ date: [`date`, `relative`],
107
+ time: [`time`],
108
+ datetime: [`date`, `time`, `datetime`, `iso`, `relative`],
109
+ }
110
+ const datetime_format_labels: Record<DateTimeFormatMode, string> = {
111
+ date: `Date`,
112
+ time: `Time`,
113
+ datetime: `Date + time`,
114
+ iso: `ISO`,
115
+ relative: `Since now`,
116
+ }
88
117
 
89
118
  const parse_numeric_string = (val: string): number | null => {
90
119
  const numeric_str = val.match(NUMERIC_WITH_ERROR_RE)?.[1] ?? val
@@ -93,14 +122,18 @@
93
122
  return isNaN(num) ? null : num
94
123
  }
95
124
 
125
+ const get_data_sort_value = (val: string): string | null =>
126
+ val.match(DATA_SORT_VALUE_RE)?.groups?.value ?? null
127
+
96
128
  // Get sort value from a cell (handles HTML data-sort-value and numbers with errors)
97
129
  const get_sort_val = (val: CellVal): string | number => {
130
+ if (val instanceof Date) return val.getTime()
98
131
  if (typeof val === `string`) {
99
132
  // Check for HTML data-sort-value attribute first
100
- const sort_attr_match = val.match(/data-sort-value="([^"]*)"/)
101
- if (sort_attr_match) {
102
- const num = Number(sort_attr_match[1])
103
- return isNaN(num) ? sort_attr_match[1] : num
133
+ const sort_attr = get_data_sort_value(val)
134
+ if (sort_attr != null) {
135
+ const num = Number(sort_attr)
136
+ return isNaN(num) ? sort_attr : num
104
137
  }
105
138
  const num = parse_numeric_string(val)
106
139
  if (num !== null) return num
@@ -108,6 +141,9 @@
108
141
  return val as string | number
109
142
  }
110
143
 
144
+ const get_cell_sort_attr = (val: CellVal): CellVal | number | null =>
145
+ is_html_str(val) ? null : val instanceof Date ? val.getTime() : val
146
+
111
147
  let {
112
148
  data = $bindable([]),
113
149
  columns = [],
@@ -121,14 +157,20 @@
121
157
  default_num_format = `.3`,
122
158
  show_heatmap = $bindable(true),
123
159
  heatmap_class = `heatmap`,
160
+ onrowpointerdown,
124
161
  onrowclick,
125
162
  onrowdblclick,
163
+ row_title,
126
164
  column_order = $bindable([]),
127
165
  export_data = false,
128
166
  show_column_toggle = false,
129
167
  search = false,
168
+ search_query = $bindable(``),
130
169
  show_row_select = false,
131
170
  pagination = false,
171
+ virtual = false,
172
+ on_visible_range,
173
+ controls_target = undefined,
132
174
  selected_rows = $bindable([]),
133
175
  hidden_columns = $bindable([]),
134
176
  scroll_style,
@@ -159,8 +201,12 @@
159
201
  default_num_format?: string
160
202
  show_heatmap?: boolean
161
203
  heatmap_class?: string
204
+ onrowpointerdown?: (event: PointerEvent, row: RowData) => void
162
205
  onrowclick?: (event: MouseEvent | KeyboardEvent, row: RowData) => void
163
206
  onrowdblclick?: (event: MouseEvent, row: RowData) => void
207
+ // Per-row hover tooltip content (rendered via the table tooltip
208
+ // attachment; `\r` breaks lines, HTML must be pre-escaped by the caller)
209
+ row_title?: (row: RowData) => string | null | undefined
164
210
  // Array of column IDs to control display order. IDs are derived as:
165
211
  // - Ungrouped columns: col.key ?? col.label
166
212
  // - Grouped columns: `${col.key ?? col.label} (${col.group})`
@@ -169,8 +215,22 @@
169
215
  export_data?: ExportData
170
216
  show_column_toggle?: boolean
171
217
  search?: Search
218
+ // Current search/filter query. Bindable so parents can control or persist it.
219
+ search_query?: string
172
220
  show_row_select?: boolean
173
221
  pagination?: Pagination
222
+ // Opt-in infinite-scroll row virtualization. Renders only the rows near the
223
+ // viewport plus spacer rows, so DOM size stays bounded for any data length.
224
+ // Inactive when pagination is enabled. Off by default (every row renders);
225
+ // pass true (or a config object) to enable.
226
+ virtual?: VirtualScroll
227
+ // Notifies the parent which slice of the sorted+filtered rows is rendered
228
+ // (e.g. to progressively fetch more data as the user scrolls near the end).
229
+ on_visible_range?: (range: { start: number; end: number; total: number }) => void
230
+ // Host element to render the search/export/settings buttons into (e.g. an
231
+ // embedding panel's own header) instead of a row above the table. When set,
232
+ // the buttons are always visible; when unset they render inline as usual.
233
+ controls_target?: HTMLElement | null
174
234
  selected_rows?: RowData[]
175
235
  hidden_columns?: string[]
176
236
  scroll_style?: string
@@ -214,8 +274,7 @@
214
274
  if (!container_el) return
215
275
  const read_page_bg = () => {
216
276
  if (!container_el) return
217
- const page_bg = getComputedStyle(container_el).getPropertyValue(`--page-bg`)
218
- .trim()
277
+ const page_bg = getComputedStyle(container_el).getPropertyValue(`--page-bg`).trim()
219
278
  page_bg_lum = luminance(page_bg || `white`)
220
279
  }
221
280
  read_page_bg()
@@ -250,17 +309,19 @@
250
309
  : null,
251
310
  )
252
311
 
253
- // Mutable page size writable $derived allows user to change via dropdown
312
+ // Mutable page size: user can change it, but parent pagination.page_size changes still resync.
254
313
  let effective_page_size = $derived(pagination_config?.page_size ?? 25)
255
314
 
256
315
  // Normalize search config
257
316
  let search_config = $derived(
258
317
  search
259
318
  ? {
260
- placeholder: `Filter...`,
261
- expanded: false,
262
- ...(typeof search === `object` ? search : {}),
263
- }
319
+ placeholder: `Filter...`,
320
+ expanded: false,
321
+ keys: undefined as string[] | undefined,
322
+ fuzzy: false,
323
+ ...(typeof search === `object` ? search : {}),
324
+ }
264
325
  : null,
265
326
  )
266
327
 
@@ -270,10 +331,10 @@
270
331
  let export_config = $derived(
271
332
  export_data
272
333
  ? {
273
- formats: default_formats,
274
- filename: `table-export`,
275
- ...(typeof export_data === `object` ? export_data : {}),
276
- }
334
+ formats: default_formats,
335
+ filename: `table-export`,
336
+ ...(typeof export_data === `object` ? export_data : {}),
337
+ }
277
338
  : null,
278
339
  )
279
340
 
@@ -281,16 +342,13 @@
281
342
  // This ensures immediate sorting on first render without waiting for effects
282
343
  let sort_state = $derived<SortState>({
283
344
  column: sort.column || initial_sort_config?.column || ``,
284
- ascending: sort.column
285
- ? sort.dir !== `desc`
286
- : initial_sort_config?.direction !== `desc`,
345
+ ascending: sort.column ? sort.dir !== `desc` : initial_sort_config?.direction !== `desc`,
287
346
  })
288
347
 
289
348
  // Multi-column sort state (for Shift+click)
290
349
  let multi_sort = $state<MultiSortState>([])
291
350
 
292
- // Search/filter state
293
- let search_query = $state(``)
351
+ // Search/filter state (query itself is the bindable search_query prop)
294
352
  let search_expanded = $derived(search_config?.expanded ?? false)
295
353
 
296
354
  // Pagination state
@@ -306,6 +364,10 @@
306
364
  // Per-column color scale overrides
307
365
  let color_scale_overrides = new SvelteMap<string, string>()
308
366
 
367
+ // Per-column date/time display overrides (user-toggled via header)
368
+ let datetime_format_overrides = new SvelteMap<string, DateTimeFormatMode>()
369
+ let datetime_select_open_col_id = $state<string | null>(null)
370
+
309
371
  const color_scale_options = [
310
372
  `interpolateViridis`,
311
373
  `interpolatePlasma`,
@@ -320,9 +382,7 @@
320
382
 
321
383
  // Columns that have a color gradient
322
384
  let colored_columns = $derived(
323
- columns.filter((col) =>
324
- col.color_scale !== null && col.color_scale !== undefined
325
- ),
385
+ columns.filter((col) => col.color_scale !== null && col.color_scale !== undefined),
326
386
  )
327
387
 
328
388
  // Column resize state
@@ -346,6 +406,169 @@
346
406
  // Helper to make column IDs (needed since column labels in different groups can be repeated)
347
407
  const get_col_id = (col: Label) =>
348
408
  col.group ? `${col.key ?? col.label} (${col.group})` : (col.key ?? col.label)
409
+ const get_datetime_label_id = (col_id: string) =>
410
+ `datetime-format-label-${encodeURIComponent(col_id)}`
411
+
412
+ const has_explicit_datetime_format = (col: Label): boolean =>
413
+ col.format_type === `datetime` || Boolean(col.datetime_format)
414
+
415
+ const normalize_timestamp = (val: number): number | null => {
416
+ if (!Number.isFinite(val)) return null
417
+ const abs = Math.abs(val)
418
+ if (abs >= 1_000_000_000_000 && abs < 100_000_000_000_000) return val
419
+ if (abs >= 1_000_000_000 && abs < 1_000_000_000_000) return val * 1000
420
+ return null
421
+ }
422
+
423
+ const parse_datetime_string = (val: string): number | null => {
424
+ const clean = strip_html(val).trim()
425
+ const date_only = clean.match(DATE_ONLY_RE)
426
+ if (date_only?.groups) {
427
+ const year = Number(date_only.groups.year)
428
+ const month = Number(date_only.groups.month)
429
+ const day = Number(date_only.groups.day)
430
+ return new Date(year, month - 1, day).getTime()
431
+ }
432
+ if (!DATE_TIME_RE.test(clean)) return null
433
+ const parsed = Date.parse(
434
+ clean.replace(` `, `T`).replace(/\.(?<millis>\d{3})\d+/, `.$<millis>`),
435
+ )
436
+ return Number.isNaN(parsed) ? null : parsed
437
+ }
438
+
439
+ const value_datetime_kind = (val: CellVal, col: Label): DateTimeColumnKind | null => {
440
+ if (typeof val === `string` && DATE_ONLY_RE.test(strip_html(val).trim())) {
441
+ return `date`
442
+ }
443
+ return parse_datetime_val(val, col) === null ? null : `datetime`
444
+ }
445
+
446
+ const parse_datetime_val = (val: CellVal, col: Label): number | null => {
447
+ if (val instanceof Date) return Number.isNaN(val.getTime()) ? null : val.getTime()
448
+ if (typeof val === `number`) {
449
+ return has_explicit_datetime_format(col) ? normalize_timestamp(val) : null
450
+ }
451
+ if (typeof val !== `string`) return null
452
+
453
+ const parsed_text = parse_datetime_string(val)
454
+ if (parsed_text !== null) return parsed_text
455
+ if (!has_explicit_datetime_format(col)) return null
456
+
457
+ const sort_attr = get_data_sort_value(val)
458
+ return normalize_timestamp(Number(sort_attr ?? strip_html(val).trim()))
459
+ }
460
+
461
+ const infer_datetime_column_kind = (col: Label): DateTimeColumnKind | null => {
462
+ if (col.datetime_format === `date`) return `date`
463
+ if (col.datetime_format === `time`) return `time`
464
+ if (col.datetime_format || col.format_type === `datetime`) return `datetime`
465
+
466
+ const col_id = get_col_id(col)
467
+ let has_date_value = false
468
+ for (const row of data.slice(0, 25)) {
469
+ const kind = value_datetime_kind(row[col_id], col)
470
+ if (kind === `datetime`) return `datetime`
471
+ if (kind === `date`) has_date_value = true
472
+ }
473
+ if (has_date_value) return `date`
474
+ return null
475
+ }
476
+
477
+ let datetime_column_kinds = $derived.by(() => {
478
+ const kinds = new SvelteMap<string, DateTimeColumnKind>()
479
+ for (const col of columns) {
480
+ const kind = infer_datetime_column_kind(col)
481
+ if (kind) kinds.set(get_col_id(col), kind)
482
+ }
483
+ return kinds
484
+ })
485
+
486
+ const is_datetime_column = (col: Label): boolean =>
487
+ datetime_column_kinds.has(get_col_id(col))
488
+
489
+ const datetime_column_kind = (col: Label): DateTimeColumnKind =>
490
+ datetime_column_kinds.get(get_col_id(col)) ?? `datetime`
491
+
492
+ const datetime_format_options = (col: Label): DateTimeFormatMode[] =>
493
+ datetime_format_modes_by_kind[datetime_column_kind(col)]
494
+
495
+ const datetime_mode = (col: Label): DateTimeFormatMode => {
496
+ const options = datetime_format_options(col)
497
+ const selected =
498
+ datetime_format_overrides.get(get_col_id(col)) ??
499
+ col.datetime_format ??
500
+ datetime_column_kind(col)
501
+ return options.includes(selected) ? selected : options[0]
502
+ }
503
+
504
+ function set_datetime_format(col: Label, mode: DateTimeFormatMode) {
505
+ if (datetime_format_options(col).includes(mode)) {
506
+ datetime_format_overrides.set(get_col_id(col), mode)
507
+ }
508
+ }
509
+
510
+ const pad2 = (val: number): string => String(val).padStart(2, `0`)
511
+
512
+ function format_date(date: Date): string {
513
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
514
+ }
515
+
516
+ function format_time(date: Date): string {
517
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
518
+ }
519
+
520
+ function format_datetime(date: Date): string {
521
+ return `${format_date(date)} ${format_time(date)}`
522
+ }
523
+
524
+ // Ticks once a minute while any column shows relative times, so "Xm ago"
525
+ // cells don't go stale (format granularity is minutes).
526
+ let relative_now_ms = $state(Date.now())
527
+ $effect(() => {
528
+ const shows_relative = columns.some(
529
+ (col) => is_datetime_column(col) && datetime_mode(col) === `relative`,
530
+ )
531
+ if (!shows_relative) return
532
+ relative_now_ms = Date.now() // refresh immediately when relative mode turns on
533
+ const interval = setInterval(() => (relative_now_ms = Date.now()), 60_000)
534
+ return () => clearInterval(interval)
535
+ })
536
+
537
+ function format_since_now(timestamp: number): string {
538
+ const diff = relative_now_ms - timestamp
539
+ let remaining_minutes = Math.max(0, Math.floor(Math.abs(diff) / 60_000))
540
+ const parts: string[] = []
541
+ const units = [
542
+ [`y`, 365 * 24 * 60],
543
+ [`mo`, 30 * 24 * 60],
544
+ [`w`, 7 * 24 * 60],
545
+ [`d`, 24 * 60],
546
+ [`h`, 60],
547
+ [`m`, 1],
548
+ ] as const
549
+
550
+ for (const [suffix, minutes_per_unit] of units) {
551
+ const value = Math.floor(remaining_minutes / minutes_per_unit)
552
+ if (value === 0 && parts.length === 0 && suffix !== `m`) continue
553
+ if (value > 0 || suffix === `m`) parts.push(`${value}${suffix}`)
554
+ remaining_minutes -= value * minutes_per_unit
555
+ if (parts.length >= 3) break
556
+ }
557
+
558
+ return `${parts.join(` `)} ${diff >= 0 ? `ago` : `from now`}`
559
+ }
560
+
561
+ function format_datetime_cell(val: CellVal, col: Label): string | null {
562
+ const timestamp = parse_datetime_val(val, col)
563
+ if (timestamp === null) return null
564
+ const date = new Date(timestamp)
565
+ const mode = datetime_mode(col)
566
+ if (mode === `date`) return format_date(date)
567
+ if (mode === `time`) return format_time(date)
568
+ if (mode === `datetime`) return format_datetime(date)
569
+ if (mode === `iso`) return date.toISOString()
570
+ return format_since_now(timestamp)
571
+ }
349
572
 
350
573
  // Sync column_order with columns: initialize if empty, remove stale IDs, append new IDs
351
574
  $effect(() => {
@@ -368,8 +591,11 @@
368
591
  // After drag reorder, column_order differs from col_ids (default order) but the
369
592
  // computed new_order equals the current column_order — assigning a new array
370
593
  // reference would re-trigger this effect endlessly.
371
- if (new_order.length === column_order.length &&
372
- new_order.every((id, idx) => id === column_order[idx])) return
594
+ if (
595
+ new_order.length === column_order.length &&
596
+ new_order.every((id, idx) => id === column_order[idx])
597
+ )
598
+ return
373
599
 
374
600
  column_order = new_order
375
601
  })
@@ -457,23 +683,19 @@
457
683
  function handle_drop(event: DragEvent, target_col: Label) {
458
684
  event.preventDefault()
459
685
 
460
- // Block cross-group (or group→ungroup) reorders to preserve group contiguity
461
- if (!drag_col_id || drag_col_id === get_col_id(target_col)) {
462
- reset_drag_state()
463
- return
464
- }
465
-
466
- // Block cross-group reorders to preserve group contiguity
467
- if (get_drag_col_group() !== target_col.group) {
468
- reset_drag_state()
469
- return
470
- }
471
-
472
686
  const target_col_id = get_col_id(target_col)
473
- const drag_idx = column_order.indexOf(drag_col_id)
687
+ const drag_idx = drag_col_id ? column_order.indexOf(drag_col_id) : -1
474
688
  const target_idx = column_order.indexOf(target_col_id)
475
689
 
476
- if (drag_idx === -1 || target_idx === -1) {
690
+ // Block no-op drops and cross-group (or group→ungroup) reorders to
691
+ // preserve group contiguity
692
+ if (
693
+ !drag_col_id ||
694
+ drag_col_id === target_col_id ||
695
+ get_drag_col_group() !== target_col.group ||
696
+ drag_idx === -1 ||
697
+ target_idx === -1
698
+ ) {
477
699
  reset_drag_state()
478
700
  return
479
701
  }
@@ -489,22 +711,37 @@
489
711
  reset_drag_state()
490
712
  }
491
713
 
714
+ // True when every char of query appears in text in order (subsequence match),
715
+ // e.g. "mdla" matches "Model A". Cheap fuzzy matching for short filter queries.
716
+ const fuzzy_match = (text: string, query: string): boolean => {
717
+ let query_idx = 0
718
+ for (const char of text) {
719
+ if (char === query[query_idx] && ++query_idx === query.length) return true
720
+ }
721
+ return query.length === 0
722
+ }
723
+
724
+ const row_matches_query = (row: RowData, query: string): boolean => {
725
+ const values = search_config?.keys
726
+ ? search_config.keys.map((key) => row[key])
727
+ : Object.values(row)
728
+ return values.some((val) => {
729
+ if (val == null) return false
730
+ const clean_val = strip_html(String(val)).toLowerCase()
731
+ if (clean_val.includes(query)) return true
732
+ return (search_config?.fuzzy ?? false) && fuzzy_match(clean_val, query)
733
+ })
734
+ }
735
+
492
736
  // Filter data based on search query
493
737
  let filtered_data = $derived.by(() => {
494
- const base_data = data?.filter?.((row) =>
495
- Object.values(row).some((val) => val !== undefined)
496
- ) ?? []
497
-
498
- if (!search_query.trim()) return base_data
738
+ const base_data =
739
+ data?.filter?.((row) => Object.values(row).some((val) => val !== undefined)) ?? []
499
740
 
500
741
  const query = search_query.toLowerCase().trim()
501
- return base_data.filter((row) =>
502
- Object.values(row).some((val) => {
503
- if (val == null) return false
504
- const clean_val = strip_html(String(val)).toLowerCase()
505
- return clean_val.includes(query)
506
- })
507
- )
742
+ if (!query) return base_data
743
+
744
+ return base_data.filter((row) => row_matches_query(row, query))
508
745
  })
509
746
 
510
747
  let sorted_data = $derived.by(() => {
@@ -514,11 +751,8 @@
514
751
  if (!sort_state.column && multi_sort.length === 0) return filtered_data
515
752
 
516
753
  // Build sort criteria: multi_sort takes precedence, fallback to single sort
517
- const sort_criteria = multi_sort.length > 0
518
- ? multi_sort
519
- : sort_state.column
520
- ? [sort_state]
521
- : []
754
+ const sort_criteria =
755
+ multi_sort.length > 0 ? multi_sort : sort_state.column ? [sort_state] : []
522
756
 
523
757
  if (sort_criteria.length === 0) return filtered_data
524
758
 
@@ -559,17 +793,91 @@
559
793
  })
560
794
  })
561
795
 
562
- // Paginated data
563
- let paginated_data = $derived.by(() => {
564
- if (!pagination_config) return sorted_data
565
- const start = (current_page - 1) * effective_page_size
566
- return sorted_data.slice(start, start + effective_page_size)
796
+ // --- Infinite scroll (virtualized rows). Opt-in via the `virtual` prop (and
797
+ // inactive under pagination): only rows near the viewport render; spacer rows
798
+ // preserve scroll geometry so the DOM stays bounded for any row count.
799
+ let scroll_el = $state<HTMLDivElement>()
800
+ let scroll_top = $state(0)
801
+ let viewport_height = $state(0)
802
+ let avg_row_height = $state(33) // refined from rendered rows after mount
803
+
804
+ let virtual_config = $derived(
805
+ pagination_config || !virtual
806
+ ? null
807
+ : { overscan: 10, min_window: 60, ...(typeof virtual === `object` ? virtual : {}) },
808
+ )
809
+
810
+ let virtual_range = $derived.by(() => {
811
+ const total = sorted_data.length
812
+ if (!virtual_config) return { start: 0, end: total }
813
+ const { overscan, min_window } = virtual_config
814
+ // Shrinking data can leave scroll_top past the new content height (the
815
+ // browser only clamps the real scrollTop after a re-render); clamp here so
816
+ // the window and spacers never index past the data.
817
+ const max_scroll = Math.max(0, total * avg_row_height - viewport_height)
818
+ const first_visible = Math.floor(Math.min(scroll_top, max_scroll) / avg_row_height)
819
+ const visible_count = Math.ceil(viewport_height / avg_row_height)
820
+ const start = Math.max(0, first_visible - overscan)
821
+ const end = Math.min(
822
+ total,
823
+ Math.max(first_visible + visible_count + overscan, start + min_window),
824
+ )
825
+ return { start, end }
826
+ })
827
+
828
+ const sync_viewport = () => {
829
+ if (!scroll_el) return
830
+ scroll_top = scroll_el.scrollTop
831
+ viewport_height = scroll_el.clientHeight
832
+ }
833
+
834
+ // Refine the row-height estimate from actually rendered rows (needed for
835
+ // accurate spacer heights).
836
+ $effect(() => {
837
+ if (!virtual_config || !scroll_el) return
838
+ void virtual_range // re-measure whenever the rendered window changes
839
+ const rows = scroll_el.querySelectorAll<HTMLTableRowElement>(
840
+ `tbody tr:not(.virtual-spacer):not(.empty-row)`,
841
+ )
842
+ let height_sum = 0
843
+ for (const row of rows) height_sum += row.offsetHeight
844
+ const measured = rows.length ? height_sum / rows.length : 0
845
+ // threshold stops measure→window→measure feedback loops from tiny jitters
846
+ if (measured > 0 && Math.abs(measured - avg_row_height) > 0.5) {
847
+ avg_row_height = Math.min(400, Math.max(8, measured))
848
+ }
849
+ sync_viewport()
567
850
  })
568
851
 
569
- let total_pages = $derived(
570
- Math.ceil(sorted_data.length / effective_page_size),
852
+ // Track scroll-container resizes (e.g. dashboard card resizing)
853
+ $effect(() => {
854
+ if (!virtual_config || !scroll_el || typeof ResizeObserver === `undefined`) return
855
+ const observer = new ResizeObserver(sync_viewport)
856
+ observer.observe(scroll_el)
857
+ return () => observer.disconnect()
858
+ })
859
+
860
+ // Window of sorted_data rendered in the DOM (one page, or the virtual window).
861
+ // start doubles as the absolute index of the first rendered row (for row
862
+ // numbering and stable cell-selection coordinates).
863
+ let display_range = $derived.by(() => {
864
+ if (!pagination_config) return virtual_range
865
+ const start = (current_page - 1) * effective_page_size
866
+ return { start, end: Math.min(sorted_data.length, start + effective_page_size) }
867
+ })
868
+ let display_rows = $derived(sorted_data.slice(display_range.start, display_range.end))
869
+ let spacer_top = $derived(virtual_config ? virtual_range.start * avg_row_height : 0)
870
+ let spacer_bottom = $derived(
871
+ virtual_config ? (sorted_data.length - virtual_range.end) * avg_row_height : 0,
571
872
  )
572
873
 
874
+ // Report the rendered slice so hosts can progressively fetch rows on demand
875
+ $effect(() => {
876
+ on_visible_range?.({ ...display_range, total: sorted_data.length })
877
+ })
878
+
879
+ let total_pages = $derived(Math.ceil(sorted_data.length / effective_page_size))
880
+
573
881
  // Track previous values to detect actual changes
574
882
  let prev_search_query = $state(``)
575
883
  let prev_data_length = $state(0)
@@ -619,15 +927,20 @@
619
927
  } else {
620
928
  // Toggle direction
621
929
  multi_sort = multi_sort.map((sort_entry, idx) =>
622
- idx === existing_idx ? { ...sort_entry, ascending: !sort_entry.ascending } : sort_entry
930
+ idx === existing_idx
931
+ ? { ...sort_entry, ascending: !sort_entry.ascending }
932
+ : sort_entry,
623
933
  )
624
934
  }
625
935
  } else {
626
936
  // Add to multi-sort
627
- multi_sort = [...multi_sort, {
628
- column: col_id,
629
- ascending: col.better === `lower`,
630
- }]
937
+ multi_sort = [
938
+ ...multi_sort,
939
+ {
940
+ column: col_id,
941
+ ascending: col.better === `lower`,
942
+ },
943
+ ]
631
944
  }
632
945
  // Clear single sort when using multi-sort
633
946
  sort = { column: ``, dir: `asc` }
@@ -635,9 +948,14 @@
635
948
  // Regular click - single column sort
636
949
  multi_sort = [] // Clear multi-sort
637
950
  // Use sort_state.column for comparison since it includes initial_sort fallback
638
- const new_dir = sort_state.column !== col_id
639
- ? (col.better === `lower` ? `asc` : `desc`)
640
- : (sort_state.ascending ? `desc` : `asc`)
951
+ const new_dir =
952
+ sort_state.column !== col_id
953
+ ? col.better === `lower`
954
+ ? `asc`
955
+ : `desc`
956
+ : sort_state.ascending
957
+ ? `desc`
958
+ : `asc`
641
959
 
642
960
  // Save previous sort state in case we need to revert on error
643
961
  const prev_sort = { ...sort }
@@ -676,60 +994,62 @@
676
994
  return typeof val === `string` ? parse_numeric_string(val) : null
677
995
  }
678
996
 
679
- // Memoize parsed column values to avoid O() re-parsing in calc_color
680
- let parsed_column_values = $derived.by(() => {
681
- const result = new SvelteMap<string, (number | null)[]>()
682
- for (const col of ordered_columns) {
997
+ // Memoized per-column color scales: the O(rows) numeric scan + min/max +
998
+ // d3 scale construction run once per column when data/filter/overrides
999
+ // change, instead of once per CELL per render (which made a 50-row page over
1000
+ // a 2,000-row snapshot rescan the full column 500+ times). Built from
1001
+ // filtered_data (not sorted_data) since min/max don't depend on row order,
1002
+ // so re-sorting doesn't rebuild the scales. Only visible columns get scales
1003
+ // since calc_color is only ever invoked for visible cells.
1004
+ let column_color_scales = $derived.by(() => {
1005
+ const scales = new SvelteMap<string, (val: number | null | undefined) => CellColor>()
1006
+ if (!show_heatmap) return scales
1007
+ for (const col of visible_columns) {
683
1008
  if (col.color_scale === null) continue
684
1009
  const col_id = get_col_id(col)
685
- result.set(col_id, sorted_data.map((row) => parse_numeric_val(row[col_id])))
1010
+ const parsed_vals = filtered_data.map((row) => parse_numeric_val(row[col_id]))
1011
+ const better = better_overrides.get(col_id) ?? col.better
1012
+ const scale = (color_scale_overrides.get(col_id) ??
1013
+ col.color_scale ??
1014
+ `interpolateViridis`) as Parameters<typeof make_cell_color_scale>[2]
1015
+ scales.set(
1016
+ col_id,
1017
+ make_cell_color_scale(parsed_vals, better, scale, col.scale_type || `linear`),
1018
+ )
686
1019
  }
687
- return result
1020
+ return scales
688
1021
  })
689
1022
 
690
- function calc_color(val: CellVal, col: Label) {
691
- if (!show_heatmap || col.color_scale === null) {
692
- return { bg: null, text: null }
693
- }
1023
+ function calc_color(val: CellVal, col: Label): CellColor {
1024
+ const color_fn = column_color_scales.get(get_col_id(col))
1025
+ if (!color_fn) return { bg: null, text: null }
694
1026
 
695
1027
  // Parse numeric value from strings with uncertainty notation
696
- const numeric_val = parse_numeric_val(val)
697
- if (numeric_val === null) return { bg: null, text: null }
698
-
699
- const col_id = get_col_id(col)
700
- // Use memoized parsed values for the column
701
- const numeric_vals = parsed_column_values.get(col_id) ?? []
702
-
703
- const better = better_overrides.get(col_id) ?? col.better
704
- const scale = (color_scale_overrides.get(col_id) ?? col.color_scale ??
705
- `interpolateViridis`) as Parameters<typeof calc_cell_color>[3]
706
- const color = calc_cell_color(
707
- numeric_val,
708
- numeric_vals,
709
- better,
710
- scale,
711
- col.scale_type || `linear`,
712
- )
1028
+ const color = color_fn(parse_numeric_val(val))
713
1029
 
714
1030
  // Recompute text contrast against effective bg (cell bg blended with page bg by opacity).
715
1031
  // Approximation: blend luminances directly; accurate enough for black/white text choice.
716
1032
  if (color.bg && heatmap_opacity < 1) {
717
- const blended_lum = luminance(color.bg) * heatmap_opacity +
718
- page_bg_lum * (1 - heatmap_opacity)
719
- color.text = blended_lum > 0.7 ? `black` : `white`
1033
+ const blended_lum =
1034
+ luminance(color.bg) * heatmap_opacity + page_bg_lum * (1 - heatmap_opacity)
1035
+ return { bg: color.bg, text: blended_lum > 0.7 ? `black` : `white` }
720
1036
  }
721
1037
  return color
722
1038
  }
723
1039
 
724
1040
  let visible_columns = $derived(
725
- ordered_columns.filter((col) =>
726
- col.visible !== false && !hidden_columns.includes(get_col_id(col))
1041
+ ordered_columns.filter(
1042
+ (col) => col.visible !== false && !hidden_columns.includes(get_col_id(col)),
727
1043
  ),
728
1044
  )
1045
+ // total cell count per body row (for spacer + empty-message colspans)
1046
+ let body_colspan = $derived(
1047
+ visible_columns.length + (show_row_select ? 1 : 0) + (show_row_numbers ? 1 : 0),
1048
+ )
729
1049
 
730
1050
  const sort_indicator = (col: Label, current_sort_state: SortState) => {
731
- const hide_sort_indicator = col.show_sort_indicator === false ||
732
- col.style?.includes(`--hide-sort-indicator`)
1051
+ const hide_sort_indicator =
1052
+ col.show_sort_indicator === false || col.style?.includes(`--hide-sort-indicator`)
733
1053
  if (hide_sort_indicator) return ``
734
1054
 
735
1055
  const col_id = get_col_id(col)
@@ -742,56 +1062,264 @@
742
1062
  return `<span style="font-size: 0.8em;">${arrow}${badge}</span>`
743
1063
  }
744
1064
 
745
- const is_sorted = current_sort_state.column === col_id
746
- if (!is_sorted) return ``
747
1065
  // Show indicator only for actively sorted columns.
1066
+ if (current_sort_state.column !== col_id) return ``
748
1067
  const arrow = current_sort_state.ascending ? `↓` : `↑`
749
-
750
- return arrow ? `<span style="font-size: 0.8em;">${arrow}</span>` : ``
1068
+ return `<span style="font-size: 0.8em;">${arrow}</span>`
751
1069
  }
752
1070
 
753
- // Context menu state for column header right-click
1071
+ // Context menu state for column right-click (headers and body cells)
754
1072
  let context_menu_col = $state<string | null>(null)
755
1073
  let context_menu_pos = $state({ x: 0, y: 0 })
756
1074
 
757
- const better_sections = [
1075
+ const better_section = {
1076
+ title: `Gradient direction`,
1077
+ options: [
1078
+ { value: `higher`, label: `▲ Higher is better` },
1079
+ { value: `lower`, label: `▼ Lower is better` },
1080
+ ],
1081
+ }
1082
+
1083
+ function open_column_context_menu(event: MouseEvent, col_id: string) {
1084
+ event.preventDefault()
1085
+ event.stopPropagation()
1086
+ context_menu_col = col_id
1087
+ const rect = container_el?.getBoundingClientRect()
1088
+ context_menu_pos = {
1089
+ x: event.clientX - (rect?.left ?? 0),
1090
+ y: event.clientY - (rect?.top ?? 0),
1091
+ }
1092
+ }
1093
+
1094
+ // ---- Cell range selection: drag selects a rectangle of cells, Shift/Cmd+
1095
+ // drag adds disjoint rectangles, Cmd/Ctrl+C copies as TSV (blocks separated
1096
+ // by newlines), Escape or clicking outside clears. Selection coordinates
1097
+ // are absolute sorted_data row indices plus visible-column indices, cleared
1098
+ // whenever the rendered data changes (sort, page, filter, refresh).
1099
+ interface CellRect {
1100
+ start_row: number
1101
+ start_col: number
1102
+ end_row: number
1103
+ end_col: number
1104
+ }
1105
+ let selected_cell_rects = $state<CellRect[]>([])
1106
+ let cell_drag_active = $state(false)
1107
+ let cell_drag_moved = false
1108
+ let suppress_row_click = false
1109
+
1110
+ const rect_bounds = (rect: CellRect) => ({
1111
+ row_lo: Math.min(rect.start_row, rect.end_row),
1112
+ row_hi: Math.max(rect.start_row, rect.end_row),
1113
+ col_lo: Math.min(rect.start_col, rect.end_col),
1114
+ col_hi: Math.max(rect.start_col, rect.end_col),
1115
+ })
1116
+
1117
+ let selected_cell_keys = $derived.by(() => {
1118
+ const keys = new Set<string>()
1119
+ for (const rect of selected_cell_rects) {
1120
+ const { row_lo, row_hi, col_lo, col_hi } = rect_bounds(rect)
1121
+ for (let row_idx = row_lo; row_idx <= row_hi; row_idx++) {
1122
+ for (let col_idx = col_lo; col_idx <= col_hi; col_idx++) {
1123
+ keys.add(`${row_idx}:${col_idx}`)
1124
+ }
1125
+ }
1126
+ }
1127
+ return keys
1128
+ })
1129
+
1130
+ // Stale (row, col) coordinates must not survive sort/page/filter/refresh,
1131
+ // nor column reorder/hide (col indices point into visible_columns). Depends
1132
+ // on sorted_data + current_page + visible_columns (not the virtual window)
1133
+ // so plain scrolling in infinite mode doesn't wipe an active selection.
1134
+ $effect(() => {
1135
+ void sorted_data
1136
+ void current_page
1137
+ void visible_columns
1138
+ selected_cell_rects = []
1139
+ })
1140
+
1141
+ const is_interactive_cell_target = (target: EventTarget | null): boolean =>
1142
+ target instanceof Element && Boolean(target.closest(`button, a, input, select, textarea`))
1143
+
1144
+ function start_cell_drag(event: PointerEvent, row_idx: number, col_idx: number) {
1145
+ if (event.button !== 0 || is_interactive_cell_target(event.target)) return
1146
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey
1147
+ const rect = { start_row: row_idx, start_col: col_idx, end_row: row_idx, end_col: col_idx }
1148
+ selected_cell_rects = additive ? [...selected_cell_rects, rect] : [rect]
1149
+ cell_drag_active = true
1150
+ cell_drag_moved = false
1151
+ }
1152
+
1153
+ function extend_cell_drag(event: PointerEvent) {
1154
+ if (!cell_drag_active) return
1155
+ const target_cell =
1156
+ event.target instanceof Element
1157
+ ? event.target.closest<HTMLElement>(`td[data-row-idx]`)
1158
+ : null
1159
+ const active_rect = selected_cell_rects.at(-1)
1160
+ if (!target_cell || !active_rect) return
1161
+ const row_idx = Number(target_cell.dataset.rowIdx)
1162
+ const col_idx = Number(target_cell.dataset.colIdx)
1163
+ if (row_idx === active_rect.end_row && col_idx === active_rect.end_col) return
1164
+ if (!cell_drag_moved) {
1165
+ cell_drag_moved = true
1166
+ // A native text selection may have started before user-select: none
1167
+ // kicked in; drop it so the cell selection is the only visible one.
1168
+ globalThis.getSelection()?.removeAllRanges()
1169
+ }
1170
+ selected_cell_rects = [
1171
+ ...selected_cell_rects.slice(0, -1),
1172
+ { ...active_rect, end_row: row_idx, end_col: col_idx },
1173
+ ]
1174
+ }
1175
+
1176
+ function end_cell_drag() {
1177
+ if (!cell_drag_active) return
1178
+ cell_drag_active = false
1179
+ // A drag that crossed cells must not fire the row click on release
1180
+ if (cell_drag_moved) suppress_row_click = true
1181
+ }
1182
+
1183
+ function suppress_click_after_cell_drag(event: MouseEvent) {
1184
+ if (!suppress_row_click) return
1185
+ suppress_row_click = false
1186
+ event.stopPropagation()
1187
+ event.preventDefault()
1188
+ }
1189
+
1190
+ function clear_cell_selection_on_outside_pointerdown(event: PointerEvent) {
1191
+ // A drag's suppress flag is consumed by the click right after pointerup;
1192
+ // if that click never fired (released outside the table), any NEW
1193
+ // interaction must not inherit it.
1194
+ suppress_row_click = false
1195
+ if (selected_cell_rects.length === 0) return
1196
+ if (event.target instanceof Node && container_el?.contains(event.target)) return
1197
+ selected_cell_rects = []
1198
+ }
1199
+
1200
+ // Raw cell value as clipboard text (numbers keep full precision, dates go
1201
+ // ISO, HTML cells lose their markup)
1202
+ const cell_copy_text = (val: CellVal): string => {
1203
+ if (val == null || (typeof val === `number` && Number.isNaN(val))) return ``
1204
+ if (val instanceof Date) return val.toISOString()
1205
+ if (typeof val === `object`) return JSON.stringify(val)
1206
+ return strip_html(String(val)).trim()
1207
+ }
1208
+
1209
+ function copy_selected_cells() {
1210
+ const blocks = selected_cell_rects.map((rect) => {
1211
+ const bounds = rect_bounds(rect)
1212
+ const { row_lo, col_lo } = bounds
1213
+ // rects hold absolute indices into the sorted+filtered rows
1214
+ const row_hi = Math.min(bounds.row_hi, sorted_data.length - 1)
1215
+ const col_hi = Math.min(bounds.col_hi, visible_columns.length - 1)
1216
+ const lines: string[] = []
1217
+ for (let row_idx = row_lo; row_idx <= row_hi; row_idx++) {
1218
+ const cells: string[] = []
1219
+ for (let col_idx = col_lo; col_idx <= col_hi; col_idx++) {
1220
+ cells.push(
1221
+ cell_copy_text(sorted_data[row_idx][get_col_id(visible_columns[col_idx])]),
1222
+ )
1223
+ }
1224
+ lines.push(cells.join(`\t`))
1225
+ }
1226
+ return lines.join(`\n`)
1227
+ })
1228
+ void navigator.clipboard?.writeText(blocks.join(`\n`))
1229
+ }
1230
+
1231
+ // Every sorted+filtered value of one column (all pages), one per line
1232
+ function copy_column_values(col_id: string) {
1233
+ void navigator.clipboard?.writeText(
1234
+ sorted_data.map((row) => cell_copy_text(row[col_id])).join(`\n`),
1235
+ )
1236
+ }
1237
+
1238
+ function handle_cell_selection_keydown(event: KeyboardEvent) {
1239
+ if (selected_cell_rects.length === 0) return
1240
+ if (event.key === `Escape`) {
1241
+ selected_cell_rects = []
1242
+ return
1243
+ }
1244
+ if (event.key !== `c` || !(event.metaKey || event.ctrlKey)) return
1245
+ // Native text selections and focused form fields keep native copy
1246
+ if (is_interactive_cell_target(event.target)) return
1247
+ if (globalThis.getSelection()?.toString()) return
1248
+ event.preventDefault()
1249
+ copy_selected_cells()
1250
+ }
1251
+
1252
+ let context_menu_column = $derived(
1253
+ visible_columns.find((col) => get_col_id(col) === context_menu_col),
1254
+ )
1255
+ let context_menu_sections = $derived([
758
1256
  {
759
- title: `Gradient direction`,
1257
+ title: `Copy`,
760
1258
  options: [
761
- { value: `higher`, label: `▲ Higher is better` },
762
- { value: `lower`, label: `▼ Lower is better` },
1259
+ { value: `copy_column`, label: `Copy column (${sorted_data.length} values)` },
1260
+ ...(selected_cell_keys.size > 0
1261
+ ? [
1262
+ {
1263
+ value: `copy_selection`,
1264
+ label: `Copy selection (${selected_cell_keys.size} cells)`,
1265
+ },
1266
+ ]
1267
+ : []),
763
1268
  ],
764
1269
  },
765
- ] as const
1270
+ // Gradient direction only applies to heatmap-colored columns
1271
+ ...(allow_better_toggle && context_menu_column?.color_scale != null
1272
+ ? [better_section]
1273
+ : []),
1274
+ ])
1275
+
1276
+ function handle_context_menu_select(section_title: string, option: { value: string }) {
1277
+ if (section_title === `Copy`) {
1278
+ if (option.value === `copy_column` && context_menu_col) {
1279
+ copy_column_values(context_menu_col)
1280
+ } else if (option.value === `copy_selection`) {
1281
+ copy_selected_cells()
1282
+ }
1283
+ } else if (context_menu_col) {
1284
+ const current = better_overrides.get(context_menu_col)
1285
+ if (current === option.value) better_overrides.delete(context_menu_col)
1286
+ else {
1287
+ better_overrides.set(context_menu_col, option.value as `higher` | `lower`)
1288
+ }
1289
+ }
1290
+ context_menu_col = null
1291
+ }
1292
+
1293
+ // Row selection via an ID-indexed Set so per-row checks are O(1) instead of
1294
+ // linear scans over selected_rows (matters for large virtualized datasets)
1295
+ let selected_id_set = $derived(new Set(selected_rows.map((row) => get_row_id(row))))
766
1296
 
767
- // Row selection using WeakMap-based ID lookup instead of O(n) JSON.stringify comparison
768
1297
  function toggle_row_select(row: RowData) {
769
1298
  const row_id = get_row_id(row)
770
- const idx = selected_rows.findIndex((selected_row) => get_row_id(selected_row) === row_id)
771
- if (idx !== -1) {
772
- selected_rows = selected_rows.filter((_, i) => i !== idx)
773
- } else {
774
- selected_rows = [...selected_rows, row]
775
- }
1299
+ selected_rows = selected_id_set.has(row_id)
1300
+ ? selected_rows.filter((selected_row) => get_row_id(selected_row) !== row_id)
1301
+ : [...selected_rows, row]
776
1302
  }
777
1303
 
778
1304
  function is_row_selected(row: RowData): boolean {
779
- const row_id = get_row_id(row)
780
- return selected_rows.some((selected_row) => get_row_id(selected_row) === row_id)
1305
+ return selected_id_set.has(get_row_id(row))
781
1306
  }
782
1307
 
783
- // Select-all: checks if every row on the current page is selected
1308
+ // Select-all scope: the current page under pagination, every sorted+filtered
1309
+ // row in infinite-scroll mode (the virtual window is a rendering detail)
1310
+ let select_all_rows = $derived(pagination_config ? display_rows : sorted_data)
784
1311
  let all_page_selected = $derived(
785
- paginated_data.length > 0 && paginated_data.every((row) => is_row_selected(row)),
1312
+ select_all_rows.length > 0 &&
1313
+ select_all_rows.every((row) => selected_id_set.has(get_row_id(row))),
786
1314
  )
787
1315
 
788
1316
  function toggle_select_all() {
789
1317
  if (all_page_selected) {
790
- const page_ids = new Set(paginated_data.map(get_row_id))
791
- selected_rows = selected_rows.filter((row) => !page_ids.has(get_row_id(row)))
1318
+ const scope_ids = new Set(select_all_rows.map(get_row_id))
1319
+ selected_rows = selected_rows.filter((row) => !scope_ids.has(get_row_id(row)))
792
1320
  } else {
793
1321
  const already = new Set(selected_rows.map(get_row_id))
794
- const new_rows = paginated_data.filter((row) => !already.has(get_row_id(row)))
1322
+ const new_rows = select_all_rows.filter((row) => !already.has(get_row_id(row)))
795
1323
  selected_rows = [...selected_rows, ...new_rows]
796
1324
  }
797
1325
  }
@@ -817,44 +1345,26 @@
817
1345
  const val = row[get_col_id(col)]
818
1346
  if (val == null) return ``
819
1347
  return quote(strip_html(String(val)))
820
- })
1348
+ }),
821
1349
  )
822
1350
  return [headers.join(delimiter), ...rows.map((row) => row.join(delimiter))].join(`\n`)
823
1351
  }
824
1352
 
825
- function export_csv(filename = `table-export`) {
826
- download_file(serialize_table(`,`, true), `${filename}.csv`, `text/csv`)
1353
+ function export_csv(filename: string) {
1354
+ download(serialize_table(`,`, true), `${filename}.csv`, `text/csv`)
827
1355
  }
828
1356
 
829
- function export_json(filename = `table-export`) {
1357
+ function export_json(filename: string) {
830
1358
  const rows = export_rows.map((row) => {
831
1359
  const clean_row: Record<string, unknown> = {}
832
1360
  for (const col of visible_columns) {
833
1361
  const col_id = get_col_id(col)
834
1362
  const val = row[col_id]
835
- clean_row[strip_html(col.label)] = typeof val === `string`
836
- ? strip_html(val)
837
- : val
1363
+ clean_row[strip_html(col.label)] = typeof val === `string` ? strip_html(val) : val
838
1364
  }
839
1365
  return clean_row
840
1366
  })
841
- download_file(
842
- JSON.stringify(rows, null, 2),
843
- `${filename}.json`,
844
- `application/json`,
845
- )
846
- }
847
-
848
- function download_file(content: string, filename: string, mime_type: string) {
849
- const blob = new Blob([content], { type: mime_type })
850
- const url = URL.createObjectURL(blob)
851
- const link = document.createElement(`a`)
852
- link.href = url
853
- link.download = filename
854
- document.body.append(link)
855
- link.click()
856
- document.body.removeChild(link)
857
- URL.revokeObjectURL(url)
1367
+ download(JSON.stringify(rows, null, 2), `${filename}.json`, `application/json`)
858
1368
  }
859
1369
 
860
1370
  function copy_to_clipboard() {
@@ -863,11 +1373,9 @@
863
1373
 
864
1374
  // Column visibility toggle
865
1375
  function toggle_column(col_id: string) {
866
- if (hidden_columns.includes(col_id)) {
867
- hidden_columns = hidden_columns.filter((id) => id !== col_id)
868
- } else {
869
- hidden_columns = [...hidden_columns, col_id]
870
- }
1376
+ hidden_columns = hidden_columns.includes(col_id)
1377
+ ? hidden_columns.filter((id) => id !== col_id)
1378
+ : [...hidden_columns, col_id]
871
1379
  }
872
1380
 
873
1381
  // Column resize handlers
@@ -900,18 +1408,27 @@
900
1408
  let hint_config = $derived(
901
1409
  sort_hint
902
1410
  ? {
903
- position: `bottom` as const,
904
- permanent: false,
905
- ...(typeof sort_hint === `string` ? { text: sort_hint } : sort_hint),
906
- }
1411
+ position: `bottom` as const,
1412
+ permanent: false,
1413
+ ...(typeof sort_hint === `string` ? { text: sort_hint } : sort_hint),
1414
+ }
907
1415
  : null,
908
1416
  )
909
1417
  </script>
910
1418
 
1419
+ <svelte:window
1420
+ onpointerdown={(event) => {
1421
+ close_datetime_select_on_outside_pointerdown(event)
1422
+ clear_cell_selection_on_outside_pointerdown(event)
1423
+ }}
1424
+ onpointerup={end_cell_drag}
1425
+ onkeydown={handle_cell_selection_keydown}
1426
+ />
1427
+
911
1428
  {#snippet sort_hint_element(pos: `top` | `bottom`)}
912
1429
  {#if hint_config?.position === pos}
913
1430
  <div
914
- class="sort-hint {hint_config.class ?? ``}"
1431
+ class={[`sort-hint`, hint_config.class]}
915
1432
  class:permanent={hint_config.permanent}
916
1433
  style={hint_config.style}
917
1434
  >
@@ -920,20 +1437,29 @@
920
1437
  {/if}
921
1438
  {/snippet}
922
1439
 
1440
+ <!-- svelte-ignore a11y_no_static_element_interactions (capture-phase guard swallowing the click that follows a cell-range drag) -->
923
1441
  <div
924
1442
  {@attach table_tooltips}
925
1443
  {...rest_props}
926
1444
  bind:this={container_el}
927
- class="table-container {rest_props.class ?? ``}"
1445
+ class={[`table-container`, rest_props.class]}
1446
+ class:cell-dragging={cell_drag_active}
928
1447
  style:--heatmap-opacity="{heatmap_opacity * 100}%"
1448
+ onclickcapture={suppress_click_after_cell_drag}
929
1449
  onmouseleave={() => {
930
1450
  show_column_dropdown = false
931
1451
  show_export_dropdown = false
932
1452
  context_menu_col = null
933
1453
  }}
934
1454
  >
935
- <!-- Floating control buttons -->
936
- <section class="control-buttons">
1455
+ <!-- Control buttons: render inline above the table, or teleport into a host
1456
+ toolbar (controls_target) so embedding panels reuse their own header row -->
1457
+ <section
1458
+ class="control-buttons"
1459
+ class:portaled={Boolean(controls_target)}
1460
+ class:force-visible={controls_open || show_column_dropdown || show_export_dropdown}
1461
+ {@attach portal(controls_target)}
1462
+ >
937
1463
  {#if search_config}
938
1464
  {#if search_expanded || search_query}
939
1465
  <input
@@ -953,15 +1479,15 @@
953
1479
  }}
954
1480
  {@attach tooltip({ content: `Clear`, placement: `top` })}
955
1481
  >
956
- <Icon icon="Cross" style="width: 10px" />
1482
+ <Icon icon="Cross" />
957
1483
  </button>
958
1484
  {:else}
959
1485
  <button
960
1486
  class="icon-btn"
961
- onclick={() => search_expanded = true}
1487
+ onclick={() => (search_expanded = true)}
962
1488
  {@attach tooltip({ content: `Search`, placement: `top` })}
963
1489
  >
964
- <Icon icon="Search" style="width: 14px" />
1490
+ <Icon icon="Search" />
965
1491
  </button>
966
1492
  {/if}
967
1493
  {/if}
@@ -971,10 +1497,10 @@
971
1497
  <button
972
1498
  class="icon-btn"
973
1499
  class:active={show_column_dropdown}
974
- onclick={() => show_column_dropdown = !show_column_dropdown}
1500
+ onclick={() => (show_column_dropdown = !show_column_dropdown)}
975
1501
  {@attach tooltip({ content: `Columns`, placement: `top` })}
976
1502
  >
977
- <Icon icon="Columns" style="width: 14px" />
1503
+ <Icon icon="Columns" />
978
1504
  </button>
979
1505
  {#if show_column_dropdown}
980
1506
  <div class="dropdown-pane">
@@ -999,10 +1525,10 @@
999
1525
  <button
1000
1526
  class="icon-btn"
1001
1527
  class:active={show_export_dropdown}
1002
- onclick={() => show_export_dropdown = !show_export_dropdown}
1528
+ onclick={() => (show_export_dropdown = !show_export_dropdown)}
1003
1529
  {@attach tooltip({ content: `Export`, placement: `top` })}
1004
1530
  >
1005
- <Icon icon="Export" style="width: 14px" />
1531
+ <Icon icon="Export" />
1006
1532
  </button>
1007
1533
  {#if show_export_dropdown}
1008
1534
  <div class="dropdown-pane">
@@ -1045,118 +1571,114 @@
1045
1571
  {#if show_row_select && selected_rows.length > 0}
1046
1572
  <button
1047
1573
  class="icon-btn selection-badge"
1048
- onclick={() => selected_rows = []}
1574
+ onclick={() => (selected_rows = [])}
1049
1575
  title="Clear {selected_rows.length} selected rows"
1050
1576
  >
1051
1577
  <span class="badge">{selected_rows.length}</span>
1052
- <Icon icon="Cross" style="width: 10px" />
1578
+ <Icon icon="Cross" />
1053
1579
  </button>
1054
1580
  {/if}
1055
1581
 
1056
- {#if controls}
1057
- {@render controls()}
1058
- {/if}
1059
- </section>
1060
-
1061
- {#if show_controls}
1062
- <DraggablePane
1063
- bind:show={controls_open}
1064
- closed_icon="Settings"
1065
- open_icon="Cross"
1066
- toggle_props={{
1067
- title: `${controls_open ? `Close` : `Open`} table controls`,
1068
- style: `position: absolute; top: 5pt; right: 1ex; z-index: 10`,
1069
- }}
1070
- pane_props={{ style: `max-height: 60vh; overflow-y: auto; font-size: 0.85em` }}
1071
- >
1072
- <SettingsSection
1073
- title="Heatmap"
1074
- current_values={{ show_heatmap, heatmap_opacity }}
1075
- on_reset={() => {
1076
- show_heatmap = true
1077
- heatmap_opacity = 1
1078
- }}
1079
- >
1080
- <label><input type="checkbox" bind:checked={show_heatmap} /> Show heatmap</label>
1081
- {#if show_heatmap}
1082
- <label>
1083
- Opacity
1084
- <input
1085
- type="range"
1086
- min="0"
1087
- max="1"
1088
- step="0.05"
1089
- bind:value={heatmap_opacity}
1090
- />
1091
- <input
1092
- type="number"
1093
- min="0"
1094
- max="1"
1095
- step="0.05"
1096
- bind:value={heatmap_opacity}
1097
- style="width: 3.5em"
1098
- />
1099
- </label>
1100
- {/if}
1101
- </SettingsSection>
1102
-
1103
- <SettingsSection
1104
- title="Display"
1105
- current_values={{ show_row_numbers }}
1106
- on_reset={() => {
1107
- show_row_numbers = false
1582
+ {#if show_controls}
1583
+ <DraggablePane
1584
+ bind:show={controls_open}
1585
+ closed_icon="Settings"
1586
+ open_icon="Cross"
1587
+ toggle_props={{ title: `${controls_open ? `Close` : `Open`} table controls` }}
1588
+ position="fixed"
1589
+ pane_props={{
1590
+ style: `--pane-max-height: 60vh; overflow-y: auto; font-size: 0.85em`,
1108
1591
  }}
1109
1592
  >
1110
- <label><input type="checkbox" bind:checked={show_row_numbers} /> Row
1111
- numbers</label>
1112
- </SettingsSection>
1593
+ <SettingsSection
1594
+ title="Heatmap"
1595
+ current_values={{ show_heatmap, heatmap_opacity }}
1596
+ on_reset={() => {
1597
+ show_heatmap = true
1598
+ heatmap_opacity = 1
1599
+ }}
1600
+ >
1601
+ <label><input type="checkbox" bind:checked={show_heatmap} /> Show heatmap</label>
1602
+ {#if show_heatmap}
1603
+ <label>
1604
+ Opacity
1605
+ <input type="range" min="0" max="1" step="0.05" bind:value={heatmap_opacity} />
1606
+ <input
1607
+ type="number"
1608
+ min="0"
1609
+ max="1"
1610
+ step="0.05"
1611
+ bind:value={heatmap_opacity}
1612
+ style="width: 3.5em"
1613
+ />
1614
+ </label>
1615
+ {/if}
1616
+ </SettingsSection>
1113
1617
 
1114
- {#if colored_columns.length > 0}
1115
1618
  <SettingsSection
1116
- title="Column Colors"
1117
- current_values={Object.fromEntries([...better_overrides, ...color_scale_overrides])}
1619
+ title="Display"
1620
+ current_values={{ show_row_numbers }}
1118
1621
  on_reset={() => {
1119
- better_overrides.clear()
1120
- color_scale_overrides.clear()
1622
+ show_row_numbers = false
1121
1623
  }}
1122
1624
  >
1123
- {#each colored_columns as col (get_col_id(col))}
1124
- {@const col_id = get_col_id(col)}
1125
- <div class="col-color-row">
1126
- <span class="col-color-label">{@html sanitize_html(col.label)}</span>
1127
- <select
1128
- value={color_scale_overrides.get(col_id) ?? col.color_scale ??
1129
- `interpolateViridis`}
1130
- onchange={(event) => {
1131
- const val = event.currentTarget.value
1132
- if (
1133
- val === (col.color_scale ?? `interpolateViridis`)
1134
- ) color_scale_overrides.delete(col_id)
1135
- else color_scale_overrides.set(col_id, val)
1136
- }}
1137
- >
1138
- {#each color_scale_options as scale (scale)}
1139
- <option value={scale}>{scale.replace(`interpolate`, ``)}</option>
1140
- {/each}
1141
- </select>
1142
- <select
1143
- value={better_overrides.get(col_id) ?? col.better ?? ``}
1144
- onchange={(event) => {
1145
- const val = event.currentTarget.value
1146
- if (!val) better_overrides.delete(col_id)
1147
- else better_overrides.set(col_id, val as `higher` | `lower`)
1148
- }}
1149
- >
1150
- <option value="">Default</option>
1151
- <option value="higher">▲ High</option>
1152
- <option value="lower">▼ Low</option>
1153
- </select>
1154
- </div>
1155
- {/each}
1625
+ <label><input type="checkbox" bind:checked={show_row_numbers} /> Row numbers</label>
1156
1626
  </SettingsSection>
1157
- {/if}
1158
- </DraggablePane>
1159
- {/if}
1627
+
1628
+ {#if colored_columns.length > 0}
1629
+ <SettingsSection
1630
+ title="Column Colors"
1631
+ current_values={Object.fromEntries([
1632
+ ...better_overrides,
1633
+ ...color_scale_overrides,
1634
+ ])}
1635
+ on_reset={() => {
1636
+ better_overrides.clear()
1637
+ color_scale_overrides.clear()
1638
+ }}
1639
+ >
1640
+ {#each colored_columns as col (get_col_id(col))}
1641
+ {@const col_id = get_col_id(col)}
1642
+ <div class="col-color-row">
1643
+ <span class="col-color-label">{@html sanitize_html(col.label)}</span>
1644
+ <select
1645
+ value={color_scale_overrides.get(col_id) ??
1646
+ col.color_scale ??
1647
+ `interpolateViridis`}
1648
+ onchange={(event) => {
1649
+ const val = event.currentTarget.value
1650
+ if (val === (col.color_scale ?? `interpolateViridis`))
1651
+ color_scale_overrides.delete(col_id)
1652
+ else color_scale_overrides.set(col_id, val)
1653
+ }}
1654
+ >
1655
+ {#each color_scale_options as scale (scale)}
1656
+ <option value={scale}>{scale.replace(`interpolate`, ``)}</option>
1657
+ {/each}
1658
+ </select>
1659
+ <select
1660
+ value={better_overrides.get(col_id) ?? col.better ?? ``}
1661
+ onchange={(event) => {
1662
+ const val = event.currentTarget.value
1663
+ if (!val) better_overrides.delete(col_id)
1664
+ else better_overrides.set(col_id, val as `higher` | `lower`)
1665
+ }}
1666
+ >
1667
+ <option value="">Default</option>
1668
+ <option value="higher">▲ High</option>
1669
+ <option value="lower">▼ Low</option>
1670
+ </select>
1671
+ </div>
1672
+ {/each}
1673
+ </SettingsSection>
1674
+ {/if}
1675
+ </DraggablePane>
1676
+ {/if}
1677
+
1678
+ {#if controls}
1679
+ {@render controls()}
1680
+ {/if}
1681
+ </section>
1160
1682
 
1161
1683
  {@render sort_hint_element(`top`)}
1162
1684
 
@@ -1164,6 +1686,8 @@
1164
1686
  class="table-scroll"
1165
1687
  style={scroll_style}
1166
1688
  class:has-scroll={scroll_style}
1689
+ bind:this={scroll_el}
1690
+ onscroll={virtual_config ? sync_viewport : undefined}
1167
1691
  >
1168
1692
  {#if loading}
1169
1693
  <div class="loading-overlay">
@@ -1186,14 +1710,11 @@
1186
1710
  {#if !col.group}
1187
1711
  <th class:sticky-col={col.sticky}></th>
1188
1712
  {:else}
1189
- {@const group_cols = visible_columns.filter((column) =>
1190
- column.group === col.group
1191
- )}
1713
+ {@const group_cols = visible_columns.filter(
1714
+ (column) => column.group === col.group,
1715
+ )}
1192
1716
  <!-- Only render the group header once for each group by checking if this is the first column of this group -->
1193
- {#if visible_columns.findIndex((column) => column.group === col.group) ===
1194
- visible_columns.findIndex((column) =>
1195
- column.group === col.group && column.label === col.label
1196
- )}
1717
+ {#if visible_columns.findIndex((column) => column.group === col.group) === visible_columns.findIndex((column) => column.group === col.group && column.label === col.label)}
1197
1718
  <th title={col.description} colspan={group_cols.length}>
1198
1719
  {@html sanitize_html(col.group)}
1199
1720
  </th>
@@ -1221,61 +1742,47 @@
1221
1742
  {/if}
1222
1743
  {#each visible_columns as col (get_col_id(col))}
1223
1744
  {@const col_id = get_col_id(col)}
1224
- {@const drag_side = drag_over_col_id === col_id
1225
- ? get_drag_side(col_id)
1226
- : null}
1745
+ {@const is_datetime = is_datetime_column(col)}
1746
+ {@const dt_mode = datetime_mode(col)}
1747
+ {@const datetime_label_id = get_datetime_label_id(col_id)}
1748
+ {@const drag_side = drag_over_col_id === col_id ? get_drag_side(col_id) : null}
1227
1749
  {@const col_width = column_widths[col_id]}
1228
1750
  <th
1229
1751
  title={col.description}
1230
1752
  tabindex={col.sortable === false ? undefined : 0}
1231
1753
  role={col.sortable === false ? undefined : `button`}
1232
- oncontextmenu={(event) => {
1233
- if (
1234
- !allow_better_toggle || col.color_scale === null ||
1235
- col.color_scale === undefined
1236
- ) return
1237
- event.preventDefault()
1238
- event.stopPropagation()
1239
- context_menu_col = col_id
1240
- const rect = container_el?.getBoundingClientRect()
1241
- context_menu_pos = {
1242
- x: event.clientX - (rect?.left ?? 0),
1243
- y: event.clientY - (rect?.top ?? 0),
1244
- }
1245
- }}
1754
+ oncontextmenu={(event) => open_column_context_menu(event, col_id)}
1246
1755
  onclick={(event) => {
1247
1756
  if (!drag_col_id && !resize_col_id) {
1248
- sort_rows(
1249
- col.label,
1250
- col.group,
1251
- event,
1252
- )
1757
+ sort_rows(col.label, col.group, event)
1253
1758
  }
1254
1759
  }}
1255
1760
  onkeydown={(event) => {
1256
1761
  if (
1257
1762
  (event.key === `Enter` || event.key === ` `) &&
1258
- !drag_col_id && !resize_col_id
1763
+ !drag_col_id &&
1764
+ !resize_col_id
1259
1765
  ) {
1260
1766
  event.preventDefault()
1261
1767
  sort_rows(col.label, col.group, event)
1262
1768
  }
1263
1769
  }}
1264
1770
  style={`${col.style ?? ``}${
1265
- col_width
1266
- ? `; width: ${col_width}px; min-width: ${col_width}px`
1267
- : ``
1771
+ col_width ? `; width: ${col_width}px; min-width: ${col_width}px` : ``
1268
1772
  }`}
1269
1773
  class:sticky-col={col.sticky}
1270
1774
  class:not-sortable={col.sortable === false}
1271
1775
  class:dragging={drag_col_id === col_id}
1272
1776
  class:resizing={resize_col_id === col_id}
1777
+ class:datetime-select-open={datetime_select_open_col_id === col_id}
1273
1778
  data-drag-side={drag_side}
1274
1779
  draggable="true"
1275
1780
  aria-dropeffect="move"
1276
1781
  aria-sort={sort_state.column === col_id
1277
- ? (sort_state.ascending ? `ascending` : `descending`)
1278
- : `none`}
1782
+ ? sort_state.ascending
1783
+ ? `ascending`
1784
+ : `descending`
1785
+ : `none`}
1279
1786
  ondragstart={(event: DragEvent & { currentTarget: HTMLElement }) => {
1280
1787
  handle_drag_start(event, col)
1281
1788
  event.currentTarget.setAttribute(`aria-grabbed`, `true`)
@@ -1294,6 +1801,67 @@
1294
1801
  {@html sanitize_html(col.label)}
1295
1802
  {/if}
1296
1803
  {@html sanitize_html(sort_indicator(col, sort_state))}
1804
+ {#if is_datetime}
1805
+ <span class="datetime-format-control">
1806
+ <button
1807
+ type="button"
1808
+ class="datetime-format-trigger"
1809
+ aria-labelledby={datetime_label_id}
1810
+ aria-haspopup="listbox"
1811
+ aria-expanded={datetime_select_open_col_id === col_id}
1812
+ data-mode={dt_mode}
1813
+ onkeydown={(event) => event.stopPropagation()}
1814
+ onmousedown={(event) => event.stopPropagation()}
1815
+ onpointerdown={(event) => event.stopPropagation()}
1816
+ onclick={(event) => {
1817
+ event.stopPropagation()
1818
+ datetime_select_open_col_id =
1819
+ datetime_select_open_col_id === col_id ? null : col_id
1820
+ }}
1821
+ {@attach tooltip({
1822
+ content: `Date/time format: ${datetime_format_labels[dt_mode]}`,
1823
+ placement: `top`,
1824
+ })}
1825
+ >
1826
+ <Icon icon="Calendar" />
1827
+ <span id={datetime_label_id} class="sr-only">
1828
+ Date/time format for {strip_html(col.label)}
1829
+ </span>
1830
+ </button>
1831
+ {#if datetime_select_open_col_id === col_id}
1832
+ <select
1833
+ class="datetime-format-select"
1834
+ aria-labelledby={datetime_label_id}
1835
+ value={dt_mode}
1836
+ size={datetime_format_options(col).length}
1837
+ onclick={(event) => {
1838
+ event.stopPropagation()
1839
+ if (event.currentTarget.value === dt_mode) {
1840
+ datetime_select_open_col_id = null
1841
+ }
1842
+ }}
1843
+ onkeydown={(event) => {
1844
+ event.stopPropagation()
1845
+ if (event.key === `Escape`) datetime_select_open_col_id = null
1846
+ }}
1847
+ onmousedown={(event) => event.stopPropagation()}
1848
+ onpointerdown={(event) => event.stopPropagation()}
1849
+ oninput={(event) => {
1850
+ event.stopPropagation()
1851
+ set_datetime_format(
1852
+ col,
1853
+ event.currentTarget.value as DateTimeFormatMode,
1854
+ )
1855
+ datetime_select_open_col_id = null
1856
+ }}
1857
+ >
1858
+ {#each datetime_format_options(col) as mode (mode)}
1859
+ <option value={mode}>{datetime_format_labels[mode]}</option>
1860
+ {/each}
1861
+ </select>
1862
+ {/if}
1863
+ </span>
1864
+ {/if}
1297
1865
  <!-- Column resize handle -->
1298
1866
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1299
1867
  <span
@@ -1301,7 +1869,7 @@
1301
1869
  onmousedown={(event) => start_resize(event, col)}
1302
1870
  role="separator"
1303
1871
  aria-orientation="vertical"
1304
- aria-valuenow={column_widths[get_col_id(col)] ?? 100}
1872
+ aria-valuenow={col_width ?? 100}
1305
1873
  aria-valuemin={50}
1306
1874
  aria-valuemax={500}
1307
1875
  ></span>
@@ -1309,33 +1877,48 @@
1309
1877
  {/each}
1310
1878
  </tr>
1311
1879
  </thead>
1312
- <tbody>
1313
- {#each paginated_data as row, row_idx (get_row_id(row))}
1880
+ {#snippet virtual_spacer(height: number)}
1881
+ <!-- preserves scroll geometry for the unrendered rows above/below the window -->
1882
+ {#if height > 0}
1883
+ <tr class="virtual-spacer" aria-hidden="true" style:height="{height}px">
1884
+ <td colspan={body_colspan}></td>
1885
+ </tr>
1886
+ {/if}
1887
+ {/snippet}
1888
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions (drag cell-range selection; keyboard copy handled on window) -->
1889
+ <tbody onpointermove={extend_cell_drag}>
1890
+ {@render virtual_spacer(spacer_top)}
1891
+ {#each display_rows as row, row_idx (get_row_id(row))}
1892
+ {@const abs_idx = display_range.start + row_idx}
1314
1893
  {@const row_selected = show_row_select && is_row_selected(row)}
1315
1894
  <tr
1316
- animate:flip={{ duration: 500 }}
1895
+ animate:flip={{ duration: virtual_config ? 0 : 500 }}
1317
1896
  style={row.style}
1318
- class={row.class ?? ``}
1897
+ class={row.class}
1319
1898
  class:selected={row_selected}
1899
+ data-title={row_title?.(row) || undefined}
1320
1900
  tabindex={onrowclick ? 0 : undefined}
1901
+ onpointerdown={onrowpointerdown
1902
+ ? (event) => onrowpointerdown(event, row)
1903
+ : undefined}
1321
1904
  onclick={onrowclick ? (event) => onrowclick(event, row) : undefined}
1322
1905
  ondblclick={onrowdblclick ? (event) => onrowdblclick(event, row) : undefined}
1323
1906
  onkeydown={onrowclick
1324
- ? (event) => {
1325
- if (event.key === `Enter` || event.key === ` `) {
1326
- event.preventDefault()
1327
- onrowclick(event, row)
1328
- } else if (event.key === `ArrowDown`) {
1329
- event.preventDefault()
1330
- const next = event.currentTarget.nextElementSibling
1331
- if (next instanceof HTMLElement) next.focus()
1332
- } else if (event.key === `ArrowUp`) {
1333
- event.preventDefault()
1334
- const prev = event.currentTarget.previousElementSibling
1335
- if (prev instanceof HTMLElement) prev.focus()
1336
- }
1337
- }
1338
- : undefined}
1907
+ ? (event) => {
1908
+ if (event.key === `Enter` || event.key === ` `) {
1909
+ event.preventDefault()
1910
+ onrowclick(event, row)
1911
+ } else if (event.key === `ArrowDown`) {
1912
+ event.preventDefault()
1913
+ const next = event.currentTarget.nextElementSibling
1914
+ if (next instanceof HTMLElement) next.focus()
1915
+ } else if (event.key === `ArrowUp`) {
1916
+ event.preventDefault()
1917
+ const prev = event.currentTarget.previousElementSibling
1918
+ if (prev instanceof HTMLElement) prev.focus()
1919
+ }
1920
+ }
1921
+ : undefined}
1339
1922
  >
1340
1923
  {#if show_row_select}
1341
1924
  <td class="select-col">
@@ -1347,36 +1930,45 @@
1347
1930
  </td>
1348
1931
  {/if}
1349
1932
  {#if show_row_numbers}
1350
- <td class="row-num-col">
1351
- {(current_page - 1) * effective_page_size + row_idx + 1}
1352
- </td>
1933
+ <td class="row-num-col">{abs_idx + 1}</td>
1353
1934
  {/if}
1354
- {#each visible_columns as col (get_col_id(col))}
1355
- {@const val = row[get_col_id(col)]}
1935
+ {#each visible_columns as col, col_idx (get_col_id(col))}
1936
+ {@const col_id = get_col_id(col)}
1937
+ {@const val = row[col_id]}
1356
1938
  {@const color = calc_color(val, col)}
1357
- {@const col_width = column_widths[get_col_id(col)]}
1939
+ {@const col_width = column_widths[col_id]}
1940
+ {@const date_val = is_datetime_column(col)
1941
+ ? format_datetime_cell(val, col)
1942
+ : null}
1358
1943
  <td
1359
1944
  data-col={col.label}
1360
- data-sort-value={is_html_str(val) ? null : val}
1945
+ data-sort-value={get_cell_sort_attr(val)}
1946
+ data-row-idx={abs_idx}
1947
+ data-col-idx={col_idx}
1361
1948
  class:sticky-col={col.sticky}
1949
+ class:cell-selected={selected_cell_keys.has(`${abs_idx}:${col_idx}`)}
1950
+ onpointerdown={(event) => start_cell_drag(event, abs_idx, col_idx)}
1951
+ oncontextmenu={(event) => {
1952
+ // keep the native context menu for links/buttons/inputs inside cells
1953
+ if (is_interactive_cell_target(event.target)) return
1954
+ open_column_context_menu(event, col_id)
1955
+ }}
1362
1956
  style:--cell-bg={color.bg}
1363
1957
  style:color={color.text}
1364
1958
  style={`${col.cell_style ?? col.style ?? ``}${
1365
- col_width
1366
- ? `; width: ${col_width}px; max-width: ${col_width}px`
1367
- : ``
1959
+ col_width ? `; width: ${col_width}px; max-width: ${col_width}px` : ``
1368
1960
  }`}
1369
1961
  >
1370
1962
  {#if special_cells?.[col.label]}
1371
1963
  {@render special_cells[col.label]({ row, col, val })}
1372
1964
  {:else if cell}
1373
1965
  {@render cell({ row, col, val })}
1966
+ {:else if date_val != null}
1967
+ {date_val}
1374
1968
  {:else if typeof val === `number` && !Number.isNaN(val)}
1375
1969
  {format_num(val, col.format ?? default_num_format)}
1376
1970
  {:else if val === undefined || val === null || Number.isNaN(val)}
1377
- <span {@attach tooltip({ content: `Not available` })}>
1378
- n/a
1379
- </span>
1971
+ <span {@attach tooltip({ content: `Not available` })}> n/a </span>
1380
1972
  {:else}
1381
1973
  {@html sanitize_html(val)}
1382
1974
  {/if}
@@ -1386,15 +1978,11 @@
1386
1978
  {:else}
1387
1979
  {#if empty_message}
1388
1980
  <tr class="empty-row">
1389
- <td
1390
- colspan={visible_columns.length + (show_row_select ? 1 : 0) +
1391
- (show_row_numbers ? 1 : 0)}
1392
- >
1393
- {empty_message}
1394
- </td>
1981
+ <td colspan={body_colspan}>{empty_message}</td>
1395
1982
  </tr>
1396
1983
  {/if}
1397
1984
  {/each}
1985
+ {@render virtual_spacer(spacer_bottom)}
1398
1986
  </tbody>
1399
1987
  {#if footer}
1400
1988
  <tfoot>
@@ -1406,12 +1994,18 @@
1406
1994
 
1407
1995
  {@render sort_hint_element(`bottom`)}
1408
1996
 
1997
+ {#if virtual_config && sorted_data.length > display_rows.length}
1998
+ <div class="row-count-info">
1999
+ {display_rows.length} of {sorted_data.length} rows
2000
+ </div>
2001
+ {/if}
2002
+
1409
2003
  {#if pagination_config && total_pages > 1}
1410
2004
  <div class="pagination">
1411
2005
  <button
1412
2006
  class="page-btn"
1413
2007
  disabled={current_page === 1}
1414
- onclick={() => current_page = 1}
2008
+ onclick={() => (current_page = 1)}
1415
2009
  title="First page"
1416
2010
  >
1417
2011
  «
@@ -1452,7 +2046,7 @@
1452
2046
  <button
1453
2047
  class="page-btn"
1454
2048
  disabled={current_page === total_pages}
1455
- onclick={() => current_page = total_pages}
2049
+ onclick={() => (current_page = total_pages)}
1456
2050
  title="Last page"
1457
2051
  >
1458
2052
  »
@@ -1461,8 +2055,10 @@
1461
2055
  <select
1462
2056
  class="page-size-select"
1463
2057
  onchange={(event) => {
1464
- effective_page_size = parseInt(event.currentTarget.value, 10)
2058
+ const page_size = parseInt(event.currentTarget.value, 10)
2059
+ effective_page_size = page_size
1465
2060
  current_page = 1
2061
+ pagination_config.on_page_size_change?.(page_size)
1466
2062
  }}
1467
2063
  >
1468
2064
  {#each pagination_config.page_sizes as size (size)}
@@ -1476,11 +2072,13 @@
1476
2072
  {/if}
1477
2073
 
1478
2074
  <ContextMenu
1479
- sections={better_sections}
1480
- selected_values={{ 'Gradient direction': better_overrides.get(context_menu_col ?? ``) ?? `` }}
2075
+ sections={context_menu_sections}
2076
+ selected_values={{
2077
+ 'Gradient direction': better_overrides.get(context_menu_col ?? ``) ?? ``,
2078
+ }}
1481
2079
  position={context_menu_pos}
1482
2080
  visible={context_menu_col !== null}
1483
- on_close={() => context_menu_col = null}
2081
+ on_close={() => (context_menu_col = null)}
1484
2082
  style={[
1485
2083
  `--surface-bg: light-dark(#fff, #1e1e1e)`,
1486
2084
  `--border-color: light-dark(rgba(0,0,0,0.15), rgba(255,255,255,0.15))`,
@@ -1490,13 +2088,7 @@
1490
2088
  `--accent-color: light-dark(rgba(0,0,0,0.1), rgba(255,255,255,0.15))`,
1491
2089
  `z-index: 200`,
1492
2090
  ].join(`; `)}
1493
- on_select={(_, option) => {
1494
- if (!context_menu_col) return
1495
- const current = better_overrides.get(context_menu_col)
1496
- if (current === option.value) better_overrides.delete(context_menu_col)
1497
- else better_overrides.set(context_menu_col, option.value as `higher` | `lower`)
1498
- context_menu_col = null
1499
- }}
2091
+ on_select={handle_context_menu_select}
1500
2092
  />
1501
2093
  </div>
1502
2094
 
@@ -1518,15 +2110,30 @@
1518
2110
  .table-scroll.has-scroll {
1519
2111
  border: 1px solid light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.12));
1520
2112
  border-radius: var(--border-radius, 3pt);
1521
- overflow-x: hidden;
1522
- overflow-y: auto;
1523
2113
  }
1524
2114
  table {
1525
2115
  border-collapse: separate;
1526
2116
  border-spacing: 0;
1527
2117
  display: table; /* Override global display: block to enable sticky headers */
1528
2118
  }
1529
- th, td {
2119
+ /* during a cell-range drag, native text selection would fight the
2120
+ rectangle highlight */
2121
+ .table-container.cell-dragging {
2122
+ cursor: cell;
2123
+ user-select: none;
2124
+ }
2125
+ /* background-image stacks on top of the per-cell heatmap background-color,
2126
+ so selected heatmap cells stay tinted underneath */
2127
+ td.cell-selected {
2128
+ background-image: linear-gradient(
2129
+ color-mix(in srgb, var(--accent-color, #4a9eff) 30%, transparent),
2130
+ color-mix(in srgb, var(--accent-color, #4a9eff) 30%, transparent)
2131
+ );
2132
+ box-shadow: inset 0 0 0 1px
2133
+ color-mix(in srgb, var(--accent-color, #4a9eff) 55%, transparent);
2134
+ }
2135
+ th,
2136
+ td {
1530
2137
  padding: var(--heatmap-cell-padding, 1pt 5pt);
1531
2138
  text-align: var(--heatmap-text-align, left);
1532
2139
  border: var(--heatmap-cell-border, none);
@@ -1552,6 +2159,78 @@
1552
2159
  th:hover {
1553
2160
  background: var(--heatmap-header-hover-bg, var(--nav-bg));
1554
2161
  }
2162
+ th.datetime-select-open {
2163
+ overflow: visible;
2164
+ z-index: 30;
2165
+ }
2166
+ .datetime-format-control {
2167
+ display: inline-flex;
2168
+ align-items: center;
2169
+ margin-left: 3px;
2170
+ position: relative;
2171
+ vertical-align: middle;
2172
+ }
2173
+ .datetime-format-trigger {
2174
+ display: inline-grid;
2175
+ place-items: center;
2176
+ width: 14px;
2177
+ height: 14px;
2178
+ padding: 0;
2179
+ border: 0;
2180
+ border-radius: 3px;
2181
+ background: transparent;
2182
+ color: inherit;
2183
+ cursor: pointer;
2184
+ line-height: 1;
2185
+ }
2186
+ .datetime-format-trigger:hover,
2187
+ .datetime-format-trigger[aria-expanded='true'] {
2188
+ background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.16));
2189
+ }
2190
+ .datetime-format-trigger :global(svg) {
2191
+ width: 10px;
2192
+ height: 10px;
2193
+ opacity: 0.75;
2194
+ transform: translateY(-1px);
2195
+ }
2196
+ .sr-only {
2197
+ position: absolute;
2198
+ width: 1px;
2199
+ height: 1px;
2200
+ padding: 0;
2201
+ margin: -1px;
2202
+ overflow: hidden;
2203
+ clip: rect(0, 0, 0, 0);
2204
+ white-space: nowrap;
2205
+ border: 0;
2206
+ }
2207
+ .datetime-format-select {
2208
+ position: absolute;
2209
+ top: calc(100% + 2px);
2210
+ right: 0;
2211
+ z-index: 20;
2212
+ min-width: max-content;
2213
+ max-width: 10em;
2214
+ padding: 2px;
2215
+ border: 1px solid light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.18));
2216
+ border-radius: 3px;
2217
+ background: var(--heatmap-header-bg, var(--page-bg, Canvas));
2218
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
2219
+ color: inherit;
2220
+ cursor: pointer;
2221
+ font-size: 0.9em;
2222
+ line-height: 1.35;
2223
+ outline: none;
2224
+ }
2225
+ .datetime-format-select option {
2226
+ padding: 3px 8px;
2227
+ }
2228
+ .datetime-format-select option:checked {
2229
+ background: light-dark(rgba(74, 158, 255, 0.18), rgba(122, 179, 255, 0.28));
2230
+ box-shadow: 0 0 0 100vmax light-dark(rgba(74, 158, 255, 0.18), rgba(122, 179, 255, 0.28))
2231
+ inset;
2232
+ color: inherit;
2233
+ }
1555
2234
  th.dragging {
1556
2235
  opacity: 0.4;
1557
2236
  cursor: grabbing;
@@ -1576,10 +2255,20 @@
1576
2255
  td.sticky-col {
1577
2256
  position: sticky;
1578
2257
  left: 0;
1579
- background: var(--page-bg, Canvas);
2258
+ background: var(--heatmap-sticky-cell-bg, var(--page-bg, Canvas));
1580
2259
  z-index: 1;
1581
2260
  border-right: 1px solid var(--border, #ddd);
1582
2261
  }
2262
+ /* separate odd-row var so consumers with striped rows can composite their stripe
2263
+ color over the opaque sticky background (which must stay opaque to occlude columns
2264
+ scrolling beneath it), e.g.
2265
+ --heatmap-sticky-cell-odd-bg: linear-gradient(var(--stripe), var(--stripe)), var(--page-bg) */
2266
+ tbody tr:nth-child(odd) td.sticky-col {
2267
+ background: var(
2268
+ --heatmap-sticky-cell-odd-bg,
2269
+ var(--heatmap-sticky-cell-bg, var(--page-bg, Canvas))
2270
+ );
2271
+ }
1583
2272
  tbody tr:hover {
1584
2273
  filter: var(--heatmap-row-hover-filter, brightness(1.1));
1585
2274
  }
@@ -1613,35 +2302,57 @@
1613
2302
  transition: opacity 0.15s;
1614
2303
  }
1615
2304
  .table-container:hover .control-buttons,
1616
- .control-buttons:focus-within {
2305
+ .control-buttons:focus-within,
2306
+ /* keep visible while a dropdown/pane is open or when hosted in a panel
2307
+ toolbar (portaled out of the table, so container hover can't reveal it) */
2308
+ .control-buttons.force-visible,
2309
+ .control-buttons.portaled {
1617
2310
  opacity: 1;
1618
2311
  pointer-events: auto;
1619
2312
  }
1620
- .icon-btn {
1621
- padding: 2px 4px;
2313
+ .control-buttons.portaled {
2314
+ margin: 0;
2315
+ }
2316
+ /* .pane-toggle = the settings-pane gear, which sits in the control row and
2317
+ must match the other .icon-btn buttons: uniform square ghost buttons */
2318
+ .icon-btn,
2319
+ .control-buttons > :global(button.pane-toggle) {
2320
+ box-sizing: border-box;
2321
+ inline-size: 22px;
2322
+ block-size: 22px;
2323
+ padding: 0;
1622
2324
  border: none;
1623
2325
  border-radius: 3px;
1624
- background: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1));
1625
- color: light-dark(#333, #ddd);
2326
+ background: transparent;
2327
+ /* dim resting color so the hover jump to full contrast reads clearly */
2328
+ color: light-dark(#6b7280, #98a0ae);
1626
2329
  cursor: pointer;
1627
2330
  display: flex;
1628
2331
  align-items: center;
1629
2332
  justify-content: center;
1630
2333
  gap: 2px;
1631
2334
  font-size: 0.8em;
2335
+ transition: color 0.02s linear;
1632
2336
  }
1633
- .icon-btn :global(svg) {
1634
- width: 12px;
1635
- height: 12px;
2337
+ .icon-btn :global(svg),
2338
+ .control-buttons > :global(button.pane-toggle svg) {
2339
+ width: 14px;
2340
+ height: 14px;
1636
2341
  }
1637
- .icon-btn:hover {
1638
- background: light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.2));
2342
+ /* toolbar buttons give color-only hover feedback — no background shading */
2343
+ .icon-btn:hover,
2344
+ .control-buttons > :global(button.pane-toggle:hover) {
2345
+ background: transparent;
2346
+ color: light-dark(#000, #fff);
1639
2347
  }
1640
2348
  .icon-btn.active {
1641
- background: light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.25));
2349
+ color: var(--active-color, #4a9eff);
1642
2350
  }
1643
2351
  .selection-badge {
1644
2352
  position: relative;
2353
+ /* row-count badge next to the clear icon makes this one wider */
2354
+ inline-size: auto;
2355
+ padding: 0 4px;
1645
2356
  }
1646
2357
  .selection-badge .badge {
1647
2358
  background: var(--highlight, #4a9eff);
@@ -1727,7 +2438,8 @@
1727
2438
  tr.highlight {
1728
2439
  background-color: var(--nav-bg) !important;
1729
2440
  }
1730
- tr.highlight, tr.highlight :global(a) {
2441
+ tr.highlight,
2442
+ tr.highlight :global(a) {
1731
2443
  color: var(--highlight) !important;
1732
2444
  }
1733
2445
 
@@ -1749,6 +2461,18 @@
1749
2461
  border-top: 1px solid var(--highlight, #4a9eff);
1750
2462
  border-bottom: 1px solid var(--highlight, #4a9eff);
1751
2463
  }
2464
+ /* Virtualized rows: spacers keep scroll geometry; the count line replaces
2465
+ pagination in infinite-scroll mode */
2466
+ .virtual-spacer td {
2467
+ padding: 0;
2468
+ border: none;
2469
+ }
2470
+ .row-count-info {
2471
+ padding: 4px 8px;
2472
+ font-size: 0.8em;
2473
+ text-align: right;
2474
+ opacity: 0.6;
2475
+ }
1752
2476
  /* Pagination */
1753
2477
  .pagination {
1754
2478
  display: flex;
@@ -1868,11 +2592,13 @@
1868
2592
  font-style: italic;
1869
2593
  }
1870
2594
  .row-num-col {
1871
- text-align: right;
2595
+ text-align: var(--heatmap-row-num-align, right);
1872
2596
  color: var(--text-muted, #888);
1873
2597
  font-size: 0.85em;
1874
2598
  width: 2em;
1875
- padding-right: 8px !important;
2599
+ /* left default matches the th,td --heatmap-cell-padding fallback */
2600
+ padding-left: var(--heatmap-row-num-padding-left, 5pt);
2601
+ padding-right: var(--heatmap-row-num-padding-right, 8px) !important;
1876
2602
  }
1877
2603
  .page-size-select {
1878
2604
  padding: 2px 4px;