Skip to content
D
Documentation

Tune large-graph rendering

how-to
5 min readUpdated

Use renderer configuration, level of detail (LOD), and batched mutations when a large diagram spends too much time painting. The examples below mount a 900-node mesh, let you compare near and overview zooms, and move every node in one batch. A performance overlay reports measurements from the mounted scene; Angular also exposes its own render-loop metrics.

1. Prepare the scene and renderer policy

In your existing framework project, install the packages for your framework.

JavaScript:

bash
npm install @grafloria/element @grafloria/renderer @grafloria/engine

Angular:

bash
npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element

Qwik:

bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik

React:

bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom

Vue:

bash
npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue

Create this shared file beside your component or browser entry point. Type the input data with NodeSpec and EdgeSpec. Pass SVGRendererConfig through the binding's rendererConfig prop, or JavaScript's renderer option.

The renderer ships an adaptive QualityGovernor; enable it through configuration rather than creating a separate governor that the canvas never uses. It measures renderer frame times, lowers detail when the budget is exceeded, and restores detail after sustained headroom. Zoom determines what detail is useful; the governor determines what the machine can afford.

The live DiagramModel holds the LODConfig. This example keeps the shipped feature sets and raises the medium tier's lower zoom bound from 0.5 to 0.6. At zooms between 0.5 and 0.6, the diagram therefore uses sketch detail before any governor bias.

ts
import type { DiagramModel, LODConfig } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec, SVGRendererConfig } from '@grafloria/renderer';

export const nodes: NodeSpec[] = [];
export const edges: EdgeSpec[] = [];
const side = 30;
const id = (row: number, col: number) => `n${row * side + col}`;

for (let row = 0; row < side; row++) {
  for (let col = 0; col < side; col++) {
    nodes.push({
      id: id(row, col), label: `${row * side + col}`,
      position: { x: col * 120, y: row * 80 },
      size: { width: 92, height: 46 },
    });
    if (col + 1 < side) {
      edges.push({ source: id(row, col), target: id(row, col + 1) });
    }
    if (row + 1 < side) {
      edges.push({ source: id(row, col), target: id(row + 1, col) });
    }
  }
}

export const rendererConfig = {
  enableCaching: true,
  maxCacheSize: 2000,
  qualityGovernor: { budgetMs: 16.7 },
} satisfies SVGRendererConfig;

export function configureLOD(model: DiagramModel): void {
  const policy: LODConfig = {
    tiers: model.getLODConfig().tiers.map(tier => ({
      ...tier,
      minZoom: tier.name === 'medium' ? 0.6 : tier.minZoom,
    })),
  };
  model.setLODConfig(policy);
}

2. Measure and batch on the mounted instance

For JavaScript, Qwik, React, and Vue, use the DiagramInstance received at initialization. getQualityState() returns the tier actually painted and, when enabled, the governor's last verdict. Read it after painting, not immediately after changing zoom. The instance's viewport is a ViewportController; call its setZoom() method to change the camera scale.

Use the shipped PerfHud to display a PerfSnapshot. It is an opt-in DOM overlay with pointer-events: none; it does not collect measurements for you. This helper times a synchronous repaint and counts node and link elements in the diagram container. It refreshes on initialization and after each button action, not continuously.

The HUD's FPS, dirty, and routed counters remain 0 here because this helper does not instrument those counters. frameMs measures the explicit repaint, including the synchronous DOM work; it is not an average frame duration. mountedViews counts node elements in this SVG-only sample. Use the visible-to-total ratios to inspect culling, and the tier and governor verdict to explain reduced detail.

ts
import { PerfHud, type DiagramInstance, type PerfSnapshot } from '@grafloria/renderer';
import { configureLOD } from './scene';

export function refresh(api: DiagramInstance, hud: PerfHud): void {
  const start = performance.now();
  api.renderNow();
  const frameMs = performance.now() - start;
  const model = api.getModel();
  const quality = api.getQualityState();
  const visibleNodes = api.container.querySelectorAll('[data-node-id]').length;
  const snapshot: PerfSnapshot = {
    fps: 0, frameMs,
    nodes: model.getNodes().length,
    links: model.getLinks().length,
    visibleNodes,
    visibleLinks: api.container.querySelectorAll('[data-link-id]').length,
    mountedViews: visibleNodes,
    dirtyNodes: 0, dirtyLinks: 0, routedLinks: 0,
    tier: quality.tier,
    governor: quality.governor,
  };
  hud.update(snapshot);
}

export function initialize(api: DiagramInstance, host: HTMLElement): PerfHud {
  configureLOD(api.getModel());
  const hud = new PerfHud(host);
  hud.show();
  api.fitView(40);
  api.viewport.setZoom(0.7);
  refresh(api, hud);
  return hud;
}

export function zoom(api: DiagramInstance, hud: PerfHud, value: number): void {
  api.viewport.setZoom(value);
  refresh(api, hud);
}

export function shift(api: DiagramInstance, hud: PerfHud): void {
  api.batchUpdate(model => {
    for (const node of model.getNodes()) {
      node.setPosition(node.position.x + 20, node.position.y);
    }
  });
  refresh(api, hud);
}

