matterviz 0.4.1 → 0.4.3

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