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:
bashnpm install @grafloria/element @grafloria/renderer @grafloria/engine
Angular:
bashnpm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element
Qwik:
bashnpm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik
React:
bashnpm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom
Vue:
bashnpm 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.
tsimport 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.
tsimport { 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);
ts// app.component.ts
import { AfterViewInit, Component, ElementRef, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { nodes, edges, rendererConfig, configureLOD } from './scene';
@Component({
selector: 'app-root', standalone: true,
imports: [DiagramCanvasComponent],
template: `
<button (click)="setZoom(0.7)">Near</button>
<button (click)="setZoom(0.15)">Overview</button>
<button (click)="move()">Move all +20</button>
<button (click)="inspect()">Inspect metrics</button>
<pre #readout>Click Inspect metrics after a repaint.</pre>
<grafloria-diagram-canvas
[(nodes)]="nodes" [(edges)]="edges"
[rendererConfig]="rendererConfig"
style="display:block;height:480px" />
`,
})
export class AppComponent implements AfterViewInit {
readonly canvas = viewChild.required(DiagramCanvasComponent);
readonly readout = viewChild.required<ElementRef<HTMLElement>>('readout');
nodes: NodeSpec[] = nodes;
edges: EdgeSpec[] = edges;
readonly rendererConfig = rendererConfig;
ngAfterViewInit(): void {
const model = this.canvas().activeEngine()?.getDiagram();
if (model) configureLOD(model);
this.canvas().fitToContent(40);
this.setZoom(0.7);
}
setZoom(value: number): void {
this.canvas().viewportController()?.setZoom(value);
this.canvas().scheduleRender();
}
move(): void {
const model = this.canvas().activeEngine()?.getDiagram();
if (!model) return;
model.beginBatch();
try {
for (const node of model.getNodes()) {
node.setPosition(node.position.x + 20, node.position.y);
}
} finally {
model.endBatch();
}
this.canvas().scheduleRender();
}
inspect(): void {
this.readout().nativeElement.textContent =
JSON.stringify(this.canvas().getPerformanceMetrics(), null, 2);
}
}
tsx// app.tsx
import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import { type DiagramInstance, type PerfHud } from '@grafloria/renderer';
import { nodes, edges, rendererConfig } from './scene';
import { initialize, zoom, shift } from './inspection';
export default component$(() => {
const host = useSignal<HTMLElement>();
const api = useSignal<NoSerialize<DiagramInstance>>();
const hud = useSignal<NoSerialize<PerfHud>>();
useVisibleTask$(({ cleanup }) => {
cleanup(() => hud.value?.hide());
});
return <section>
<button onClick$={() => { if (api.value && hud.value) zoom(api.value, hud.value, 0.7); }}>Near</button>
<button onClick$={() => { if (api.value && hud.value) zoom(api.value, hud.value, 0.15); }}>Overview</button>
<button onClick$={() => { if (api.value && hud.value) shift(api.value, hud.value); }}>Move all +20</button>
<div style={{ height: '480px', position: 'relative' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} rendererConfig={rendererConfig}
onInit$={instance => {
api.value = noSerialize(instance);
if (host.value) hud.value = noSerialize(initialize(instance, host.value));
}} />
<div ref={host} style={{ position: 'absolute', top: '0', left: '0' }} />
</div>
</section>;
});
tsx// App.tsx
import { useEffect, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance, PerfHud } from '@grafloria/renderer';
import { nodes, edges, rendererConfig } from './scene';
import { initialize, zoom, shift } from './inspection';
export default function App() {
const host = useRef<HTMLDivElement>(null);
const api = useRef<DiagramInstance | null>(null);
const hud = useRef<PerfHud | null>(null);
useEffect(() => () => { hud.current?.hide(); }, []);
function setZoom(value: number) {
if (api.current && hud.current) zoom(api.current, hud.current, value);
}
return <section>
<button onClick={() => setZoom(0.7)}>Near</button>
<button onClick={() => setZoom(0.15)}>Overview</button>
<button onClick={() => { if (api.current && hud.current) shift(api.current, hud.current); }}>Move all +20</button>
<div style={{ height: 480, position: 'relative' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} rendererConfig={rendererConfig}
onInit={instance => {
api.current = instance;
hud.current?.hide();
if (host.current) hud.current = initialize(instance, host.current);
}} />
<div ref={host} style={{ position: 'absolute', top: 0, left: 0 }} />
</div>
</section>;
}
vue<!-- App.vue --> <script setup lang="ts"> import { onUnmounted, ref, shallowRef } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance, PerfHud } from '@grafloria/renderer'; import { nodes, edges, rendererConfig } from './scene'; import { initialize, zoom, shift } from './inspection'; const host = ref<HTMLElement>(); const api = shallowRef<DiagramInstance>(); const hud = shallowRef<PerfHud>(); function onInit(instance: DiagramInstance): void { api.value = instance; if (host.value) hud.value = initialize(instance, host.value); } function setZoom(value: number): void { if (api.value && hud.value) zoom(api.value, hud.value, value); } function move(): void { if (api.value && hud.value) shift(api.value, hud.value); } onUnmounted(() => hud.value?.hide()); </script> <template> <section> <button @click="setZoom(0.7)">Near</button> <button @click="setZoom(0.15)">Overview</button> <button @click="move">Move all +20</button> <div style="height:480px;position:relative"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :renderer-config="rendererConfig" @init="onInit" /> <div ref="host" style="position:absolute;top:0;left:0" /> </div> </section> </template>
The JavaScript sample opens with a labeled mesh and the performance overlay.
The Angular sample adds an explicit metrics inspection button.
The Qwik sample shows the mesh and its overlay.
The React sample shows the numbered nodes behind the overlay.
The Vue sample opens on the same mesh slice.
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
| Option | Type | Default | What it does |
|---|---|---|---|
enableCaching | boolean | true | Enables VNode caching. |
maxCacheSize | number | 1000 | Bounds the renderer's LRU VNode cache. |
qualityGovernor | boolean | GovernorOptions | true | Uses defaults with true, tunes the governor with an object, or disables adaptive bias with false. |
qualityGovernor.budgetMs | number | 16.7 | Sets the frame-time budget in milliseconds. |
qualityGovernor.window | number | 12 | Sets the normal decision window. |
qualityGovernor.recoveryWindows | number | 3 | Requires this many consecutive fast windows before restoring one tier. |
qualityGovernor.maxBias | 0 | 1 | 2 | 2 | Limits 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.
| Tier | Shipped zoom range | What renders |
|---|---|---|
high | zoom >= 1 | All LOD features. |
medium | 0.5 <= zoom < 1 | Labels, borders, ports, decorations, handles, routing, link detail, and gradients. |
sketch | 0.2 <= zoom < 0.5 | Borders, routing, and link detail; no labels or ports. |
low | zoom < 0.2 | No optional LOD features: plain boxes and direct lines. |
Pitfalls
- Pass
qualityGovernor: falsewhen 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
rendererConfiginto 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.
Live demos and related tasks
- Stress test: compare viewport culling and layout on a 900-node mesh.
- Contextual zoom: compare the four rendered detail tiers.
- Perf HUD and quality governor, with source: inspect the shipped overlay and governor behavior.
- Lay out a diagram: arrange the graph rather than only changing render cost.
Was this page helpful?