batchUpdate() runs a synchronous mutator against the live model and coalesces the resulting repaint. The extra renderNow() above forces that queued paint to finish before measuring and reading the quality state. For ordinary updates without immediate inspection, use the queued repaint instead of forcing every frame.

3. Mount the mesh in your framework

Choose one tab. Each sample starts at 0.7×, shows a slice of the mesh, and leaves all 900 nodes in the model. Click Overview to zoom out; click Move all +20 to shift the entire graph right. Detail can fall below the zoom-derived tier when the governor detects expensive frames.

These mounts add renderer configuration, LOD setup, and performance controls to the framework mounting patterns in Edit nodes.

ts
// main.ts — mount() returns cleanup; call it when your host view unmounts.
import { render } from '@grafloria/element';
import { nodes, edges, rendererConfig } from './scene';
import { initialize, zoom, shift } from './inspection';

export function mount(parent: HTMLElement): () => void {
  const view = document.createElement('section');
  view.innerHTML = `
    <button data-near>Near</button>
    <button data-overview>Overview</button>
    <button data-move>Move all +20</button>
    <div data-stage style="height:480px;position:relative">
      <div data-canvas style="height:100%"></div>
      <div data-hud style="position:absolute;top:0;left:0"></div>
    </div>`;
  parent.appendChild(view);
  const host = view.querySelector<HTMLElement>('[data-canvas]')!;
  const hudHost = view.querySelector<HTMLElement>('[data-hud]')!;
  const api = render({ nodes, edges }, host, { renderer: rendererConfig });
  const hud = initialize(api, hudHost);
  view.querySelector<HTMLButtonElement>('[data-near]')!.onclick = () => zoom(api, hud, 0.7);
  view.querySelector<HTMLButtonElement>('[data-overview]')!.onclick = () => zoom(api, hud, 0.15);
  view.querySelector<HTMLButtonElement>('[data-move]')!.onclick = () => shift(api, hud);
  return () => { hud.hide(); api.dispose(); view.remove(); };
}

const parent = document.getElementById('app')!;
export const unmount = mount(parent);

The JavaScript sample opens with a labeled mesh and the performance overlay.

JavaScript: a slice of the numbered mesh, the performance HUD, and Near, Overview, and Move all +20 buttons.

The Angular sample adds an explicit metrics inspection button.

Angular: numbered nodes and directional links below the controls and the initial metrics prompt.

The Qwik sample shows the mesh and its overlay.

Qwik: a grid of connected boxes beneath three controls, with the performance HUD overlaying the upper-left corner.

The React sample shows the numbered nodes behind the overlay.

React: numbered boxes connected horizontally and vertically, with the HUD reporting medium detail.

The Vue sample opens on the same mesh slice.

Vue: the labeled mesh, performance overlay, and Near, Overview, and Move all +20 controls.

Angular does not expose the renderer-level instance through this component. Batch through the model obtained from activeEngine(), then queue the component's repaint. Click Inspect metrics after a paint to get fps, frameTime, droppedFrames, and sampleCount. Its ring buffers retain up to 60 painted frames; skipped frames do not add samples. frameTime is the average duration in that window. The source counts a dropped frame when rendering exceeds 32 ms, not the governor's 16.7 ms budget. This return shape differs from the renderer's PerformanceMetrics.

Options that matter

OptionTypeDefaultWhat it does
enableCachingbooleantrueEnables VNode caching.
maxCacheSizenumber1000Bounds the renderer's LRU VNode cache.
qualityGovernorboolean | GovernorOptionstrueUses defaults with true, tunes the governor with an object, or disables adaptive bias with false.
qualityGovernor.budgetMsnumber16.7Sets the frame-time budget in milliseconds.
qualityGovernor.windownumber12Sets the normal decision window.
qualityGovernor.recoveryWindowsnumber3Requires this many consecutive fast windows before restoring one tier.
qualityGovernor.maxBias0 | 1 | 22Limits how many tiers below the zoom-derived tier the governor can render.

GovernorOptions also controls the down/up thresholds and catastrophic-frame escalation. The defaults use a median, a dead band, and slower recovery to avoid oscillating between detail levels.

The shipped LOD policy uses these inclusive lower bounds. The sample changes only the medium bound to 0.6.

TierShipped zoom rangeWhat renders
highzoom >= 1All LOD features.
medium0.5 <= zoom < 1Labels, borders, ports, decorations, handles, routing, link detail, and gradients.
sketch0.2 <= zoom < 0.5Borders, routing, and link detail; no labels or ports.
lowzoom < 0.2No optional LOD features: plain boxes and direct lines.

Pitfalls

  • Pass qualityGovernor: false when comparing zoom tiers deterministically. Otherwise the reported tier can be simpler than the zoom policy requests.
  • Configure React, Vue, and Qwik renderer options when mounting: their bindings pass rendererConfig into instance creation. Angular recreates its renderer when that input changes, so keep the object stable during ordinary data edits.
  • Keep the batchUpdate() callback synchronous. It ends the model batch when the callback returns, not when asynchronous work finishes.
  • Render batching is not an undoable edit. For user-facing operations that need history, use commands and history.
  • At fit-to-content, the full mesh can be visible, so a high visible-node count does not by itself indicate failed culling. Compare it with the near view.
  • For container sizing, see theme a canvas.

Was this page helpful?