@svgrid/grid 3.0.2 → 3.0.4

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 (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -25,7 +25,13 @@ import {
25
25
  type TableFeatures,
26
26
  type ChartType,
27
27
  type ChartSpec,
28
+ type ChartReducer,
29
+ type ChartTimeBucket,
28
30
  type ChartValueFormat,
31
+ type ChartZoomWindow,
32
+ type ChartPanelIndicator,
33
+ type ChartFormatState,
34
+ type OhlcColumns,
29
35
  } from "./index";
30
36
  import {
31
37
  createRowScrollScaling,
@@ -53,6 +59,8 @@ import {
53
59
  rawToNumber,
54
60
  } from "./SvGrid.helpers";
55
61
  import { createFeatures } from "./features";
62
+ import { pushHistory, type HistoryStep as SharedHistoryStep } from "./history";
63
+ import { buildMergeIndex } from "./merges";
56
64
  import {
57
65
  createScrollSync,
58
66
  } from "./scroll-sync";
@@ -373,7 +381,12 @@ export function createSvGridController<
373
381
  let pendingScrollTop = 0;
374
382
  let pendingScrollLeft = 0;
375
383
  let scrollSyncRaf: number | null = null;
376
- let selectionRange = $state<SelectionRange>({ anchor: null, focus: null });
384
+ // `$state.raw`: every writer replaces the whole object, and every rendered
385
+ // cell's selection derived reads it. Under a deep proxy each of those ~300
386
+ // deriveds tracked the nested anchor/focus row/col signals separately (six
387
+ // dependencies per cell to mark and re-check on every arrow key) where one
388
+ // will do.
389
+ let selectionRange = $state.raw<SelectionRange>({ anchor: null, focus: null });
377
390
  // Extra committed ranges for multi-range (Ctrl+drag) selection. The
378
391
  // `selectionRange` above is always the ACTIVE range being manipulated; these
379
392
  // are the finished ones. Full selection = these + the active range.
@@ -418,20 +431,21 @@ export function createSvGridController<
418
431
  // Full-row editing: the row currently in whole-row edit + its per-column
419
432
  // draft (keyed by column id). Null when not in full-row mode.
420
433
  let fullRowEdit = $state<{ rowId: string; draft: Record<string, unknown> } | null>(null);
421
- let editedCellValues = $state<Record<string, unknown>>({});
434
+ // `$state.raw`, not `$state`: every writer replaces the whole object, and
435
+ // the render path asks `key in editedCellValues` once per rendered cell. On
436
+ // a deep proxy that `in` check, run inside an effect, creates and keeps a
437
+ // per-key signal for every cell that ever scrolls into view - a leak that
438
+ // grows with rows x columns and a Map lookup per cell per render.
439
+ let editedCellValues = $state.raw<Record<string, unknown>>({});
422
440
 
423
441
  // ---- Undo / redo (history + pointer model) ---------------------------
424
442
  // VSCode-style: one ordered history array, plus a pointer to the index
425
443
  // of the NEXT undo step. Avoids the dual-stack edge cases where
426
444
  // multiple undo-redo cycles can lose entries.
427
- // exported for the editing slice (undo/redo)
428
- type HistoryStep = {
429
- rowId: string
430
- columnId: string
431
- field: string
432
- before: unknown
433
- after: unknown
434
- }
445
+ // exported for the editing slice (undo/redo). The shape lives in history.ts
446
+ // so the controller, editing.ts and clipboard.ts cannot drift apart - they
447
+ // previously declared it twice and open-coded the push in three places.
448
+ type HistoryStep = SharedHistoryStep
435
449
  const UNDO_LIMIT = 200
436
450
  let history = $state<HistoryStep[]>([])
437
451
  /** Index in `history` of the LAST applied step. -1 means "nothing applied".
@@ -441,6 +455,11 @@ export function createSvGridController<
441
455
  /** Bumps on every undo / redo / record so $derived consumers can
442
456
  * observe via the api without subscribing to history directly. */
443
457
  let historyVersion = $state(0)
458
+ /** Set while a `runHistoryGroup` call is on the stack, so every step pushed
459
+ * inside one action shares a group id and undoes together. Deliberately not
460
+ * $state: it is written and read synchronously inside a single call and
461
+ * nothing renders from it. */
462
+ let historyGroupId: string | undefined = undefined
444
463
 
445
464
  // ---- Hover tooltip (custom popover, not native title=) ---------------
446
465
  // Triggered by per-column `tooltip` field OR per-cell `notes` prop.
@@ -479,6 +498,24 @@ export function createSvGridController<
479
498
  let editorSelectAll = true;
480
499
  /** Per-column width overrides set by the resize handles. */
481
500
  let columnWidths = $state<Record<string, number>>({});
501
+ /**
502
+ * Columns folded to nothing, the way a sheet hides a column: the column
503
+ * keeps its index, its cells and its width for later, and takes no room.
504
+ * Distinct from `hiddenColumns`, which takes a column out of the model
505
+ * and shifts every index after it; a spreadsheet's formulas and formats
506
+ * are keyed by index and could not follow that.
507
+ */
508
+ let collapsedColumns = $state<Record<string, boolean>>({});
509
+ function setColumnCollapsed(columnId: string, collapsed: boolean): void {
510
+ if (!!collapsedColumns[columnId] === collapsed) return;
511
+ if (collapsed) {
512
+ collapsedColumns = { ...collapsedColumns, [columnId]: true };
513
+ } else {
514
+ const next = { ...collapsedColumns };
515
+ delete next[columnId];
516
+ collapsedColumns = next;
517
+ }
518
+ }
482
519
  const MIN_COLUMN_WIDTH = 40;
483
520
  /** Columns pinned to the left or right edge of the grid (sticky positioning).
484
521
  * Seeded from `props.initialColumnPinning` so demos / tests can show the
@@ -570,26 +607,54 @@ export function createSvGridController<
570
607
  const hb = column.columnDef?.hideBelow;
571
608
  return hb != null && viewportWidth > 0 && viewportWidth < hb;
572
609
  }
573
- const scrollMetrics = $derived.by(function scrollMetrics_d() {
610
+ /**
611
+ * Live scroll geometry of the scroll container (feeds the custom
612
+ * scrollbars and `hasVerticalOverflow`).
613
+ *
614
+ * Measured from a plain `$effect`, NOT inside a `$derived`, on purpose.
615
+ * `scrollTop` / `scrollHeight` / `scrollWidth` reads force a synchronous
616
+ * layout whenever the tree is dirty. As a derived this was pulled by the
617
+ * shell's `class:sv-grid-has-vscroll` at the very start of every scroll
618
+ * flush - before the recycled rows had been rewritten - so it forced a
619
+ * full table layout that the row writes then invalidated again, and the
620
+ * browser laid the table out a second time before painting. Two table
621
+ * layouts per arrow key on a large grid. An effect runs after the flush
622
+ * has written the DOM, so the reads below land on a tree the browser has
623
+ * to lay out anyway, and the pre-paint pass is incremental.
624
+ *
625
+ * The version reads keep the same triggers the derived had: a scroll
626
+ * tick, a shell resize, and either virtualizer's window/size change (data
627
+ * load, row / column count change) - without those the scrollbar kept a
628
+ * stale `content-size` ≈ 0, its hidden-check tripped, it set
629
+ * `pointer-events: none`, and the user could not drag it.
630
+ */
631
+ let scrollMetrics = $state.raw({
632
+ scrollTop: 0,
633
+ scrollLeft: 0,
634
+ scrollHeight: 0,
635
+ scrollWidth: 0,
636
+ });
637
+ $effect(function scrollMetrics_e() {
574
638
  scrollVersion;
575
639
  viewportVersion;
576
- // Track the virtualizers' versions too so when data loads or row /
577
- // column counts change, scrollMetrics re-reads the DOM's grown
578
- // scrollHeight / scrollWidth. Without these deps the scrollbar
579
- // receives a stale `content-size` ≈ 0, its hidden-check trips, it
580
- // sets `pointer-events: none`, and the user can't drag it. The
581
- // identifiers below are declared further down - derived callbacks
582
- // run lazily, so by the time this fires they're in scope.
583
640
  virtualizer.version;
584
641
  columnVirtualizerVersion;
585
- return {
586
- scrollTop: scrollContainer?.scrollTop ?? 0,
587
- scrollLeft: scrollContainer?.scrollLeft ?? 0,
588
- clientHeight: scrollContainer?.clientHeight ?? 0,
589
- clientWidth: scrollContainer?.clientWidth ?? 0,
590
- scrollHeight: scrollContainer?.scrollHeight ?? 0,
591
- scrollWidth: scrollContainer?.scrollWidth ?? 0,
592
- };
642
+ const el = scrollContainer;
643
+ if (!el) return;
644
+ const scrollTop = el.scrollTop;
645
+ const scrollLeft = el.scrollLeft;
646
+ const scrollHeight = el.scrollHeight;
647
+ const scrollWidth = el.scrollWidth;
648
+ const prev = untrack(() => scrollMetrics);
649
+ if (
650
+ prev.scrollTop === scrollTop &&
651
+ prev.scrollLeft === scrollLeft &&
652
+ prev.scrollHeight === scrollHeight &&
653
+ prev.scrollWidth === scrollWidth
654
+ ) {
655
+ return;
656
+ }
657
+ scrollMetrics = { scrollTop, scrollLeft, scrollHeight, scrollWidth };
593
658
  });
594
659
  /** Vertical overflow from the virtualizer's authoritative total size,
595
660
  * NOT from `scrollMetrics.scrollHeight` alone. Reading DOM dimensions
@@ -1697,8 +1762,14 @@ export function createSvGridController<
1697
1762
  const paginationPageSize = $derived(
1698
1763
  externalPaginationEnabled ? (props.pageSize ?? 10) : paginationState.pageSize,
1699
1764
  );
1765
+ // Tracks `dataStateVersion`, which the store subscription bumps when the
1766
+ // rowSelection slice (among others) changes - NOT the catch-all
1767
+ // `gridStateVersion`, which also bumps on every active-cell move. Every
1768
+ // rendered row and cell reads this (`sv-grid-row-selected`, `aria-selected`),
1769
+ // so on the catch-all each arrow key re-marked and re-checked ~400 effects
1770
+ // to find the same object.
1700
1771
  const rowSelectionState = $derived.by(function rowSelectionState_d() {
1701
- gridStateVersion;
1772
+ dataStateVersion;
1702
1773
  return grid.getState().rowSelection ?? {};
1703
1774
  });
1704
1775
 
@@ -2017,6 +2088,7 @@ export function createSvGridController<
2017
2088
  let chartAiHandler = $state<
2018
2089
  ((prompt: string) => Promise<Record<string, unknown> | null>) | null
2019
2090
  >(null);
2091
+ let chartExplainHandler = $state<(() => Promise<{ summary: string; insights: string[] } | null>) | null>(null);
2020
2092
 
2021
2093
  // Multiple charts, each an independently-configured working set switched by a
2022
2094
  // tab strip. Pickers / spec read the ACTIVE chart.
@@ -2024,7 +2096,7 @@ export function createSvGridController<
2024
2096
  id: string;
2025
2097
  title: string;
2026
2098
  type: ChartType;
2027
- reduce: "sum" | "avg" | "count";
2099
+ reduce: ChartReducer;
2028
2100
  dimensionId: string | null;
2029
2101
  measureId: string | null;
2030
2102
  seriesId: string | null | undefined;
@@ -2045,8 +2117,28 @@ export function createSvGridController<
2045
2117
  logScale: boolean | null;
2046
2118
  timeAxis: boolean | null;
2047
2119
  valueFormat: ChartValueFormat | null;
2120
+ /** Calendar unit a date dimension is grouped by. Null = exact values. */
2121
+ bucket: ChartTimeBucket | null;
2122
+ /** Histogram bin count. Null = Sturges' rule. */
2123
+ bins: number | null;
2124
+ funnelShape: "trapezoid" | "pyramid" | "cone" | null;
2125
+ candleStyle: "classic" | "hollow" | "heikin-ashi" | null;
2126
+ /** The chart's zoom window (category index range), kept per tab. */
2127
+ zoom: ChartZoomWindow | null;
2128
+ /** Candlestick / OHLC: which columns hold the prices (column ids). Null
2129
+ * guesses from the column names. */
2130
+ ohlc: { open: string | null; high: string | null; low: string | null; close: string | null; volume: string | null } | null;
2131
+ /** Indicators: panes under the price (volume, rsi, macd, stochastic,
2132
+ * atr, obv) and overlays on it (sma, ema, bb, vwap). */
2133
+ indicators: ChartPanelIndicator[];
2134
+ /** The builder's Format tab: titles, axes, legend, per-series style. */
2135
+ format: ChartFormatState | null;
2136
+ /** An unlinked chart: the spec it was frozen with and when. */
2137
+ frozen: { spec: ChartSpec; at: string } | null;
2048
2138
  };
2049
2139
  let chartSeq = 0;
2140
+ const isZoomWindow = (v: unknown): v is ChartZoomWindow =>
2141
+ !!v && typeof v === "object" && Number.isInteger((v as ChartZoomWindow).i0) && Number.isInteger((v as ChartZoomWindow).i1);
2050
2142
  const makeChart = (title: string): ChartTabState => ({
2051
2143
  id: `chart-${chartSeq++}`,
2052
2144
  title,
@@ -2067,10 +2159,49 @@ export function createSvGridController<
2067
2159
  logScale: null,
2068
2160
  timeAxis: null,
2069
2161
  valueFormat: null,
2162
+ bucket: null,
2163
+ bins: null,
2164
+ funnelShape: null,
2165
+ candleStyle: null,
2166
+ zoom: null,
2167
+ ohlc: null,
2168
+ indicators: [],
2169
+ format: null,
2170
+ frozen: null,
2070
2171
  });
2172
+ // The saved view (`GridState.charts[i]`) names four fields differently
2173
+ // from the tab; everything else is the tab's own key.
2174
+ const TAB_ALIASES: Record<string, string> = { dimensionId: "dimension", measureId: "measure", seriesId: "series", measure2Id: "measure2" };
2175
+ /** A tab as the saved view carries it, without the runtime id. */
2176
+ const tabToState = (c: ChartTabState): Record<string, unknown> => {
2177
+ const out: Record<string, unknown> = {};
2178
+ for (const k in c) if (k !== "id") out[TAB_ALIASES[k] ?? k] = k === "seriesId" ? (c.seriesId ?? null) : c[k as keyof ChartTabState];
2179
+ return out;
2180
+ };
2181
+ /** A tab from a saved-view snapshot: a fresh tab with every known field
2182
+ * the snapshot carries, and the shape-checked ones dropped when malformed. */
2183
+ const tabFromState = (c: Record<string, unknown>, i: number): ChartTabState => {
2184
+ const t = makeChart(typeof c.title === "string" ? c.title : `Chart ${i + 1}`) as unknown as Record<string, unknown>;
2185
+ for (const k in t) {
2186
+ const v = c[TAB_ALIASES[k] ?? k];
2187
+ if (k !== "id" && k !== "title" && v !== undefined) t[k] = v;
2188
+ }
2189
+ if (!isZoomWindow(t.zoom)) t.zoom = null;
2190
+ if (!Array.isArray(t.indicators)) t.indicators = [];
2191
+ if (typeof t.bins !== "number") t.bins = null;
2192
+ if (!t.frozen || typeof t.frozen !== "object" || !(t.frozen as { spec?: unknown }).spec) t.frozen = null;
2193
+ return t as unknown as ChartTabState;
2194
+ };
2071
2195
  // svelte-ignore state_referenced_locally
2072
2196
  let charts = $state<ChartTabState[]>([makeChart("Chart 1")]);
2073
2197
  let activeChartIndex = $state(0);
2198
+ /**
2199
+ * Named chart configurations the user keeps: a tab's whole state under a
2200
+ * name, applied to any tab later. Part of the saved view (`GridState.savedCharts`)
2201
+ * only when there is one, so existing snapshots do not change.
2202
+ */
2203
+ type SavedChartEntry = { name: string; savedAt: string; tab: Record<string, unknown> };
2204
+ let savedCharts = $state<SavedChartEntry[]>([]);
2074
2205
  const activeChart = $derived(
2075
2206
  (charts[Math.min(activeChartIndex, charts.length - 1)] ?? charts[0])!,
2076
2207
  );
@@ -2083,6 +2214,10 @@ export function createSvGridController<
2083
2214
  const chartDataLabels = $derived(activeChart.dataLabels);
2084
2215
  const chartLogScale = $derived(activeChart.logScale);
2085
2216
  const chartTimeAxis = $derived(activeChart.timeAxis);
2217
+ const chartBucket = $derived(activeChart.bucket);
2218
+ const chartBins = $derived(activeChart.bins);
2219
+ const chartFunnelShape = $derived(activeChart.funnelShape);
2220
+ const chartCandleStyle = $derived(activeChart.candleStyle);
2086
2221
  const chartValueFormat = $derived(activeChart.valueFormat);
2087
2222
 
2088
2223
  const asMeasureList = (m: string | string[] | undefined): string[] =>
@@ -2198,9 +2333,13 @@ export function createSvGridController<
2198
2333
  const col = allColumns.find((c) => c.id === effectiveChartMeasureId);
2199
2334
  const label = col ? columnLabel(col) : "";
2200
2335
  if (!label) return undefined;
2201
- return chartReduce === "count"
2202
- ? "Count"
2203
- : `${chartReduce === "avg" ? "Average" : "Sum"} of ${label}`;
2336
+ const word: Record<string, string> = {
2337
+ sum: "Sum", avg: "Average", min: "Min", max: "Max", median: "Median",
2338
+ first: "First", last: "Last", countDistinct: "Distinct",
2339
+ };
2340
+ if (chartReduce === "count") return "Count";
2341
+ const w = word[chartReduce] ?? (chartReduce.startsWith("p") ? `P${chartReduce.slice(1)}` : "Sum");
2342
+ return `${w} of ${label}`;
2204
2343
  });
