Examples
One grid, drawn twice from the same frame — once as DOM, once as canvas.
Both panes below share a single createGrid instance over 10,000 resources
and two weeks at 15-minute resolution — 10,000 × 1,345 cells, 3,000 items.
Neither renderer knows about the other; they only read the frame the engine
hands them, which is why scrolling one moves the other.
That is the whole point of a headless engine: the frame is plain numbers, so two renderers with nothing in common can draw the same state.
10,001 × 1,345 cellsrows in frame 1columns in frame 1items in frame 0DOM nodes 0scroll 672, 0canvas hover –
DOM
Absolutely positioned inside a box the size of frame.total, scrolled
natively by the browser. Pinned rows and columns are the one exception: they
are translated by the current scroll so they stay put.
Only what the frame lists is in the document. At this size that is a few hundred nodes against 13.45 million cells, and the count above changes as you scroll.
Canvas
The same frame drawn with CanvasRenderingContext2D. There is no native
scrolling here: wheel events move grid.setScroll, which the pane above
follows through its binder — scroll either one and both track.
A canvas has no scroll container, so it subtracts frame.scroll once and
draws everything in screen space. Pinned entries are the exception in the
other direction: on the pinned axis their start is already a screen
coordinate, so it is used unchanged.
function draw(ctx, frame, viewport, colors) {
const { scroll } = frame;
ctx.clearRect(0, 0, viewport.width, viewport.height);
const bodyRows = frame.rows.filter((r) => !r.pinned);
const bodyColumns = frame.columns.filter((c) => !c.pinned);
const pinnedRows = frame.rows.filter((r) => r.pinned);
const pinnedColumns = frame.columns.filter((c) => c.pinned);
// Gridlines. Half-pixel centres keep a 1px line off the seam between two
// device pixels.
ctx.strokeStyle = colors.line;
for (const row of bodyRows) {
const y = Math.round(row.start + row.size - scroll.y) + 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(viewport.width, y);
ctx.stroke();
}
for (const col of bodyColumns) {
const x = Math.round(col.start - scroll.x) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, viewport.height);
ctx.stroke();
}
// Items. `x` and `y` are content space and already account for lanes.
for (const it of frame.items) {
ctx.beginPath();
ctx.roundRect(it.x - scroll.x, it.y - scroll.y, it.width, it.height, 4);
ctx.fill();
}
// Pinned label column and header row: `start` is already screen space on
// the pinned axis, only the other axis is scrolled.
for (const col of pinnedColumns) {
for (const row of bodyRows) {
ctx.fillRect(col.start, row.start - scroll.y, col.size, row.size);
}
}
for (const row of pinnedRows) {
for (const col of bodyColumns) {
ctx.fillRect(col.start - scroll.x, row.start, col.size, row.size);
}
}
}Wheel events are the only input the canvas needs, and hit-testing is one
call — viewportPoint converts a pointer event into the space cellAt
expects:
const onWheel = (e: WheelEvent) => {
const max = grid.maxScroll();
const { scroll } = grid.getState();
grid.setScroll({
x: Math.min(Math.max(scroll.x + e.deltaX, 0), max.x),
y: Math.min(Math.max(scroll.y + e.deltaY, 0), max.y),
});
};
const onPointerMove = (e: PointerEvent) => {
const hit = grid.cellAt(viewportPoint(canvas, e));
// { row, column } or null outside the content
};The full source of both renderers, the shared data and the theme-token
palette is in
apps/docs/components/examples.