FlattenedDataViewModel
ViewModel for flat tables with row grouping, packed row metadata, expand/collapse, and pagination.
FlattenedDataViewModel extends GridDataViewModel. It adds a single-level row facet for group labels and packed per-row metadata for hierarchical grouping with expand/collapse. Constructed from FlattenedDataViewModelParams.
What it adds and why
Unlike pivot grids (where all data is fetched at once), flat tables can have millions of rows loaded via pagination, with hierarchical grouping where rows have different depths and expand/collapse states. The viewmodel needs to track this per-row metadata efficiently because it sits in the render hot path - every scroll frame reads it.
On top of the base class, FlattenedDataViewModel provides:
- Row facet - a single-level array of group labels
- Row metadata - packed
Uint8Arrayencoding depth, isLeaf, and isExpanded per row - Expand/collapse methods - flip expand state in the metadata
getSelectPath- reconstructs the full group path for a row index
Row facet and row metadata
Unlike PivotDataViewModel which has multi-level row facets (one level per dimension), FlattenedDataViewModel has a single-level row facet. All grouping depth information is encoded in the row metadata instead.
The row facet array (rowFacet) has one entry per row. Group rows always have a string label (the group field's value at that depth). When the deepest group-by level is the leaf level, there are no null entries - every row is a group row with a label. When there are data rows below the deepest group level (individual rows that aren't group headers), those data rows have null in the rowFacet.
The row metadata array (rowMeta) is a packed Uint8Array where each byte encodes the row's depth, isLeaf, and isExpanded state.
Case 1: Deepest group level is the leaf level (no null entries)
A table grouped by ["continent", "country"] where Europe is expanded:
rowFacet: ["Europe", "Germany", "UK", "North America"]
rowMeta: [
createRowMeta(0, false, true), // Europe depth=0, group, expanded
createRowMeta(1, true, false), // Germany depth=1, leaf
createRowMeta(1, true, false), // UK depth=1, leaf
createRowMeta(0, false, false), // North America depth=0, group, collapsed
]Every row has a string label in rowFacet. Germany and UK are leaf rows (isLeaf: true) at the deepest group level - no null entries needed.
Case 2: Data rows below the deepest group level (null entries)
A table grouped by ["continent"] where Europe is expanded to show individual data rows:
rowFacet: ["Europe", "Germany", null, null, "UK", null, "North America"]
rowMeta: [
createRowMeta(0, false, true), // Europe depth=0, group, expanded
createRowMeta(1, false, false), // Germany depth=1, group, collapsed
createRowMeta(2, true, false), // (data row) depth=2, leaf
createRowMeta(2, true, false), // (data row) depth=2, leaf
createRowMeta(1, false, false), // UK depth=1, group, collapsed
createRowMeta(2, true, false), // (data row) depth=2, leaf
createRowMeta(0, false, false), // North America depth=0, group, collapsed
]The data rows (Laptop, Phone) have null in rowFacet - they are individual records below the deepest group level. Their data appears in the data columns (Product, Revenue, Cost) instead of the group column.
Packed Uint8Array
Each row's metadata is packed into a single byte instead of an object. This avoids creating millions of small objects on every update, which would cause GC pressure and slow down rendering.
Bit layout:
- Bits 7-4: depth (up to 15 nesting levels)
- Bit 3: isLeaf (1 = leaf/data row, 0 = group row)
- Bit 2: isExpanded (1 = expanded, 0 = collapsed)
import { createRowMeta } from "grid";
createRowMeta(0, false, true); // depth=0, group, expanded → 0b0000_0100 = 4
createRowMeta(1, true, false); // depth=1, leaf → 0b0001_1000 = 24
createRowMeta(2, false, false); // depth=2, group, collapsed → 0b0010_0000 = 32getSlice unpacks these bytes into FlatRowMeta objects for the visible slice only - the slice is small (only the visible rows), so unpacking is cheap.
expand / collapse / toggleExpand
expand(rowIndex: number): void
collapse(rowIndex: number): void
toggleExpand(rowIndex: number): voidThese flip the expand bit in the metadata byte at the given row index. They are viewmodel-level state changes only - they update what the renderer sees (the ▼/▶ icon direction), but they do not add or remove rows. The datamodel handles the actual data fetching separately.
For example, calling toggleExpand(6) on the EU row (collapsed, isExpanded=false) flips it to isExpanded=true. The renderer immediately shows ▼ instead of ▶. But the children rows are not yet in the viewmodel - those arrive when the datamodel fetches them.
Typical flow:
- User clicks a group row (e.g. EU at row index 6)
- Renderer calls
viewModel.toggleExpand(6)- icon flips to ▼ immediately - Application calls
dataModel.expand(["EU"])- triggers data fetch - DataModel returns new
FlattenedDataViewModelParamswith EU's children included viewModel.updateData(newParams)andgrid.data = viewModel- renderer shows the children
getSelectPath
getSelectPath(rowIndex: number): string[]Walks backward through row metadata to reconstruct the full group path from a row index. Returns an array of group values from the root to the target row.
// If rows are: USA (depth 0) → California (depth 1) → row at index 5
viewModel.getSelectPath(5);
// ["USA", "California"]Used when the renderer needs to tell the datamodel which group was clicked (e.g. for expand/collapse or drill-down).
getSlice
getSlice returns a FlatSliceResult which extends BaseSliceResult with:
rowFacets: (string | null)[]- group label per row, ornullfor data rowsrowMeta: FlatRowMeta[]- unpacked metadata for the visible slice
const slice = viewModel.getSlice(0, 0, 3, 4);
slice.rowFacets;
// ["USA", null, null, "Canada"]
// Index 0: group row "USA"
// Index 1-2: data/leaf rows (null facet)
// Index 3: group row "Canada"
slice.rowMeta;
// [
// { depth: 0, isLeaf: false, isExpanded: true }, // USA - expanded group
// { depth: 1, isLeaf: true, isExpanded: false }, // leaf row under USA
// { depth: 1, isLeaf: true, isExpanded: false }, // leaf row under USA
// { depth: 0, isLeaf: false, isExpanded: false }, // Canada - collapsed group
// ]updateData
updateData(params: FlattenedDataViewModelParams): voidSame pattern as PivotDataViewModel - swaps internal data arrays and pagination state without constructing a new instance. metaState is preserved.
Pagination: totalRows and offsetTop
The data source (in the server) may have 1,000,000 rows but only 10,000 are loaded in data[][]. The viewmodel uses two fields to position the loaded block within the full scroll space:
totalRows- total rows in the full dataset. The renderer uses this for scrollbar height (totalRows * rowHeight).offsetTop- number of rows before the loaded block. The renderer addsoffsetTop * rowHeightas top padding so the loaded rows sit at the correct scroll position.
Example with pageSize 10,000:
// Page 1 (first load) - user is at the top
{ totalRows: 1_000_000, offsetTop: 0, data: /* 10,000 rows */ }
// Page 5 - user scrolled down
{ totalRows: 1_000_000, offsetTop: 40_000, data: /* 10,000 rows */ }
// All data fits in one page - no pagination
{ totalRows: 500, offsetTop: 0, data: /* 500 rows */ }
// (totalRows === numRows and offsetTop === 0)See StandardTableDataModel for how pages are managed and fetched.
API Reference.
Extends GridDataViewModel.
ViewModel for flat/standard tables. Adds a single-level row facet for group labels and packed per-row metadata (Uint8Array) for hierarchical grouping with expand/collapse. Supports pagination via totalRows and offsetTop.
Prop
Type
expandfunctionSets the expand bit in the metadata byte. Viewmodel-level only - does not fetch children.
Type
(rowIndex: number) => void
Parameters
rowIndex -
The row index to expand.
collapsefunctionClears the expand bit in the metadata byte. Viewmodel-level only - does not remove children.
Type
(rowIndex: number) => void
Parameters
rowIndex -
The row index to collapse.
toggleExpandfunctionFlips the expand bit in the metadata byte. Viewmodel-level only - does not fetch or remove children.
Type
(rowIndex: number) => void
Parameters
rowIndex -
The row index to toggle.
updateDatafunctionSwaps internal data arrays, row facet, row metadata, and pagination state without constructing a new instance. metaState is preserved.
Type
(params: FlattenedDataViewModelParams) => void
Parameters
params -
getSelectPathfunctionWalks backward through row metadata to reconstruct the full group path from root to the given row. Used to identify which group was clicked (e.g. for expand/collapse).
Type
(rowIndex: number) => string[]
Parameters
rowIndex -
The row index to build the path for.
Returns
Array of group values from root to the target row, e.g. ["Europe", "Germany"].
numRowFacetLevelsnumberAlways 1 when rowFacet is provided, 0 otherwise. Unlike pivot viewmodels, flat tables have at most one row facet level - depth is encoded in the row metadata instead.
Type
number
rowFacetsFacetDataRow facet wrapped in level-major format: [rowFacet] (single level). Returns [] when no rowFacet was provided.
Type
FacetData
getSlicefunctionReturns a FlatSliceResult for the given range. Extends the base slice with single-level rowFacets and unpacked rowMeta (FlatRowMeta objects) for the visible rows.
Type
(x0: number, y0: number, x1: number, y1: number) => FlatSliceResult
Parameters
x0 -
Start column index (inclusive).
y0 -
Start row index (inclusive).
x1 -
End column index (exclusive).
y1 -
End row index (exclusive).
Returns
Slice data with rowFacets and rowMeta for the visible rows.
PivotDataViewModel
ViewModel for pivot grids with multi-level row and column facets and aggregated data cells.
Grid & Layout
The Grid class is the public entry point for rendering. It receives a GridDataViewModel and renders the grid. It wraps a layout engine, a cell pool, and a selection rule store. The layout handles viewport calculation, cell placement, and DOM management.