2205
2344
  /**
2206
2345
  * Does this column hold dates? Declared type first, then a probe of the
@@ -2223,6 +2362,41 @@ export function createSvGridController<
2223
2362
  const chartDimensionIsDate = $derived.by<boolean>(() =>
2224
2363
  columnIsDate(allColumns.find((c) => c.id === effectiveChartDimensionId)),
2225
2364
  );
2365
+ // The chart derivation engine (rowsToChartSpec) is loaded lazily the first time
2366
+ // charting is enabled, keeping chart.ts (~11 KB gzip) out of the base <SvGrid>
2367
+ // bundle - it rides in the same lazy chunk as the chart panel. `chartSpec` stays
2368
+ // null until it resolves, a tick after the panel itself starts loading (which it
2369
+ // already does). The `getAggregate` server path uses the local bucketsToSpec and
2370
+ // does not need the engine.
2371
+ // The whole module, not just `rowsToChartSpec`: the panel can now reach
2372
+ // every chart type, and the ones that do not read a categories-by-series
2373
+ // grid (scatter, gauge, treemap, calendar, sankey) need their own builders
2374
+ // from the same lazy chunk. Same `import()`, same boundary.
2375
+ let chartEngine = $state<typeof import("./chart") | null>(null);
2376
+ $effect(() => {
2377
+ if (chartingEnabled && !chartEngine)
2378
+ import("./chart").then((m) => (chartEngine = m));
2379
+ });
2380
+
2381
+ const chartIsOhlc = $derived(chartType === "candlestick" || chartType === "ohlc");
2382
+ /** The price columns: what the reader picked, else the engine's guess
2383
+ * from the column names (nothing until the engine chunk has loaded). */
2384
+ const effectiveChartOhlc = $derived<OhlcColumns>(
2385
+ chartEngine
2386
+ ? chartEngine.guessOhlcColumns(chartableColumns.measures, activeChart.ohlc)
2387
+ : { open: null, high: null, low: null, close: null, volume: null, ...activeChart.ohlc },
2388
+ );
2389
+ /** The date column a candlestick reads: the picked dimension when it is a
2390
+ * date, else the first date column. */
2391
+ const effectiveChartOhlcDateId = $derived(
2392
+ chartDimensionIsDate ? effectiveChartDimensionId : (chartableColumns.dates[0]?.id ?? null),
2393
+ );
2394
+ const chartIndicators = $derived(activeChart.indicators);
2395
+ /** The panes under a price chart and the overlay on it, split by the engine. */
2396
+ const chartIndicatorSplit = $derived(
2397
+ chartIsOhlc && chartEngine ? chartEngine.splitPanelIndicators(chartIndicators) : { panes: [], overlay: null },
2398
+ );
2399
+ const chartIndicatorPanes = $derived(chartIndicatorSplit.panes);
2226
2400
  const chartRows = $derived.by<TData[]>(() => {
2227
2401
  const rects = getSelectionRects();
2228
2402
  const pushLeaf = (out: TData[], row: Row<TData> | undefined) => {
@@ -2272,52 +2446,6 @@ export function createSvGridController<
2272
2446
  if (seq === chartReqSeq) chartServerBuckets = [];
2273
2447
  });
2274
2448
  });
