Skip to main content
Version: 4.3

Standalone link routing

Standalone link routing routes your links around the elements in the way, as a step of its own rather than as a by-product of a layout. It ships as @joint/router-avoid and wraps libavoid - a C++ library for automatic, obstacle-avoiding orthogonal connector routing - compiled to WebAssembly via libavoid-js. The package is open source (MPL-2.0) and requires JointJS 4.3 or newer.

How it differs from the built-in routers

The built-in routers are per-link functions: you set router: { name: 'manhattan' } on a link, and the router computes that one link's route from its vertices.

libavoid works the other way around. It maintains a single incremental router shared by the whole graph - every element is an obstacle, every link is a connector - and reroutes the connectors affected whenever an obstacle moves. Because routes are computed for the graph as a whole, links can be nudged apart from one another, share corridors cleanly, and react to elements they are not even connected to.

@joint/router-avoid wraps that engine in a RouterService that listens to your dia.Graph and writes each computed route straight onto the link's vertices and source/target anchors.

info

There is no router: { name: 'avoid' } attribute. Once a link is connected to two tracked elements on a routed graph, it is routed automatically - you never set a router on it.

Use it forInstead of
Dense flowcharts where many links share the same corridors and should be nudged apart rather than overlapmanhattan, which routes each link in isolation
Diagrams where links must avoid elements they are not connected torightAngle, which only clears its own end elements
Large graphs, where routing can be moved off the main thread into a Workerany built-in router, which always runs on the main thread

Stay with the built-in routers when you need per-link control over the routing strategy, when your links carry user-placed vertices (checkpoints are not supported), or when you would rather not ship a WebAssembly binary.

Installation

Install the avoid router open-source package (@joint/router-avoid) using your package manager:

npm add @joint/router-avoid

The package depends on @joint/core and on libavoid-js, which ships the routing engine as a libavoid.wasm binary next to its JavaScript. Both are installed for you, but the .wasm file has to be served alongside your bundle - see serving the WebAssembly binary below.

Now import the package:

import { initAvoidRouter } from '@joint/router-avoid';

Routing a graph

initAvoidRouter() loads the WebAssembly module, registers the graph's current elements and links with libavoid, and returns a RouterService for that graph:

import { dia, shapes } from '@joint/core';
import { initAvoidRouter } from '@joint/router-avoid';

const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
el: document.getElementById('paper'),
model: graph,
cellViewNamespace: shapes
});

const avoidRouter = await initAvoidRouter(graph, {
shapeBufferDistance: 20,
idealNudgingDistance: 10
});

avoidRouter.start();

Two things are worth pausing on.

The returned service is not started. start() is what attaches the graph listener that keeps the graph routed as cells are added, moved, resized and reconnected. Without it, nothing happens. If you only need a single routing pass over a static diagram, use one-shot routing instead.

No link carries a router attribute. You never write router: { name: 'avoid' }. Any link connected to two tracked elements is picked up automatically, and its computed route is written onto its vertices and source/target anchors:

// Added after `start()` - routed automatically, no `router` needed.
graph.addCell(new shapes.standard.Link({
source: { id: a.id },
target: { id: b.id }
}));

Awaiting the router

initAvoidRouter() resolves once the module has loaded and the graph's content has been registered. The routes themselves are computed asynchronously and applied as they arrive, so the promise resolving does not mean every link has its final route yet.

Each link gets an interim rightAngle route right away, so the diagram never renders with links in an obviously broken state while libavoid catches up. To know when a specific link has settled, listen for link:routed; to know when the whole graph has settled, listen for idle:

avoidRouter.on('idle', () => console.log('All routes settled.'));

Example

Drag any element to see the routes recompute. The dashed red box is an obstacle like any other element - none of the links are connected to it, and all of them route around it.

Ports

Links connected to ports are routed to the port, not to the element as a whole. When an element is registered, the router turns each of its ports into a libavoid pin: the port's position is normalised against the element's size, and the side it sits on decides which direction a connector may approach it from. A port in the left group is approached from the left, one in the top group from above, and so on.

Nothing extra is needed to opt in - name a port on either end and it is honoured:

new shapes.standard.Link({
source: { id: input.id, port: 'out' },
target: { id: filter.id, port: 'in' }
});

Elements with no ports get a single pin at their centre that accepts connectors from any direction, which is why the demos elsewhere on this page route to element bodies without any of this.

warning

Pins are built when an element is first registered with the router. From then on the element is only moved and resized, and there is no listener for port changes - so a port added to an element the router already knows about does not become a pin, and links to it cannot be routed.

