Grouped and tree tables
Render hierarchical rows from grouped data or tree data.
The FlattenedDataViewModel normally renders a flat,
non-pivot table. But it also carries one packed metadata byte per row that
encodes the row's nesting depth, whether it is a leaf, and whether it is expanded,
and with that it can render a grouped table that shows a hierarchy. The "flat"
renderer reads the byte to indent each row and to ask a row-facet renderer for its
marker. A flat table is really just the special case of a grouped table where
every row sits at the same depth.
import { FlattenedDataViewModel, createRowMeta } from "grid/dist/renderer";
// createRowMeta(depth, isLeaf, isExpanded) packs the three fields into one byte.
grid.data = new FlattenedDataViewModel({
data: [[3_677_000], [38_100]], // column-major data cells
columnFacets: [["Population", "Mortality"]],
rowFacet: ["Europe", "Berlin"], // the label per row; the hierarchy lives here
rowMeta: new Uint8Array([
createRowMeta(0, false, true), // group row: depth 0, has children, open
createRowMeta(1, true, false), // leaf row: depth 1, no children
]),
options: { facetDefs: { row: [{ text: "Region", trackRenderer }], col: [{ text: "" }], axis: "col" } },
});Expand and collapse are a pure viewmodel refresh. The viewmodel's
expand, collapse, and toggleExpand methods only flip the metadata bit - they
do not add or remove child rows. So a click walks the source hierarchy, re-emits
just the rows that sit under an open branch, and swaps them in with
updateData followed by a draw. Both tables below share one engine that does
exactly this; only the data and the marker glyph differ.
Source
import Grid, { FlattenedDataViewModel, createRowMeta } from "grid/dist/renderer";
import type { FacetCellRenderer, FacetDataContext, FacetRendererContext, FacetCellContent, CellRenderer, RendererContext } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
// A node in the source hierarchy. Leaves carry their own `values` (one per data
// column); group nodes leave `values` undefined and have their columns rolled up
// from descendants. `expanded` seeds the initial open/closed state.
export interface TreeNode {
name: string;
values?: number[];
children?: TreeNode[];
expanded?: boolean;
}
export interface ColumnDef {
label: string;
format: (value: number) => string;
width?: number;
}
export interface TableConfig {
facetLabel: string;
columns: ColumnDef[];
tree: TreeNode[];
marker: "chevron" | "plusminus";
facetWidth?: number;
height?: number;
}
// Builds an interactive grouped/tree table from an in-memory hierarchy. The whole
// widget lives on the DataViewModel -> Renderer bridge: no DataSource, no
// DataModel. Expand and collapse are pure viewmodel refreshes - flatten the tree
// into new params, then `updateData` + `draw`.
export function buildHierarchicalTable(el: HTMLElement, config: TableConfig): () => void {
const gridMount = createGridMount(el, config.height ?? 440);
const grid = new Grid({}, gridMount, "flat");
const disposeTheme = syncGridTheme(grid);
el.append(gridMount);
// Rebuilt on every expand/collapse so a facet click can map its row index back
// to the node it should toggle.
let visibleNodes: TreeNode[] = [];
const options = {
vTrackDefs: config.columns.map((column) => ({
colSize: { strategy: "static" as const, width: column.width ?? 1, unit: "fr" as const },
valueFormatter: (value: unknown) => (typeof value === "number" ? column.format(value) : ""),
renderer: rightAlignValue,
})),
facetDefs: {
row: [{ text: config.facetLabel, facetField: null, colSize: { strategy: "static" as const, width: config.facetWidth ?? 260, unit: "px" as const }, trackRenderer }],
col: [{ text: "" }],
axis: "col" as const,
},
};
const first = flatten(config.tree, config.columns);
visibleNodes = first.nodes;
const viewModel = new FlattenedDataViewModel({ ...first.params, options });
grid.data = viewModel;
grid.draw();
return () => {
disposeTheme();
el.removeChild(gridMount);
};
// A group node renders its marker (which toggles the branch); a leaf renders a
// matching spacer so every label lines up under its parent.
function trackRenderer(data: string, dataCtx: FacetDataContext, _ctx: FacetRendererContext): FacetCellContent {
const meta = dataCtx.flatMeta!;
const label = String(data ?? "");
if (meta.isLeaf) return { left: spacer(), content: label };
const marker = makeMarker(config.marker, meta.isExpanded);
marker.addEventListener("click", (event) => {
event.stopPropagation();
const node = visibleNodes[dataCtx.index];
node.expanded = !node.expanded;
rebuild();
});
return { left: marker, content: label };
}
function rebuild(): void {
const next = flatten(config.tree, config.columns);
visibleNodes = next.nodes;
viewModel.updateData(next.params);
grid.draw();
}
}
/* ===================================== utils ===================================== */
interface FlatResult {
params: {
data: (number | null)[][];
columnFacets: string[][];
rowFacet: (string | null)[];
rowMeta: Uint8Array;
totalRows: number;
};
nodes: TreeNode[];
}
// Depth-first walk that emits one row per node, descending into a group only when
// it is expanded. Each row gets a packed metadata byte (depth, isLeaf, isExpanded)
// and its data columns - a leaf's own values, or the column-wise sum for a group.
function flatten(tree: TreeNode[], columns: ColumnDef[]): FlatResult {
const numCols = columns.length;
const rowFacet: (string | null)[] = [];
const meta: number[] = [];
const data: (number | null)[][] = Array.from({ length: numCols }, () => []);
const nodes: TreeNode[] = [];
const walk = (node: TreeNode, depth: number): void => {
const isLeaf = !node.children || node.children.length === 0;
rowFacet.push(node.name);
meta.push(createRowMeta(depth, isLeaf, Boolean(node.expanded)));
const values = rollup(node, numCols);
for (let i = 0; i < numCols; i++) data[i].push(values[i]);
nodes.push(node);
if (!isLeaf && node.expanded) {
for (const child of node.children!) walk(child, depth + 1);
}
};
for (const root of tree) walk(root, 0);
return {
params: {
data,
columnFacets: [columns.map((column) => column.label)],
rowFacet,
rowMeta: new Uint8Array(meta),
totalRows: rowFacet.length,
},
nodes,
};
}
function rollup(node: TreeNode, numCols: number): number[] {
if (!node.children || node.children.length === 0) return node.values ?? new Array(numCols).fill(0);
const sums = new Array(numCols).fill(0);
for (const child of node.children) {
const childValues = rollup(child, numCols);
for (let i = 0; i < numCols; i++) sums[i] += childValues[i];
}
return sums;
}
// A custom renderer flips the cell to `.custom-rendered`, which centres content
// and zeroes padding - restore right alignment and the theme padding here so the
// already-formatted value sits flush right with tabular figures.
const rightAlignValue: CellRenderer<string> = (data, _dataCtx, ctx: RendererContext) => {
const cell = ctx.container;
cell.style.justifyContent = "flex-end";
cell.style.paddingLeft = "calc(var(--cell-padding-x) * 1px)";
cell.style.paddingRight = "calc(var(--cell-padding-x) * 1px)";
cell.style.paddingTop = "calc(var(--cell-padding-y) * 1px)";
cell.style.paddingBottom = "calc(var(--cell-padding-y) * 1px)";
cell.style.fontVariantNumeric = "tabular-nums";
return data == null ? "" : String(data);
};
function spacer(): HTMLElement {
const el = document.createElement("span");
el.style.cssText = "display:inline-block;width:14px;flex-shrink:0;";
return el;
}
function makeMarker(kind: "chevron" | "plusminus", expanded: boolean): HTMLElement {
const el = document.createElement("span");
el.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:14px;flex-shrink:0;cursor:pointer;opacity:0.7;";
el.innerHTML = kind === "chevron" ? chevronSvg(expanded) : plusMinusSvg(expanded);
return el;
}
function chevronSvg(expanded: boolean): string {
const path = expanded ? "M1 3 L5 7 L9 3" : "M3 1 L7 5 L3 9";
return `<svg width="10" height="10" viewBox="0 0 10 10" style="display:block"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function plusMinusSvg(expanded: boolean): string {
const vertical = expanded ? "" : `<line x1="6" y1="3" x2="6" y2="9" stroke="currentColor" stroke-width="1.4"/>`;
return `<svg width="12" height="12" viewBox="0 0 12 12" style="display:block"><rect x="0.6" y="0.6" width="10.8" height="10.8" rx="2" fill="none" stroke="currentColor" stroke-width="1" opacity="0.6"/><line x1="3" y1="6" x2="9" y2="6" stroke="currentColor" stroke-width="1.4"/>${vertical}</svg>`;
}Grouped table
A grouped table nests aggregates: continent over country over city, with
Population and Mortality measured at each city and rolled up as sums into
the country and continent rows above. Every group row owns a chevron; a leaf
city owns none. Clicking the chevron flips that node's expanded flag and rebuilds
the visible rows.
// The row-facet renderer owns the marker and reads the row's unpacked metadata.
function trackRenderer(label, ctx) {
if (ctx.flatMeta.isLeaf) return { left: spacer(), content: label }; // city: no marker
const chevron = makeChevron(ctx.flatMeta.isExpanded);
chevron.addEventListener("click", () => {
visibleNodes[ctx.index].expanded = !visibleNodes[ctx.index].expanded;
viewModel.updateData(flatten(tree)); // re-emit open branches, then grid.draw()
grid.draw();
});
return { left: chevron, content: label };
}Try it below: click a continent's chevron to reveal its countries, then a country to reveal its cities. The Population and Mortality columns on a group row always equal the sum of everything currently beneath it.
Source
import { buildHierarchicalTable } from "./table";
import { REGIONS } from "./data";
const number = (value: number): string => value.toLocaleString("en-US");
export function mount(el: HTMLElement): () => void {
return buildHierarchicalTable(el, {
facetLabel: "Region",
facetWidth: 260,
marker: "chevron",
columns: [
{ label: "Population", format: number },
{ label: "Mortality", format: number },
],
tree: REGIONS,
});
}Tree table
On the surface a tree table looks much like the grouped one: folders are group
rows that toggle with a +/- box, and files are leaves that show their size and
nothing to click. What differs is the data preparation. Instead of a fixed
continent, country, city shape, the directory nests to an arbitrary depth, so the
tree has to be walked and flattened level by level with no assumption about how
deep it goes. Yet from the viewmodel's perspective nothing changes: the same
packed metadata byte describes every row, createRowMeta packs up to 15
levels, and the renderer indents each by its depth. That is the payoff of the
design. An unbounded hierarchy and a fixed two-level one flow through the exact
same viewmodel contract.
// Same engine, a different marker. The +/- box replaces the chevron.
buildHierarchicalTable(el, {
facetLabel: "Path",
marker: "plusminus",
columns: [{ label: "Size", format: bytes }],
tree: FILES, // folders roll up size; files are leaves carrying [sizeInBytes]
});Try it below: expand src, then components, to walk down the tree. A folder's
Size is the total of every file under it.
Source
import { buildHierarchicalTable } from "./table";
import { FILES } from "./data";
const bytes = (value: number): string => {
if (value >= 1_000_000) return `${(value / 1_048_576).toFixed(1)} MB`;
if (value >= 1_000) return `${(value / 1_024).toFixed(1)} KB`;
return `${value} B`;
};
export function mount(el: HTMLElement): () => void {
return buildHierarchicalTable(el, {
facetLabel: "Path",
facetWidth: 300,
marker: "plusminus",
columns: [{ label: "Size", format: bytes }],
tree: FILES,
});
}Driving it from a DataModel
Both tables keep the whole hierarchy in memory, so the viewmodel is the only layer
they need. When the rows live behind an external datasource, such as an API or an
in-browser WASM database, or there are simply too many to hold at once, a
StandardTableDataModel sits in front of the viewmodel.
It produces the very same FlattenedDataViewModelParams, so the renderer and the
metadata byte never change. What it adds is lazy fetching, expand and collapse over
a query, and a paged cache.
Expand and collapse
expand(groupPath) and collapse(groupPath) take the group's value path, for
example ["Europe", "Germany"], and each returns the refreshed
FlattenedDataViewModelParams to hand straight to viewModel.updateData(...)
before a draw - exactly the shape our in-memory rebuild uses. The difference is
where the child rows come from. Expansion is progressive: each call drills one
level deeper, so expand(["USA"]) reveals states and expand(["USA", "California"])
then reveals cities, fetching that level's rows on first open. collapse only flips
a flag and keeps the children in cache, so re-expanding a collapsed group is a cache
hit with no query.
Pagination
getViewModelData(ir) is the single entry point. The IR carries the requested
startRow and endRow alongside groupBy, sort, filter, and project. The
model does not fetch the whole table: it fetches only the pages that overlap the
requested range and serves the rest from cache. It returns the full contiguous
loaded block with an offsetTop, so the renderer can size the scrollbar against the
virtual row count and position the loaded rows within it. Changing groupBy,
filter, sort, or project resets the cache, since those change which rows exist.
How expand and collapse pack with page management
The cache is the piece that lets the two features coexist. pages is not a flat
list but a tree of PageNodes: each node holds a fixed slice of rows
(config.pageSize), with its data left null until the range is scrolled into
view. Expanding a group row creates a nested subtree of child pages under that row's
expandedRows entry, so the cache mirrors the group hierarchy and every expanded
branch is paged independently. Collapsing hides a subtree but keeps it cached.
When the total fetched page count crosses config.maxNumPageBeforeEviction, the
pages furthest from the current scroll position are evicted first, so a deep, long
session stays bounded in memory.
Class outline
abstract class StandardTableDataModel extends DataModel<StandardDataFetchAndTransformIR, FlattenedDataViewModelParams> {
config: StandardTableConfig;
pages: PageNode[];
topLevelRowCount: number;
columnMetadata: StandardColumnMetadata[];
constructor(schema: DataSchema[], config: Partial<StandardTableConfig>, metadataPlumber?: StandardMetadataPlumber);
abstract getData(ir: StandardDataFetchAndTransformIR, metadataResolver?: StandardMetadataResolver<unknown>): Promise<GetRowsResponse>;
abstract getRangeOfColumn(field: string): Promise<ColumnRangeValues>;
setViewModelOptions(options: GridDataViewModelOptions): void;
setConfig(config: Partial<StandardTableConfig>): void;
getViewModelData(ir: StandardDataFetchAndTransformIR): Promise<FlattenedDataViewModelParams>;
expand(groupPath: string[]): Promise<FlattenedDataViewModelParams>;
collapse(groupPath: string[]): Promise<FlattenedDataViewModelParams>;
protected buildResolverInput(ir: StandardDataFetchAndTransformIR): StandardMetadataResolverInput;
getExpandedPaths(): string[][];
computeTotalLogicalRows(): number;
}
interface StandardTableConfig {
pageSize: number;
maxNumPageBeforeEviction: number;
}
interface PageNode {
data: any[][] | null;
physicalStart: number;
rowCount: number;
expandedRows: Map<number, ExpandedGroup>;
metadata?: PageMetadata;
}
interface ExpandedGroup {
expanded: boolean;
totalRowCount: number;
pages: PageNode[];
}
interface PageMetadata {
rows?: StandardPageRowMetadata[];
cells?: StandardPageCellMetadata[];
}
interface StandardPageRowMetadata {
rowIdx: number;
meta: MetadataValue;
}
interface StandardPageCellMetadata {
rowIdx: number;
colIdx: number;
meta: MetadataValue;
}
interface StandardDataFetchAndTransformIR {
startRow: number;
endRow: number;
groupPath: string[];
groupBy: string[];
project: string[];
sort: SortEntry[];
filter: ScalarFilter[];
metadata?: unknown;
}
interface SortEntry {
field: string;
direction: SortDirection;
by?: string;
}
type SortDirection = "asc" | "desc" | "noop";
interface ScalarFilter {
type: "scalar";
field: string;
subtype?: "date";
op: "eq" | "neq" | "in" | "not_in"
| "gt" | "lt" | "gte" | "lte"
| "between"
| "contains" | "doesNotContain" | "startsWith" | "endsWith"
| "before" | "after"
| "empty" | "notEmpty";
value: string | string[] | number | number[] | null;
}
interface StandardMetadataResolverInput {
ir: StandardDataFetchAndTransformIR;
schema: DataSchema[];
dataSource?: DataSource<any>;
}
interface DataSource {
execute(req: T): Promise<Record<string, any>[]>;
addRef(): void;
release(): Promise<void>;
}