Column

Column Metadata Visuals

Precompute per-column and per-cell metadata, then read it from a custom renderer to draw heat bars and percentile meters.

A viewmodel can carry metadata alongside its data: arbitrary key-values (Record<string, unknown>) precomputed once, so the cell renderer stays dumb and fast - it reads the metadata for its coordinates and draws, doing no scanning of its own.

Metadata attaches at different scopes, each a different granularity that is supplied and read differently. The one you pick is about what the value means: a statistic over a whole column, a fact about a whole row, or something specific to a single cell.

ScopeAttach withRead in the renderer withKeyed by
Whole columnvalueColumnsgetValueColumnMeta(colIndex)column index
Whole rowvalueRowsgetValueRowMeta(rowIndex)row index
Single cellvalueCellsgetValueCellMeta(colIndex, rowIndex)column + row

Each getter returns undefined where nothing was attached, and the colIndex / rowIndex a renderer receives are absolute data indices - the same space the getters expect. (Column facets and headers carry their own metadata too - columnFacets, rowFacets, headers, read with getColumnFacetMeta / getHeaderMeta - for header-level context.)

grid.data = new FlattenedDataViewModel({
  data,
  columnFacets,
  metadata: {
    valueColumns: [{ colIndex: 1, meta: { min: 0, max: 190000 } }],           // whole column
    valueRows: [{ rowIndex: 0, meta: { flagged: true } }],                    // whole row
    valueCells: [{ colIndex: 1, rowIndex: 0, meta: { percentile: 78 } }],     // one cell
  },
});

const renderer = (value, dataCtx) => {
  const colMeta = dataCtx.viewModel.metadata?.getValueColumnMeta(dataCtx.colIndex);
  const rowMeta = dataCtx.viewModel.metadata?.getValueRowMeta(dataCtx.rowIndex);
  const cellMeta = dataCtx.viewModel.metadata?.getValueCellMeta(dataCtx.colIndex, dataCtx.rowIndex);
  // ...draw from whichever scope applies
};

The two demos below use whole-column metadata (a column's min/max) and per-cell metadata (a value's percentile).

Heat bars from per-column metadata

Each numeric column stores its min and max in valueColumns metadata. The renderer reads that back with getValueColumnMeta, normalizes the cell against its own column's range, and draws a bar whose width is that fraction and whose colour runs cool (low) to warm (high). The normalization lives in metadata, so every column is scaled to itself with no per-column renderer wiring.

metadata: {
  valueColumns: numericColumns.map((colIndex) => ({
    colIndex, meta: { min: colMin(colIndex), max: colMax(colIndex) },
  })),
},

const heatBar = (value, dataCtx, ctx) => {
  const { min, max } = dataCtx.viewModel.metadata.getValueColumnMeta(dataCtx.colIndex);
  const frac = (dataCtx.rawValue - min) / (max - min);   // where in the column's range
  // ...bar width = frac, colour = heat(frac)
};

Try it below: every numeric column is its own mini bar chart. A long, warm bar is near the top of that column's range; a short, cool one is near the bottom - Base Salary and Total OT are scaled independently.

Source

import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { CellRenderer, ValueFormatter } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";

const COLUMNS = [
  { field: "agency_name", label: "Agency", numeric: false },
  { field: "base_salary", label: "Base Salary", numeric: true },
  { field: "regular_gross_paid", label: "Regular Gross", numeric: true },
  { field: "total_ot_paid", label: "Total OT", numeric: true },
  { field: "total_other_pay", label: "Other Pay", numeric: true },
];

const COL_FR = [1.6, 1.1, 1.1, 1.1, 1.1];
const GRID_HEIGHT = 420;

// Match the theme's cell padding so custom-rendered cells (which the grid strips
// padding from) line up with the built-in text cells.
const CELL_PADDING = "calc(var(--cell-padding-y) * 1px) calc(var(--cell-padding-x) * 1px)";

const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const numberFmt: ValueFormatter<SampleValue> = (value) => (typeof value === "number" ? number.format(value) : "");