Call start() again after changing an element's ports. It re-syncs the whole graph and rebuilds the pins.

note

Port ids must be strings. JointJS does not handle numeric port ids, and the router keys its pins by them.

Example

The links attach to named ports and approach each one from its own side. Drag the nodes around to see the routes keep that contract while avoiding the obstacle. Add port adds a port to Filter plus a link into it, then calls start() again so the new pin is registered.

Choosing how to run it

initAvoidRouter() hands you a RouterService that is idle. Two independent choices decide how it does its work.

When routes are computed - the service either follows the graph, or runs on demand:

start()routeAll() / routeSubgraph()
Graph listenerAttached - re-routes on every add, move, resize and reconnectNone
RunsContinuously, until stop() or destroy()Once per call, resolving when the pass is done
ScopeThe whole graphThe whole graph, or just the cells you pass
SuitsEditors, anything the user manipulatesRead-only diagrams, exports, one-time layouts

Where they are computed - the engine runs either on the main thread or in a Worker:

Main thread (default)worker: true
Blocks the UIYes, while routingNo
LatencyLower - no messaging overheadHigher - changes are batched by debounceTime
SuitsSmall and medium graphsLarge graphs, heavy interaction

The two choices are orthogonal - all four combinations are valid, and a one-shot pass off the main thread is a perfectly ordinary setup for a large read-only diagram:

const avoidRouter = await initAvoidRouter(graph, { worker: true });
await avoidRouter.routeAll();
avoidRouter.destroy();

start() opens with a full-graph pass of its own, so it composes with routeAll() but undoes the isolation of a routeSubgraph() pass - see one-shot routing.

Controlling the routes

Spacing

Two options tune the shape of the routes libavoid produces:

OptionEffect
shapeBufferDistanceSpacing added to the sides of each shape when determining obstacle sizes. Larger values keep routes further away from the elements. Defaults to 10.
idealNudgingDistanceSpacing used when nudging apart overlapping corners and line segments. Larger values spread parallel links out more. Defaults to 5.
const avoidRouter = await initAvoidRouter(graph, {
shapeBufferDistance: 20,
idealNudgingDistance: 10
});

shapeBufferDistance does double duty: besides inflating obstacles for libavoid, it is the margin used around elements when the built-in fallback route is computed. idealNudgingDistance only ever reaches the libavoid engine.

note

Both options configure the routing engine at creation time and cannot be changed on a running service. To change them, destroy() the service and create a replacement, as the example below does.

Choosing what to track

By default every element is an obstacle and every link is routed. Two predicates narrow that down.

trackElement decides which elements libavoid routes around. Returning false removes the element from the router entirely - links pass straight through it. Useful for annotations, backgrounds, legends, and other decoration:

const avoidRouter = await initAvoidRouter(graph, {
trackElement: ({ element }) => element.get('type') !== 'app.Note'
});

trackLink decides which links the service routes. Returning false leaves the link entirely alone, so its own router and connector attributes stay in effect - this is how you mix an avoid-routed graph with a handful of links that use a built-in router:

const avoidRouter = await initAvoidRouter(graph, {
trackLink: ({ link }) => !link.get('manual')
});
warning

Excluding an element does not exclude the links attached to it. A link whose end connects to an untracked element cannot be routed by libavoid at all, and falls back to the built-in rightAngle route with the reason 'untracked'. If you want such links left alone entirely, exclude them with trackLink too.

Narrowing the two predicates is also what makes it possible to run more than one service on a single graph. Give each a disjoint set of elements and links - every link a service tracks connecting elements that same service tracks - and they will not fight over anything. Each drives its own libavoid engine, so neither sees the other's obstacles: links routed by one pass straight through elements tracked by the other. Give them distinct changeFlag values so you can still tell their writes apart.

Example

Drag the sliders to see how the spacing options change the routes, and untick the checkbox to drop the yellow note out of the router.

Telling the router's writes apart

The service applies a route by calling link.set() with an opt flag, so its own writes can be recognized in your own change listeners. The flag name is available as changeFlag and defaults to 'avoidRouter':

graph.on('change:vertices', (link, vertices, opt) => {
if (opt[avoidRouter.changeFlag]) return; // routed by libavoid
// ... a change your application made
});

Rename it with the changeFlag option when 'avoidRouter' would collide with a flag you already use.

Taking over how routes are applied

