@svgrid/grid 3.0.8 → 3.0.9

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 (80) hide show
  1. package/dist/GridMenus.svelte +3 -2
  2. package/dist/SvGrid.controller.svelte.d.ts +60 -8
  3. package/dist/SvGrid.controller.svelte.js +475 -110
  4. package/dist/SvGrid.css +186 -3
  5. package/dist/SvGrid.svelte +281 -163
  6. package/dist/SvGrid.types.d.ts +24 -7
  7. package/dist/cdn/{GridMenus-D15bhpX5.js → GridMenus-B1sipj6y.js} +165 -165
  8. package/dist/cdn/{GridMenus-B8-Gbfq2.js → GridMenus-DlAzfHLk.js} +201 -201
  9. package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
  10. package/dist/cdn/{src-Dz625Fhx.js → src-BYsa66I0.js} +9738 -10174
  11. package/dist/cdn/{src-CleI0g9l.js → src-pA4RroLV.js} +10702 -11138
  12. package/dist/cdn/svgrid.js +27 -27
  13. package/dist/cdn/svgrid.svelte-external.js +27 -27
  14. package/dist/cdn/{validate-GEs4ZPSW.js → validate-XuMe4_KR.js} +2 -2
  15. package/dist/cell-render.js +25 -3
  16. package/dist/columns.js +16 -1
  17. package/dist/conditional-formatting.js +9 -2
  18. package/dist/core.d.ts +93 -0
  19. package/dist/core.js +313 -52
  20. package/dist/filtering/excel-filters.js +41 -6
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/selection.d.ts +2 -2
  24. package/dist/selection.js +32 -8
  25. package/dist/server-block-cache.d.ts +7 -32
  26. package/dist/server-block-cache.js +21 -12
  27. package/dist/server-data-source.js +3 -1
  28. package/dist/server.d.ts +1 -0
  29. package/dist/server.js +1 -0
  30. package/dist/spreadsheet.js +7 -0
  31. package/dist/validate.js +3 -11
  32. package/dist/virtualization/column-virtualizer.d.ts +4 -0
  33. package/dist/virtualization/column-virtualizer.js +2 -0
  34. package/dist/virtualization/types.d.ts +20 -0
  35. package/dist/virtualization/virtualizer.js +90 -24
  36. package/dist/windowed-brand.d.ts +22 -0
  37. package/dist/windowed-brand.js +60 -0
  38. package/dist/windowed-data.d.ts +6 -0
  39. package/dist/windowed-data.js +34 -0
  40. package/dist/windowed-row-model.d.ts +1 -0
  41. package/dist/windowed-row-model.js +97 -0
  42. package/package.json +1 -1
  43. package/src/GridMenus.svelte +3 -2
  44. package/src/SvGrid.controller.svelte.ts +469 -114
  45. package/src/SvGrid.css +186 -3
  46. package/src/SvGrid.svelte +281 -163
  47. package/src/SvGrid.types.ts +24 -7
  48. package/src/cell-render.ts +24 -2
  49. package/src/columns.ts +16 -1
  50. package/src/conditional-formatting.ts +6 -4
  51. package/src/core.sort.test.ts +61 -0
  52. package/src/core.ts +309 -52
  53. package/src/counting-cell.test.svelte +11 -0
  54. package/src/filtering/excel-filters.ts +39 -6
  55. package/src/headless.reactivity.svelte.test.ts +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/new-features.test.ts +24 -0
  58. package/src/selection.test.ts +14 -0
  59. package/src/selection.ts +31 -8
  60. package/src/server-block-cache.ts +27 -10
  61. package/src/server-data-source.ts +3 -1
  62. package/src/server.ts +6 -0
  63. package/src/spreadsheet.ts +6 -0
  64. package/src/svgrid.behavior.test.ts +29 -0
  65. package/src/svgrid.column-cell-recycling.test.ts +120 -0
  66. package/src/svgrid.interaction.test.ts +4 -1
  67. package/src/svgrid.new-features.wrapper.test.ts +5 -4
  68. package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
  69. package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
  70. package/src/validate.test.ts +4 -4
  71. package/src/validate.ts +3 -14
  72. package/src/virtualization/column-virtualizer.ts +6 -0
  73. package/src/virtualization/types.ts +20 -0
  74. package/src/virtualization/virtualizer.test.ts +152 -0
  75. package/src/virtualization/virtualizer.ts +93 -27
  76. package/src/windowed-brand.ts +67 -0
  77. package/src/windowed-data.test.ts +105 -0
  78. package/src/windowed-data.ts +36 -0
  79. package/src/windowed-row-model.ts +113 -0
  80. package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
