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>;
}

On this page