By default the service writes routes directly with link.set(). That is usually what you want, but it does mean every reroute lands in whatever change-tracking layer you have attached to the graph - a command manager would happily record hundreds of routing steps while an element is being dragged.

setRouteAttributes replaces that default. When it is provided, the service never calls link.set() itself - applying the route is entirely up to you:

const avoidRouter = await initAvoidRouter(graph, {
setRouteAttributes: ({ link, attributes, origin, routing }) => {
// `routing` is true while libavoid is still working on this link -
// another call for the same link follows. Only record the final one.
link.set(attributes, { silentUndo: routing });
}
});

The callback receives the link, the computed RouteAttributes (the two ends with their anchors, plus the vertices in between), where the route came from (origin), whether it is provisional (routing), and - for a final fallback route - why the link was unroutable (unroutableReason).

Events and fallback routes

Routing is asynchronous. A change to the graph does not produce a route immediately - the request goes to libavoid, and the route comes back some time later, possibly after several more changes have arrived. The service reports that lifecycle through four events.

The routing cycle

EventArgumentsMeaning
link:routing(link)A routing cycle opened - libavoid is computing this link's route.
link:routed(link, { origin, reason })The cycle closed with a route applied.
link:routing:cancelled(link)The cycle closed without a route: the link became unroutable, was removed, or the service was destroyed while libavoid was still working.
idle()No link in the graph has an open routing cycle any more.

Every link:routing is closed by exactly one link:routed or link:routing:cancelled, no matter how many changes arrive in between. Repeated changes while a computation is in flight - dragging an element, for instance - do not open a second cycle. That pairing makes the events safe to drive a "still routing" indicator with:

const setPending = (link: dia.Link) => {
highlighters.addClass.add(link.findView(paper), 'root', 'pending', {
className: 'pending'
});
};

const clearPending = (link: dia.Link) => {
highlighters.addClass.remove(link.findView(paper), 'pending');
};

// Both closing events clear the indicator, so bind them in one call.
avoidRouter.on({
'link:routing': setPending,
'link:routed': clearPending,
'link:routing:cancelled': clearPending
});

on() takes either a single event name or a map of them, as mvc.Events does everywhere else. The space-separated form (on('link:routed link:routing:cancelled', fn)) works at runtime too, but it is not covered by the service's typings - the map keeps TypeScript happy.

idle is the graph-wide equivalent, and the right moment for work that should wait until the diagram has settled - fitting the paper to its content, taking an export, or resolving a promise your application is waiting on:

avoidRouter.on('idle', () => paper.fitToContent({ useModelGeometry: true, padding: 20 }));

Where a route came from

link:routed reports the route's origin:

  • 'avoid' - the route was computed by libavoid.
  • 'fallback' - the built-in rightAngle route was applied instead.

The fallback route is used in two situations. It is applied as an interim route to every link the moment a change arrives, so no link is ever left with a stale route while libavoid catches up. And it is the final route for links libavoid cannot handle - those carry a reason alongside the origin:

avoidRouter.on('link:routed', (link, { origin, reason }) => {
if (origin === 'fallback' && reason) {
console.log(`${link.id} could not be routed: ${reason}`);
}
});

libavoid also does not expose a way to check whether a route it produced is valid, so the service applies a heuristic to the returned path and falls back to the rightAngle route when the result cannot be trusted. Those fallbacks report origin: 'fallback' with no reason.

The reason is one of three values:

ReasonCause
'unconnected'One or both ends are a loose point rather than being connected to a cell - a link end dragged onto blank paper, say.
'unsupported'One or both ends are connected to another link. libavoid cannot route to a connector.
'untracked'Both ends are connected to an element, but at least one of them is excluded via trackElement.

interceptUnroutableLink is called before the fallback route is applied. Return true to claim the link - the built-in fallback is skipped entirely and routing that link is left to you:

const avoidRouter = await initAvoidRouter(graph, {
interceptUnroutableLink: ({ link, reason }) => {
if (reason !== 'unconnected') return false; // let the fallback handle it

// A link end is loose - being dragged, most likely. Keep our own
// routing for it instead of taking the built-in fallback.
link.router('normal');
return true;
}
});

Returning false - the default behavior when no callback is given - lets the built-in rightAngle fallback route be applied as usual.

Example

Hover a link and drag one of its arrowheads onto blank paper: the link becomes 'unconnected' and takes the fallback route. Drop it back on an element to see it routed by libavoid again. Links waiting on a route are dashed and grey.

One-shot routing

