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.

Grid is the entry point for rendering a grid. You create a Grid instance, set a viewmodel on it, and call draw(). Internally, the Grid delegates all rendering to a layout engine - either StandardLayout (for pivot grids) or GroupedRowLayout (for flat/grouped tables). You never interact with the layout directly.

Constructor

const grid = new Grid(config, mountPoint, layoutType);
  • config - a partial GridConfig object. Missing fields use defaults.
  • mountPoint - the DOM element the grid renders into. The grid attaches a shadow DOM to this element for style isolation.
  • layoutType - "pivot" (default) uses StandardLayout for multi-level row/column facets with merge/span. "flat" uses GroupedRowLayout for single-level row facets with depth-based indentation.
const mountEl = document.getElementById("grid-root")!;

// Pivot grid (default)
const pivotGrid = new Grid({ overscan: 3 }, mountEl);

// Flat/grouped table
const flatGrid = new Grid({}, mountEl, "flat");

Setting data

Set a viewmodel on the grid via the data setter. This tells the layout what to render. After setting data, call draw() to trigger the first render.

const viewModel = new PivotDataViewModel(params);
grid.data = viewModel;
grid.draw();

To update data, update the viewmodel and reassign it:

viewModel.updateData(newParams);
grid.data = viewModel;
grid.draw();

Render cycle

grid.draw() triggers a full render cycle. Internally, the layout:

  1. Calculates the viewport - determines which rows and columns are visible based on scroll position, row heights, and column widths. This produces a viewport range (x0, y0, x1, y1) plus overscan.
  2. Fetches the data slice - calls viewModel.getViewportData(x0, y0, x1, y1) to get only the data needed for the visible range.
  3. Renders cells - places column facets (headers), row facets, data cells, fixture cells, and selection overlays into the DOM.
  4. Auto-sizes columns - measures rendered cells and adjusts column widths based on content. The sizing strategy ("max-cell", "clamped-width", or "static") comes from the viewmodel's VTrackDef and FacetDef definitions.
  5. Emits renderComplete - fires with the viewport bounds { x0, y0, x1, y1 } so consumers know what was rendered.

scheduleDraw() is a debounced variant - it batches multiple draw requests into a single requestAnimationFrame callback.

Cell pooling

The layout reuses DOM elements via a cell manager to avoid DOM churn during scrolling. Each cell is identified by a string key (e.g. "row-h-0-42" for a row facet cell at row 42). On each render frame:

  1. beginFrame() - marks the start of a frame; clears the used-keys set.
  2. acquire(key) - returns an existing cell for that key, or pops one from the pool (or creates a new <div> if the pool is empty). Returns [cell, needsAppend] - needsAppend is true when the cell is new to this frame and must be appended to the DOM.
  3. endFrame() - any cell whose key was not used this frame is released: stripped of styles/classes/content, returned to the pool, and removed from the DOM.

This means scrolling by a few rows only creates cells for the newly visible rows while recycling cells that scrolled out of view.

Scroll handling

The layout listens for wheel events on the mount element and translates scroll position into viewport row/column indices. Scrolling is axis-locked per gesture - horizontal or vertical, not both - to prevent diagonal drift.

Each scroll event schedules a render via requestAnimationFrame, so scroll-triggered renders are throttled to the display refresh rate.

To programmatically scroll:

grid.scrollTo("row", 500);    // scroll to row 500
grid.scrollTo("column", 10);  // scroll to column 10

scrollTo("column", n) retries up to 5 times (waiting for renderComplete between attempts) to handle cases where column auto-sizing changes the scroll geometry.

Two layout types

Both layouts share column facet rendering and corner cell rendering. They differ only in how row facets are drawn.

Column facet merging (common)

Column facets (the header rows above the data area) are merged the same way in both layouts. The merge algorithm operates on two axes:

  • Primary axis (horizontal): consecutive columns at the same facet level with the same value merge into a single cell with colspan. For example, if columns 0-3 all have "Q1" at level 0, they produce one cell spanning 4 columns.
  • Secondary axis (vertical): when a facet value at a deeper level is null, the cell at the shallower level expands downward with rowspan. This happens when a higher-level grouping has no sub-grouping at that position.
Q1Q2
RevCostRevCost
ActBudActBudActBudActBud

Level 0: Q1 and Q2 each span 4 columns (colspan=4). Level 1: Rev and Cost each span 2 columns. Level 2: leaf cells, one per column.

When a level has null values (no sub-grouping), the parent spans vertically:

TotalQ1Q2
RevCost

Total and Q2 have null at deeper levels, so they span all 3 rows (rowspan=3). Rev and Cost are leaf cells at level 1.

Non-leaf column facet cells (those with colspan > 1) get horizontal sticky scrolling - their label stays visible as you scroll within the spanned range.

