DEV Community

loggerhead turtle
loggerhead turtle

Posted on Originally published at treease.com

How I Made a Canvas JSON Viewer Fast with Viewport Virtualization

When you build a visual tool for structured data, everything feels instantaneous on toy examples. A 20-line JSON payload renders crisply into an interactive graph with clean nodes, collapsible trees, and smooth connectors.

Then you drop in a real-world file: a 15 MB API response containing nested objects, deep arrays, and hundreds of thousands of key-value pairs.

Suddenly, the browser locks up. The DOM or Canvas scene graph explodes with tens of thousands of objects. Panning drops from 60 fps to single digits, and zooming triggers multi-second layout thrashing.

Here is how I tackled this problem when building the graph visualizer for Treease by separating semantic completeness from visual materialization.


The Core Dilemma: Completeness vs. Canvas Weight

The naive mental model for a canvas or SVG graph is 1:1 mapping: for every node in the data, instantiate a renderable object in the scene.

[Full JSON AST]  ->  [Canvas Scene Graph / DOM Nodes]
Enter fullscreen mode Exit fullscreen mode

This model breaks down quickly because:

  1. Scene Graph Bloat: The cost of hit-testing, layout calculations, and paint passes scales linearly with document size, even when most content is offscreen.
  2. Memory Overhead: Holding thousands of active visual display objects consumes hundreds of megabytes of RAM.

The intuitive workaround is aggressive lazy loading, for example parsing only what is expanded. But that breaks critical user workflows:

  • How do you search across the entire document?
  • How do you jump to a deeply nested path?
  • How do you show global error indicators or relationship highlights?

The Architectural Shift

The solution was to decouple the data model from the render surface:

[ Full Semantic Graph (In-Memory / Fast Lookups) ]
                      |
                      v Viewport Frustum Culling
[ Materialized Scene (Only Visible Nodes + Overscan) ]
Enter fullscreen mode Exit fullscreen mode
  • Semantic Completeness: Keep the entire document parsed, indexed, and queryable in memory. Global search, tree navigation, and path queries run against the lightweight in-memory structure.
  • Visual Materialization: Only instantiate canvas render objects for elements currently within, or adjacent to, the camera viewport.

Let's look at the four specific techniques that made this work.


1. Viewport Virtualization with Bounded Overscan

The foundation is simple bounding-box intersection, but with an important detail: directional overscan.

If you only materialize elements strictly within the viewport, fast panning will cause visible flashing as objects pop into existence.

interface ViewportRect {
  minX: number;
  minY: number;
  maxX: number;
  maxY: number;
}

function calculateOverscanBounds(
  viewport: ViewportRect,
  overscanFactor = 0.5
): ViewportRect {
  const width = viewport.maxX - viewport.minX;
  const height = viewport.maxY - viewport.minY;
  const padX = width * overscanFactor;
  const padY = height * overscanFactor;

  return {
    minX: viewport.minX - padX,
    minY: viewport.minY - padY,
    maxX: viewport.maxX + padX,
    maxY: viewport.maxY + padY,
  };
}
Enter fullscreen mode Exit fullscreen mode

The Rendering Pipeline

On each camera transform:

  1. Compute the visible world-coordinate rectangle from the camera matrix.
  2. Expand it by the overscan margin.
  3. Query a lightweight spatial index to find intersecting nodes.
  4. Diff and reconcile: retain already materialized objects, create new ones entering the bounds, and unmount or recycle objects that left the bounds.
function updateVisibleScene(camera: Camera, spatialIndex: SpatialIndex) {
  const visibleBounds = calculateOverscanBounds(camera.getWorldBounds());
  const visibleNodes = spatialIndex.search(visibleBounds);

  reconcileSceneGraph(visibleNodes);
}
Enter fullscreen mode Exit fullscreen mode

2. Sampling Dense Edge Groups

Nodes are only half the battle. In graph layouts, a single parent array or hub object might have hundreds or thousands of outgoing edges.

Drawing thousands of overlapping curves inside a small visible region creates two problems:

  1. Performance: Computing and rasterizing large numbers of vector paths tanks fill rate.
  2. Visual Usability: Individual lines become an undifferentiated blob.
Without Sampling:  [Node A] ==========> [2000 Children]
With Sampling:     [Node A] - - - - -> [Representative Paths]
Enter fullscreen mode Exit fullscreen mode

Dynamic Stride Sampling

When the number of outgoing edges exceeds a density threshold, we switch to stride-based sampling:

function getMaterializedEdges(node: GraphNode, maxEdgeSampleCap = 32): Edge[] {
  const allEdges = node.outgoingEdges;
  const total = allEdges.length;

  if (total <= maxEdgeSampleCap) {
    return allEdges;
  }

  const stride = Math.ceil(total / maxEdgeSampleCap);
  const sampled: Edge[] = [];

  for (let i = 0; i < total; i += stride) {
    sampled.push(allEdges[i]);
  }

  return sampled;
}
Enter fullscreen mode Exit fullscreen mode

3. Row-Level Virtualization Inside Large Objects

A common JSON edge case is a single flat object with thousands of keys:

{
  "metric_0001": 42,
  "metric_0002": 88
}
Enter fullscreen mode Exit fullscreen mode

Even if the object node intersects the viewport, drawing every key-value row would waste hundreds of draw calls.

Instead of treating a node as an indivisible box, we treat large nodes as vertically virtualized sub-containers:

┌──────────────────────────────┐
│ (offscreen rows not drawn)   │
├──────────────────────────────┤
│ "metric_0412": 104           │
│ "metric_0413": 89            │
│ "metric_0414": 92            │
├──────────────────────────────┤
│ (offscreen rows not drawn)   │
└──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Implementation Concept

If each row has a predictable or pre-computable height, we can determine the visible slice in constant time:

function getVisiblePropertyRange(
  nodeTop: number,
  rowHeight: number,
  totalRows: number,
  viewport: ViewportRect
) {
  const relativeTop = Math.max(0, viewport.minY - nodeTop);
  const relativeBottom = Math.max(0, viewport.maxY - nodeTop);

  const startIdx = Math.max(0, Math.floor(relativeTop / rowHeight) - 2);
  const endIdx = Math.min(totalRows - 1, Math.ceil(relativeBottom / rowHeight) + 2);

  return { startIdx, endIdx };
}
Enter fullscreen mode Exit fullscreen mode

The node container keeps its true total height in layout, but only renders the visible slice.


4. Virtualized Scrolling for Table Nodes

When JSON arrays contain uniform objects, a tabular representation is often more readable than a deeply nested tree:

[
  { "id": 1, "name": "Alice", "status": "active" },
  { "id": 2, "name": "Bob", "status": "pending" }
]
Enter fullscreen mode Exit fullscreen mode

Table nodes in a 2D canvas graph combine two coordinate systems:

  1. World canvas coordinates for graph panning.
  2. Local scroll coordinates for scrolling inside the table itself.

By attaching a virtualized row pool to the table node, we reuse a fixed number of cell renderers regardless of whether the array has 10 items or 100,000 items.


Takeaway

Rendering performance is not about how fast you can draw 50,000 elements. It is about finding clean abstractions so you only ever have to draw 50.

If you are dealing with large graphs, trees, or structured documents on the web, keep asking:

  1. Does this offscreen element need a visual representation right now?
  2. Can the data model answer queries without touching the render tree?

Top comments (0)