feat(joint-router-avoid): new avoid router package - #3457
Conversation
… content after paper unfreeze Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- rename unroutable reason 'untracked-element' to 'untracked',
matching the trackElement option it derives from
- replace useWorker/workerUpdateDebounceTime with nested
worker: boolean | { debounceTime } so the debounce option
cannot be set without the worker it belongs to
- align provider-level default values with initAvoidRouter
(shapeBufferDistance 10, idealNudgingDistance 5)
- export public option/callback types under their real names
(index.mts still aliased the old Skip* names)
- document why WorkerProvider.sync may resolve on an
uncorrelated 'processed' response
- update READMEs and examples to the current API
Note: test/index.js still asserts 'untracked-element' and needs
updating by the package author.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
routeAll()/routeSubgraph() resolve with { status: 'done' | 'cancelled' }
instead of void. destroy() settles in-flight and queued passes as
'cancelled' rather than rejecting, so fire-and-forget callers get no
unhandled rejections; provider errors unrelated to destruction still
reject. Passes are serialized - each replaces the provider's entire
content, so overlapping calls now queue instead of racing.
The result object is additive-extensible (e.g. per-status data later)
without breaking the signature.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new JointJS workspace package, @joint/router-avoid, which integrates the libavoid WASM router to keep dia.Graph links orthogonally routed and obstacle-avoiding (optionally via a Web Worker), while also extracting the built-in rightAngle path-finding logic into a reusable @joint/core algorithm export (alg.rightAnglePath). It also introduces a TypeScript demo app showcasing usage and multiple routing modes.
Changes:
- Adds
@joint/router-avoidpackage:RouterService, main-thread and Worker providers, build/test setup (Rollup + Karma/QUnit), and full docs. - Refactors
@joint/corerightAnglerouter to delegate path-finding toalg.rightAnglePathand addsuseModelGeometryoption (plus typings). - Adds
examples/avoid-router-tsdemo workspace and updates monorepo packaging/lockfile accordingly.
Reviewed changes
Copilot reviewed 45 out of 50 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds workspace entries/deps for the new router package and the new demo workspace. |
| package.json | Updates pack-all to include @joint/router-avoid. |
| packages/joint-router-avoid/package.json | Defines the new published router package (exports, scripts, deps). |
| packages/joint-router-avoid/README.md | Public documentation for API, events, caveats, and licensing notes. |
| packages/joint-router-avoid/SECURITY.md | Package security policy. |
| packages/joint-router-avoid/LICENSE | MPL-2.0 license file for the package. |
| packages/joint-router-avoid/.gitignore | Ignores dist/node_modules/coverage outputs for the new workspace. |
| packages/joint-router-avoid/eslint.config.mjs | ESLint flat-config wiring for the new workspace (incl. test globals). |
| packages/joint-router-avoid/tsconfig.json | Base TS compiler configuration for the new workspace. |
| packages/joint-router-avoid/tsconfig.esm.json | ESM build TS config for emitting dist/esm. |
| packages/joint-router-avoid/tsconfig.cjs.json | CJS build TS config for emitting dist/cjs. |
| packages/joint-router-avoid/rollup.config.mjs | UMD bundling configuration (banner + minified output). |
| packages/joint-router-avoid/karma.conf.js | Browser test runner configuration for the router package (WASM proxying, coverage). |
| packages/joint-router-avoid/src/index.mts | Public entrypoint exports for the package. |
| packages/joint-router-avoid/src/init.mts | Entry points to load libavoid and initialize a RouterService (main-thread vs Worker). |
| packages/joint-router-avoid/src/RouterService.mts | Core graph-listening service applying avoid/fallback routes and emitting routing lifecycle events. |
| packages/joint-router-avoid/src/providers/Provider.mts | Provider abstraction for main-thread vs Worker-based avoid execution. |
| packages/joint-router-avoid/src/providers/MainThreadProvider.mts | Main-thread provider implementation that drives avoid synchronously. |
| packages/joint-router-avoid/src/providers/WorkerProvider.mts | Worker-backed provider implementation with message-based batching and sync serialization. |
| packages/joint-router-avoid/src/providers/Worker.mts | Worker script that hosts the avoid router and debounced message processing. |
| packages/joint-router-avoid/test/index.html | Test runner page for browser-based QUnit tests. |
| packages/joint-router-avoid/test/libavoid-loader.mjs | Sets up the libavoidJs global for the UMD build tests. |
| packages/joint-router-avoid/test/index.js | End-to-end QUnit tests for RouterService behavior/events (main-thread provider). |
| packages/joint-core/src/core.mjs | Exposes alg namespace from @joint/core runtime exports. |
| packages/joint-core/src/alg/index.mjs | Adds alg barrel export for the extracted algorithm(s). |
| packages/joint-core/src/alg/rightAnglePath.mjs | New extracted path-finding implementation used by rightAngle and external consumers. |
| packages/joint-core/src/routers/rightAngle.mjs | Refactors router to use rightAnglePath and adds useModelGeometry option handling. |
| packages/joint-core/types/index.d.ts | Exposes alg namespace from @joint/core type exports. |
| packages/joint-core/types/alg.d.ts | Declares the new alg.rightAnglePath public API in types. |
| packages/joint-core/types/routers.d.ts | Adds useModelGeometry to RightAngleRouterArguments typings. |
| examples/avoid-router-ts/package.json | Adds demo workspace package definition and deps. |
| examples/avoid-router-ts/README.md | Demo README / setup and licensing notes. |
| examples/avoid-router-ts/.gitignore | Ignores build/dist/node_modules for the demo. |
| examples/avoid-router-ts/tsconfig.json | Demo TS compiler config. |
| examples/avoid-router-ts/webpack.config.js | Demo webpack dev/build configuration (incl. wasm copy). |
| examples/avoid-router-ts/index.html | Demo HTML shell with tabbed canvases. |
| examples/avoid-router-ts/styles.scss | Demo shared styling (tabs/canvas layout). |
| examples/avoid-router-ts/src/index.ts | Demo entrypoint wiring tab switching and lazy initialization. |
| examples/avoid-router-ts/src/common.ts | Shared demo helpers (paper creation, zoom, link interactions). |
| examples/avoid-router-ts/src/simple-graph/shapes.ts | Demo simple-graph custom shapes/ports. |
| examples/avoid-router-ts/src/simple-graph/resize-tool.ts | Demo resize tool implementation. |
| examples/avoid-router-ts/src/simple-graph/example.ts | Demo “simple graph” example using initAvoidRouter. |
| examples/avoid-router-ts/src/simple-graph-extra/shapes.ts | Demo “simple extra” custom shapes with subgraph grouping. |
| examples/avoid-router-ts/src/simple-graph-extra/resize-tool.ts | Demo resize tool for “simple extra”. |
| examples/avoid-router-ts/src/simple-graph-extra/example.ts | Demo with two independent RouterService instances on one graph. |
| examples/avoid-router-ts/src/large-graph/shapes.ts | Demo large-graph shapes/ports for worker-backed routing. |
| examples/avoid-router-ts/src/large-graph/example.ts | Demo large-graph example using worker: true routing. |
| examples/avoid-router-ts/src/containers/shapes.ts | Demo container and container-link shapes for isolated routing passes. |
| examples/avoid-router-ts/src/containers/example.ts | Demo using routeSubgraph() for container-isolated one-shot routing. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- reject WorkerProvider.init() instead of hanging forever when the Worker fails to load or its avoid initialization fails (new 'error' worker response, worker.onerror/onmessageerror wiring) - swallow the destroy()-induced rejection of the background sync started by start()/graph reset, so a normal teardown does not surface as an unhandled promise rejection - skip the main-thread WASM load when the Worker provider is used - the Worker loads its own copy, the main-thread one was pure waste - drop the unused _collection parameter from the reset handler Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 50 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
packages/joint-router-avoid/src/providers/MainThreadProvider.mts:83
setShape()recomputesshape.pinsin the caller, but when the shape already exists this branch only callsmoveShape()and returns, so pin definitions can never be added/updated after first creation. This makes the behavior depend on ports being fully known up-front; if ports are added/changed later, the provider will still have the old pin set.
packages/joint-router-avoid/src/providers/Worker.mts:217- When a shape already exists,
handleUpdateShape()only moves the shape rect and returns, ignoringshape.pins. As a result, pins are effectively immutable after first registration, which can cause mismatches if an element’s ports (and thus pins) are added/changed after the initial sync.
Applying route attributes - the consumer's setRouteAttributes callback, or consumer change listeners reacting to link.set() - can throw. Both apply paths now run through a shared applySafely() that closes any open routing cycle via link:routing:cancelled before the error propagates, so a buggy consumer cannot break the routing/routed pairing or the idle event's accounting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 50 changed files in this pull request and generated no new comments.
Suppressed comments (6)
packages/joint-router-avoid/src/providers/Worker.mts:297
- Typo in the
@ts-expect-errorexplanation comment: "do not defined" → "not defined".
packages/joint-router-avoid/src/providers/MainThreadProvider.mts:199 - Typo in the
@ts-expect-errorexplanation comment: "do not defined" → "not defined".
packages/joint-router-avoid/src/RouterService.mts:614 x: x / widthandy: y / heightwill produceInfinity/NaNwhen an element has a zero width/height (which can happen with programmatic models), yielding invalid pin coordinates and potentially breaking avoid routing. Consider guarding the normalization to avoid division by zero.
packages/joint-router-avoid/src/RouterService.mts:745- When using
setRouteAttributes, the callback never receivesrouting: falsefor final avoid-computed routes (it’s omitted entirely). SinceSetRouteAttributesCallbackParametersdocumentsroutingastruefor provisional andfalsefor final, it would be clearer/safer to passrouting: falsehere.
packages/joint-router-avoid/src/providers/Worker.mts:247 - Typo in the
@ts-expect-errorexplanation comment: "do not defined" → "not defined".
This issue also appears on line 296 of the same file.
packages/joint-router-avoid/src/providers/MainThreadProvider.mts:159
- Typo in the
@ts-expect-errorexplanation comment: "do not defined" → "not defined".
This issue also appears on line 198 of the same file.
- guard pin normalization against zero-size elements - dividing by a zero dimension fed NaN/Infinity pin coordinates into the avoid WASM module - pass explicit routing: false to setRouteAttributes for final avoid routes, matching the documented contract - fix '@ts-expect-error do not defined' typos Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 50 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/joint-core/src/alg/rightAnglePath.mjs:324
- In the right→left S-shaped overlap branch, the fallback route starts at
middleXinstead of routing outward from the source (sOffsetX). This can make the first segment go into the source element rather than away from it, leading to intersecting/invalid paths.
const middleX = (sOffsetX + tOffsetX) / 2;
return [
{ x: middleX, y: sOffsetY },
{ x: middleX, y: middleY },
{ x: middleX, y: middleY },
packages/joint-router-avoid/src/RouterService.mts:672
setRouting()emitslink:routingevery time it's called, even if the link is already inpendingLinks. During rapid changes (e.g. dragging), this can produce multiplelink:routingevents for a single open routing cycle, but only onelink:routedto close it, which breaks the “cycle” semantics described in the doc comment and can confuse event consumers.
packages/joint-core/src/alg/rightAnglePath.mjs:245- In the left→right S-shaped overlap branch, the fallback route starts at
middleXinstead of routing outward from the source (sOffsetX). This makes the first segment go into the source bbox (from the left side anchor toward the interior) and can produce paths that intersect the element.
This issue also appears on line 320 of the same file.
const middleX = (sOffsetX + tOffsetX) / 2;
return [
{ x: middleX, y: sOffsetY },
{ x: middleX, y: middleY },
{ x: middleX, y: middleY },
Repeated changes while a link's routing cycle is already open (e.g. dragging an element) re-emitted link:routing without a closing link:routed, breaking the documented one-to-one pairing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supersedes #3443.
Summary
@joint/router-avoid, a new package that routes JointJS links via libavoid (WASM, throughlibavoid-js), keeping a graph's links obstacle-avoiding and orthogonally routed - norouter:attribute needed once a link connects two elements. Ships both a main-thread provider and a Web-Worker-backed provider (worker: true) for larger graphs.rightAnglerouter's path-finding into a new public@joint/corealg.rightAnglePathfunction, shared between the built-inrightAnglerouter and@joint/router-avoid's fallback route (used while avoid computes a link's real route, or when a link can't be routed by avoid at all). Fixes stale flat-property references left over from the bbox-based refactor that were breaking most of therightAnglerouter's own test suite.useModelGeometryto therightAnglerouter's options.examples/avoid-router-ts, a TypeScript demo app (simple graph, two independentRouterServiceinstances on onedia.Graph, large graph routed via a Web Worker).API surface
initAvoidRouter(graph, options)/loadAvoidRouter(filePath?)- entry points (also on the UMD global asjoint.routers.avoid.*). The returnedRouterServiceis not started:start()/stop()for continuous routing,routeAll()/routeSubgraph(cells)for one-shot passes (e.g. routing each container's content independently),isStarted,destroy().RoutingResult-{ status: 'done' | 'cancelled' }.destroy()settles in-flight and queued passes as'cancelled'instead of rejecting (no unhandled rejections for fire-and-forget callers); provider errors unrelated to destruction still reject. Overlapping passes are queued and run one after another. The result object is additive-extensible (e.g. per-status data later) without a breaking signature change.trackLink/trackElementselect what is routed/tracked as an obstacle.interceptUnroutableLinkgives the consumer first refusal on a link avoid can't route, with a reason:'unconnected'(loose end),'untracked'(end element excluded viatrackElement), or'unsupported'(link-to-link connection).setRouteAttributesoverrides how computed routes are applied (e.g. through a command manager), receivingorigin: 'avoid' | 'fallback'and the provisional/final state;changeFlagnames theoptflag marking the router's own writes.worker: boolean | { debounceTime?: number }runs libavoid inside a Web Worker;debounceTimecontrols how long the Worker batches incoming updates before processing them in one transaction.link:routing,link:routed({ origin, reason }),link:routing:cancelled,idle.Test plan
yarn testfor@joint/router-avoidpasses (28/28)@joint/core's test suite passes, including the previously-brokenrightAnglerouter suiteexamples/avoid-router-tstype-checks (tsc --noEmit)🤖 Generated with Claude Code