Corner cells (common)

Corner cells sit at the intersection of row facet columns and column facet rows - the top-left rectangle of the grid. Both layouts render them identically.

The corner area has numColFacetLevels rows and numRowFacetLevels columns. What goes in each cell depends on the viewmodel's facetDefs.axis:

When axis is "col" (column-oriented facets):

  • All rows except the last: the column facet header (e.g. "Quarter") spans horizontally across all row facet columns (colspan = numRowFacetLevels). Only the first column in each row renders; the rest are merged.
  • Last row: each column shows a row facet header (e.g. "Region", "City") - one per row facet level.
Quarter
RegionCity

2 col facet levels, 2 row facet levels. "Quarter" spans both row facet columns (colspan=2). The last row shows row facet headers.

When axis is "row" (row-oriented facets):

  • First row: each column shows a row facet header that spans vertically across all column facet rows (rowspan = numColFacetLevels).
  • All other rows: merged into the first row's span; not rendered.

Corner cells are sticky in both directions (they stay visible when scrolling horizontally or vertically) and are used for column width measurement of the row facet tracks.

StandardLayout - row facet merging (pivot)

Used when layoutType is "pivot" (default). Row facets use the same merge algorithm as column facets, but rotated - the primary merge axis is vertical (rows) and the secondary axis is horizontal (columns):

  • Primary axis (vertical): consecutive rows with the same facet path at a given level merge into a single cell with rowspan.
  • Secondary axis (horizontal): when a deeper facet level has null, the shallower level's cell spans horizontally with colspan.

The input data for this example (3 facet levels, 8 rows):

// rowFacets[rowIndex] = [level0, level1, level2]
const rowFacets = [
  ["USA",     null,  null  ],  // row 0: null at level 1,2 → spans 3 columns
  ["USA",     "CA",  "SF"  ],  // row 1
  ["USA",     "CA",  "LA"  ],  // row 2
  ["USA",     "NY",  null  ],  // row 3: null at level 2 → spans 2 columns
  ["USA",     "NY",  "NYC" ],  // row 4
  ["Germany", null,  null  ],  // row 5: spans 3 columns
  ["France",  null,  null  ],  // row 6: spans 3 columns
  ["France",  "IDF", "Paris"], // row 7
];

Rendered as:

level 0level 1level 2
USA
USACASF
LA
NY
NYNYC
Germany
France
FranceIDFParis

Row 0: "USA" spans 3 columns (colspan=3) because level 1 and 2 are null. Rows 1-4: "USA" at level 0 merges vertically (rowspan=4). "CA" merges rows 1-2 (rowspan=2). Row 3: "NY" spans 2 columns. Rows 5-6: single-row horizontal spans.

Non-leaf row facet cells (those with rowspan > 1) get sticky label positioning - the label stays vertically centered within the visible portion of the cell as you scroll. When a merged cell partially scrolls out of the viewport, the layout computes a translateY offset to keep the label centered in the visible area, clamped so it never leaves the cell bounds.

GroupedRowLayout - indented row facets (flat/grouped)

Used when layoutType is "flat". Extends StandardLayout and only overrides renderRowFacets(). Instead of multi-level columns with merge/span, it renders a single row facet column. Each row gets one cell; the horizontal position is indented by depth * 16px where depth comes from FlatRowMeta.

▼ USA
▼ California
SF
LA
▶ Texas

Single column. Depth 0: no indent. Depth 1: 16px indent. Depth 2: 32px indent. Group rows (non-leaf) show expand/collapse icons.

Each row facet cell's left CSS property is set to the base position plus depth * 16px. A box-shadow fills the gap between the indent and the grid edge so there is no white space. The --depth CSS variable is set on each cell for custom styling.

The FacetDef.trackRenderer for the row facet controls what each cell looks like - expand/collapse icons, text labels, and interactive behavior are all handled by the renderer.

viewDataEmpty event

When the user scrolls beyond the loaded data range, the layout emits a viewDataEmpty event with { startRow, endRow }. This is the pagination trigger - the consumer listens for it and fetches the page covering the requested range.

The event is debounced by GridConfig.dataFetchDebounceMs (default: 150ms) to avoid flooding the data source during fast scrolling.

grid.on("viewDataEmpty", ({ startRow, endRow }) => {
  // Fetch data for rows startRow..endRow from the data source
  dataModel.getRows({ startRow, endRow }).then((result) => {
    viewModel.updateData(result);
    grid.data = viewModel;
    grid.draw();
  });
});

See Events for all events.

GridConfig

GridConfig controls layout behavior. Pass a partial config to the constructor; missing fields use defaults.

