@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.
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +475 -110
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +281 -163
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-D15bhpX5.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-B8-Gbfq2.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-Dz625Fhx.js → src-BYsa66I0.js} +9738 -10174
- package/dist/cdn/{src-CleI0g9l.js → src-pA4RroLV.js} +10702 -11138
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/{validate-GEs4ZPSW.js → validate-XuMe4_KR.js} +2 -2
- package/dist/cell-render.js +25 -3
- package/dist/columns.js +16 -1
- package/dist/conditional-formatting.js +9 -2
- package/dist/core.d.ts +93 -0
- package/dist/core.js +313 -52
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +32 -8
- package/dist/server-block-cache.d.ts +7 -32
- package/dist/server-block-cache.js +21 -12
- package/dist/server-data-source.js +3 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/spreadsheet.js +7 -0
- package/dist/validate.js +3 -11
- package/dist/virtualization/column-virtualizer.d.ts +4 -0
- package/dist/virtualization/column-virtualizer.js +2 -0
- package/dist/virtualization/types.d.ts +20 -0
- package/dist/virtualization/virtualizer.js +90 -24
- package/dist/windowed-brand.d.ts +22 -0
- package/dist/windowed-brand.js +60 -0
- package/dist/windowed-data.d.ts +6 -0
- package/dist/windowed-data.js +34 -0
- package/dist/windowed-row-model.d.ts +1 -0
- package/dist/windowed-row-model.js +97 -0
- package/package.json +1 -1
- package/src/GridMenus.svelte +3 -2
- package/src/SvGrid.controller.svelte.ts +469 -114
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +281 -163
- package/src/SvGrid.types.ts +24 -7
- package/src/cell-render.ts +24 -2
- package/src/columns.ts +16 -1
- package/src/conditional-formatting.ts +6 -4
- package/src/core.sort.test.ts +61 -0
- package/src/core.ts +309 -52
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +1 -1
- package/src/index.ts +6 -0
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +14 -0
- package/src/selection.ts +31 -8
- package/src/server-block-cache.ts +27 -10
- package/src/server-data-source.ts +3 -1
- package/src/server.ts +6 -0
- package/src/spreadsheet.ts +6 -0
- package/src/svgrid.behavior.test.ts +29 -0
- package/src/svgrid.column-cell-recycling.test.ts +120 -0
- package/src/svgrid.interaction.test.ts +4 -1
- package/src/svgrid.new-features.wrapper.test.ts +5 -4
- package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
- package/src/validate.test.ts +4 -4
- package/src/validate.ts +3 -14
- package/src/virtualization/column-virtualizer.ts +6 -0
- package/src/virtualization/types.ts +20 -0
- package/src/virtualization/virtualizer.test.ts +152 -0
- package/src/virtualization/virtualizer.ts +93 -27
- package/src/windowed-brand.ts +67 -0
- package/src/windowed-data.test.ts +105 -0
- package/src/windowed-data.ts +36 -0
- package/src/windowed-row-model.ts +113 -0
- package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
package/src/core.ts
CHANGED
|
@@ -809,7 +809,7 @@ export const filterFns = {
|
|
|
809
809
|
* One object per table, referenced by every row, instead of one closure scope
|
|
810
810
|
* per row. See {@link BASE_ROW_METHODS}.
|
|
811
811
|
*/
|
|
812
|
-
type BaseRowCtx<TData extends RowData> = {
|
|
812
|
+
export type BaseRowCtx<TData extends RowData> = {
|
|
813
813
|
grid: SvGrid<TData>
|
|
814
814
|
store: { state: Record<string, any> }
|
|
815
815
|
columns: Array<Column<TData>>
|
|
@@ -833,25 +833,110 @@ type BaseRowCtx<TData extends RowData> = {
|
|
|
833
833
|
* Non-enumerable string keys would have hidden them from JSON but also from the
|
|
834
834
|
* spread, silently breaking every cloned row.
|
|
835
835
|
*/
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
const
|
|
836
|
+
// Exported for windowed-row-model.ts only; index.ts does not re-export them.
|
|
837
|
+
export const ROW_CTX = Symbol('svgrid.row.ctx')
|
|
838
|
+
export const ROW_VALUES = Symbol('svgrid.row.values')
|
|
839
|
+
export const ROW_CELLS = Symbol('svgrid.row.cells')
|
|
839
840
|
// Where the filtered stage last put this row: the generation of that full
|
|
840
841
|
// filter and the row's index in its output. Lets a tick swap a replaced row
|
|
841
842
|
// into place without re-filtering everything (see createFilteredRowModel).
|
|
842
|
-
const ROW_FILTER_GEN = Symbol('svgrid.row.filterGen')
|
|
843
|
-
const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')
|
|
843
|
+
export const ROW_FILTER_GEN = Symbol('svgrid.row.filterGen')
|
|
844
|
+
export const ROW_FILTER_POS = Symbol('svgrid.row.filterPos')
|
|
844
845
|
// Stamped on the rows a sort repair drops, so the merge skips them with one
|
|
845
846
|
// property read per row instead of a Set lookup (3 ms per 100k rows).
|
|
846
|
-
const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
|
|
847
|
+
export const ROW_SORT_DROPPED = Symbol('svgrid.row.sortDropped')
|
|
847
848
|
// The key(s) the last full sort computed for a row: the key itself for a
|
|
848
849
|
// one-clause sort, an array for several. Read by the repair path so a tick
|
|
849
850
|
// never recomputes a kept row's key.
|
|
850
|
-
const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
|
|
851
|
+
export const ROW_SORT_KEY = Symbol('svgrid.row.sortKey')
|
|
851
852
|
let sortDropGeneration = 0
|
|
852
853
|
|
|
854
|
+
const LAZY_ROWS = Symbol('svgrid.lazyRows')
|
|
855
|
+
const LAZY_FILL = Symbol('svgrid.lazyRows.fill')
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* A real array of `length` rows whose entries are made on first read.
|
|
859
|
+
*
|
|
860
|
+
* A Proxy over a holey array: `get` fills a hole by calling `make`, and
|
|
861
|
+
* `has` / `getOwnPropertyDescriptor` report every index as present, so
|
|
862
|
+
* map, filter, slice, sort and iteration see a dense array (without them
|
|
863
|
+
* they would skip the holes). Writes go to the backing array.
|
|
864
|
+
*/
|
|
865
|
+
function lazyRows<T>(length: number, make: (index: number) => T): T[] {
|
|
866
|
+
const store = new Array<T | undefined>(length)
|
|
867
|
+
const at = (key: string | symbol): number => {
|
|
868
|
+
if (typeof key !== 'string') return -1
|
|
869
|
+
const c = key.charCodeAt(0)
|
|
870
|
+
if (c < 48 || c > 57) return -1
|
|
871
|
+
const i = +key
|
|
872
|
+
return i < length && (i | 0) === i ? i : -1
|
|
873
|
+
}
|
|
874
|
+
// Make every row not made yet, in one plain loop, and return the backing
|
|
875
|
+
// array: dense from then on, and read without the trap.
|
|
876
|
+
const fill = () => {
|
|
877
|
+
for (let i = 0; i < length; i++) if (store[i] === undefined) store[i] = make(i)
|
|
878
|
+
return store as T[]
|
|
879
|
+
}
|
|
880
|
+
return new Proxy(store, {
|
|
881
|
+
get(t, key, receiver) {
|
|
882
|
+
if (key === LAZY_ROWS) return t
|
|
883
|
+
if (key === LAZY_FILL) return fill
|
|
884
|
+
const i = at(key)
|
|
885
|
+
if (i < 0) return Reflect.get(t, key, receiver)
|
|
886
|
+
let row = t[i]
|
|
887
|
+
if (row === undefined) t[i] = row = make(i)
|
|
888
|
+
return row
|
|
889
|
+
},
|
|
890
|
+
has(t, key) {
|
|
891
|
+
if (key === LAZY_ROWS) return true
|
|
892
|
+
return at(key) >= 0 || Reflect.has(t, key)
|
|
893
|
+
},
|
|
894
|
+
getOwnPropertyDescriptor(t, key) {
|
|
895
|
+
const i = at(key)
|
|
896
|
+
if (i < 0) return Reflect.getOwnPropertyDescriptor(t, key)
|
|
897
|
+
let row = t[i]
|
|
898
|
+
if (row === undefined) t[i] = row = make(i)
|
|
899
|
+
return { value: row, writable: true, enumerable: true, configurable: true }
|
|
900
|
+
},
|
|
901
|
+
}) as T[]
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The rows as a plain dense array. For a lazyRows() list, makes the rows not
|
|
906
|
+
* made yet and returns its backing array - the same row objects, without the
|
|
907
|
+
* Proxy in front. Anything that reads every row (a sort, a filter) should go
|
|
908
|
+
* through this first: a trap per read, over several passes of a million rows,
|
|
909
|
+
* cost more than building the rows did.
|
|
910
|
+
*/
|
|
911
|
+
export function denseRows<T>(rows: T[]): T[] {
|
|
912
|
+
const fill = (rows as unknown as Record<symbol, (() => T[]) | undefined>)[LAZY_FILL]
|
|
913
|
+
return fill ? fill() : rows
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** The backing array of a lazyRows() list (rows made so far, holes elsewhere), or null. */
|
|
917
|
+
function lazyRowsBacking<T>(rows: T[] | null): Array<T | undefined> | null {
|
|
918
|
+
return rows ? ((rows as unknown as Record<symbol, Array<T | undefined> | undefined>)[LAZY_ROWS] ?? null) : null
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* The row model for windowed data (windowed-data.ts), or null when `data` is
|
|
923
|
+
* an ordinary array. It lives in windowed-row-model.ts and is installed by the
|
|
924
|
+
* first createWindowedData() call, so a grid that is never handed windowed
|
|
925
|
+
* data does not bundle it: this file imports nothing from either module.
|
|
926
|
+
*/
|
|
927
|
+
export type WindowedRowModel<TData extends RowData> = (data: unknown, columns: Array<Column<TData>>) => RowModel<TData> | null
|
|
928
|
+
export type WindowedRowModelInstall = <TData extends RowData>(ctx: {
|
|
929
|
+
rowCtxFor: (columns: Array<Column<TData>>) => BaseRowCtx<TData>
|
|
930
|
+
getRowId: () => ((row: TData, index: number) => string) | undefined
|
|
931
|
+
}) => WindowedRowModel<TData>
|
|
932
|
+
let installWindowedRowModel: WindowedRowModelInstall | null = null
|
|
933
|
+
/** Internal: windowed-row-model.ts installs itself here. */
|
|
934
|
+
export function registerWindowedRowModel(install: WindowedRowModelInstall): void {
|
|
935
|
+
installWindowedRowModel = install
|
|
936
|
+
}
|
|
937
|
+
|
|
853
938
|
/** A base row's private fields, on top of the public {@link Row} surface. */
|
|
854
|
-
type BaseRowState<TData extends RowData> = Row<TData> & {
|
|
939
|
+
export type BaseRowState<TData extends RowData> = Row<TData> & {
|
|
855
940
|
[ROW_CTX]: BaseRowCtx<TData>
|
|
856
941
|
[ROW_VALUES]: Array<unknown> | null
|
|
857
942
|
[ROW_CELLS]: Array<Cell<TData>> | null
|
|
@@ -876,7 +961,7 @@ type BaseRowState<TData extends RowData> = Row<TData> & {
|
|
|
876
961
|
* `{ ...row, depth }`, and a spread copies own properties but not a prototype.
|
|
877
962
|
* A class here would silently strip every method off a cloned row.
|
|
878
963
|
*/
|
|
879
|
-
const BASE_ROW_METHODS = {
|
|
964
|
+
export const BASE_ROW_METHODS = {
|
|
880
965
|
getCanExpand(this: BaseRowState<RowData>) {
|
|
881
966
|
return false
|
|
882
967
|
},
|
|
@@ -1456,6 +1541,8 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1456
1541
|
desc: boolean
|
|
1457
1542
|
compare: (a: any, b: any) => number
|
|
1458
1543
|
keyOf: (row: Row<TData>) => any
|
|
1544
|
+
/** Set when every key is an integer rank in [0, rankCount): the text rank path. */
|
|
1545
|
+
rankCount?: number
|
|
1459
1546
|
}> = []
|
|
1460
1547
|
|
|
1461
1548
|
for (const clause of sorting) {
|
|
@@ -1486,6 +1573,7 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1486
1573
|
let compare: (a: any, b: any) => number
|
|
1487
1574
|
let keyOf: (row: Row<TData>) => any
|
|
1488
1575
|
let autoMode: 'number' | 'mixed' | 'text' | undefined
|
|
1576
|
+
let rankCount: number | undefined
|
|
1489
1577
|
|
|
1490
1578
|
if (comparator === sortFns.number) {
|
|
1491
1579
|
keyOf = (row) => numberSortKey(row.getCellValueByColumnId(columnId))
|
|
@@ -1529,9 +1617,20 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1529
1617
|
// column. Reading every k-th row is no more expensive and does not care
|
|
1530
1618
|
// how the rows are arranged.
|
|
1531
1619
|
const rankLimit = n >> 1
|
|
1532
|
-
|
|
1620
|
+
// Each distinct value's id (its insertion order), when ranking.
|
|
1621
|
+
let idOf: Map<string, number> | null = null
|
|
1533
1622
|
if (n > 0) {
|
|
1534
|
-
|
|
1623
|
+
// Ranking pays when the column has at most n/2 distinct values. A
|
|
1624
|
+
// small sample cannot see that by asking whether half of it repeats:
|
|
1625
|
+
// 1M rows of 80k values, each one 12 times over, gave 256 samples
|
|
1626
|
+
// with almost no repeats, so ranking was skipped and the sort made
|
|
1627
|
+
// 20M collator calls (1.75 s; ranked, it is a fraction of that).
|
|
1628
|
+
// Count the repeats in a larger sample instead. S samples from u
|
|
1629
|
+
// equally common values repeat about S^2 / 2u times, so u <= n/2
|
|
1630
|
+
// shows as at least S^2 / n repeats. Up to SAMPLE rows the whole
|
|
1631
|
+
// column is the sample and the test is exact.
|
|
1632
|
+
const SAMPLE = 2048
|
|
1633
|
+
const sampleTarget = Math.min(n, SAMPLE)
|
|
1535
1634
|
const stride = Math.max(1, Math.floor(n / sampleTarget))
|
|
1536
1635
|
const sample = new Set<string>()
|
|
1537
1636
|
let sampled = 0
|
|
@@ -1539,16 +1638,30 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1539
1638
|
sample.add(autoTextKey(rows[i]!.getCellValueByColumnId(columnId)))
|
|
1540
1639
|
sampled++
|
|
1541
1640
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1641
|
+
const repeats = sampled - sample.size
|
|
1642
|
+
const worthRanking = stride === 1 ? sample.size <= rankLimit : repeats * n >= sampled * sampled
|
|
1643
|
+
if (worthRanking) idOf = new Map()
|
|
1544
1644
|
}
|
|
1545
1645
|
|
|
1646
|
+
// One hash per row: the value's id is looked up (or assigned) as the
|
|
1647
|
+
// row is read, and the rank lookup further down is an array read by
|
|
1648
|
+
// id. It used to add every value to a Set and then look every value up
|
|
1649
|
+
// again in a rank Map, two hashes per row.
|
|
1650
|
+
const ids: Int32Array | null = idOf ? new Int32Array(n) : null
|
|
1546
1651
|
for (let i = 0; i < n; i++) {
|
|
1547
1652
|
const s = autoTextKey(rows[i]!.getCellValueByColumnId(columnId))
|
|
1548
1653
|
strings[i] = s
|
|
1549
|
-
if (
|
|
1550
|
-
|
|
1551
|
-
if (
|
|
1654
|
+
if (idOf) {
|
|
1655
|
+
let id = idOf.get(s)
|
|
1656
|
+
if (id === undefined) {
|
|
1657
|
+
id = idOf.size
|
|
1658
|
+
idOf.set(s, id)
|
|
1659
|
+
if (idOf.size > rankLimit) {
|
|
1660
|
+
idOf = null
|
|
1661
|
+
continue
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
ids![i] = id
|
|
1552
1665
|
}
|
|
1553
1666
|
}
|
|
1554
1667
|
|
|
@@ -1565,11 +1678,15 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1565
1678
|
// Guarded on the uniqueness ratio: when nearly every value is distinct
|
|
1566
1679
|
// the ranking pass cannot save any collator calls and would just add an
|
|
1567
1680
|
// O(n) Map build, so that case keeps comparing directly.
|
|
1568
|
-
if (
|
|
1569
|
-
|
|
1570
|
-
const
|
|
1571
|
-
|
|
1572
|
-
|
|
1681
|
+
if (idOf) {
|
|
1682
|
+
// Map keys come back in insertion order, which is id order.
|
|
1683
|
+
const values = Array.from(idOf.keys())
|
|
1684
|
+
const byRank = values.map((_, id) => id).sort((a, b) => compareCollatedKeys(values[a]!, values[b]!))
|
|
1685
|
+
const rankById = new Int32Array(values.length)
|
|
1686
|
+
for (let r = 0; r < byRank.length; r++) rankById[byRank[r]!] = r
|
|
1687
|
+
for (let i = 0; i < n; i++) keys[i] = rankById[ids![i]!]!
|
|
1688
|
+
const rankIds = idOf
|
|
1689
|
+
rankCount = values.length
|
|
1573
1690
|
compare = compareNumericKeys
|
|
1574
1691
|
// A replacement whose text is not among the ranked values has no
|
|
1575
1692
|
// rank; the repair path treats that as "sort everything again".
|
|
@@ -1577,7 +1694,8 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1577
1694
|
const value = row.getCellValueByColumnId(columnId)
|
|
1578
1695
|
// A number arriving in a tick makes the column mixed; resort fully.
|
|
1579
1696
|
if (typeof value === 'number') return MISSING_KEY
|
|
1580
|
-
|
|
1697
|
+
const id = rankIds.get(autoTextKey(value))
|
|
1698
|
+
return id === undefined ? MISSING_KEY : rankById[id]!
|
|
1581
1699
|
}
|
|
1582
1700
|
} else {
|
|
1583
1701
|
keys = strings
|
|
@@ -1593,7 +1711,7 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1593
1711
|
compare = comparator
|
|
1594
1712
|
}
|
|
1595
1713
|
|
|
1596
|
-
clauses.push({ keys, desc: clause.desc, compare, keyOf })
|
|
1714
|
+
clauses.push({ keys, desc: clause.desc, compare, keyOf, rankCount })
|
|
1597
1715
|
}
|
|
1598
1716
|
|
|
1599
1717
|
if (!clauses.length) return null
|
|
@@ -1601,8 +1719,10 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1601
1719
|
// Sort an index array, then materialise. `Array.prototype.sort` is stable,
|
|
1602
1720
|
// so equal keys keep their original relative order exactly as the previous
|
|
1603
1721
|
// `[...rows].sort(...)` did.
|
|
1604
|
-
|
|
1605
|
-
|
|
1722
|
+
// An index array the comparator sorts in place, or a typed array from
|
|
1723
|
+
// countingOrder / radixOrder for a large one-column numeric sort.
|
|
1724
|
+
let order: ArrayLike<number> = new Array<number>(rows.length)
|
|
1725
|
+
for (let i = 0; i < order.length; i++) (order as number[])[i] = i
|
|
1606
1726
|
|
|
1607
1727
|
// Single-clause sorts get a specialised comparator.
|
|
1608
1728
|
//
|
|
@@ -1618,22 +1738,26 @@ export function createSortedRowModel<TData extends RowData>(
|
|
|
1618
1738
|
// which the spec coerces to 0, and they differ only in producing 0 versus
|
|
1619
1739
|
// -0 for equal keys, which sorts identically.
|
|
1620
1740
|
if (clauses.length === 1) {
|
|
1621
|
-
const { keys, compare, desc } = clauses[0]!
|
|
1622
|
-
if (
|
|
1623
|
-
order
|
|
1741
|
+
const { keys, compare, desc, rankCount } = clauses[0]!
|
|
1742
|
+
if (rankCount !== undefined && rows.length >= STABLE_NUMERIC_SORT_MIN) {
|
|
1743
|
+
order = countingOrder(keys as number[], rankCount, desc)
|
|
1744
|
+
} else if (compare === compareNumericKeys && rows.length >= STABLE_NUMERIC_SORT_MIN) {
|
|
1745
|
+
order = radixOrder(keys as number[], desc)
|
|
1746
|
+
} else if (compare === compareNumericKeys) {
|
|
1747
|
+
;(order as number[]).sort(
|
|
1624
1748
|
desc
|
|
1625
1749
|
? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
|
|
1626
1750
|
: function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
|
|
1627
1751
|
)
|
|
1628
1752
|
} else {
|
|
1629
|
-
order.sort(
|
|
1753
|
+
;(order as number[]).sort(
|
|
1630
1754
|
desc
|
|
1631
1755
|
? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
|
|
1632
1756
|
: function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
|
|
1633
1757
|
)
|
|
1634
1758
|
}
|
|
1635
1759
|
} else {
|
|
1636
|
-
order.sort(function compareRowsByClauses(ia, ib) {
|
|
1760
|
+
;(order as number[]).sort(function compareRowsByClauses(ia, ib) {
|
|
1637
1761
|
for (let k = 0; k < clauses.length; k++) {
|
|
1638
1762
|
const clause = clauses[k]!
|
|
1639
1763
|
const result = clause.compare(clause.keys[ia], clause.keys[ib])
|
|
@@ -1850,6 +1974,90 @@ function autoSortMode(rows: ReadonlyArray<Row<any>>, columnId: string): 'number'
|
|
|
1850
1974
|
* Left as its own function so the sort path has one place to change if that
|
|
1851
1975
|
* ever stops being true. Re-measure before "optimising" this again.
|
|
1852
1976
|
*/
|
|
1977
|
+
/**
|
|
1978
|
+
* From this many rows, a one-column numeric sort orders its row indices
|
|
1979
|
+
* without a comparator. `Array.prototype.sort` with a JS comparator made
|
|
1980
|
+
* ~20M comparator calls for 1M rows, most of a numeric sort's time; both
|
|
1981
|
+
* orders below are stable, as that sort is, so equal keys keep input order.
|
|
1982
|
+
* Under it the comparator sort is cheaper than the bucket arrays.
|
|
1983
|
+
*/
|
|
1984
|
+
const STABLE_NUMERIC_SORT_MIN = 20_000
|
|
1985
|
+
|
|
1986
|
+
/** Stable order of `keys` that are integer ranks in [0, rankCount): one counting pass. */
|
|
1987
|
+
function countingOrder(keys: ArrayLike<number>, rankCount: number, desc: boolean): Uint32Array {
|
|
1988
|
+
const n = keys.length
|
|
1989
|
+
const starts = new Uint32Array(rankCount + 1)
|
|
1990
|
+
for (let i = 0; i < n; i++) starts[(desc ? rankCount - 1 - keys[i]! : keys[i]!) + 1]!++
|
|
1991
|
+
for (let r = 0; r < rankCount; r++) starts[r + 1]! += starts[r]!
|
|
1992
|
+
const out = new Uint32Array(n)
|
|
1993
|
+
for (let i = 0; i < n; i++) out[starts[desc ? rankCount - 1 - keys[i]! : keys[i]!]!++] = i
|
|
1994
|
+
return out
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* Stable order of float keys: an LSD radix sort over the IEEE bits, four
|
|
2001
|
+
* 16-bit passes, a pass skipped when every key shares its digit. The bits are
|
|
2002
|
+
* mapped so unsigned order is numeric order (sign bit flipped for positives,
|
|
2003
|
+
* every bit for negatives), and inverted for a descending sort, which keeps
|
|
2004
|
+
* equal keys in input order exactly as a descending comparator does. -0 is
|
|
2005
|
+
* folded into 0 first, since the comparator treated them as equal. Keys are
|
|
2006
|
+
* never NaN here: numberSortKey and dateSortKey map those to -Infinity.
|
|
2007
|
+
*/
|
|
2008
|
+
function radixOrder(keys: ArrayLike<number>, desc: boolean): Uint32Array {
|
|
2009
|
+
const n = keys.length
|
|
2010
|
+
const f64 = new Float64Array(n)
|
|
2011
|
+
for (let i = 0; i < n; i++) f64[i] = keys[i]! + 0
|
|
2012
|
+
const words = new Uint32Array(f64.buffer)
|
|
2013
|
+
const lo = new Uint32Array(n)
|
|
2014
|
+
const hi = new Uint32Array(n)
|
|
2015
|
+
const loAt = LITTLE_ENDIAN ? 0 : 1
|
|
2016
|
+
const hiAt = 1 - loAt
|
|
2017
|
+
for (let i = 0; i < n; i++) {
|
|
2018
|
+
let h = words[2 * i + hiAt]!
|
|
2019
|
+
let l = words[2 * i + loAt]!
|
|
2020
|
+
if (h & 0x80000000) {
|
|
2021
|
+
h = ~h >>> 0
|
|
2022
|
+
l = ~l >>> 0
|
|
2023
|
+
} else {
|
|
2024
|
+
h = (h | 0x80000000) >>> 0
|
|
2025
|
+
}
|
|
2026
|
+
if (desc) {
|
|
2027
|
+
h = ~h >>> 0
|
|
2028
|
+
l = ~l >>> 0
|
|
2029
|
+
}
|
|
2030
|
+
hi[i] = h
|
|
2031
|
+
lo[i] = l
|
|
2032
|
+
}
|
|
2033
|
+
let src = new Uint32Array(n)
|
|
2034
|
+
for (let i = 0; i < n; i++) src[i] = i
|
|
2035
|
+
let dst = new Uint32Array(n)
|
|
2036
|
+
const counts = new Uint32Array(65536)
|
|
2037
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
2038
|
+
const word = pass < 2 ? lo : hi
|
|
2039
|
+
const shift = pass % 2 === 0 ? 0 : 16
|
|
2040
|
+
counts.fill(0)
|
|
2041
|
+
for (let i = 0; i < n; i++) counts[(word[i]! >>> shift) & 0xffff]!++
|
|
2042
|
+
// Every key in one bucket: this digit cannot change the order.
|
|
2043
|
+
if (counts[(word[src[0]!]! >>> shift) & 0xffff] === n) continue
|
|
2044
|
+
let sum = 0
|
|
2045
|
+
for (let d = 0; d < 65536; d++) {
|
|
2046
|
+
const c = counts[d]!
|
|
2047
|
+
counts[d] = sum
|
|
2048
|
+
sum += c
|
|
2049
|
+
}
|
|
2050
|
+
for (let i = 0; i < n; i++) {
|
|
2051
|
+
const idx = src[i]!
|
|
2052
|
+
dst[counts[(word[idx]! >>> shift) & 0xffff]!++] = idx
|
|
2053
|
+
}
|
|
2054
|
+
const t = src
|
|
2055
|
+
src = dst
|
|
2056
|
+
dst = t
|
|
2057
|
+
}
|
|
2058
|
+
return src
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1853
2061
|
function compareCollatedKeys(a: string, b: string): number {
|
|
1854
2062
|
return a.localeCompare(b)
|
|
1855
2063
|
}
|
|
@@ -1953,6 +2161,24 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
1953
2161
|
/** Old row -> new row for the rebuild the next pipeline run follows; see `replacedRowsOf`. */
|
|
1954
2162
|
let replacedRows: Map<Row<TData>, Row<TData>> | null = null
|
|
1955
2163
|
let cachedRowCtx: BaseRowCtx<TData> | null = null
|
|
2164
|
+
/** This grid's windowed-data row model, made on first use from the installed hook. */
|
|
2165
|
+
let windowedRowModel: WindowedRowModel<TData> | null = null
|
|
2166
|
+
|
|
2167
|
+
/** The shared row context for `columns`, reused while they are the same. */
|
|
2168
|
+
function rowCtxFor(columns: Array<Column<TData>>): BaseRowCtx<TData> {
|
|
2169
|
+
if (cachedRowCtx && cachedRowCtx.columns === columns) return cachedRowCtx
|
|
2170
|
+
const columnIndexById = new Map<string, number>()
|
|
2171
|
+
for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
|
|
2172
|
+
cachedRowCtx = {
|
|
2173
|
+
grid: grid as SvGrid<TData>,
|
|
2174
|
+
store,
|
|
2175
|
+
columns,
|
|
2176
|
+
columnCount: columns.length,
|
|
2177
|
+
columnIndexById,
|
|
2178
|
+
}
|
|
2179
|
+
return cachedRowCtx
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1956
2182
|
let cachedRowModel: RowModel<TData> | null = null
|
|
1957
2183
|
let cachedRowModelBaseRows: Array<Row<TData>> | null = null
|
|
1958
2184
|
let cachedPipeline = options._rowModels
|
|
@@ -2165,6 +2391,11 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2165
2391
|
// (a $state.raw signal), and the rebuild below touched it twice per row:
|
|
2166
2392
|
// 200k tracked reads per 100k-row tick, 11 ms of a 65 ms tick.
|
|
2167
2393
|
const data = options.data
|
|
2394
|
+
if (installWindowedRowModel) {
|
|
2395
|
+
windowedRowModel ??= installWindowedRowModel<TData>({ rowCtxFor, getRowId: () => options.getRowId })
|
|
2396
|
+
const model = windowedRowModel(data, columns)
|
|
2397
|
+
if (model) return model
|
|
2398
|
+
}
|
|
2168
2399
|
if (cachedBaseRowsInput !== data || cachedBaseRowsColumns !== columns) {
|
|
2169
2400
|
// Same columns as last time: the shared context still describes them,
|
|
2170
2401
|
// and a row whose data object sits at the same index can keep its
|
|
@@ -2174,7 +2405,10 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2174
2405
|
// landing. The reused row drops its memoised values and cells, since
|
|
2175
2406
|
// an app may have changed the object in place before passing a new
|
|
2176
2407
|
// array - that is the case the old rebuild covered by accident.
|
|
2177
|
-
|
|
2408
|
+
// A lazy row list (lazyRows below) is read through its backing array,
|
|
2409
|
+
// so reusing rows never makes the ones nobody looked at.
|
|
2410
|
+
const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? (lazyRowsBacking(cachedBaseRows) ?? cachedBaseRows) : null
|
|
2411
|
+
const previousInput = cachedBaseRowsInput
|
|
2178
2412
|
// Same length as last time: a replacement, not an add or remove, so
|
|
2179
2413
|
// each new row stands in for the old row at its index and the stages
|
|
2180
2414
|
// can repair rather than recompute. Anything else is structural.
|
|
@@ -2201,7 +2435,6 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2201
2435
|
cachedRowCtx = rowCtx
|
|
2202
2436
|
}
|
|
2203
2437
|
|
|
2204
|
-
cachedBaseRows = new Array(data.length)
|
|
2205
2438
|
const getRowId = options.getRowId
|
|
2206
2439
|
const m = BASE_ROW_METHODS as unknown as {
|
|
2207
2440
|
getCanExpand: Row<TData>['getCanExpand']
|
|
@@ -2212,25 +2445,12 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2212
2445
|
getAllCells: Row<TData>['getAllCells']
|
|
2213
2446
|
getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
|
|
2214
2447
|
}
|
|
2215
|
-
|
|
2448
|
+
// `_values` and `_cells` stay null until something reads them - a
|
|
2449
|
+
// 100k-row grid showing twenty rows must not materialise every row's
|
|
2450
|
+
// values or cell objects to paint.
|
|
2451
|
+
const makeRow = (index: number): BaseRowState<TData> => {
|
|
2216
2452
|
const original = data[index]!
|
|
2217
|
-
|
|
2218
|
-
if (kept && kept.original === original) {
|
|
2219
|
-
// Read before writing: a row nobody rendered (a million off-screen
|
|
2220
|
-
// placeholders under a row model) has nothing memoised, and a
|
|
2221
|
-
// store per row was a measurable share of each block landing.
|
|
2222
|
-
if (kept[ROW_VALUES] !== null) kept[ROW_VALUES] = null
|
|
2223
|
-
if (kept[ROW_CELLS] !== null) kept[ROW_CELLS] = null
|
|
2224
|
-
cachedBaseRows[index] = kept
|
|
2225
|
-
continue
|
|
2226
|
-
}
|
|
2227
|
-
// Past a quarter of the rows the merge stops being cheaper than a
|
|
2228
|
-
// sort with the ranking pass; let the stages do the full work.
|
|
2229
|
-
if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null
|
|
2230
|
-
// `_values` and `_cells` stay null until something reads them - a
|
|
2231
|
-
// 100k-row grid showing twenty rows must not materialise every row's
|
|
2232
|
-
// values or cell objects to paint.
|
|
2233
|
-
const row: BaseRowState<TData> = {
|
|
2453
|
+
return {
|
|
2234
2454
|
id: getRowId ? getRowId(original, index) : String(index),
|
|
2235
2455
|
index,
|
|
2236
2456
|
original,
|
|
@@ -2250,6 +2470,36 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2250
2470
|
getAllCells: m.getAllCells,
|
|
2251
2471
|
getCellValueByColumnId: m.getCellValueByColumnId,
|
|
2252
2472
|
}
|
|
2473
|
+
}
|
|
2474
|
+
if (!previous) {
|
|
2475
|
+
// A first build has nothing to reuse or repair: make each row the
|
|
2476
|
+
// first time something reads it. A grid that only shows the first
|
|
2477
|
+
// screen makes ~40 row objects, not 100k; a sort or a filter reads
|
|
2478
|
+
// every row and makes them then. Building them all up front was
|
|
2479
|
+
// most of a 100k-row mount (~24 ms, plus the garbage).
|
|
2480
|
+
cachedBaseRows = lazyRows(data.length, makeRow)
|
|
2481
|
+
} else cachedBaseRows = new Array(data.length)
|
|
2482
|
+
for (let index = 0; previous && index < data.length; index++) {
|
|
2483
|
+
const original = data[index]!
|
|
2484
|
+
const kept = previous[index] as BaseRowState<TData> | undefined
|
|
2485
|
+
if (kept && kept.original === original) {
|
|
2486
|
+
// Read before writing: a row nobody rendered (a million off-screen
|
|
2487
|
+
// placeholders under a row model) has nothing memoised, and a
|
|
2488
|
+
// store per row was a measurable share of each block landing.
|
|
2489
|
+
if (kept[ROW_VALUES] !== null) kept[ROW_VALUES] = null
|
|
2490
|
+
if (kept[ROW_CELLS] !== null) kept[ROW_CELLS] = null
|
|
2491
|
+
cachedBaseRows[index] = kept
|
|
2492
|
+
continue
|
|
2493
|
+
}
|
|
2494
|
+
// Past a quarter of the rows the merge stops being cheaper than a
|
|
2495
|
+
// sort with the ranking pass; let the stages do the full work.
|
|
2496
|
+
if (replacedRows && replacedRows.size * 4 >= data.length) replacedRows = null
|
|
2497
|
+
const row = makeRow(index)
|
|
2498
|
+
// The previous list was lazy and never made this row, but its data
|
|
2499
|
+
// object is the same one: hand the old list this row too, so a
|
|
2500
|
+
// caller still holding it gets the same object for the same data,
|
|
2501
|
+
// as when every row was built up front.
|
|
2502
|
+
if (kept === undefined && previousInput?.[index] === original) previous[index] = row
|
|
2253
2503
|
cachedBaseRows[index] = row
|
|
2254
2504
|
if (replacedRows && kept) replacedRows.set(kept, row)
|
|
2255
2505
|
}
|
|
@@ -2295,7 +2545,14 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
2295
2545
|
return cachedRowModel
|
|
2296
2546
|
}
|
|
2297
2547
|
|
|
2298
|
-
|
|
2548
|
+
// A stage with work to do reads every row; give it the dense array.
|
|
2549
|
+
const anyStageActive =
|
|
2550
|
+
(currentSlices.sorting?.length ?? 0) > 0 ||
|
|
2551
|
+
(currentSlices.columnFilters?.length ?? 0) > 0 ||
|
|
2552
|
+
(currentSlices.grouping?.length ?? 0) > 0 ||
|
|
2553
|
+
(currentSlices.expanded as unknown) === true ||
|
|
2554
|
+
Object.keys(currentSlices.expanded ?? {}).length > 0
|
|
2555
|
+
let rows: Array<Row<TData>> = anyStageActive ? denseRows(cachedBaseRows) : cachedBaseRows
|
|
2299
2556
|
|
|
2300
2557
|
const pipeline = options._rowModels ?? {}
|
|
2301
2558
|
const ordered: Array<RowModelFactory<TData> | undefined> = [
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Test cell (svgrid.column-cell-recycling.test.ts): records the column it
|
|
3
|
+
// was created for, so a test can tell a re-created instance from one that
|
|
4
|
+
// was handed another column's props.
|
|
5
|
+
import { untrack } from 'svelte'
|
|
6
|
+
|
|
7
|
+
let { columnId, value }: { columnId: string; value: unknown } = $props()
|
|
8
|
+
const createdFor = untrack(() => columnId)
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<span data-counting-cell data-created-for={createdFor}>{String(value)}</span>
|
|
@@ -207,6 +207,39 @@ export {
|
|
|
207
207
|
/** A filter with its needle-side work already done. Call per row. */
|
|
208
208
|
export type CompiledExcelFilter = (cellValue: unknown) => boolean
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* A text test, remembered per distinct cell text.
|
|
212
|
+
*
|
|
213
|
+
* The text operators fold every cell (lowercase, and for non-ASCII text NFD
|
|
214
|
+
* plus diacritic stripping) before comparing, and a column the user filters
|
|
215
|
+
* is usually a category - region, status, owner - with a handful of distinct
|
|
216
|
+
* values over every row. Folding each row again was most of a 1M-row filter
|
|
217
|
+
* (417 ms). The answer depends only on the cell's text, so it is kept per
|
|
218
|
+
* text. A column that turns out mostly unique (more than half of the first
|
|
219
|
+
* 2,048 lookups missed) stops remembering and tests directly, and the memo
|
|
220
|
+
* never grows past MEMO_CAP entries.
|
|
221
|
+
*/
|
|
222
|
+
const MEMO_CAP = 16_384
|
|
223
|
+
function memoText(test: (s: string) => boolean): CompiledExcelFilter {
|
|
224
|
+
let memo: Map<string, boolean> | null = new Map()
|
|
225
|
+
let lookups = 0
|
|
226
|
+
let hits = 0
|
|
227
|
+
return (cellValue) => {
|
|
228
|
+
const s = typeof cellValue === 'string' ? cellValue : String(cellValue ?? '')
|
|
229
|
+
if (memo === null) return test(s)
|
|
230
|
+
lookups++
|
|
231
|
+
const known = memo.get(s)
|
|
232
|
+
if (known !== undefined) {
|
|
233
|
+
hits++
|
|
234
|
+
return known
|
|
235
|
+
}
|
|
236
|
+
const result = test(s)
|
|
237
|
+
if (memo.size < MEMO_CAP) memo.set(s, result)
|
|
238
|
+
if (lookups >= 2048 && hits * 2 < lookups) memo = null
|
|
239
|
+
return result
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
210
243
|
/**
|
|
211
244
|
* Compile a filter once, then test many rows against it.
|
|
212
245
|
*
|
|
@@ -228,21 +261,21 @@ export function compileExcelFilter(
|
|
|
228
261
|
|
|
229
262
|
switch (filter.operator) {
|
|
230
263
|
case 'contains':
|
|
231
|
-
return (
|
|
264
|
+
return memoText((s) => fold(s).includes(needle))
|
|
232
265
|
case 'notContains':
|
|
233
266
|
// An empty needle is no constraint (mirrors `contains` returning true),
|
|
234
267
|
// so nothing is excluded until the user types something.
|
|
235
268
|
if (needle === '') return () => true
|
|
236
|
-
return (
|
|
269
|
+
return memoText((s) => !fold(s).includes(needle))
|
|
237
270
|
case 'equals':
|
|
238
|
-
return (
|
|
271
|
+
return memoText((s) => fold(s) === needle)
|
|
239
272
|
case 'notEquals':
|
|
240
273
|
if (needle === '') return () => true
|
|
241
|
-
return (
|
|
274
|
+
return memoText((s) => fold(s) !== needle)
|
|
242
275
|
case 'startsWith':
|
|
243
|
-
return (
|
|
276
|
+
return memoText((s) => fold(s).startsWith(needle))
|
|
244
277
|
case 'endsWith':
|
|
245
|
-
return (
|
|
278
|
+
return memoText((s) => fold(s).endsWith(needle))
|
|
246
279
|
case 'regex': {
|
|
247
280
|
// Case-insensitive by default (matches the accent/case-folded feel of
|
|
248
281
|
// the other text operators). An invalid pattern matches nothing rather
|
|
@@ -69,7 +69,7 @@ describe('headless state reactivity', () => {
|
|
|
69
69
|
// `$derived(table.getRowModel().rows)` reads only the framework-free
|
|
70
70
|
// engine and never recomputes, which is the bug this guards.
|
|
71
71
|
const rows = $derived.by(() => {
|
|
72
|
-
sorting
|
|
72
|
+
void sorting
|
|
73
73
|
return table.getRowModel().rows
|
|
74
74
|
})
|
|
75
75
|
expect(rows.map((r) => (r.original as Repo).name)).toEqual(['svelte', 'vite', 'sv-grid'])
|
package/src/index.ts
CHANGED
|
@@ -801,6 +801,12 @@ export {
|
|
|
801
801
|
type BlockFetchResult,
|
|
802
802
|
type BlockState,
|
|
803
803
|
} from './server-block-cache'
|
|
804
|
+
export {
|
|
805
|
+
createWindowedData,
|
|
806
|
+
windowedSourceOf,
|
|
807
|
+
isWindowedData,
|
|
808
|
+
type WindowedSource,
|
|
809
|
+
} from './windowed-data'
|
|
804
810
|
export {
|
|
805
811
|
toServerFilterColumns,
|
|
806
812
|
type GridRowModel,
|
package/src/new-features.test.ts
CHANGED
|
@@ -70,6 +70,8 @@ describe('createSvGrid - getRowId', () => {
|
|
|
70
70
|
},
|
|
71
71
|
}).getRowModel().rows
|
|
72
72
|
expect(rows).toHaveLength(data.length)
|
|
73
|
+
// Rows are made when read (a first build is lazy), so read each one.
|
|
74
|
+
expect(rows.map((r) => r.id)).toEqual(['ada', 'linus', 'grace'])
|
|
73
75
|
expect(seen).toEqual([
|
|
74
76
|
{ id: 'ada', index: 0 },
|
|
75
77
|
{ id: 'linus', index: 1 },
|
|
@@ -77,6 +79,28 @@ describe('createSvGrid - getRowId', () => {
|
|
|
77
79
|
])
|
|
78
80
|
})
|
|
79
81
|
|
|
82
|
+
it('makes a row, and asks getRowId for it, only when the row is read', () => {
|
|
83
|
+
// A first build is lazy: a grid that only shows its first screen must not
|
|
84
|
+
// build a row object (or call getRowId) for every row of a large dataset.
|
|
85
|
+
const seen: number[] = []
|
|
86
|
+
const rows = createSvGrid<typeof features, Row>({
|
|
87
|
+
_features: features,
|
|
88
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
89
|
+
columns,
|
|
90
|
+
data,
|
|
91
|
+
getRowId: (row, index) => {
|
|
92
|
+
seen.push(index)
|
|
93
|
+
return row.id
|
|
94
|
+
},
|
|
95
|
+
}).getRowModel().rows
|
|
96
|
+
expect(seen).toEqual([])
|
|
97
|
+
expect(rows[2]!.id).toBe('grace')
|
|
98
|
+
expect(seen).toEqual([2])
|
|
99
|
+
// The same object every time it is read.
|
|
100
|
+
expect(rows[2]).toBe(rows[2])
|
|
101
|
+
expect(seen).toEqual([2])
|
|
102
|
+
})
|
|
103
|
+
|
|
80
104
|
it('preserves the same id across `getRowModel()` invocations (stable identity)', () => {
|
|
81
105
|
const grid = createSvGrid<typeof features, Row>({
|
|
82
106
|
_features: features,
|