Orthogonal edge routing on a grid for node-link diagrams: A* with per-direction cell occupancy, Steiner-style bus joins, and crossing "hops". Framework-free core + an optional Svelte 5 canvas where the consumer owns the chips.
| Schematic with named ports | Org chart |
|---|---|
![]() |
![]() |
| Oil & gas + cost layers | Radial tree |
|---|---|
![]() |
![]() |
| Mission flow — real data, 40+ buses into one hub chip |
|---|
![]() |
- Grid & occupancy — the canvas is rasterized into square cells
(
res, default 12px). Node boxes (inflated byBOX_INFLATE) hard-block cells. Each free cell can host one horizontal and one vertical run; same-direction reuse by another bus is practically prohibited, perpendicular crossings pay a small toll. Lanes separate themselves; crossings happen only where they're worth it. - Buses & joins — edges sharing
(source, bus)form a bus. Later edges of a bus seed their search from every cell the bus already routed (cost ~0), so branches leave the trunk at the optimal point.exitCostprices opening a new trunk out of a source: raise it to force merges even over shorter direct paths. Color follows the bus: a bus shares geometry, so every edge in it should render the same color — if you color per target, split the buses along the same lines (bus: colorGroupOf(target)). - Endpoints are exclusive — two buses never share a start/goal cell, so
stubs and arrowheads never stack. This caps a chip's connection capacity at
roughly its perimeter in cells (~2·(w+h)/res): a hub collecting more buses
than that exhausts its ring and degrades into violations. Corridor gaps
can't grow a ring — a finer
rescan (the mission-flow example seats a 40-bus hub at 8px that overflows at 12px). - Nothing is dropped — if a dense node row walls the grid off, a desperate
pass routes through obstacles at a huge cost and the result counts it in
violations.violations > 0is a layout problem, not a router bug: the layout does not supply enough corridor space. - Corridor supply —
corridorGaps(res)returns the empirically-validated minimumrowGap/chipGap/sidePadthe layout must provide so that routing stays violation-free. A chip gap must fit one aligned free cell column (2·res + 2·inflate); side padding keeps the outer columns routable. - Hops — crossings are disambiguated without geometry: each bus paints a background-colored halo under its paths, cutting a small gap into every bus drawn before it (the cut line reads as tunneling under). Per-bus grouping keeps a bus's own T-junctions intact.
- Cost layers — the router is deliberately agnostic of higher-level layout
concepts (lanes, sections, keep-out zones).
opts.costRegionslets the consumer express them anyway: rects with a per-cellbias— positive repels routes, negative attracts them (e.g. a "lanes layer" biasing its corridors negative). The step cost is floored, so negative biases are safe. - Port constraints —
opts.nodeSidesrestricts which box sides a node's edges may attach to (schematic pins, side-anchored UML). For POSITIONED named ports, register tiny pad elements at the pin locations as their own nodes (mcu:pb0) and connect edges to those — the body registers separately as a pure obstacle (see the circuit example); small boxes are first-class. - Pre-built layers —
withLayers(base, portsLayer(…), keepOutLayer(…), corridorLayer(…))composes declarative routing facts into opts; falsy entries are skipped so layers toggle inline (withLayers(base, zoneOn && keepOutLayer([zone]))).
import { routeGrid, corridorGaps } from 'grid-router';
const result = routeGrid(
boxes, // Map<string, GridBox> — measured node rects (t/b/l/r/cx/cy)
edges, // { id, source, target, bus?, data? }[]
width, // routable canvas size in px
height,
{ res: 12, costOwn: 0.02, costTurn: 1, exitCost: 0 } // defaults shown
);
// result.conns: { id, source, target, bus, data, d /* SVG path */ }[]
// result.violations: number (0 = clean; see corridorGaps)
// result.bottom, result.debug (occupancy cells for overlays)The component draws only connectors. You lay out arbitrary chip markup in
the default snippet and tag each connectable element with the provided
register action; chip events stay on your own DOM. Size the component's
container with CSS — it measures itself (no width/height props).
<script lang="ts">
import { GridDiagram, type GridConn } from 'grid-router/svelte';
let hovered = $state<string | undefined>();
const edges = [{ id: 'e1', source: 'a', target: 'b', bus: 'ok', data: { tone: 'ok' } }];
</script>
<GridDiagram
{edges}
opts={{ res: 12 }}
connStyle={(c) => ({
color: (c.data as { tone?: string })?.tone === 'ok' ? '#3fb950' : '#8b94a1',
dashed: false,
arrowAt: 'end',
class: hovered && hovered !== c.id ? 'dim' : ''
})}
onconnenter={(c) => (hovered = c.id)}
onconnleave={() => (hovered = undefined)}
onrouted={({ violations, ms }) => console.log(violations, ms)}
>
{#snippet children(register)}
<div class="my-layout">
<div use:register={'a'}>chip A</div>
<div use:register={'b'}>chip B</div>
</div>
{/snippet}
</GridDiagram>
<style>
/* consumer-driven highlight: connStyle classes land on package paths */
:global(path.dim) { opacity: 0.15; }
</style>Layout contract: use the published CSS vars --gr-row-gap / --gr-chip-gap
(from corridorGaps(res)) as your row/chip gaps, and set --grid-diagram-bg
to your panel background so crossing hops cut with the right color.
Live at https://geptyro.github.io/grid-router/, or locally:
cd examples && npm install && npm run dev # http://localhost:5200- Electronic circuit — real schematic symbols with POSITIONED NAMED PORTS
(
bat:+,mcu:pb0— tiny registered pads at the lead tips; the body is just an obstacle); nets as buses (VCC/GND rails share trunks), no arrowheads (arrowAt: 'none'), hover a wire to trace its whole net. - Org chart — one bus per manager, reporting lines merge; hover a line or a person to light the whole team.
- Oil & gas + cost layers — absolutely-positioned network, buses per
product; toggle a maintenance keep-out zone (
keepOutLayer) and watch the flows detour, or hit randomize layout — chips scatter and the router re-routes live (therevisionprop triggers re-measure when chips move without resizing the canvas). - Radial tree — a mind map: root at the CENTER, color-coded limbs radiating in all directions via polar placement; each parent's children are one bus, so limbs fuse near the trunk (drive it with the merge knob). Randomize tree regenerates the whole topology.
npm install
npm run build # tsc → dist (core)
npm test # vitestThe Svelte entry ships as source (src/svelte) and is compiled by the
consumer's bundler (Vite + svelte plugin resolve the svelte export
condition).