start() attaches a graph listener and keeps the diagram routed for as long as it is running. That is not always what you want. A read-only diagram, a report, a graph you lay out once and then export - all of these need the routes computed exactly once, and nothing after that.

Two methods do that, and neither attaches a listener:

MethodRoutes
routeAll()Every cell currently in the graph.
routeSubgraph(cells)Only the given cells.
const avoidRouter = await initAvoidRouter(graph);

await avoidRouter.routeAll();
paper.transformToFitContent({ useModelGeometry: true, padding: 20 });

avoidRouter.destroy();

Both return a promise whether or not a Worker is in use, and the pass is queued rather than run during the call - so await is not optional. On the main thread the routing itself is a blocking WebAssembly call, but it happens after your call has returned; reading a link's vertices straight after routeAll() still gives you the interim route, not the final one.

Both resolve with a RoutingResult: 'done' when every route was applied, or 'cancelled' when destroy() interrupted the pass. A cancelled pass resolves rather than rejecting, so a fire-and-forget call cannot leave you with an unhandled rejection.

In straight-line code like the above there is nothing to check - nothing can destroy the service while you are awaiting it.

The status matters when you treat the finished pass as authoritative, because a cancelled one leaves links on the interim rightAngle routes applied at the start of the pass. Persisting or exporting those would record half-routed links as if they were final:

const { status } = await avoidRouter.routeAll();
if (status === 'cancelled') return; // links are still on interim routes

await save(graph.toJSON());
warning

Both methods reset libavoid's state in one go, which conflicts with the incremental updates a running graph listener applies. They throw while the service is started - check isStarted or call stop() first.

Passes run strictly one after another. A pass invoked while another is still in flight waits for it, since each one replaces the engine's entire content.

info

start() opens with a full-graph routing pass of its own. After routeAll() that simply recomputes the same routes, so the two compose - but it undoes what routeSubgraph() established, routing the whole graph as one set again. If per-group routing has to hold, do not start the service afterwards.

Routing groups in isolation

routeSubgraph() is the more interesting of the two. It resets libavoid's state to contain exactly the cells you hand it - anything outside that set is neither routed nor treated as an obstacle - and it leaves routes already applied to links outside the set untouched.

That makes it possible to route parts of a diagram independently. A container's internal links can be routed around that container's own children, oblivious to every other container on the paper, and the links between containers can then be routed in their own pass with each container's bounding box as a single opaque obstacle:

// Each container's own content, in isolation.
await avoidRouter.routeSubgraph(containerA.getEmbeddedCells());
await avoidRouter.routeSubgraph(containerB.getEmbeddedCells());

// Then the links between the containers, treating each as one box.
await avoidRouter.routeSubgraph([containerA, containerB, ...containerLinks]);

Routing the whole graph in one pass would produce a very different result: the containers' children would be obstacles for the container-to-container links, and links from different containers would be nudged apart from one another.

note

Because no listener is attached, nothing re-routes an element that is later moved or resized. Either make the paper read-only, as the example below does with interactive: false, or re-run the passes yourself after a change.

Example

Three containers, each with its own internal mesh routed in isolation, plus the red container-to-container links routed in a final pass.

Running in a Worker

libavoid runs on the main thread by default. That is fine for small and medium diagrams, but route computation is CPU-bound: on a large graph, or while an element is being dragged across one, it competes with rendering and input handling for the same thread.

Setting worker: true moves the whole routing engine into a Worker thread. The graph listener still runs on the main thread, but the shapes and connectors are shipped over postMessage and the routes come back the same way:

const avoidRouter = await initAvoidRouter(graph, {
worker: true,
shapeBufferDistance: 20,
idealNudgingDistance: 5
});

avoidRouter.start();

Nothing else changes. The service exposes the same methods and emits the same events either way, and the choice is independent of whether you keep the graph routed with start() or run one-shot passes.

Because the Worker loads its own copy of the WebAssembly module, the module is deliberately not loaded on the main thread when worker is set - loading it twice would only waste memory and startup time.

Batching changes

The Worker debounces incoming changes: it waits for a quiet window after the last message before applying the whole batch and running a single routing pass. That keeps a drag gesture - which produces a change per pointer move - from queueing dozens of separate passes.

The window is 100 ms by default. Pass an object instead of true to change it:

const avoidRouter = await initAvoidRouter(graph, {
worker: { debounceTime: 250 }
});

Longer windows batch more aggressively, at the cost of routes visibly lagging behind the pointer. Set debounceTime: 0 to apply every change immediately - useful when changes arrive rarely and you want each one routed at once.