2275
- const bucketsToSpec = (
2276
- buckets: ReadonlyArray<{ category: string; series?: string; value: number }>,
2277
- type: ChartType,
2278
- hasSeries: boolean,
2279
- ): ChartSpec => {
2280
- const cats: string[] = [];
2281
- const catIdx = new Map<string, number>();
2282
- const ensureCat = (c: string) => {
2283
- let i = catIdx.get(c);
2284
- if (i === undefined) { i = cats.length; catIdx.set(c, i); cats.push(c); }
2285
- return i;
2286
- };
2287
- const seriesMap = new Map<string, number[]>();
2288
- for (const b of buckets) {
2289
- const ci = ensureCat(b.category);
2290
- const key = hasSeries ? (b.series ?? "") : "value";
2291
- let arr = seriesMap.get(key);
2292
- if (!arr) { arr = []; seriesMap.set(key, arr); }
2293
- arr[ci] = (arr[ci] ?? 0) + b.value;
2294
- }
2295
- return {
2296
- type,
2297
- categories: cats,
2298
- series: [...seriesMap.entries()].map(([label, values]) => ({
2299
- label,
2300
- values: cats.map((_, i) => values[i] ?? 0),
2301
- })),
2302
- };
2303
- };
2304
-
2305
- // The chart derivation engine (rowsToChartSpec) is loaded lazily the first time
2306
- // charting is enabled, keeping chart.ts (~11 KB gzip) out of the base <SvGrid>
2307
- // bundle - it rides in the same lazy chunk as the chart panel. `chartSpec` stays
2308
- // null until it resolves, a tick after the panel itself starts loading (which it
2309
- // already does). The `getAggregate` server path uses the local bucketsToSpec and
2310
- // does not need the engine.
2311
- // The whole module, not just `rowsToChartSpec`: the panel can now reach
2312
- // every chart type, and the ones that do not read a categories-by-series
2313
- // grid (scatter, gauge, treemap, calendar, sankey) need their own builders
2314
- // from the same lazy chunk. Same `import()`, same boundary.
2315
- let chartEngine = $state<typeof import("./chart") | null>(null);
2316
- $effect(() => {
2317
- if (chartingEnabled && !chartEngine)
2318
- import("./chart").then((m) => (chartEngine = m));
2319
- });
2320
-
2321
2449
  /** Put the chart's numbers in the same locale as the grid's. A grid that set
2322
2450
  * `localization.locale` has already said which locale its data is in, and a
2323
2451
  * chart of that data reading in a different one is a bug nobody would think
@@ -2329,27 +2457,52 @@ export function createSvGridController<
2329
2457
  if (chartCfg?.currency) spec.currency = chartCfg.currency;
2330
2458
  };
2331
2459
 
2332
- const chartSpec = $derived.by<ChartSpec | null>(() => {
2460
+ const chartSpecLive = $derived.by<ChartSpec | null>(() => {
2333
2461
  if (!chartingEnabled || !chartCfg) return null;
2334
2462
  if (chartCfg.getAggregate) {
2335
- if (!chartServerBuckets) return null;
2336
- const spec = bucketsToSpec(chartServerBuckets, chartType, !!effectiveChartSeriesId);
2463
+ if (!chartServerBuckets || !chartEngine) return null;
2464
+ const spec = chartEngine.bucketsToChartSpec(chartServerBuckets, chartType, !!effectiveChartSeriesId);
2337
2465
  if (effectiveChartStacked || chartCfg.stacked100) spec.stacked = true;
2338
2466
  if (chartCfg.stacked100) spec.stacked100 = true;
2339
2467
  if (chartCfg.palette) spec.palette = chartCfg.palette;
2340
2468
  if (effectiveChartTimeAxis) spec.xType = "time";
2469
+ else if (chartBucket && chartDimensionIsDate) spec.xType = "ordinal-time";
2341
2470
  if (effectiveChartLogScale) spec.yScale = "log";
2342
2471
  if (effectiveChartValueFormat) spec.valueFormat = effectiveChartValueFormat;
2343
2472
  applyChartLocale(spec);
2344
2473
  return spec;
2345
2474
  }
2346
2475
  if (chartCfg.buildSpec) return chartCfg.buildSpec(chartRows) ?? null;
2476
+ // Engine not loaded yet (lazy): render nothing until it resolves.
2477
+ if (!chartEngine) return null;
2478
+ // Shared by the direct and aggregated paths so the two cannot drift apart
2479
+ // again - which is how these came to be skipped for scatter, gauge and
2480
+ // boxplot in the first place. Declared here, at the derived's own scope:
2481
+ // a function declaration inside the `else` block below is block-scoped,
2482
+ // and the aggregated path calls it after that block has closed.
2483
+ function applyChartValueSettings<T extends {
2484
+ xType?: string; yScale?: string; valueFormat?: unknown;
2485
+ }>(spec: T): T {
2486
+ if (effectiveChartTimeAxis) spec.xType = "time";
2487
+ if (effectiveChartLogScale) spec.yScale = "log";
2488
+ if (effectiveChartValueFormat) spec.valueFormat = effectiveChartValueFormat;
2489
+ applyChartLocale(spec as never);
2490
+ return spec;
2491
+ }
2347
2492
 
2493
+ // Pivot mode: the pivot on screen is the chart, rows as categories and
2494
+ // columns as series; the pickers have nothing to choose.
2495
+ let spec: ChartSpec;
2496
+ if (pivotResult) {
2497
+ spec = chartEngine.pivotResultToChartSpec(pivotResult, {
2498
+ type: chartType, stacked: effectiveChartStacked, stacked100: effectiveChartStacked100,
2499
+ measures: pivotConfig!.values,
2500
+ ...(effectiveChartPalette ? { palette: effectiveChartPalette } : {}),
2501
+ });
2502
+ } else {
2348
2503
  const category = fieldOf(effectiveChartDimensionId);
2349
2504
  const values = effectiveChartMeasureIds.map((id) => fieldOf(id)).filter((f): f is string => !!f);
2350
- if (!category || !values.length) return null;
2351
- // Engine not loaded yet (lazy): render nothing until it resolves.
2352
- if (!chartEngine) return null;
2505
+ if (!chartIsOhlc && (!category || !values.length)) return null;
2353
2506
  const seriesField = fieldOf(effectiveChartSeriesId);
2354
2507
  const rowsForChart = chartRows as Array<Record<string, unknown>>;
2355
2508
 
@@ -2360,26 +2513,51 @@ export function createSvGridController<
2360
2513
  // base bundle, which every grid pays for whether or not it ever charts.
2361
2514
  // Everything else falls through to the aggregation below and is reshaped
2362
2515
  // afterwards, so reduce / sort / topN / "Other" keep working for all of it.
2516
+ const ohlcOpts = chartIsOhlc
2517
+ ? chartEngine.ohlcDirectOptions(fieldOf, effectiveChartOhlcDateId, effectiveChartOhlc, { bucket: chartBucket, candleStyle: chartCandleStyle })
2518
+ : null;
2363
2519
  const direct = chartEngine.rowsToDirectSpec(chartType, rowsForChart, {
2364
- category: category as string,
2520
+ ...(ohlcOpts ?? {}),
2521
+ category: ohlcOpts?.category ?? (category as string),
2365
2522
  ...(values[0] ? { value: values[0] } : {}),
2366
2523
  ...(fieldOf(effectiveChartMeasure2Id) ? { value2: fieldOf(effectiveChartMeasure2Id)! } : {}),
2367
2524
  ...(seriesField ? { series: seriesField } : {}),
2368
2525
  reduce: chartReduce,
2526
+ ...(chartBins ? { bins: chartBins } : {}),
2369
2527
  ...(effectiveChartPalette ? { palette: effectiveChartPalette } : {}),
2370
2528
  });
2371
- if (direct) return direct;
2529
+ // Number formatting and the scale/axis-type settings are about how values
2530
+ // READ, so they apply to a scatter or a gauge exactly as they do to a bar
2531
+ // chart. They used to live only on the aggregated path below, so setting
2532
+ // `valueFormat` or `logScale` on a direct type silently did nothing.
2533
+ //
2534
+ // The series-shaped settings (trend, averageLine, seriesTypes) stay on the
2535
+ // aggregated path: a direct spec does not have the series shape they read.
2536
+ if (direct) {
2537
+ if (chartIsOhlc) {
2538
+ // Overlays ride on the price series; the panes are composed by the
2539
+ // panel body from `chartIndicatorPanes`.
2540
+ const overlay = chartIndicatorSplit.overlay;
2541
+ if (overlay && direct.series[0]) direct.series[0].overlay = overlay;
2542
+ }
2543
+ return applyChartValueSettings(direct);
2544
+ }
2372
2545
  // A direct type that could not build (scatter with no Y picked yet, for
2373
2546
  // instance) must not fall through to the bar-chart path and draw the wrong
2374
2547
  // chart - it renders the panel's empty state instead.
2375
- if (chartType === "scatter" || chartType === "gauge" || chartType === "boxplot") return null;
2548
+ if (
2549
+ chartType === "scatter" || chartType === "gauge" || chartType === "boxplot" ||
2550
+ chartType === "histogram" || chartType === "range-bar" || chartType === "range-area" ||
2551
+ chartType === "dumbbell" || chartType === "bullet" || chartIsOhlc
2552
+ ) return null;
2376
2553
 
2377
- const spec = chartEngine.rowsToChartSpec<Record<string, unknown>>(rowsForChart, {
2554
+ spec = chartEngine.rowsToChartSpec<Record<string, unknown>>(rowsForChart, {
2378
2555
  type: chartType,
2379
2556
  category: category as string,
2380
2557
  value: values.length === 1 ? values[0]! : values,
2381
2558
  ...(seriesField ? { series: seriesField } : {}),
2382
2559
  reduce: chartReduce,
2560
+ ...(chartBucket && chartDimensionIsDate ? { bucket: chartBucket } : {}),
2383
2561
  stacked: effectiveChartStacked || effectiveChartStacked100,
2384
2562
  stacked100: effectiveChartStacked100,
2385
2563
  ...(effectiveChartPalette ? { palette: effectiveChartPalette } : {}),
@@ -2388,10 +2566,16 @@ export function createSvGridController<
2388
2566
  ...(chartCfg.sort ? { sort: chartCfg.sort } : {}),
2389
2567
  });
2390
2568
 
2569
+ }
2391
2570
  // Reshape the aggregated grid into whatever this type actually reads.
2392
2571
  // These run AFTER the aggregation so grouping, reduce, sort, topN and the
2393
2572
  // "Other" bucket apply to them exactly as they do to a bar chart.
2394
- if (chartType === "treemap") spec.treemap = chartEngine.specToTreemap(spec);
2573
+ if (chartType === "treemap" || chartType === "sunburst") spec.treemap = chartEngine.specToTreemap(spec);
2574
+ else if (chartType === "chord") {
2575
+ const flow = chartEngine.specToSankey(spec);
2576
+ spec.sankeyNodes = flow.nodes;
2577
+ spec.sankeyLinks = flow.links;
2578
+ }
2395
2579
  else if (chartType === "calendar") spec.calendarValues = chartEngine.specToCalendar(spec);
2396
2580
  else if (chartType === "sankey") {
2397
2581
  const flow = chartEngine.specToSankey(spec);
@@ -2400,10 +2584,7 @@ export function createSvGridController<
2400
2584
  }
2401
2585
 
2402
2586
  if (effectiveChartOrientation === "horizontal") spec.orientation = "horizontal";
2403
- if (effectiveChartTimeAxis) spec.xType = "time";
2404
- if (effectiveChartLogScale) spec.yScale = "log";
2405
- if (effectiveChartValueFormat) spec.valueFormat = effectiveChartValueFormat;
2406
- applyChartLocale(spec);
2587
+ applyChartValueSettings(spec);
2407
2588
  if (chartType !== "pie" && spec.orientation !== "horizontal" && !spec.yAxisTitle && chartAutoYAxisTitle) {
2408
2589
  spec.yAxisTitle = chartAutoYAxisTitle;
2409
2590
  }
@@ -2413,6 +2594,11 @@ export function createSvGridController<
2413
2594
  }
2414
2595
  if (chartCfg.annotations) spec.annotations = chartCfg.annotations;
2415
2596
  if (chartCfg.patternFallback) spec.patternFallback = true;
2597
+ if (chartFunnelShape && chartType === "funnel") spec.funnelShape = chartFunnelShape;
2598
+ if (chartCandleStyle && (chartType === "candlestick" || chartType === "ohlc")) spec.candleStyle = chartCandleStyle;
2599
+ // A stream is a stacked area on a wiggle baseline; the picker's Stacked
2600
+ // box has nothing to add, so the offset is applied here outright.
2601
+ if (chartType === "stream") { spec.stacked = true; spec.stackOffset = "wiggle"; }
2416
2602
  const refLines = [...(chartCfg.referenceLines ?? [])];
2417
2603
  if (chartCfg.averageLine && spec.series[0]?.values.length) {
2418
2604
  const vals = spec.series[0].values;
@@ -2421,7 +2607,10 @@ export function createSvGridController<
2421
2607
  }
2422
2608
  if (refLines.length) spec.referenceLines = refLines;
2423
2609
  for (const s of spec.series) {
2424
- if (chartCfg.trend) s.overlay = chartCfg.trend === "linear" ? "linear" : `${chartCfg.trend}:7`;
2610
+ if (chartCfg.trend) {
2611
+ const t = chartCfg.trend;
2612
+ s.overlay = t === "sma" || t === "ema" ? `${t}:7` : t === "poly" ? "poly:2" : t;
2613
+ }
2425
2614
  if (chartCfg.smooth) s.smooth = true;
2426
2615
  if (chartCfg.seriesTypes?.[s.label]) s.type = chartCfg.seriesTypes[s.label];
2427
2616
  if (chartCfg.seriesAxes?.[s.label]) s.axis = chartCfg.seriesAxes[s.label];
@@ -2429,6 +2618,43 @@ export function createSvGridController<
2429
2618
  return spec;
2430
2619
  });
2431
2620
 
2621
+ /**
2622
+ * What the panel renders: an unlinked chart's frozen snapshot, else the live
2623
+ * derivation with the builder's format applied. The format runs through
2624
+ * the lazy engine so its code stays out of the base bundle.
2625
+ */
2626
+ const chartSpec = $derived.by<ChartSpec | null>(() => {
2627
+ if (activeChart.frozen) return activeChart.frozen.spec;
2628
+ const live = chartSpecLive;
2629
+ if (!live || !activeChart.format || !chartEngine) return live;
2630
+ return chartEngine.applyChartFormat(live, activeChart.format);
2631
+ });
2632
+ let announcedFirstChart = false;
2633
+ $effect(() => {
2634
+ if (!chartPanelOpen || announcedFirstChart || !chartCfg?.onChartCreated) return;
2635
+ announcedFirstChart = true;
2636
+ const c = charts[0]!;
2637
+ untrack(() => chartCfg?.onChartCreated?.({ index: 0, title: c.title, type: c.type, spec: null }));
2638
+ });
2639
+ // Lifecycle: one `onChartChanged` per burst of spec changes.
2640
+ let chartChangeTimer: ReturnType<typeof setTimeout> | null = null;
2641
+ $effect(() => {
2642
+ const cb = chartCfg?.onChartChanged;
2643
+ const spec = chartSpec;
2644
+ const index = activeChartIndex;
2645
+ if (!cb) return;
2646
+ untrack(() => {
2647
+ if (chartChangeTimer) clearTimeout(chartChangeTimer);
2648
+ chartChangeTimer = setTimeout(() => {
2649
+ chartChangeTimer = null;
2650
+ const c = charts[index] ?? activeChart;
2651
+ cb({ index, title: c.title, type: c.type, spec });
2652
+ }, 150);
2653
+ });
2654
+ return () => {
2655
+ if (chartChangeTimer) { clearTimeout(chartChangeTimer); chartChangeTimer = null; }
2656
+ };
2657
+ });
2432
2658
  /**
2433
2659
  * Can a click on this chart be turned back into a grid filter?
2434
2660
  *
@@ -2438,12 +2664,19 @@ export function createSvGridController<
2438
2664
  * at all), or a scatter (the mark carries a point label, not a group).
2439
2665
  */
