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.
| Scope | Attach with | Read in the renderer with | Keyed by |
|---|---|---|---|
| Whole column | valueColumns | getValueColumnMeta(colIndex) | column index |
| Whole row | valueRows | getValueRowMeta(rowIndex) | row index |
| Single cell | valueCells | getValueCellMeta(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";
}