Skip to main content
Version: 4.3

Performance

Large diagrams strain an app in two different ways. Query cost grows with every cell the app has to walk through when it looks something up by location: what is inside this region, what sits under this point. Rendering cost grows with the DOM the browser has to keep alive, which depends both on how many cells are mounted and on how much markup each one renders. @joint/react-plus gives you a lever for each. The spatial index keeps location queries fast, virtual rendering keeps the number of mounted cells small, and level of detail keeps each mounted cell cheap. They are independent, so you can enable any one on its own, but they work best together.

Spatial index

Location queries answer questions like "which elements are inside this rectangle" or "which elements lie under this one". A plain graph answers them by checking every cell, which is fine for a small diagram but gets slower as it grows. The spatialIndex prop on <Diagram> backs the graph with a quad-tree index instead, so those lookups only visit the region they ask about:

<Diagram initialCells={initialCells} spatialIndex>
...
</Diagram>

Turning it on already speeds up the framework's own hit-testing on large graphs: region selection, drop-based embedding, and link snapping all run location queries internally, and they pick up the index without any query code on your side.

Pass true for the defaults, or an options object to tune the quad-tree. See the tuning section below.

Querying from your own code

The index pays off most when your own features ask spatial questions. The find methods are part of the regular graph API, so the code does not change at all: reach the graph with useGraph() from any component inside the diagram and call them. With spatialIndex on, every call hits the quad-tree instead of walking the whole graph.

import { useGraph } from '@joint/react-plus';

const { graph } = useGraph();

// Elements intersecting a rectangle, in graph coordinates.
const inArea = graph.findElementsInArea({ x: 0, y: 0, width: 500, height: 500 });

// Elements whose bounding box overlaps another element's.
const covered = graph.findElementsUnderElement(element);

Each find method has an at-point variant and link and cell counterparts. The full list lives in the SearchGraph API.

The demo below runs findElementsUnderElement on every pointer move while you drag the dashed probe across a field of 1,200 cards. Cards light up the moment the probe covers them, and the badge in the top right corner tracks the live count:

Tuning

The index keeps itself up to date in one of two modes. In eager mode, the default, every graph change updates the quad-tree right away, so queries always hit a current index. In lazy mode, changes only mark the index as stale, and the next query rebuilds it in one pass. Eager mode suits diagrams where queries are frequent, like hit-testing on every pointer move. Lazy mode suits diagrams that change in bursts, like a big import followed by an occasional lookup, because it pays the indexing cost once instead of on every change:

<Diagram initialCells={initialCells} spatialIndex={{ isQuadTreeLazy: true }}>
...
</Diagram>

The options object accepts a few more knobs, all reactive, so changing a value reconfigures the live index:

  • quadTreeMaxDepth caps how deep the tree subdivides. A deeper tree localizes queries to smaller regions at the cost of more nodes.
  • quadTreeCapacity sets how many elements a tree node holds before it splits.
  • quadTreeBoundary hands the index a fixed bounding box to cover, instead of letting it size itself around the content.
  • isQuadTreeAutoGrow lets that fixed boundary expand when elements land outside it.

The defaults serve most diagrams well. Reach for these only when profiling points at the index itself.

Virtual rendering

With virtual rendering enabled, the scroller renders only the cells inside the current viewport and skips the rest. Cells mount as they scroll into view and unmount as they leave, so the DOM stays small no matter how many cells the graph holds.

<PaperScroller virtualRendering>
<Paper renderElement={renderElement} />
</PaperScroller>

Virtual rendering is a <PaperScroller> feature: the visible viewport decides which cells to render, so it has no effect on a plain <Paper> without a scroller around it.

Pass true for the defaults, or an options object to tune the behavior. The most useful knob is margin, which inflates the culling area by the given number of pixels so cells near the edge mount before they scroll into view:

<PaperScroller virtualRendering={{ margin: 300 }}>
<Paper renderElement={renderElement} />
</PaperScroller>

The demo below holds 10,000 cards. Watch the badge in the top right corner: at any moment the paper renders only the handful of cards inside the viewport. Drag the canvas, zoom, or jump to a random spot, and the count stays low while cards mount and unmount around you.

Pair it with the spatial index

On every scroll and zoom, virtual rendering asks the graph which cells intersect the viewport. That question is one of the location queries the spatial index accelerates, which is why the demo above enables both: the culling check stays fast even at 10,000 cards.

Level of detail

Virtual rendering controls how many cells are mounted. Level of detail controls how much each mounted cell renders. Zoomed far out, a node's text is a few pixels tall and its buttons are too small to hit, but its markup costs the browser exactly what it costs at 100%. Rendering something cheaper down there cuts that cost, and it buys you room in the other direction too: at close zoom you can afford a far richer node than you would want on the canvas by default.

A node's rendering is a React component, so this needs no special API. Read the zoom, pick a representation, return it:

import { usePaperScrollerViewport } from '@joint/react-plus';

function TaskNode(data: TaskData) {
const level = usePaperScrollerViewport(selectDetailLevel);
switch (level) {
case 'high':
return <EditableCard {...data} />;
case 'medium':
return <StaticCard {...data} />;
default:
return <OutlineNode {...data} />;
}
}

What the node subscribes to matters more than the switch itself. usePaperScrollerViewport takes a selector, and this one returns a detail level instead of the raw zoom:

type DetailLevel = 'high' | 'medium' | 'low';

function selectDetailLevel({ zoom }: { zoom: number }): DetailLevel {
if (zoom >= 0.75) return 'high';
if (zoom >= 0.25) return 'medium';
return 'low';
}

A node then re-renders only when the zoom crosses a threshold. Subscribe to zoom itself and every node re-renders on every wheel tick, which costs more than switching the markup saves.

The other half is geometry. Every level renders into the same box, and the low level drops HTMLHost entirely to draw a plain SVG <rect> sized from the model:

function OutlineNode({ color }: TaskData) {
const { width, height } = useCell(selectElementSize);
return <rect width={width} height={height} rx={12} fill={color} fillOpacity={0.08} />;
}
useModelGeometry is not optional here

Give every HTML level useModelGeometry. Without it, an HTMLHost measures its own DOM and writes the result back to the element, so the model size follows whatever happens to be rendered. Switch levels and the node resizes: cards jump size mid-zoom, links move to meet the new anchors, and any layout you ran no longer fits. The outline level breaks from the other side, since it reads size off the model to draw its rect. With useModelGeometry, the size you set on the cell wins and switching markup changes nothing but the subtree.

It helps to keep the levels related visually. Both cards below share a header tinted with the node's color, and the outline redraws that same header band in SVG, so zooming out looks like one node losing detail instead of three shapes taking turns.

The demo runs all three levels over a field of 80 cards. Past 75% every node is an editable form whose input and color swatches write back to the element data, so the header updates as you type. Between 25% and 75% the node is a read-only card. Below 25% it collapses to an outline. The badge in the toolbar tracks the level the nodes are rendering at:

Three levels are one choice among many. Two are often enough, a full node and an outline, and the thresholds depend on how much text your nodes carry. Zoom out until the detail stops being readable, and switch there.

Pair it with virtual rendering

The two levers multiply. Zooming out mounts more cells, which is exactly when each cell should render less, so the demo above enables virtualRendering as well: the viewport caps how many nodes exist, and the detail level caps what each one costs.

Stay in the know

Be where thousands of diagramming enthusiasts meet

Star us on GitHub