matterviz 0.4.1 → 0.4.2

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