2440
2666
  const chartCrossFilterable = $derived(
2441
- chartType !== "sankey" && chartType !== "gauge" && chartType !== "scatter",
2667
+ chartType !== "sankey" && chartType !== "gauge" && chartType !== "scatter" &&
2668
+ // A chord's arcs are both ends of a flow, a sunburst's are a hierarchy
2669
+ // and a histogram's are bins: none maps a click back to one dimension value.
2670
+ chartType !== "chord" && chartType !== "sunburst" && chartType !== "histogram",
2442
2671
  );
2443
2672
 
2673
+ /** The column a chart click filters: the picked dimension, or in pivot mode the innermost row dimension when it is a grid column. */
2674
+ const chartFilterDimId = $derived(
2675
+ pivotResult ? (chartEngine?.pivotFilterColumn(pivotConfig!.rows, chartableColumns.dims, fieldOf) ?? null) : effectiveChartDimensionId,
2676
+ );
2444
2677
  function applyChartCrossFilter(category: string) {
2445
2678
  if (!chartCrossFilterable) return;
2446
- const dimId = effectiveChartDimensionId;
2679
+ const dimId = chartFilterDimId;
2447
2680
  if (!dimId) return;
2448
2681
  const existing = valueFilters[dimId];
2449
2682
  const next = new Set(existing && existing.has(category) ? existing : []);
@@ -2452,7 +2685,7 @@ export function createSvGridController<
2452
2685
  valueFilters = { ...valueFilters, [dimId]: next };
2453
2686
  }