@@ -151,6 +151,41 @@ function scanInTokens(value) {
151
151
  // pull in SvGrid.svelte, which is a runtime cost (and a bundler hazard) for
152
152
  // anything that only wants operator semantics.
153
153
  export { ALL_FILTER_OPERATORS, SET_OPERATOR_IDS, VALUELESS_OPERATOR_IDS, RANGE_OPERATOR_IDS, isSetOperator, isValuelessOperator, isRangeOperator, } from './filter-operator-catalogue.js';
154
+ /**
155
+ * A text test, remembered per distinct cell text.
156
+ *
157
+ * The text operators fold every cell (lowercase, and for non-ASCII text NFD
158
+ * plus diacritic stripping) before comparing, and a column the user filters
159
+ * is usually a category - region, status, owner - with a handful of distinct
160
+ * values over every row. Folding each row again was most of a 1M-row filter
161
+ * (417 ms). The answer depends only on the cell's text, so it is kept per
162
+ * text. A column that turns out mostly unique (more than half of the first
163
+ * 2,048 lookups missed) stops remembering and tests directly, and the memo
164
+ * never grows past MEMO_CAP entries.
165
+ */
166
+ const MEMO_CAP = 16384;
167
+ function memoText(test) {
168
+ let memo = new Map();
169
+ let lookups = 0;
170
+ let hits = 0;
171
+ return (cellValue) => {
172
+ const s = typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
173
+ if (memo === null)
174
+ return test(s);
175
+ lookups++;
176
+ const known = memo.get(s);
177
+ if (known !== undefined) {
178
+ hits++;
179
+ return known;
180
+ }
181
+ const result = test(s);
182
+ if (memo.size < MEMO_CAP)
183
+ memo.set(s, result);
184
+ if (lookups >= 2048 && hits * 2 < lookups)
185
+ memo = null;
186
+ return result;
187
+ };
188
+ }
154
189
  /**
155
190
  * Compile a filter once, then test many rows against it.
156
191
  *
@@ -168,23 +203,23 @@ export function compileExcelFilter(filter, options) {
168
203
  const needle = fold(filter.value);
169
204
  switch (filter.operator) {
170
205
  case 'contains':
171
- return (cellValue) => fold(cellValue).includes(needle);
206
+ return memoText((s) => fold(s).includes(needle));
172
207
  case 'notContains':
173
208
  // An empty needle is no constraint (mirrors `contains` returning true),
174
209
  // so nothing is excluded until the user types something.
175
210
  if (needle === '')
176
211
  return () => true;
177
- return (cellValue) => !fold(cellValue).includes(needle);
212
+ return memoText((s) => !fold(s).includes(needle));
178
213
  case 'equals':
179
- return (cellValue) => fold(cellValue) === needle;
214
+ return memoText((s) => fold(s) === needle);
180
215
  case 'notEquals':
181
216
  if (needle === '')
182
217
  return () => true;
183
- return (cellValue) => fold(cellValue) !== needle;
218
+ return memoText((s) => fold(s) !== needle);
184
219
  case 'startsWith':
185
- return (cellValue) => fold(cellValue).startsWith(needle);
220
+ return memoText((s) => fold(s).startsWith(needle));
186
221
  case 'endsWith':
187
- return (cellValue) => fold(cellValue).endsWith(needle);
222
+ return memoText((s) => fold(s).endsWith(needle));
188
223
  case 'regex': {
189
224
  // Case-insensitive by default (matches the accent/case-folded feel of
190
225
  // the other text operators). An invalid pattern matches nothing rather
package/dist/index.d.ts CHANGED
@@ -215,6 +215,7 @@ export { createHyperFormulaSheet, type HyperFormulaSheet, type HyperFormulaSheet
215
215
  export { createCollaboration, broadcastChannelTransport, type CollabUser, type CollabCell, type CollabPresence, type CollabMessage, type CollabTransport, type Collaboration, } from './collaboration.js';
216
216
  export { createServerDataSource, type ServerDataSource, type ServerSelectionRule, type ServerRequest, type ServerResult, type ServerController, type ServerControllerOptions, type ServerState, type ServerSortModel, type ServerFilterModel, type ServerAggFn, type ServerAggregation, type ServerGroupRow, type ServerLeafRow, type ServerMoreRow, type ServerFooterRow, type ServerSkeletonRow, type ServerGrandTotalRow, type ServerPlaceholderRow, type ServerDisplayRow, type ServerDetailRow, } from './server-data-source.js';
217
217
  export { createBlockCache, createRowPlaceholder, rowPlaceholderState, type BlockCache, type BlockCacheOptions, type BlockCacheState, type BlockFetchResult, type BlockState, } from './server-block-cache.js';
218
+ export { createWindowedData, windowedSourceOf, isWindowedData, type WindowedSource, } from './windowed-data.js';
218
219
  export { toServerFilterColumns, type GridRowModel, type GridFilterState, type GridRowModelSort, } from './row-model.js';
219
220
  export { createNamedViews, memoryViews, localStorageViews, attachAutoSavedView, type SavedView, type ViewStorage, type NamedViews, type AutoSavedViewOptions, } from './named-views.js';
220
221
  export { resolveCellFormat, computeColumnStat, lerpColor, rgba, contrastText, formatNeedsStats, formatsNeedingStats, type ConditionalFormat, type ConditionalFormatSpec, type ColorScaleFormat, type ColorScaleStop, type ScaleBounds, type DataBarFormat, type IconSetFormat, type RuleFormat, type ResolvedCellFormat, } from './conditional-formatting.js';
package/dist/index.js CHANGED
@@ -255,6 +255,7 @@ export { createHyperFormulaSheet, } from './hyperformula-adapter.js';
255
255
  export { createCollaboration, broadcastChannelTransport, } from './collaboration.js';
256
256
  export { createServerDataSource, } from './server-data-source.js';
257
257
  export { createBlockCache, createRowPlaceholder, rowPlaceholderState, } from './server-block-cache.js';
258
+ export { createWindowedData, windowedSourceOf, isWindowedData, } from './windowed-data.js';
258
259
  export { toServerFilterColumns, } from './row-model.js';
259
260
  export { createNamedViews, memoryViews, localStorageViews, attachAutoSavedView, } from './named-views.js';
260
261
  export { resolveCellFormat, computeColumnStat, lerpColor, rgba, contrastText, formatNeedsStats, formatsNeedingStats, } from './conditional-formatting.js';
@@ -1,8 +1,8 @@
1
1
  import { type RowData, type TableFeatures } from "./index.js";
2
2
  import "./sv-grid-scrollbar.js";
3
3
  export declare function createSelection<TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData>(ctx: any): {
4
- isRowSelected: (rowId: string) => any;
5
- toggleRowSelectionById: (rowId: string) => void;
4
+ isRowSelected: (rowId: string, original?: unknown) => any;
5
+ toggleRowSelectionById: (rowId: string, original?: unknown) => void;
6
6
  toggleSelectAllRows: () => void;
7
7
  setSelectAllRows: (select: boolean) => void;
8
8
  setActiveCell: (rowIndex: number, colIndex: number) => void;
package/dist/selection.js CHANGED
@@ -6,21 +6,33 @@ import "./sv-grid-scrollbar.js";
6
6
  import { getColumnBaseValue, isGroupRow, } from "./cell-values.js";
7
7
  import { expandRectToMerges, originOf, endOf } from "./merges.js";
8
8
  export function createSelection(ctx) {
9
- function isRowSelected(rowId) {
9
+ /**
10
+ * A selection model needs the row's data. The body passes the row it is
11
+ * drawing as `original`; without it the row is looked up by id, a scan of
12
+ * every row - on a server model's windowed million rows, once per drawn
13
+ * row, that scan held the page for seconds after a sort.
14
+ */
15
+ function rowOriginal(rowId) {
16
+ const row = ctx.allRows.find((r) => r.id === rowId);
17
+ return row ? { found: true, original: row.original } : { found: false };
18
+ }
19
+ function isRowSelected(rowId, original) {
10
20
  const model = ctx.props.rowSelectionModel;
11
21
  if (model) {
12
- const row = ctx.allRows.find((r) => r.id === rowId);
13
- return row ? model.isSelected(rowId, row.original) : false;
22
+ if (original !== undefined)
23
+ return model.isSelected(rowId, original);
24
+ const hit = rowOriginal(rowId);
25
+ return hit.found ? model.isSelected(rowId, hit.original) : false;
14
26
  }
15
27
  return Boolean(ctx.rowSelectionState[rowId]);
16
28
  }
17
- function toggleRowSelectionById(rowId) {
29
+ function toggleRowSelectionById(rowId, original) {
18
30
  const model = ctx.props.rowSelectionModel;
19
31
  if (model) {
20
- const row = ctx.allRows.find((r) => r.id === rowId);
21
- if (!row)
32
+ const hit = original !== undefined ? { found: true, original } : rowOriginal(rowId);
33
+ if (!hit.found)
22
34
  return;
23
- model.toggle(rowId, row.original, !model.isSelected(rowId, row.original));
35
+ model.toggle(rowId, hit.original, !model.isSelected(rowId, hit.original));
24
36
  return;
25
37
  }
26
38
  ctx.grid.setRowSelection((prev) => ({ ...prev, [rowId]: !prev[rowId] }));
@@ -446,8 +458,20 @@ export function createSelection(ctx) {
446
458
  }
447
459
  /** Look up a column by id without depending on `buildApi`'s private
448
460
  * closure (those helpers don't exist at this scope). */
461
+ // id -> column, rebuilt when the column list changes. Called per header
462
+ // cell as columns scroll into view, so a linear `find` made every scroll
463
+ // frame cost more the more columns the grid has.
464
+ let columnsById = new Map();
465
+ let columnsByIdFor = null;
449
466
  function findColumnById(columnId) {
450
- return ctx.allColumns.find((c) => c.id === columnId);
467
+ const all = ctx.allColumns;
468
+ if (all !== columnsByIdFor) {
469
+ columnsById = new Map();
470
+ for (const c of all)
471
+ columnsById.set(c.id, c);
472
+ columnsByIdFor = all;
473
+ }
474
+ return columnsById.get(columnId);
451
475
  }
452
476
  function onCellPointerDown(rowIndex, colIndex, event) {
453
477
  if (event.button !== 0)
@@ -1,35 +1,3 @@
1
- /**
2
- * Block cache for server-backed rows - the engine behind infinite scrolling.
3
- *
4
- * Paging asks for "page 3". Infinite scrolling asks a different question: the
5
- * user is looking at rows 4,000 to 4,020 of a table with a million rows, so
6
- * fetch the block that covers them, keep a bounded number of recently-seen
7
- * blocks, and render something sensible for every row nobody has fetched yet.
8
- * That is all this module does, in plain TypeScript with no Svelte and no DOM.
9
- *
10
- * The shape of the problem (and most of the vocabulary) is the same one every
11
- * grid with a server row model solves, so the options here are deliberately
12
- * recognisable: `blockSize`, `maxBlocksInCache`, `maxConcurrentRequests`,
13
- * `blockLoadDebounceMs`, `initialRowCount`, `overflowRows`.
14
- *
15
- * Three things it takes seriously:
16
- *
17
- * - **Unloaded rows still have to render.** `rows()` always returns a dense
18
- * array, with a shared sentinel object in every slot that has no data. The
19
- * grid recognises those (see {@link rowPlaceholderState}) and draws a
20
- * skeleton or a retry affordance instead of an empty row, so the scrollbar
21
- * never lies and scrolling never leaves a hole.
22
- * - **The row count may be unknown.** A backend that cannot cheaply count
23
- * says so (omit `rowCount`, or send `-1`) and the list grows by a block at
24
- * a time until a short block proves where the end is.
25
- * - **Requests are not free.** In-flight blocks are deduplicated, at most
26
- * `maxConcurrentRequests` are open at once, a fast scroll debounces rather
27
- * than firing a request per frame, and blocks abandoned by eviction or
28
- * purge are aborted.
29
- *
30
- * Enterprise's server row model runs one of these per group level; the free
31
- * flat controller runs exactly one.
32
- */
33
1
  /**
34
2
  * The mark that says "this row is not data yet".
35
3
  *
@@ -155,6 +123,13 @@ export type BlockCache<TData> = {
155
123
  setViewport(startIndex: number, endIndex: number): void;
156
124
  /** The dense row array to hand the grid. Placeholders fill unloaded slots. */
157
125
  rows(): ReadonlyArray<TData>;
126
+ /**
127
+ * The same rows as windowed data (see windowed-data.ts): `length` is the
128
+ * row count and each entry is read through `getRow` when the grid asks for
129
+ * it, so handing it over costs O(1) whatever the count. A new array per
130
+ * change, like `rows()`.
131
+ */
132
+ windowedRows(): ReadonlyArray<TData>;
158
133
  /** One row, without building the array. Returns a placeholder when unloaded. */
159
134
  getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW;
160
135
  rowCount(): number | null;
@@ -30,6 +30,7 @@
30
30
  * Enterprise's server row model runs one of these per group level; the free
31
31
  * flat controller runs exactly one.
32
32
  */
33
+ import { createWindowedData } from './windowed-data.js';
33
34
  /**
34
35
  * The mark that says "this row is not data yet".
35
36
  *
@@ -99,6 +100,7 @@ export function createBlockCache(options) {
99
100
  let debounceTimer = null;
100
101
  let emitScheduled = false;
101
102
  let rowsCache = null;
103
+ let windowedCache = null;
102
104
  const blockOf = (rowIndex) => Math.floor(rowIndex / blockSize);
103
105
  const startOf = (blockIndex) => blockIndex * blockSize;
104
106
  /**
@@ -156,7 +158,7 @@ export function createBlockCache(options) {
156
158
  * mutations notifies once. Every mutating path ends here.
157
159
  */
158
160
  function emit() {
159
- rowsCache = null; // rebuilt lazily, on the next read
161
+ rowsCache = windowedCache = null; // rebuilt lazily, on the next read
160
162
  if (disposed || emitScheduled)
161
163
  return;
162
164
  emitScheduled = true;
@@ -281,6 +283,15 @@ export function createBlockCache(options) {
281
283
  ensureBlock(i);
282
284
  }
283
285
  }
286
+ function rowAt(index) {
287
+ const block = blocks.get(blockOf(index));
288
+ if (!block)
289
+ return LOADING_ROW;
290
+ if (block.status === 'failed')
291
+ return FAILED_ROW;
292
+ const row = block.rows[index - startOf(blockOf(index))];
293
+ return row ?? LOADING_ROW;
294
+ }
284
295
  function abortAll() {
285
296
  for (const block of blocks.values())
286
297
  block.controller?.abort();
@@ -330,15 +341,13 @@ export function createBlockCache(options) {
330
341
  rowsCache = out;
331
342
  return out;
332
343
  },
333
- getRow(index) {
334
- const block = blocks.get(blockOf(index));
335
- if (!block)
336
- return LOADING_ROW;
337
- if (block.status === 'failed')
338
- return FAILED_ROW;
339
- const row = block.rows[index - startOf(blockOf(index))];
340
- return row ?? LOADING_ROW;
344
+ windowedRows() {
345
+ if (windowedCache)
346
+ return windowedCache;
347
+ windowedCache = createWindowedData(currentLength(), (i) => rowAt(i));
348
+ return windowedCache;
341
349
  },
350
+ getRow: rowAt,
342
351
  rowCount: () => (countKnown ? count : null),
343
352
  lastRowKnown: () => countKnown,
344
353
  retryFailed() {
@@ -376,7 +385,7 @@ export function createBlockCache(options) {
376
385
  blocks.clear();
377
386
  count = null;
378
387
  countKnown = false;
379
- rowsCache = null;
388
+ rowsCache = windowedCache = null;
380
389
  fetchViewport();
381
390
  emit();
382
391
  },
@@ -524,7 +533,7 @@ export function createBlockCache(options) {
524
533
  controller: null,
525
534
  });
526
535
  }
527
- rowsCache = null;
536
+ rowsCache = windowedCache = null;
528
537
  }
529
538
  /** Forget every block from `fromBlock` on, aborting any in flight. */
530
539
  function dropLoadedFrom(fromBlock) {
@@ -534,6 +543,6 @@ export function createBlockCache(options) {
534
543
  block.controller?.abort();
535
544
  blocks.delete(index);
536
545
  }
537
- rowsCache = null;
546
+ rowsCache = windowedCache = null;
538
547
  }
539
548
  }
@@ -53,7 +53,9 @@ export function createServerDataSource(source, options) {
53
53
  function emitFromCache() {
54
54
  if (!cache || disposed)
55
55
  return;
56
- const rows = cache.rows();
56
+ // Windowed: the grid reads only the rows it shows, so a block landing
57
+ // costs the same at 100M rows as at 10K.
58
+ const rows = cache.windowedRows();
57
59
  state.rows = rows;
58
60
  state.rowCount = cache.rowCount();
59
61
  state.lastRowKnown = cache.lastRowKnown();
package/dist/server.d.ts CHANGED
@@ -10,4 +10,5 @@
10
10
  */
11
11
  export { createServerDataSource, type ServerDataSource, type ServerSelectionRule, type ServerRequest, type ServerResult, type ServerController, type ServerControllerOptions, type ServerState, type ServerSortModel, type ServerFilterModel, type ServerAggFn, type ServerAggregation, type ServerGroupRow, type ServerLeafRow, type ServerMoreRow, type ServerFooterRow, type ServerSkeletonRow, type ServerGrandTotalRow, type ServerPlaceholderRow, type ServerDisplayRow, type ServerDetailRow, } from './server-data-source.js';
12
12
  export { createBlockCache, createRowPlaceholder, rowPlaceholderState, type BlockCache, type BlockCacheOptions, type BlockCacheState, type BlockFetchResult, type BlockState, } from './server-block-cache.js';
13
+ export { createWindowedData, windowedSourceOf, isWindowedData, type WindowedSource, } from './windowed-data.js';
13
14
  export { toServerFilterColumns, type GridRowModel, type GridFilterState, type GridRowModelSort, } from './row-model.js';
package/dist/server.js CHANGED
@@ -10,4 +10,5 @@
10
10
  */
11
11
  export { createServerDataSource, } from './server-data-source.js';
12
12
  export { createBlockCache, createRowPlaceholder, rowPlaceholderState, } from './server-block-cache.js';
13
+ export { createWindowedData, windowedSourceOf, isWindowedData, } from './windowed-data.js';
13
14
  export { toServerFilterColumns, } from './row-model.js';
@@ -88,6 +88,13 @@ const OVERLAY_CLASS = 'sv-cell-border-overlay';
88
88
  * every relevant DOM mutation inside the grid, and whenever the action
89
89
  * options update. */
90
90
  function apply(root, opts) {
91
+ // Merges here are colspan / rowspan on the rendered cells, which only the
92
+ // table algorithm honours: keep this grid's rows in table layout (the
93
+ // flex-row rules in SvGrid.css skip a table marked this way).
94
+ for (const table of root.querySelectorAll('table.sv-grid-table')) {
95
+ if (table.getAttribute('data-row-layout') !== 'table')
96
+ table.setAttribute('data-row-layout', 'table');
97
+ }
91
98
  // ---- 1. Clean up anything the LAST run added -----------------------
92
99
  for (const td of root.querySelectorAll(`td[${MARK}]`)) {
93
100
  td.removeAttribute('colspan');
package/dist/validate.js CHANGED
@@ -155,17 +155,9 @@ export function validateGridConfig(input) {
155
155
  'so summary bars will under-report. Remove `treeData` and set ' +
156
156
  '`gantt.parentField` instead.');
157
157
  }
158
- // ---- 7. Pinning that column virtualization will hide ----------------------
159
- // Documented incompatibility: the virtualizer recycles column DOM nodes, so
160
- // sticky pinning cannot survive it. `columnVirtualization` defaults to ON,
161
- // which means the natural way to write this silently does nothing.
162
- const pinned = (input.initialColumnPinning?.left?.length ?? 0) +
163
- (input.initialColumnPinning?.right?.length ?? 0);
164
- if (pinned > 0 && input.columnVirtualization !== false) {
165
- messages.push('[svgrid] `initialColumnPinning` is set while column virtualization is on ' +
166
- '(its default), so the pinned columns will not stick - the virtualizer ' +
167
- 'recycles column nodes. Add `columnVirtualization={false}`.');
168
- }
158
+ // (7. was "pinning versus column virtualization". Pinned columns now render
159
+ // as their own runs beside the virtual window, so the pair is supported and
160
+ // there is nothing to warn about.)
169
161
  // ---- 8. Server-mode contracts left half-wired -----------------------------
170
162
  // Each of these makes the grid hand control to the consumer. Miss the other
171
163
  // half and the feature looks broken rather than unconfigured.
@@ -4,6 +4,10 @@ export declare function createColumnVirtualizer(input: {
4
4
  viewportWidth: number;
5
5
  scrollOffset?: number;
6
6
  overscan?: number;
7
+ /** Columns kept behind the scroll direction; defaults to `overscan`. */
8
+ overscanBehind?: number;
9
+ /** Fewest columns left ahead of a scroll before the window moves. */
10
+ overscanMin?: number;
7
11
  /** Either a uniform size (number) or a per-column size function. */
8
12
  estimateSize?: number | ColumnSizeEstimator;
9
13
  }): {
@@ -6,6 +6,8 @@ export function createColumnVirtualizer(input) {
6
6
  viewportHeight: input.viewportWidth,
7
7
  scrollOffset: input.scrollOffset ?? 0,
8
8
  overscan: input.overscan ?? 4,
9
+ overscanBehind: input.overscanBehind,
10
+ overscanMin: input.overscanMin,
9
11
  });
10
12
  return {
11
13
  ...virtualizer,
@@ -15,8 +15,28 @@ export type VirtualizerOptions = {
15
15
  */
16
16
  estimateSize: number | ((index: number) => number);
17
17
  overscan?: number;
18
+ /**
19
+ * Items kept on the side the scroll is moving away from (`overscan` is kept
20
+ * ahead of it). Defaults to `overscan`, a symmetric window. Before the
21
+ * first scroll there is no direction and both sides get `overscan`.
22
+ */
23
+ overscanBehind?: number;
24
+ /**
25
+ * Keep the rendered window while a scroll leaves at least this many items
26
+ * rendered ahead of it, and move the window only when the scroll runs past
27
+ * that: the window is then rebuilt with the full `overscan` ahead, so it
28
+ * moves `overscan - overscanMin + 1` items at a time instead of one. Each
29
+ * move costs about the same whether one item enters or several, so fewer,
30
+ * larger moves are less work, and the scroll frames in between change
31
+ * nothing. A window built at rest, or after a jump to items outside the
32
+ * rendered window, carries `overscanMin` instead of `overscan`. Undefined
33
+ * moves the window on every item boundary, always with `overscan`.
34
+ */
35
+ overscanMin?: number;
18
36
  viewportHeight: number;
19
37
  scrollOffset?: number;
38
+ /** Internal: the sign of the last scroll movement (-1, 0, 1). */
39
+ scrollDirection?: number;
20
40
  };
21
41
  export type VirtualizerState = {
22
42
  items: Array<VirtualItem>;
@@ -5,6 +5,8 @@ function sameOptions(a, b) {
5
5
  return (a.count === b.count &&
6
6
  a.estimateSize === b.estimateSize &&
7
7
  (a.overscan ?? 6) === (b.overscan ?? 6) &&
8
+ a.overscanBehind === b.overscanBehind &&
9
+ a.overscanMin === b.overscanMin &&
8
10
  a.viewportHeight === b.viewportHeight &&
9
11
  (a.scrollOffset ?? 0) === (b.scrollOffset ?? 0));
10
12
  }
@@ -107,11 +109,12 @@ function buildVariableItems(startIndex, endIndex, offsets) {
107
109
  }
108
110
  return items;
109
111
  }
110
- function createState(options,
112
+ /** The items under the viewport at the current scroll offset, before any
113
+ * overscan. */
114
+ function visibleRange(options,
111
115
  /** Pre-built cumulative offsets for the variable-size path. */
112
116
  offsets) {
113
117
  const count = Math.max(options.count, 0);
114
- const overscan = Math.max(options.overscan ?? 6, 0);
115
118
  const viewportHeight = Math.max(options.viewportHeight, 0);
116
119
  if (typeof options.estimateSize === 'function' && offsets) {
117
120
  const totalSize = offsets[count] ?? 0;
@@ -140,29 +143,47 @@ offsets) {
140
143
  hi = mid;
141
144
  }
142
145
  const visibleEnd = Math.max(lo - 1, visibleStart);
143
- const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1);
144
- const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1);
145
- return {
146
- items: endIndex >= startIndex ? buildVariableItems(startIndex, endIndex, offsets) : [],
147
- totalSize,
148
- startIndex,
149
- endIndex,
150
- scrollOffset,
151
- viewportHeight,
152
- };
146
+ return { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize: 0 };
153
147
  }
154
148
  // Uniform-size fast path (original behavior).
155
- const estimateSize = Math.max(typeof options.estimateSize === 'number' ? options.estimateSize : 1, 1);
156
- const totalSize = count * estimateSize;
149
+ const uniformSize = Math.max(typeof options.estimateSize === 'number' ? options.estimateSize : 1, 1);
150
+ const totalSize = count * uniformSize;
157
151
  const maxOffset = Math.max(totalSize - viewportHeight, 0);
158
152
  const scrollOffset = clamp(options.scrollOffset ?? 0, 0, maxOffset);
159
- const visibleStart = Math.floor(scrollOffset / estimateSize);
160
- const visibleCount = Math.ceil(viewportHeight / estimateSize);
153
+ const visibleStart = Math.floor(scrollOffset / uniformSize);
154
+ const visibleCount = Math.ceil(viewportHeight / uniformSize);
161
155
  const visibleEnd = Math.min(visibleStart + visibleCount, Math.max(count - 1, 0));
162
- const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1);
163
- const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1);
156
+ return { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize };
157
+ }
158
+ function createState(options,
159
+ /** Pre-built cumulative offsets for the variable-size path. */
160
+ offsets,
161
+ /** The scroll moved on from the window already rendered. With
162
+ * `overscanMin`, only then does the full `overscan` go ahead; a window
163
+ * built at rest or after a jump carries `overscanMin`, so a thumb drag
164
+ * that jumps every frame renders no more than it needs. */
165
+ scrolling = false) {
166
+ const full = Math.max(options.overscan ?? 6, 0);
167
+ const overscan = options.overscanMin === undefined || scrolling ? full : clamp(options.overscanMin, 0, full);
168
+ // The overscan goes ahead of the scroll and `overscanBehind` behind it:
169
+ // items behind the movement were just scrolled past, and a horizontal
170
+ // scroll that kept three columns on each side drew ~40% more cells than it
171
+ // needed to (350 vs 275 in the wide benchmark).
172
+ const behind = Math.min(Math.max(options.overscanBehind ?? full, 0), overscan);
173
+ const direction = options.scrollDirection ?? 0;
174
+ const overscanBefore = direction > 0 ? behind : overscan;
175
+ const overscanAfter = direction < 0 ? behind : overscan;
176
+ const { count, viewportHeight, totalSize, scrollOffset, visibleStart, visibleEnd, uniformSize } = visibleRange(options, offsets);
177
+ const startIndex = count === 0 ? 0 : clamp(visibleStart - overscanBefore, 0, count - 1);
178
+ const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscanAfter, 0, count - 1);
179
+ let items = [];
180
+ if (endIndex >= startIndex) {
181
+ items = uniformSize > 0
182
+ ? buildUniformItems(startIndex, endIndex, uniformSize)
183
+ : buildVariableItems(startIndex, endIndex, offsets);
184
+ }
164
185
  return {
165
- items: endIndex >= startIndex ? buildUniformItems(startIndex, endIndex, estimateSize) : [],
186
+ items,
166
187
  totalSize,
167
188
  startIndex,
168
189
  endIndex,
@@ -200,13 +221,54 @@ export function createVirtualizer(initial) {
200
221
  function emit() {
201
222
  listeners.forEach((listener) => listener());
202
223
  }
203
- function recalc() {
204
- const next = createState(options, getOffsets());
224
+ function recalc(scrolling = false) {
225
+ const next = createState(options, getOffsets(), scrolling);
205
226
  if (sameState(state, next))
206
227
  return;
228
+ // Hand back the previous item object for a row whose index, offset, size
229
+ // and slot did not change. The grid's row {#each} is keyed by slot, so a
230
+ // row that stays in the window keeps its <tr>, but a new item object
231
+ // still invalidated everything that row and its cells derive from it:
232
+ // every scroll frame re-checked ~300 cells to render the two that came
233
+ // into view, and Svelte's dependency marking was most of the frame's
234
+ // script time (measured 4.8 of 8.3 ms on a 100k-row grid).
235
+ if (state.items.length > 0 && next.items.length > 0) {
236
+ const first = state.items[0].index;
237
+ const items = next.items;
238
+ for (let i = 0; i < items.length; i += 1) {
239
+ const item = items[i];
240
+ const prev = state.items[item.index - first];
241
+ if (prev && prev.index === item.index && prev.start === item.start && prev.size === item.size && prev.key === item.key) {
242
+ items[i] = prev;
243
+ }
244
+ }
245
+ }
207
246
  state = next;
208
247
  emit();
209
248
  }
249
+ /**
250
+ * What a scroll does to the window already rendered, with `overscanMin`:
251
+ * 'hold' while that window still covers the visible items plus
252
+ * `overscanMin` ahead of the scroll, 'move' once the scroll runs past that
253
+ * (the window moves on with the full overscan ahead), and 'jump' when the
254
+ * visible items are nowhere in it. Without `overscanMin` every scroll moves
255
+ * the window.
256
+ */
257
+ function scrollStep() {
258
+ const min = options.overscanMin;
259
+ if (min === undefined || state.items.length === 0)
260
+ return 'move';
261
+ const ahead = clamp(min, 0, Math.max(options.overscan ?? 6, 0));
262
+ const { count, viewportHeight, totalSize, visibleStart, visibleEnd } = visibleRange(options, getOffsets());
263
+ if (count === 0 || viewportHeight !== state.viewportHeight || totalSize !== state.totalSize)
264
+ return 'jump';
265
+ if (visibleStart > state.endIndex || visibleEnd < state.startIndex)
266
+ return 'jump';
267
+ const direction = options.scrollDirection ?? 0;
268
+ const needStart = Math.max(visibleStart - (direction < 0 ? ahead : 0), 0);
269
+ const needEnd = Math.min(visibleEnd + (direction > 0 ? ahead : 0), count - 1);
270
+ return state.startIndex <= needStart && state.endIndex >= needEnd ? 'hold' : 'move';
271
+ }
210
272
  return {
211
273
  setOptions(next) {
212
274
  const merged = { ...options, ...next };
@@ -229,10 +291,14 @@ export function createVirtualizer(initial) {
229
291
  recalc();
230
292
  },
231
293
  setScrollOffset(scrollOffset) {
232
- if ((options.scrollOffset ?? 0) === scrollOffset)
294
+ const previous = options.scrollOffset ?? 0;
295
+ if (previous === scrollOffset)
233
296
  return;
234
- options = { ...options, scrollOffset };
235
- recalc();
297
+ options = { ...options, scrollOffset, scrollDirection: scrollOffset > previous ? 1 : -1 };
298
+ const step = scrollStep();
299
+ if (step === 'hold')
300
+ return;
301
+ recalc(step === 'move');
236
302
  },
237
303
  setViewportHeight(viewportHeight) {
238
304
  if (options.viewportHeight === viewportHeight)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What marks an array as windowed data, and how to tell (windowed-data.ts has
3
+ * the full story). Kept apart from createWindowedData so code that only asks
4
+ * "is this windowed?" - the controller, the core's per-update paths - does not
5
+ * pull in the windowed row model that createWindowedData installs.
6
+ */
7
+ export type WindowedSource<T> = {
8
+ /** The number of rows, loaded or not. */
9
+ readonly length: number;
10
+ /** The entry at `index`: a loaded row, a placeholder, or undefined. */
11
+ at(index: number): T | undefined;
12
+ };
13
+ /**
14
+ * An array of `length` entries read through `at(i)`: a real `Array` (a Proxy
15
+ * over an empty one) carrying the windowed mark. Each call returns a new
16
+ * array identity, which is how a source tells the grid its rows changed.
17
+ */
18
+ export declare function makeWindowedArray<T>(length: number, at: (index: number) => T | undefined): T[];
19
+ /** The source behind windowed data, or null for an ordinary array. */
20
+ export declare function windowedSourceOf<T>(data: unknown): WindowedSource<T> | null;
21
+ /** True for windowed data. */
22
+ export declare function isWindowedData(data: unknown): boolean;