export function mount(el: HTMLElement, ctx: SampleContext): () => void {
  const gridMount = createGridMount(el, GRID_HEIGHT);
  const grid = new Grid({}, gridMount, "flat");
  const disposeTheme = syncGridTheme(grid);
  let disposed = false;
  el.append(gridMount);

  ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
    if (disposed) return;
    grid.data = new FlattenedDataViewModel({
      data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
      columnFacets: [COLUMNS.map((column) => column.label)],
      totalRows: rows.length,
      // Per-column context computed once: the min and max of each numeric column.
      // The renderer reads it back with getValueColumnMeta to normalize each cell
      // against its own column, so a bar means "where in this column's range".
      metadata: {
        valueColumns: COLUMNS.flatMap((column, colIndex) => {
          if (!column.numeric) return [];
          const nums = rows.map((row) => row[column.field]).filter((v): v is number => typeof v === "number");
          return [{ colIndex, meta: { min: Math.min(...nums), max: Math.max(...nums) } }];
        }),
      },
      options: {
        vTrackDefs: COLUMNS.map((column, i) => ({
          colSize: { strategy: "static", width: COL_FR[i], unit: "fr" },
          ...(column.numeric ? { valueFormatter: numberFmt, renderer: heatBar } : {}),
        })),
        facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
      },
    });
    grid.draw();
  });

  return () => {
    disposed = true;
    disposeTheme();
    el.removeChild(gridMount);
  };
}

/* ===================================== utils ===================================== */

// Draws each numeric cell as a heat bar. The fraction along the column's range
// comes from the per-column metadata (min/max), not the cell alone: the bar width
// is that fraction and its colour runs cool (low) to warm (high). Container styles
// are set property-by-property, never via `cssText`, which would wipe the cell's
// grid-area (see QUIRKS.md).
const heatBar: CellRenderer<SampleValue> = (value, dataCtx, ctx) => {
  const col = dataCtx.viewModel.metadata?.getValueColumnMeta(dataCtx.colIndex);
  const min = Number(col?.min);
  const max = Number(col?.max);
  const raw = typeof dataCtx.rawValue === "number" ? dataCtx.rawValue : null;
  const frac = raw == null || !(max > min) ? 0 : (raw - min) / (max - min);

  const style = ctx.container.style;
  style.display = "flex";
  style.alignItems = "center";
  style.gap = "8px";
  style.overflow = "hidden";
  style.padding = CELL_PADDING;
  style.background = `color-mix(in srgb, ${heatColor(frac)} 12%, transparent)`;

  const track = document.createElement("div");
  track.style.cssText =
    "flex:1;height:8px;border-radius:4px;overflow:hidden;" +
    "background:color-mix(in srgb, currentColor 12%, transparent);";
  const fill = document.createElement("div");
  fill.style.cssText = `height:100%;width:${Math.round(frac * 100)}%;background:${heatColor(frac)};`;
  track.appendChild(fill);

  const label = document.createElement("span");
  label.style.cssText = "min-width:56px;text-align:right;font-variant-numeric:tabular-nums;font-size:12px;";
  label.textContent = raw == null ? "" : String(value);

  return [track, label];
};

// Cool (blue) at the bottom of a column's range to warm (red) at the top.
function heatColor(frac: number): string {
  const f = Math.max(0, Math.min(1, frac));
  return `hsl(${210 - f * 198}, 75%, 55%)`;
}

Percentile meters from per-cell metadata

Ranking a value against the whole column is a cross-row calculation, so it is precomputed once into valueCells metadata - one percentile per numeric cell. The renderer reads it with getValueCellMeta and paints a five-segment meter plus a Pxx tag, coloured by band. The renderer never sorts or scans; the rank is already there.

metadata: {
  valueCells: rankedCells.map(({ colIndex, rowIndex, percentile }) => ({
    colIndex, rowIndex, meta: { percentile },
  })),
},

const meter = (value, dataCtx) => {
  const { percentile } = dataCtx.viewModel.metadata.getValueCellMeta(dataCtx.colIndex, dataCtx.rowIndex);
  // ...fill Math.round(percentile / 100 * 5) segments, colour by band
};

Try it below: each numeric cell shows its value, a meter, and its percentile within the column - P99 fills all five segments in green, a low percentile fills one in slate.

Source

import Grid, { FlattenedDataViewModel } from "grid/dist/renderer";
import type { CellRenderer, ValueFormatter, ValueCellMetadata } from "grid/dist/renderer";
import { createGridMount } from "../../runtime/mount";
import { syncGridTheme } from "../../runtime/theme";
import type { SampleContext, SampleRow, SampleValue } from "../../types";

const COLUMNS = [
  { field: "agency_name", label: "Agency", numeric: false },
  { field: "base_salary", label: "Base Salary", numeric: true },
  { field: "regular_gross_paid", label: "Regular Gross", numeric: true },
  { field: "total_ot_paid", label: "Total OT", numeric: true },
  { field: "total_other_pay", label: "Other Pay", numeric: true },
];

const COL_FR = [1.5, 1.15, 1.15, 1.15, 1.15];
const GRID_HEIGHT = 420;

