← Files Build Web Data VisualizationARCHIVED FILE
references/rendering-architecture.md
7.28 KB · Sep 30, 2026 · 22:48 UTC
# Rendering Architecture
## What Problem This Solves
This reference covers how to structure a Canvas-based visualization so it stays maintainable and interactive.
## When to Use It
Use this when designing a Canvas chart from scratch or migrating from SVG to Canvas.
## Key Takeaways
- Keep scene state separate from drawing code.
- Use multiple layers for static marks, active marks, and interaction overlays.
- Share scales and transforms across Canvas and any DOM or SVG companion layers.
- Size Canvas in two coordinate systems: CSS pixels for layout and backing-store pixels for raster sharpness.
- Use retained geometry even though Canvas itself is immediate-mode.
- Prefer HTML overlays for native controls, rich labels, menus, keyboard focus, and accessible text.
- Redraw from state after every resize, DPR change, zoom transform, or data mutation.
- Treat Canvas accessibility as a companion DOM problem, not as an afterthought.
## Architecture Pattern
Use a retained scene model:
- data records and stable ids
- scales and world-to-screen transforms
- draw commands or mark descriptors
- optional cached `Path2D` objects for moderate-size geometry
- spatial index, picking buffer, or analytic hit-test data
- invalidation state for static, marks, hover, and overlay layers
Canvas should be the renderer, not the source of truth. A hover state, selected ids, dragged id, brush extent, pan/zoom transform, and filtered data should all live outside the drawing context so the chart can redraw deterministically.
## CSS Size, Backing Store, and Browser Zoom
Canvas has layout dimensions and bitmap dimensions. Keep them synchronized:
```ts
type CanvasSize = {
cssWidth: number;
cssHeight: number;
pixelRatio: number;
};
function resizeCanvas(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
cssWidth: number,
cssHeight: number,
maxPixelRatio = 3,
): CanvasSize {
const pixelRatio = Math.max(
1,
Math.min(maxPixelRatio, globalThis.devicePixelRatio || 1),
);
const width = Math.max(1, Math.round(cssWidth));
const height = Math.max(1, Math.round(cssHeight));
const bitmapWidth = Math.max(1, Math.round(width * pixelRatio));
const bitmapHeight = Math.max(1, Math.round(height * pixelRatio));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
if (canvas.width !== bitmapWidth || canvas.height !== bitmapHeight) {
canvas.width = bitmapWidth;
canvas.height = bitmapHeight;
}
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
return { cssWidth: width, cssHeight: height, pixelRatio };
}
```
Use `globalThis.devicePixelRatio` for page zoom and high-DPI screens. Page zoom changes `devicePixelRatio`; pinch zoom is represented by `visualViewport.scale` and should only be multiplied into the backing store when the product intentionally redraws for pinch-zoom sharpness. Always clamp the pixel ratio for very large dashboards because memory grows with `pixelRatio^2`.
Watch size and ratio changes with a combination of `ResizeObserver`, `window.resize`, and a recreated resolution media query:
```ts
let stopWatchingDpr: (() => void) | undefined;
function watchDevicePixelRatio(onChange: () => void) {
stopWatchingDpr?.();
const query = matchMedia(`(resolution: ${globalThis.devicePixelRatio}dppx)`);
query.addEventListener("change", onChange, { once: true });
stopWatchingDpr = () => query.removeEventListener("change", onChange);
}
```
When the ratio or CSS size changes, resize every Canvas layer, reset each context transform, rebuild cached screen-space geometry if needed, and redraw.
If a parent stage is CSS-transformed with rotation, skew, or non-uniform scaling, `clientX - rect.left` is not enough. Keep the CSS transform matrix or use `DOMMatrix` to invert the full screen-to-stage transform before hit testing.
## Layering
Use a relatively positioned stage with stacked canvases and optional HTML/SVG overlays:
- background Canvas: slow-changing contextual surfaces, grid bands, heatmap tiles, static map geometry
- marks Canvas: primary data marks
- interaction Canvas: hover, selection, brush handles, drag preview
- HTML/SVG overlay: axes, labels, tooltips, popovers, editable controls, focusable affordances
Set passive overlay labels to `pointer-events: none`. For controls that need clicks or focus, set `pointer-events: auto`, ensure they sit above the Canvas, and decide whether they should stop propagation or route interactions back into the Canvas controller.
## HTML Overlay Quirks
- Derive overlay positions from the same transform as Canvas marks; never duplicate scale math in separate components.
- Keep the stage as the common coordinate parent so `translate(x, y)` positions align with Canvas CSS pixels.
- Reposition overlays after pan, zoom, scroll, resize, DPR changes, data updates, and font loading.
- Avoid overlaying thousands of DOM nodes; use overlays for selected, focused, edited, or annotated marks rather than every mark.
- When an overlay intercepts pointer events, maintain a clear ownership rule so dragging does not unexpectedly switch between Canvas and HTML.
- Preserve accessibility in the overlay: real buttons, links, inputs, table cells, `aria-live` updates, and keyboard focus usually belong in HTML.
## Accessibility and Focus
Canvas pixels do not create a semantic tree. For meaningful data marks, provide one of these companion structures:
- an adjacent data table or selected-detail panel for the data behind the view
- focusable HTML controls or anchors for selected, edited, or navigable marks
- fallback content inside the `<canvas>` element for basic unavailable-canvas messaging
- `aria-live` status updates for hover, brush, and selection changes that matter
- keyboard paths for selection, pan, zoom, brushing, and reset actions
Use `CanvasRenderingContext2D.drawFocusIfNeeded()` only when a Canvas-drawn affordance is backed by a real focused element. For most visualization work, HTML focus rings and overlays are easier to maintain and test than drawing focus entirely in Canvas.
## Common Mistakes
- Treating immediate-mode drawing as a reason to skip scene modeling.
- Pushing every responsibility, including labels and accessibility, into Canvas.
- Setting only `canvas.width` and `canvas.height` without matching CSS size, or setting only CSS size and accepting a blurry backing store.
- Calling `ctx.scale()` repeatedly after every resize instead of resetting with `ctx.setTransform()`.
- Letting Canvas and HTML overlays use separate transforms that drift after zooming or resizing.
- Creating one Canvas per row or mark without calculating aggregate backing-store memory.
- Drawing interactive controls in Canvas without a matching keyboard and screen-reader model.
## Adjacent Skills
- `../SKILL.md`
- `../../typescript-data-visualization-engineering/SKILL.md`
- `../../accessibility-and-inclusive-visualization/SKILL.md`
## Source Links
- [MDN: Window.devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio)
- [MDN: VisualViewport.scale](https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/scale)
- [MDN: Optimizing canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas)
- [MDN: CanvasRenderingContext2D.drawFocusIfNeeded](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawFocusIfNeeded)
- [D3 API](https://d3js.org/api)
SHA-256: ad4e573e95ccfc9c9183d31fef157c172db514fc8820100f03aa248683cfcd9a