A one-shot pass is posted to the Worker like any other change, so it waits out the same window before it runs. With the 100 ms default that is invisible, but a service that only ever runs one-shot passes has nothing to batch and the wait buys it nothing - set debounceTime: 0 there, and put it back if the service is later start()ed.

note

The main-thread provider does not debounce. debounceTime is ignored unless worker is set.

Bundler configuration

The Worker adds a second asset to the WebAssembly binary:

  • libavoid.wasm from libavoid-js, loaded by the Worker rather than by the main thread.
  • The package's own worker script, referenced as a module worker via new URL('./Worker.mjs', import.meta.url).

The worker script only resolves if your bundler detects the new URL(..., import.meta.url) pattern inside dependency code and emits Worker.mjs as its own chunk. Vite and webpack 5 do. The .wasm binary is never referenced that way and always has to be copied into your output directory yourself.

Two known snags:

  • Vite dev server - exclude the package from dependency pre-bundling, or esbuild inlines the module and destroys the pattern:

    vite.config.js
    export default { optimizeDeps: { exclude: ['@joint/router-avoid'] } };
  • Angular CLI - a known limitation. Its Web Worker handling is a TypeScript transformer that runs only over your application's own sources, so the spawn inside node_modules is left untouched and the worker 404s at runtime. Copying Worker.mjs to the output root does not help either: it contains bare specifiers a browser cannot resolve. Until this is addressed, use the main-thread provider under Angular.

warning

worker: true is not available in the UMD build. The UMD bundle does not ship the worker script, so the ESM build is required for Worker-based routing.

Serving the WebAssembly binary

libavoid-js resolves libavoid.wasm relative to the script that loads it. In a bundled application that means the binary has to be emitted next to your bundle - copy node_modules/libavoid-js/dist/libavoid.wasm into your output directory with a copy plugin for your bundler of choice (copy-webpack-plugin, vite-plugin-static-copy, rollup-plugin-copy, and so on).

If you serve it from somewhere else, point the router at it explicitly. The same option covers both providers - it is forwarded into the Worker for you:

const avoidRouter = await initAvoidRouter(graph, {
libavoidFilePath: '/assets/libavoid.wasm'
});

To load the module ahead of time - during a splash screen, say - call loadAvoidRouter() yourself. initAvoidRouter() then reuses the already loaded module:

import { loadAvoidRouter } from '@joint/router-avoid';

await loadAvoidRouter('/assets/libavoid.wasm');
warning

Keep libavoid.wasm a separately served file. libavoid-js is licensed under the LGPL-2.1-or-later, and applications shipping this package also ship the binary under that license - do not configure your bundler to inline it into your application bundle.

Tearing down

A RouterService holds resources - a graph listener, and a Worker thread when one is in use. Release them when the diagram goes away:

avoidRouter.destroy();

The instance must not be used afterwards. When replacing one service with another on the same graph, destroy the old one first - by default both track every cell, so they would fight over the same links.

There is also a UMD version available

Place the UMD distribution of the plugin into the root of your package.

One way to do that is via NPM
  1. Install the avoid router open-source package (@joint/router-avoid) using NPM:

    npm add @joint/router-avoid
  2. Navigate to the newly created node_modules folder.

  3. Copy the @joint/router-avoid/dist/umd/index.js file and paste it into the root of your package, together with libavoid-js/dist/index.js and libavoid-js/dist/libavoid.wasm.

The UMD bundle reads its dependency from the global libavoidJs, but libavoid-js ships as an ES module and defines no global of its own. Load it from a module script, publish it as a global, and only then pull the UMD bundle in:

index.html
<script src="joint.js"></script>
<script type="module">
import * as libavoidJs from './libavoid.js';
window.libavoidJs = libavoidJs;
await import('./joint-router-avoid.js');
await import('./index.js');
</script>

Access the router through the joint.routers.avoid namespace:

index.js
const avoidRouter = await joint.routers.avoid.initAvoidRouter(graph, {
shapeBufferDistance: 20
});
avoidRouter.start();
warning

The UMD build merges into JointJS's own joint.routers namespace, so avoid appears alongside manhattan, metro and the rest - but it is not one of them. It holds initAvoidRouter and loadAvoidRouter, not a router function, so link.router('avoid') throws dia.LinkView: unknown router: "avoid". Route the graph through initAvoidRouter() as above.

note

Given the module shim the UMD build requires, that the WebAssembly binary has to be served as a separate file either way, and that Worker routing is unavailable here, the ESM build is the smoother path for this package.