2454
2687
  function clearChartCrossFilter() {
2455
- const dimId = effectiveChartDimensionId;
2688
+ const dimId = chartFilterDimId;
2456
2689
  if (!dimId) return;
2457
2690
  const next = { ...valueFilters };
2458
2691
  delete next[dimId];
@@ -2647,9 +2880,46 @@ export function createSvGridController<
2647
2880
  const logical = virtualizer.getState().scrollOffset;
2648
2881
  return logical - rowScrollScaling.logicalToDom(logical);
2649
2882
  });
2883
+ /**
2884
+ * The first `frozenRows` rows are rendered ahead of the virtual window
2885
+ * and stick under the header. The virtualizer still counts them, so its
2886
+ * offsets stay in row-index space and nothing downstream has to translate;
2887
+ * the window skips them, and the top spacer gives back the height they
2888
+ * already occupy in the flow.
2889
+ */
2890
+ const frozenRowCount = $derived(
2891
+ Math.max(0, Math.min(Math.floor(props.frozenRows ?? 0), allRows.length)),
2892
+ );
2893
+ const frozenRowList = $derived(frozenRowCount ? allRows.slice(0, frozenRowCount) : []);
2894
+ /** The merged cells, indexed; null (the usual case) costs nothing. */
2895
+ const mergeIndex = $derived(buildMergeIndex(props.mergedCells));
2896
+ const frozenBandHeight = $derived.by(() => {
2897
+ if (!frozenRowCount) return 0;
2898
+ rowResizeVersion;
2899
+ autoRowHeightVersion;
2900
+ let total = 0;
2901
+ for (let i = 0; i < frozenRowCount; i += 1) total += rowSizePxOf(i);
2902
+ return total;
2903
+ });
2904
+ /** The declared or dragged height of one row; measured under auto height. */
2905
+ function rowSizePxOf(i: number): number {
2906
+ if (isRowCollapsed(i)) return 0;
2907
+ if (autoRowHeightOn) return measuredRowHeights.get(i) ?? autoRowHeightFallback;
2908
+ const own = rowResizeHeightPx(i);
2909
+ if (own != null) return own;
2910
+ const rh = props.rowHeight;
2911
+ return typeof rh === "function" ? rh(i) : (rh ?? 30);
2912
+ }
2913
+ /** Where frozen row i sticks: under the header and the frozen rows above it. */
2914
+ function frozenRowTop(i: number): number {
2915
+ let top = headerHeight;
2916
+ for (let k = 0; k < i; k += 1) top += rowSizePxOf(k);
2917
+ return top;
2918
+ }
2650
2919
  // Spacer heights in DOM space. With scaling inert these equal the original
2651
- // virtualRowStart / virtualRowBottomSpacer.
2652
- const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment, 0));
2920
+ // virtualRowStart / virtualRowBottomSpacer. The frozen band is in the flow
2921
+ // before the spacer whatever the window shows, so it comes off the spacer.
2922
+ const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment - frozenBandHeight, 0));
2653
2923
  const rowBottomSpacer = $derived(
2654
2924
  Math.max(rowDomTotalSize - (virtualRowEnd - rowOffsetAdjustment), 0),
2655
2925
  );
@@ -3003,35 +3273,104 @@ export function createSvGridController<
3003
3273
  );
3004
3274
 
3005
3275
  // ----- Interactive row resize (`rowResize` prop) -----
3006
- // Heights the user has dragged, by row index. A plain Map plus a version
3007
- // counter, exactly like `measuredRowHeights` above and for the same reason:
3008
- // the reset effect below has to read this collection to know whether there is
3009
- // anything to clear, and if the collection were reactive that read would make
3010
- // every resize re-trigger the reset and wipe the height it just stored.
3011
- const rowResizeHeights = new Map<number, number>();
3276
+ // Heights the user has dragged, or set through `api.setRowHeight`, keyed
3277
+ // by ROW ID so they belong to the row rather than to a slot: the height
3278
+ // follows its row through a sort or a filter and survives the data being
3279
+ // replaced. They used to be keyed by index and wiped on every data change,
3280
+ // which in a spreadsheet meant every keystroke, so a row dragged taller
3281
+ // snapped back the moment the next cell was typed. A plain Map plus a
3282
+ // version counter, like `measuredRowHeights` above: the readers touch the
3283
+ // version, and the Map itself stays out of the reactive graph.
3284
+ const rowResizeHeights = new Map<string, number>();
3012
3285
  let rowResizeVersion = $state(0);