const CELL_PADDING = "calc(var(--cell-padding-y) * 1px) calc(var(--cell-padding-x) * 1px)";

const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const numberFmt: ValueFormatter<SampleValue> = (value) => (typeof value === "number" ? number.format(value) : "");

export function mount(el: HTMLElement, ctx: SampleContext): () => void {
  const gridMount = createGridMount(el, GRID_HEIGHT);
  const grid = new Grid({}, gridMount, "flat");
  const disposeTheme = syncGridTheme(grid);
  let disposed = false;
  el.append(gridMount);

  ctx.loadDataset("payroll").then((rows: SampleRow[]) => {
    if (disposed) return;
    grid.data = new FlattenedDataViewModel({
      data: COLUMNS.map((column) => rows.map((row) => row[column.field] ?? null)),
      columnFacets: [COLUMNS.map((column) => column.label)],
      totalRows: rows.length,
      // Per-cell context computed once: each numeric value's percentile rank
      // within its column. Ranking is a cross-row calculation, so it is precomputed
      // into metadata; the renderer just reads getValueCellMeta and draws a meter.
      metadata: { valueCells: percentiles(rows) },
      options: {
        vTrackDefs: COLUMNS.map((column, i) => ({
          colSize: { strategy: "static", width: COL_FR[i], unit: "fr" },
          ...(column.numeric ? { valueFormatter: numberFmt, renderer: meter } : {}),
        })),
        facetDefs: { row: [], col: [{ text: "" }], axis: "col" },
      },
    });
    grid.draw();
  });

  return () => {
    disposed = true;
    disposeTheme();
    el.removeChild(gridMount);
  };
}

/* ===================================== utils ===================================== */

// Rank every numeric cell within its column and store the 0-100 percentile per
// cell. Sorting ascending, the i-th value sits at i/(n-1) of the way up.
function percentiles(rows: SampleRow[]): ValueCellMetadata[] {
  const out: ValueCellMetadata[] = [];
  COLUMNS.forEach((column, colIndex) => {
    if (!column.numeric) return;
    const entries = rows
      .map((row, rowIndex) => ({ rowIndex, value: row[column.field] }))
      .filter((e): e is { rowIndex: number; value: number } => typeof e.value === "number")
      .sort((a, b) => a.value - b.value);
    entries.forEach((entry, i) => {
      const percentile = entries.length <= 1 ? 100 : Math.round((i / (entries.length - 1)) * 100);
      out.push({ colIndex, rowIndex: entry.rowIndex, meta: { percentile } });
    });
  });
  return out;
}

// Draws each numeric cell as the formatted value, a five-segment meter, and a
// `Pxx` tag. The fill count and colour come from the precomputed percentile in
// the cell's metadata. Container styles are set property-by-property, never via
// `cssText`, which would wipe the cell's grid-area (see QUIRKS.md).
const meter: CellRenderer<SampleValue> = (value, dataCtx, ctx) => {
  const cellMeta = dataCtx.viewModel.metadata?.getValueCellMeta(dataCtx.colIndex, dataCtx.rowIndex);
  const percentile = typeof cellMeta?.percentile === "number" ? cellMeta.percentile : null;

  const style = ctx.container.style;
  style.display = "flex";
  style.alignItems = "center";
  style.gap = "8px";
  style.overflow = "hidden";
  style.padding = CELL_PADDING;

  if (percentile == null) return "";

  const label = document.createElement("span");
  label.style.cssText = "margin-right:auto;font-variant-numeric:tabular-nums;font-size:12px;";
  label.textContent = String(value);

  const tag = document.createElement("span");
  tag.textContent = `P${percentile}`;
  tag.style.cssText = `font-size:11px;font-weight:600;min-width:26px;text-align:right;color:${bandColor(percentile)};`;

  return [label, buildMeter(percentile), tag];
};

function buildMeter(percentile: number): HTMLElement {
  const wrap = document.createElement("div");
  wrap.style.cssText = "display:flex;gap:2px;align-items:center;";
  const filled = Math.round((percentile / 100) * 5);
  const color = bandColor(percentile);
  for (let i = 0; i < 5; i++) {
    const seg = document.createElement("div");
    const on = i < filled;
    seg.style.cssText =
      "width:5px;height:12px;border-radius:1px;" +
      `background:${on ? color : "color-mix(in srgb, currentColor 15%, transparent)"};`;
    wrap.appendChild(seg);
  }
  return wrap;
}

// Muted below the 40th percentile, amber through the 80th, green above it.
function bandColor(percentile: number): string {
  if (percentile >= 80) return "#16a34a";
  if (percentile >= 40) return "#d97706";
  return "#64748b";
}

On this page