graph-layout
Tier 2 · domain. Depends on code-graph and elkjs.
npm install @titan-design/graph-layoutThe problem it solves
A file-level code graph is too dense to draw. A picture of it needs two steps: collapse the graph to the level the reader cares about (packages, directories, or one package against its neighbours), then place boxes and route edges so they do not cross the boxes. Both steps lived privately inside codewatch's renderer, so a second product had to copy them.
The primitive is a level collapse plus an ELK layered layout that is a function of the graph alone. Inputs are sorted by id before ELK runs and coordinates are rounded to 0.5 px, so the same graph listed in any order lays out the same way.
When to reach for it
- You draw a code-graph snapshot as boxes and edges, flat or with nested group boxes.
- You need the layout to be reproducible: a cached picture, a diffable artifact, or a video frame that must not shift between renders.
Use code-graph for the graph itself and code-read's hierarchy.get for rolled-up values per tree depth.
Example
Verified against 0.0.0.
import { collapseToPackages, computeLayout } from "@titan-design/graph-layout";
const packages = collapseToPackages({
snapshotId: 1,
nodes: [
{ id: "packages/web/src/app.ts", kind: "file", name: "app.ts" },
{ id: "packages/api/src/server.ts", kind: "file", name: "server.ts" },
],
edges: [{ srcId: "packages/web/src/app.ts", dstId: "packages/api/src/server.ts", kind: "imports" }],
});
const { nodes, edges } = await computeLayout(packages);
// nodes: [{ id: "api", x, y, width: 180, height: 48, ... }, { id: "web", ... }]
// edges[0].attrs.route: the orthogonal route from web to api, in the root frameFor nested boxes, pass a GroupOf that names each node's ancestor groups, outermost first:
import { computeCompoundLayout } from "@titan-design/graph-layout";
const layout = await computeCompoundLayout(fileGraph, null, (n) => [`pkg:${n.id.split("/")[1]}`]);
// layout.groups: [{ id: "pkg:api", x, y, width, height }, ...], each box around its filesEdges may name a group id as either end. Such an edge is routed between the group boxes in the same pass, so a package view drawn over the file layout needs no second layout:
import { computeCompoundLayout, routeOf, routeToPathD } from "@titan-design/graph-layout";
const pkgEdge = { srcId: "pkg:web", dstId: "pkg:api", kind: "imports" };
const layout = await computeCompoundLayout(
{ ...fileGraph, edges: [...fileGraph.edges, pkgEdge] },
null,
(n) => [`pkg:${n.id.split("/")[1]}`],
);
const trunk = layout.edges.find((e) => e.srcId === "pkg:web" && e.dstId === "pkg:api")!;
const d = routeToPathD(routeOf(trunk) ?? []); // "M x y L x y …", web's border to api'sA group box is sized for everything inside it, so the package view of a compound layout is mostly empty boxes. To show the package level at its own size, lay the collapsed graph out alone with boxes sized for their labels. No two labels overlap, because each sits inside a box ELK keeps clear of the others. These boxes are not the compound layout's boxes, so a view that zooms from one level to the other moves between two layouts:
import { collapseToPackages, computeLayout, labelBoxSizing } from "@titan-design/graph-layout";
const packages = collapseToPackages(fileGraph);
const labels = new Map(packages.nodes.map((n) => [n.id, n.name]));
// Monospace labels at 28 units: each box is the label's width plus 10 on each side, and 64 tall.
const sizing = labelBoxSizing(labels, { fontSize: 28, charWidth: 0.6, padding: 10, bodyHeight: 16 });
const level1 = await computeLayout(packages, sizing);A pin is a short pill of text beside one box. placePills places each pill outside its box, inside the visible frame above a keep-out band, and clear of every label you list and every pill placed before it. It reads geometry only, so a product can run it at build time and hand the boxes to a painter. A pill with no clear spot comes back in dropped, never drawn over something:
import { placePills } from "@titan-design/graph-layout";
const { placed, dropped } = placePills(
{
frame: { x: 0, y: 0, width: 1200, height: 675 },
anchors: [{ id: "api", rect: { x: 520, y: 160, width: 160, height: 60 } }],
obstacles: [{ id: "web->api", rect: { x: 300, y: 176, width: 200, height: 28 } }],
},
[{ id: "p1", anchor: "api", width: 140, height: 32 }],
{ gap: 8, keepOut: 0.83 },
);
// placed: [{ id: "p1", anchor: "api", rect: { x: 530, y: 228, width: 140, height: 32 }, candidate: "below0" }]For an animated architecture diagram, emitRepoMap writes the whole map at once: package boxes hold directory boxes, which hold file boxes, and the edges at all three levels are routed in one pass. The result is repo-map/v1 data that titan-design's map painter reads as is:
import { emitRepoMap } from "@titan-design/graph-layout";
const map = await emitRepoMap(fileGraph, { maxFilesPerBox: 6 });
// map.nodes: [{ id: "pkg:api", level: 1, box: { x, y, w, h } }, { id: "dir:packages/api/src", level: 2, parentId: "pkg:api", ... }, ...]
// map.edges: [{ id: "pkg:web->pkg:api", level: 1, weight: 3, edgeSource: "import", route: [[x, y], ...] }, ...]
// map.sha: the same for the same snapshot in any order, and it covers the elkjs versionWhat it deliberately does not do
- No rendering. It returns coordinates and routes; drawing them is the product's job.
- No layout with fixed node positions. ELK layered places every node itself. Its interactive strategies read input positions only as ordering hints, ELK's
fixedalgorithm keeps positions but routes no edges, and elkjs does not bundle libavoid. - No pruning or label text outside the repo map. Box sizes come from the caller's
sizingmap, defaulting to 180 x 48, and a pill's size comes from itsPinSpec.emitRepoMapalone prunes (+N more) and sizes boxes for 44 px labels. - No caption policy.
placePillstakes the keep-out line asconfig.keepOut; the product owns the number.
Gotchas
- Results list nodes and edges sorted by id, not in input order.
xandyare box centres, not top-left corners, for nodes and group boxes alike. ARectinplacePillsis the other way round:xandyare its top-left corner.placePillsplaces inrankorder, then by id, whatever order the pins come in. Give a later-cued pin a higher rank so earlier pins keep the spots nearest their boxes.- A group-endpoint edge attaches only when its id equals one
groupOfreturns; any other edge whose ends are not node or group ids is dropped from the layout. elkjsis pinned to an exact version: a minor release can move every coordinate.- A route is keyed by its
(srcId, dstId)pair, so two edges of different kinds between the same pair share one route. - When ELK throws or places nothing,
computeLayoutandcomputeCompoundLayoutfall back to a grid rather than throwing.computeCompoundLayoutStrictandemitRepoMapreject, because a grid has no routes. - The rule for a file's package is fixed (
packageOf):packages/<name>gives<name>,products/<name>andapps/<name>keep their root, and any other path groups by its first segment.
Where it came from
Extracted from codewatch's @codewatch/render (the collapse functions, computeLayout, the compound layout and its ELK option sets) for a second product that draws repository maps. The extraction added the id sort and the 0.5 px rounding. codewatch moves onto this package in a later change.