3013
-
3014
- function setRowResizeHeight(index: number, height: number): void {
3015
- if (!rowResizeOn || !Number.isFinite(index) || height <= 0) return;
3016
- rowResizeHeights.set(index, Math.round(height));
3286
+ /**
3287
+ * Rows folded to nothing, the row-side twin of `collapsedColumns`: the
3288
+ * row keeps its index and its cells and takes no height. Keyed by row id
3289
+ * like the heights and announced through the same version counter, so
3290
+ * every reader of a row's size sees the change.
3291
+ */
3292
+ const collapsedRows = new Set<string>();
3293
+ function isRowCollapsed(index: number): boolean {
3294
+ rowResizeVersion;
3295
+ const id = allRows[index]?.id;
3296
+ return id != null && collapsedRows.has(id);
3297
+ }
3298
+ function setRowCollapsed(index: number, collapsed: boolean): void {
3299
+ const id = allRows[index]?.id;
3300
+ if (id == null) return;
3301
+ if (collapsed === collapsedRows.has(id)) return;
3302
+ if (collapsed) collapsedRows.add(id);
3303
+ else collapsedRows.delete(id);
3017
3304
  rowResizeVersion += 1;
3018
3305
  }
3019
- /** A user-dragged height for this row, or undefined when it has not been
3020
- * resized. Read by the view AND by the virtualizer's `estimateSize`. */
3021
- function rowResizeHeightPx(index: number): number | undefined {
3022
- return rowResizeOn ? rowResizeHeights.get(index) : undefined;
3306
+
3307
+ /**
3308
+ * A size the user changed by hand is one undo step, as it is in a
3309
+ * spreadsheet: Ctrl+Z after a column drag puts the width back. Recorded
3310
+ * only for the user's gestures; the api's own setters record nothing, so
3311
+ * a consumer restoring saved sizes does not fill the history.
3312
+ */
3313
+ function recordSizeUndo(undo: () => void, redo: () => void): void {
3314
+ pushHistory(ctx, [
3315
+ { rowId: "", columnId: "", field: "", before: undefined, after: undefined, custom: { undo, redo } },
3316
+ ]);
3317
+ }
3318
+ // The undo and redo of a size step report through the same callbacks as
3319
+ // the gesture did: a consumer keeping the sizes (a sheet saving its
3320
+ // document) would otherwise hold the width the user dragged to while the
3321
+ // grid showed the one Ctrl+Z put back.
3322
+ function resizeColumnByUser(columnId: string, width: number, before: number): void {
3323
+ const apply = (w: number) => {
3324
+ columnWidths = { ...columnWidths, [columnId]: w };
3325
+ props.onColumnResize?.({ columnId, width: w });
3326
+ };
3327
+ apply(width);
3328
+ if (before !== width) recordSizeUndo(() => apply(before), () => apply(width));
3329
+ }
3330
+ function autosizeColumnByUser(columnId: string): void {
3331
+ const before = getColumnWidth(columnId);
3332
+ autosizeColumn(columnId);
3333
+ const width = getColumnWidth(columnId);
3334
+ const apply = (w: number) => {
3335
+ columnWidths = { ...columnWidths, [columnId]: w };
3336
+ props.onColumnResize?.({ columnId, width: w });
3337
+ };
3338
+ if (before !== width) recordSizeUndo(() => apply(before), () => apply(width));
3339
+ else props.onColumnResize?.({ columnId, width });
3340
+ }
3341
+ function resizeRowByUser(index: number, height: number | null): void {
3342
+ const before = rowResizeHeightPx(index) ?? null;
3343
+ const apply = (h: number | null) => {
3344
+ setRowResizeHeight(index, h);
3345
+ props.onRowResize?.({ rowIndex: index, height: h });
3346
+ };
3347
+ apply(height);
3348
+ if (before !== height) recordSizeUndo(() => apply(before), () => apply(height));
3023
3349
  }
3024
3350
 
3025
- // A row index means a different row once the data changes, so keeping the
3026
- // heights would size new rows by the old ones - the same reasoning as the
3027
- // measured-height reset below.
3028
- $effect(() => {
3029
- void allRows.length;
3030
- void internalData;
3031
- if (rowResizeHeights.size === 0) return;
3032
- rowResizeHeights.clear();
3351
+ function setRowResizeHeight(index: number, height: number | null): void {
3352
+ const id = allRows[index]?.id;
3353
+ if (id == null) return;
3354
+ if (height == null) {
3355
+ if (!rowResizeHeights.delete(id)) return;
3356
+ } else {
3357
+ if (!Number.isFinite(height) || height <= 0) return;
3358
+ rowResizeHeights.set(id, Math.round(height));
3359
+ }
3033
3360
  rowResizeVersion += 1;
3034
- });
3361
+ }
3362
+ /** A row's own height, or undefined when it has none. Read by the view AND
3363
+ * by the virtualizer's `estimateSize`. Auto height wins over it: the
3364
+ * content decides the height there, and a manual one would be overwritten
3365
+ * by the next measurement. */
3366
+ function rowResizeHeightPx(index: number): number | undefined {
3367
+ const id = allRows[index]?.id;
3368
+ if (id == null) return undefined;
3369
+ // A collapsed row is 0 whatever else it has, under auto height too.
3370
+ if (collapsedRows.has(id)) return 0;
3371
+ if (autoRowHeightOn) return undefined;
3372
+ return rowResizeHeights.get(id);
3373
+ }
3035
3374
 
3036
3375
  /** Report a row's measured height. Called by the view's measuring action.
3037
3376
  * Sub-pixel churn is ignored so a fractional layout can't loop. */
@@ -3056,11 +3395,19 @@ export function createSvGridController<
3056
3395
  function measureRowHeight(node: HTMLElement, index: number) {
3057
3396
  let current = index;
3058
3397
  let stop: (() => void) | null = null;
3059
- const measure = () => reportRowHeight(current, node.getBoundingClientRect().height);
3398
+ // Read the rect only under autoRowHeight. `getBoundingClientRect` forces a
3399
+ // synchronous layout, and the virtualizer's slot keys hand every rendered
3400
+ // <tr> a new index on each one-row scroll, so measuring unconditionally
3401
+ // meant ~30 forced layouts per arrow key on a fixed-height grid whose
3402
+ // measurement `reportRowHeight` then threw away.
3403
+ const measure = () => {
3404
+ if (!autoRowHeightOn) return;
3405
+ reportRowHeight(current, node.getBoundingClientRect().height);
3406
+ };
3060
3407
  const attach = () => {
3061
3408
  stop?.();
3062
3409
  stop = autoRowHeightOn ? observeSizeRaf(node, measure) : null;
3063
- if (autoRowHeightOn) measure();
3410
+ measure();
3064
3411
  };
3065
3412
  attach();
3066
3413
  return {
@@ -3112,9 +3459,9 @@ export function createSvGridController<
3112
3459
  : typeof rh === "function"
3113
3460
  ? rh
3114
3461
  : (rh ?? 30);
3115
- const estimateSize = rowResizeOn
3462
+ const estimateSize = rowResizeHeights.size > 0 || collapsedRows.size > 0
3116
3463
  ? (index: number) =>
3117
- rowResizeHeights.get(index) ??
3464
+ rowResizeHeightPx(index) ??
3118
3465
  (typeof base === "function" ? base(index) : base)
3119
3466
  : base;
3120
3467
  virtualizer.setOptions({
@@ -3175,6 +3522,7 @@ export function createSvGridController<
3175
3522
  // closure each run; the virtualizer sees a new function reference and
3176
3523
  // re-derives its layout from the current per-column widths.
3177
3524
  columnWidths;
3525
+ collapsedColumns;
3178
3526
  columnVirtualizer.setOptions({
3179
3527
  count: allColumns.length,
3180
3528
  estimateSize: (index: number) => {
@@ -3243,8 +3591,11 @@ export function createSvGridController<
3243
3591
 
3244
3592
 
3245
3593
 
3594
+ // Depends on `allRows` and `rowSelectionState` only. It used to read the
3595
+ // catch-all `gridStateVersion` too, which made every arrow key walk all the
3596
+ // rows (10k iterations on the large-dataset demo, 100k on its top tier) to
3597
+ // recount a selection that had not changed.
3246
3598
  const headerSelectionState = $derived.by(function headerSelectionState_d() {
3247
- gridStateVersion;
3248
3599
  const selectable = allRows.filter((row) => !isGroupRow(row));
3249
3600
  if (!selectable.length) return "none";
3250
3601
  let selected = 0;
@@ -3261,6 +3612,10 @@ export function createSvGridController<
3261
3612
  * from a user-focused (0,0). The fill handle keys off this flag so it
3262
3613
  * stays hidden until the user actually selects something. */
3263
3614
  let userHasActivatedCell = $state(false);
3615
+ /** The column a run of Tabs began in, so the Enter that ends the run goes
3616
+ * down from there (`getEntryStep`). Plain, not state: nothing renders
3617
+ * it. `setActiveCell` clears it; the Tab and Enter paths set it back. */
3618
+ let tabRunOrigin: number | null = null;
3264
3619
 
3265
3620
 
3266
3621
 
@@ -3286,7 +3641,7 @@ export function createSvGridController<
3286
3641
  if (!props.fitColumns || isNarrowResponsive) return null;
3287
3642
  // Exclude columns hidden by a collapsed group too (#57) - otherwise their
3288
3643
  // widths stay in the fit distribution and leave a blank gap at the edge.
3289
- const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id] && !hiddenByGroupCollapse[c.id]);
3644
+ const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id] && !hiddenByGroupCollapse[c.id] && !collapsedColumns[c.id]);
3290
3645
  if (!cols.length) return null;
3291
3646
  const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
3292
3647
  const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
@@ -3712,12 +4067,22 @@ export function createSvGridController<
3712
4067
  // state mutation inside the callback (e.g. `api.setGroupBy(...)`) created
3713
4068
  // an infinite update loop. Now it's a true mount-once notification.
3714
4069
  let apiNotified = false;
4070
+ // Memoized: `buildApi()` builds a fresh object each call, and a feature pack
4071
+ // that keys per-grid state on the api (a WeakMap in @svgrid/enterprise) needs
4072
+ // the object the consumer received and the object a command handler sees to
4073
+ // be the same one. Every method reads through `ctx`, so one instance stays
4074
+ // correct for the life of the grid.
4075
+ let apiInstance: ReturnType<typeof buildApi> | null = null;
4076
+ function api() {
4077
+ if (!apiInstance) apiInstance = buildApi();
4078
+ return apiInstance;
4079
+ }
3715
4080
  $effect(() => {
3716
4081
  if (apiNotified) return;
3717
4082
  const cb = props.onApiReady;
3718
4083
  if (!cb) return;
3719
4084
  apiNotified = true;
3720
- cb(buildApi());
4085
+ cb(api());
3721
4086
  });
3722
4087
 
3723
4088
  const ctx = {
@@ -3780,6 +4145,8 @@ export function createSvGridController<
3780
4145
  set history(v) { history = v as never; },
3781
4146
  get historyPtr() { return historyPtr; },
3782
4147
  set historyPtr(v) { historyPtr = v as never; },
4148
+ get historyGroupId() { return historyGroupId; },
4149
+ set historyGroupId(v) { historyGroupId = v as never; },
3783
4150
  get historyVersion() { return historyVersion; },
3784
4151
  set historyVersion(v) { historyVersion = v as never; },
3785
4152
  get tooltip() { return tooltip; },
@@ -3802,6 +4169,9 @@ export function createSvGridController<
3802
4169
  get editorSelectAll() { return editorSelectAll; },
3803
4170
  set editorSelectAll(v) { editorSelectAll = v as never; },
3804
4171
  get columnWidths() { return columnWidths; },
4172
+ get collapsedColumns() { return collapsedColumns; },
4173
+ get mergeIndex() { return mergeIndex; },
4174
+ get setColumnCollapsed() { return setColumnCollapsed; },
3805
4175
  set columnWidths(v) { columnWidths = v as never; },
3806
4176
  get MIN_COLUMN_WIDTH() { return MIN_COLUMN_WIDTH; },
3807
4177
  get columnPinning() { return columnPinning; },
@@ -3873,6 +4243,7 @@ export function createSvGridController<
3873
4243
  get moveCellsEffective() { return moveCellsEffective; },
3874
4244
  get flushScheduledScrollSync() { return flushScheduledScrollSync; },
3875
4245
  get scheduleScrollSync() { return scheduleScrollSync; },
4246
+ get syncScrollNow() { return syncScrollNow; },
3876
4247
  get internalData() { return internalData; },
3877
4248
  set internalData(v) { internalData = v as never; },
3878
4249
  get internalColumns() { return internalColumns; },
@@ -3976,6 +4347,8 @@ export function createSvGridController<
3976
4347
  get moveColumnInPanel() { return moveColumnInPanel; },
3977
4348
  // ---- Localized chrome strings ----
3978
4349
  get messages() { return gridMessages; },
4350
+ /** The raw `localization.text` map, for the lazy chart panel's own strings. */
4351
+ get localizationText() { return props.localization?.text; },
3979
4352
  // ---- In-grid pivot mode ----
3980
4353
  get pivotConfig() { return pivotConfig; },
3981
4354
  get pivotActive() { return pivotActive; },
@@ -4008,6 +4381,32 @@ export function createSvGridController<
4008
4381
  set chartLogScale(v) { activeChart.logScale = v as never; },
4009
4382
  get chartTimeAxis() { return effectiveChartTimeAxis; },
4010
4383
  set chartTimeAxis(v) { activeChart.timeAxis = v as never; },
4384
+ get chartBucket() { return chartBucket; },
4385
+ set chartBucket(v) { activeChart.bucket = v as never; },
4386
+ get chartBins() { return chartBins; },
4387
+ set chartBins(v) { activeChart.bins = v as never; },
4388
+ get chartFunnelShape() { return chartFunnelShape; },
4389
+ set chartFunnelShape(v) { activeChart.funnelShape = v as never; },
4390
+ get chartCandleStyle() { return chartCandleStyle; },
4391
+ set chartCandleStyle(v) { activeChart.candleStyle = v as never; },
4392
+ get chartFormat() { return activeChart.format; },
4393
+ set chartFormat(v) { activeChart.format = v as never; },
4394
+ get chartFrozen() { return activeChart.frozen; },
4395
+ get chartIsOhlc() { return chartIsOhlc; },
4396
+ get chartOhlc() { return effectiveChartOhlc; },
4397
+ set chartOhlc(v) { activeChart.ohlc = v as never; },
4398
+ get chartOhlcDateId() { return effectiveChartOhlcDateId; },
4399
+ get chartIndicators() { return chartIndicators; },
4400
+ set chartIndicators(v) { activeChart.indicators = v as never; },
4401
+ get chartIndicatorPanes() { return chartIndicatorPanes; },
4402
+ get chartZoom() { return activeChart.zoom; },
4403
+ // The panel keeps one chart component across tabs, so "tabs share a
4404
+ // window" is simply every tab carrying the same window: a zoom on one is
4405
+ // written to all, and switching tabs hands the component the same value.
4406
+ set chartZoom(v) {
4407
+ if (chartCfg?.syncTabs) for (const c of charts) c.zoom = v as never;
4408
+ else activeChart.zoom = v as never;
4409
+ },
4011
4410
  get chartValueFormat() { return effectiveChartValueFormat ?? "number"; },
4012
4411
  set chartValueFormat(v) { activeChart.valueFormat = v as never; },
4013
4412
  get chartDimensionIsDate() { return chartDimensionIsDate; },
@@ -4028,8 +4427,21 @@ export function createSvGridController<
4028
4427
  get activeChartIndex() { return Math.min(activeChartIndex, charts.length - 1); },
4029
4428
  set activeChartIndex(v) { activeChartIndex = Math.max(0, Math.min(charts.length - 1, v as number)); },
4030
4429
  addChart() {
4031
- charts = [...charts, makeChart(`Chart ${charts.length + 1}`)];
4430
+ const next = makeChart(`Chart ${charts.length + 1}`);
4431
+ if (chartCfg?.syncTabs) next.zoom = activeChart.zoom;
4432
+ charts = [...charts, next];
4032
4433
  activeChartIndex = charts.length - 1;
4434
+ chartCfg?.onChartCreated?.({ index: activeChartIndex, title: next.title, type: next.type, spec: null });
4435
+ },
4436
+ /** Unlink the active chart from the grid: it keeps the spec it has now. */
4437
+ freezeChart() {
4438
+ const spec = chartSpec;
4439
+ if (!spec) return;
4440
+ activeChart.frozen = { spec: JSON.parse(JSON.stringify(spec)) as ChartSpec, at: new Date().toISOString() };
4441
+ },
4442
+ /** Link the active chart back to the grid's rows. */
4443
+ unfreezeChart() {
4444
+ activeChart.frozen = null;
4033
4445
  },
4034
4446
  removeChart(index?: number) {
4035
4447
  if (charts.length <= 1) return;
@@ -4042,48 +4454,39 @@ export function createSvGridController<
4042
4454
  if (c) c.title = title;
4043
4455
  },
4044
4456
  getChartsState() {
4045
- return charts.map((c) => ({
4046
- title: c.title,
4047
- type: c.type,
4048
- dimension: c.dimensionId,
4049
- series: c.seriesId ?? null,
4050
- measure: c.measureId,
4051
- measure2: c.measure2Id,
4052
- orientation: c.orientation,
4053
- stacked100: c.stacked100,
4054
- donut: c.donut,
4055
- palette: c.palette,
4056
- reduce: c.reduce,
4057
- stacked: c.stacked,
4058
- dataLabels: c.dataLabels,
4059
- logScale: c.logScale,
4060
- timeAxis: c.timeAxis,
4061
- valueFormat: c.valueFormat,
4062
- }));
4457
+ return charts.map(tabToState);
4063
4458
  },
4064
4459
  applyChartsState(list: ReadonlyArray<Record<string, unknown>>, active?: number) {
4065
- const restored = list.map((c, i) => ({
4066
- id: `chart-${chartSeq++}`,
4067
- title: typeof c.title === "string" ? c.title : `Chart ${i + 1}`,
4068
- type: (c.type as ChartType) ?? "bar",
4069
- reduce: (c.reduce as "sum" | "avg" | "count") ?? "sum",
4070
- dimensionId: (c.dimension as string | null) ?? null,
4071
- measureId: (c.measure as string | null) ?? null,
4072
- seriesId: c.series as string | null | undefined,
4073
- measure2Id: (c.measure2 as string | null) ?? null,
4074
- orientation: (c.orientation as "vertical" | "horizontal" | null) ?? null,
4075
- stacked100: (c.stacked100 as boolean | null) ?? null,
4076
- donut: (c.donut as boolean | null) ?? null,
4077
- palette: (c.palette as string[] | null) ?? null,
4078
- stacked: (c.stacked as boolean | null) ?? null,
4079
- dataLabels: (c.dataLabels as boolean | null) ?? null,
4080
- logScale: (c.logScale as boolean | null) ?? null,
4081
- timeAxis: (c.timeAxis as boolean | null) ?? null,
4082
- valueFormat: (c.valueFormat as ChartValueFormat | null) ?? null,
4083
- }));
4460
+ const restored = list.map(tabFromState);
4084
4461
  charts = restored.length ? restored : [makeChart("Chart 1")];
4085
4462
  activeChartIndex = Math.max(0, Math.min(charts.length - 1, active ?? 0));
4086
4463
  },
4464
+ // ---- Saved charts ----
4465
+ get savedCharts() { return savedCharts; },
4466
+ /** Keep the active tab's configuration under a name (replacing a same-named one). */
4467
+ saveChart(name: string) {
4468
+ const entry: SavedChartEntry = { name, savedAt: new Date().toISOString(), tab: tabToState(activeChart) };
4469
+ savedCharts = [...savedCharts.filter((s) => s.name !== name), entry];
4470
+ },
4471
+ /** Apply a saved chart to the active tab; the tab keeps its id and title. */
4472
+ applySavedChart(name: string): boolean {
4473
+ const entry = savedCharts.find((s) => s.name === name);
4474
+ if (!entry) return false;
4475
+ const { id: _id, title: _title, ...rest } = tabFromState(entry.tab, activeChartIndex);
4476
+ Object.assign(activeChart, rest);
4477
+ return true;
4478
+ },
4479
+ removeSavedChart(name: string) {
4480
+ savedCharts = savedCharts.filter((s) => s.name !== name);
4481
+ },
4482
+ getSavedCharts() {
4483
+ return savedCharts.map((s) => ({ ...s, tab: { ...s.tab } }));
4484
+ },
4485
+ applySavedCharts(list: ReadonlyArray<Record<string, unknown>>) {
4486
+ savedCharts = list
4487
+ .filter((s) => typeof s?.name === "string" && s.tab && typeof s.tab === "object")
4488
+ .map((s) => ({ name: s.name as string, savedAt: String(s.savedAt ?? ""), tab: s.tab as Record<string, unknown> }));
4489
+ },
4087
4490
  get chartCrossFilterable() { return chartCrossFilterable; },
4088
4491
  get chartOrientation() { return effectiveChartOrientation; },
4089
4492
  set chartOrientation(v) { activeChart.orientation = v as never; },
@@ -4098,19 +4501,40 @@ export function createSvGridController<
4098
4501
  get effectiveChartMeasure2Id() { return effectiveChartMeasure2Id; },
4099
4502
  get chartAiHandler() { return chartAiHandler; },
4100
4503
  set chartAiHandler(v) { chartAiHandler = v as never; },
4504
+ get chartExplainHandler() { return chartExplainHandler; },
4505
+ set chartExplainHandler(v) { chartExplainHandler = v as never; },
4101
4506
  applyChartConfig(config: {
4102
4507
  open?: boolean;
4103
4508
  type?: ChartType;
4104
4509
  dimension?: string | null;
4105
4510
  series?: string | null;
4106
4511
  measure?: string | null;
4107
- reduce?: "sum" | "avg" | "count";
4512
+ reduce?: ChartReducer;
4108
4513
  stacked?: boolean;
4109
4514
  dataLabels?: boolean;
4110
4515
  logScale?: boolean;
4111
4516
  timeAxis?: boolean;
4112
4517
  valueFormat?: ChartValueFormat;
4518
+ bucket?: ChartTimeBucket | null;
4519
+ bins?: number | null;
4520
+ funnelShape?: "trapezoid" | "pyramid" | "cone" | null;
4521
+ candleStyle?: "classic" | "hollow" | "heikin-ashi" | null;
4522
+ /** Second measure: scatter Y, range high end, bullet target. */
4523
+ measure2?: string | null;
4524
+ /** Candlestick / OHLC price columns (ids or fields). */
4525
+ ohlc?: { open?: string | null; high?: string | null; low?: string | null; close?: string | null; volume?: string | null } | null;
4526
+ /** Indicators: panes (volume, rsi, macd, stochastic, atr, obv) and
4527
+ * overlays (sma, ema, bb, vwap). */
4528
+ indicators?: ChartPanelIndicator[];
4529
+ /** The builder's format (titles, axes, legend, series style); `null` resets it. */
4530
+ format?: ChartFormatState | null;
4531
+ /** Unlink the chart from the grid (true) or link it back (false). */
4532
+ frozen?: boolean;
4533
+ /** A saved chart's name: applied to the active tab first, then the
4534
+ * other keys on top. */
4535
+ saved?: string;
4113
4536
  }) {
4537
+ if (config.saved) this.applySavedChart(config.saved);
4114
4538
  const cols = [...chartableColumns.dims, ...chartableColumns.measures];
4115
4539
  const resolve = (v: string | null | undefined): string | null => {
4116
4540
  if (v == null) return null;
@@ -4130,6 +4554,24 @@ export function createSvGridController<
4130
4554
  if (typeof config.logScale === "boolean") activeChart.logScale = config.logScale;
4131
4555
  if (typeof config.timeAxis === "boolean") activeChart.timeAxis = config.timeAxis;
4132
4556
  if (config.valueFormat) activeChart.valueFormat = config.valueFormat;
4557
+ if ("bucket" in config) activeChart.bucket = config.bucket ?? null;
4558
+ if ("bins" in config) activeChart.bins = config.bins ?? null;
4559
+ if ("funnelShape" in config) activeChart.funnelShape = config.funnelShape ?? null;
4560
+ if ("candleStyle" in config) activeChart.candleStyle = config.candleStyle ?? null;
4561
+ if ("measure2" in config) activeChart.measure2Id = resolve(config.measure2);
4562
+ if ("ohlc" in config) {
4563
+ const o = config.ohlc;
4564
+ activeChart.ohlc = o
4565
+ ? { open: resolve(o.open), high: resolve(o.high), low: resolve(o.low), close: resolve(o.close), volume: resolve(o.volume) }
4566
+ : null;
4567
+ }
4568
+ if (config.indicators) activeChart.indicators = [...config.indicators];
4569
+ if ("format" in config) activeChart.format = config.format ?? null;
4570
+ if (config.frozen === false) activeChart.frozen = null;
4571
+ else if (config.frozen === true && !activeChart.frozen) {
4572
+ const spec = chartSpec;
4573
+ if (spec) activeChart.frozen = { spec: JSON.parse(JSON.stringify(spec)) as ChartSpec, at: new Date().toISOString() };
4574
+ }
4133
4575
  },
4134
4576
  get toggleGroupInPanel() { return toggleGroupInPanel; },
4135
4577
  get lastSortingSerialized() { return lastSortingSerialized; },
@@ -4152,8 +4594,17 @@ export function createSvGridController<
4152
4594
  },
4153
4595
  /** True when the `rowResize` prop is on and nothing overrides it. */
4154
4596
  get rowResizeOn() { return rowResizeOn; },
4597
+ get frozenRowCount() { return frozenRowCount; },
4598
+ get frozenRowList() { return frozenRowList; },
4599
+ get frozenBandHeight() { return frozenBandHeight; },
4600
+ get frozenRowTop() { return frozenRowTop; },
4155
4601
  /** Record the height the user dragged a row to. */
4156
4602
  get setRowResizeHeight() { return setRowResizeHeight; },
4603
+ get isRowCollapsed() { return isRowCollapsed; },
4604
+ get setRowCollapsed() { return setRowCollapsed; },
4605
+ get resizeRowByUser() { return resizeRowByUser; },
4606
+ get resizeColumnByUser() { return resizeColumnByUser; },
4607
+ get autosizeColumnByUser() { return autosizeColumnByUser; },
4157
4608
  /** A row's dragged height, or undefined when it has not been resized. */
4158
4609
  get rowResizeHeightPx() {
4159
4610
  rowResizeVersion;
@@ -4216,6 +4667,8 @@ export function createSvGridController<
4216
4667
  get toggleSelectAllRows() { return toggleSelectAllRows; },
4217
4668
  get userHasActivatedCell() { return userHasActivatedCell; },
4218
4669
  set userHasActivatedCell(v) { userHasActivatedCell = v as never; },
4670
+ get tabRunOrigin() { return tabRunOrigin; },
4671
+ set tabRunOrigin(v) { tabRunOrigin = v; },
4219
4672
  get setActiveCell() { return setActiveCell; },
4220
4673
  get scrollActiveCellIntoView() { return scrollActiveCellIntoView; },
4221
4674
  get getColumnBaseWidth() { return getColumnBaseWidth; },
@@ -4225,6 +4678,8 @@ export function createSvGridController<
4225
4678
  get extendSelection() { return extendSelection; },
4226
4679
  get isCellInSelectedRange() { return isCellInSelectedRange; },
4227
4680
  get getCellRangeEdges() { return getCellRangeEdges; },
4681
+ get activeRangeRect() { return activeRangeRect; },
4682
+ get cellSelectionState() { return cellSelectionState; },
4228
4683
  get getSelectionRects() { return getSelectionRects; },
4229
4684
  get fillHandleCell() { return fillHandleCell; },
4230
4685
  get isInFillPreview() { return isInFillPreview; },
@@ -4247,6 +4702,7 @@ export function createSvGridController<
4247
4702
  get startFillDrag() { return startFillDrag; },
4248
4703
  get onFillPointerMove() { return onFillPointerMove; },
4249
4704
  get onFillPointerUp() { return onFillPointerUp; },
4705
+ get fillDownToNeighbour() { return fillDownToNeighbour; },
4250
4706
  get isOnMoveGrabStrip() { return isOnMoveGrabStrip; },
4251
4707
  get startMoveDrag() { return startMoveDrag; },
4252
4708
  get onMovePointerMove() { return onMovePointerMove; },
@@ -4266,6 +4722,7 @@ export function createSvGridController<
4266
4722
  get cutSelectionToClipboard() { return cutSelectionToClipboard; },
4267
4723
  get pasteFromClipboard() { return pasteFromClipboard; },
4268
4724
  get onGridPaste() { return onGridPaste; },
4725
+ get armPasteFallback() { return armPasteFallback; },
4269
4726
  get clearSelectedCells() { return clearSelectedCells; },
4270
4727
  get onCellDoubleClick() { return onCellDoubleClick; },
4271
4728
  get startEditingWithChar() { return startEditingWithChar; },
@@ -4342,12 +4799,12 @@ export function createSvGridController<
4342
4799
  get clearColumnFilter() { return clearColumnFilter; },
4343
4800
  get onWindowKeydown() { return onWindowKeydown; },
4344
4801
  get columnDefMatchesId() { return columnDefMatchesId; },
4345
- get buildApi() { return buildApi; },
4802
+ get buildApi() { return api; },
4346
4803
  get apiNotified() { return apiNotified; },
4347
4804
  set apiNotified(v) { apiNotified = v as never; },
4348
4805
  };
4349
4806
  const { resolveEffectiveFeatures } = createFeatures<TFeatures, TData>(ctx);
4350
- const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
4807
+ const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, syncScrollNow, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
4351
4808
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
4352
4809
  const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
4353
4810
  // Hand the aggregator to `eagerSummaries` (declared above `ctx`, which this
@@ -4356,13 +4813,13 @@ export function createSvGridController<
4356
4813
 
4357
4814
  const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
4358
4815
  const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
4359
- const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
4360
- const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, dragPreviewRect, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
4816
+ const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste, armPasteFallback } = createEditing<TFeatures, TData>(ctx);
4817
+ const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, activeRangeRect, cellSelectionState, getSelectionRects, isInFillPreview, dragPreviewRect, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
4361
4818
  const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
4362
4819
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, onRowPointerDown, destroyRowDrag } = createRowDrag<TFeatures, TData>(ctx);
4363
4820
  const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids<TFeatures, TData>(ctx);
4364
4821
  const { buildApi } = createGridApi<TFeatures, TData>(ctx);
4365
- const { readCellRaw, writeCellRaw, applyFillPattern, clearSelectedCellValues, startFillDrag, onFillPointerMove, onFillPointerUp, isOnMoveGrabStrip, startMoveDrag, onMovePointerMove, onMovePointerUp, applyMoveRange, moveDestRect, trackEdgeScroll, stopEdgeScroll, toggleBooleanCell, copySelectionToClipboard, clearSelectedCells, cutSelectionToClipboard } = createClipboard(ctx);
4822
+ const { readCellRaw, writeCellRaw, applyFillPattern, clearSelectedCellValues, startFillDrag, onFillPointerMove, onFillPointerUp, fillDownToNeighbour, isOnMoveGrabStrip, startMoveDrag, onMovePointerMove, onMovePointerUp, applyMoveRange, moveDestRect, trackEdgeScroll, stopEdgeScroll, toggleBooleanCell, copySelectionToClipboard, clearSelectedCells, cutSelectionToClipboard } = createClipboard(ctx);
4366
4823
 
4367
4824
  // Dev-time configuration checks. Silent misconfiguration was the grid's
4368
4825
  // biggest usability gap - a misspelled `field` rendered a column of blank