const grid = new Grid(
  {
    overscan: 3,
    defaultCellHeight: 24,
    dataFetchDebounceMs: 200,
    enableResizeUI: true,
    theme: "dark",
  },
  mountEl,
);

Configuration for the grid renderer. Pass a partial GridConfig to the Grid constructor; missing fields use defaultConfig.

Prop

Type

defaultCellHeightnumber

Height in pixels used for data rows when no VTrackDef.cellHeight override is set. Default: 19.

Type

number

defaultCellWidthnumber

Width in pixels used as the initial column width before auto-sizing measures actual content. Default: 50.

Type

number

overscannumber

Number of extra rows and columns rendered beyond the visible viewport in each direction. Higher values reduce blank flashes during fast scrolling but increase DOM node count. Default: 2.

Type

number

enableResizeUIunion

When true, column facet cells render a drag handle on the right edge for interactive column resizing. Double-click the handle to auto-fit to content width. Default: true.

Type

boolean

fixturesobject

Fixture classes to instantiate on each edge of the grid. The layout creates one instance per class and positions them in array order. See Fixtures.

Type

LayoutFixtureClasses

themestring

Name of the theme to apply. The layout resolves the theme via getTheme(name) and sets CSS custom properties on the grid container. Default: "light".

Type

string

columnAutosizingStrategyOnScrollunion

Controls how column widths update on scroll. "max-seen" keeps the widest measurement seen so far (columns only grow). "dynamic" re-measures every frame (columns can shrink when wide content scrolls out). Default: "max-seen".

Type

"dynamic" | "max-seen"

dataFetchDebounceMsnumber

Debounce interval in milliseconds for the viewDataEmpty event. Prevents flooding the data source with fetch requests during fast scrolling. Default: 150.

Type

number

Default values:

{
  defaultCellHeight: 19,
  defaultCellWidth: 50,
  overscan: 2,
  enableResizeUI: true,
  theme: "light",
  columnAutosizingStrategyOnScroll: "max-seen",
  dataFetchDebounceMs: 150,
  fixtures: { top: [], left: [], bottom: [], right: [] },
}

Column resize

When enableResizeUI is true (default), column facet cells render a drag handle on the right edge. Dragging the handle resizes the column(s) live:

  • Leaf-level facets (the bottom header row) resize a single data column.
  • Higher-level facets resize all leaf columns they span. The drag delta is divided equally across the spanned columns.
  • Row facet headers resize the row facet columns.
  • Double-click a resize handle to auto-fit the column to its content width.

API Reference.

Prop

Type

dataunion

Sets the viewmodel that provides data for rendering. After setting, call draw() to trigger the first render. Reassign after updateData() to push new data into the grid. Returns the current viewmodel, or undefined if none has been set.

Type

GridDataViewModel | undefined

drawfunction

Triggers a synchronous render cycle: calculates the viewport, fetches the data slice, renders cells, auto-sizes columns, and emits renderComplete. Throws if data has not been set.

Type

() => void

scheduleDrawfunction

Schedules a draw on the next animation frame. Multiple calls before the frame fires are coalesced into a single render.

Type

() => void

scrollTofunction

Programmatically scrolls to a row or column by its absolute index. For columns, retries up to 5 times to handle auto-sizing geometry changes. Throws if data has not been set.

Type

(axis: "row" | "column", absoluteIndex: number) => void

selectAllfunction

Creates a Selection builder that targets facet cells matching the predicate. Chain .selectAll() to add more predicates, then .style() or .prop() to apply effects.

Type

(predicate: FacetPredicate) => Selection

selectCellByDataIndexfunction

Selects a single cell by its data row and column index. Returns [hash, unsub] where hash identifies the selection and unsub() removes it. Returns null if the cell is already selected. Emits selectionAdded; calling unsub() emits selectionRemoved.

Type

(row: number, col: number) => SelectionResult

selectColumnByDataIndexfunction

Selects an entire column by its data index (all rows from 0 to Infinity). Column selections accumulate; adding a non-column selection clears them. Returns [hash, unsub] or null if already selected.

Type

(colIndex: number) => SelectionResult

selectRowByDataIndexfunction

Selects an entire row by its data index (all columns from 0 to Infinity). Row selections accumulate; adding a non-row selection clears them. Returns [hash, unsub] or null if already selected.

Type

(rowIndex: number) => SelectionResult

selectRangeByDataIndexfunction

Selects a rectangular range of cells. Coordinates are normalized (min/max) internally. A range selection clears all previous selections. Returns [hash, unsub] or null if already selected.

Type

(fromRow: number, fromCol: number, toRow: number, toCol: number) => SelectionResult

clearAllSelectionsfunction

Removes all active cell/row/column/range selections and triggers a re-render.

Type

() => void

On this page