Files
codegraph/ui/src/lib/map-model.ts
T
Colby McHenry 7ec9ef1818 feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address
Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
2026-08-31 23:49:39 -05:00

703 lines
29 KiB
TypeScript

/**
* The Map's layout — deterministic, and computed here rather than by a physics
* simulation (design spec §3.6, epic rule 2).
*
* Everything in this file is a pure function of the `/api/map` payload plus two
* switches (include tests, which module is selected). That is what lets the
* canvas re-render on a toggle without a round-trip, and what lets the layout
* be unit-tested — a force-directed graph settles somewhere slightly different
* every time you open it, and a diagram you cannot recognise between two visits
* is not a map of anything.
*
* The pipeline, in order:
*
* 1. **Filter.** Drop test modules unless asked for; drop links whose ends went
* with them.
* 2. **Pick a layering basis.** Prefer each link's `declared` weight — the
* edges resolved through an import, a qualified name, an inheritance clause
* or a typed receiver. Bare name matching resolves calls to `run`, `push`
* and `finish` across unrelated directories, and letting those set the
* vertical order puts the storage layer under the CLI. When too few links
* carry a declared edge to describe the repository (a language whose
* imports the resolver cannot follow), fall back to raw counts and say so.
* 3. **Break two-cycles.** Keep the heavier direction; the lighter one becomes
* a mutual dependency, drawn only when one of its modules is selected.
* 4. **Layer.** Longest path: a module sits one layer above everything it
* depends on. Layer 0 is the foundations, at the bottom.
* 5. **Order.** Barycenter, three sweeps, from a stable alphabetical start.
* 6. **Place, then port.** Boxes get x/y; each edge gets a distinct port along
* its endpoints' edges so a bundle fans out instead of knotting at a corner.
*
* An edge that points *up* after all that — a broken two-cycle, or a link with
* no declared edge behind it — is marked `back` and drawn only when a module it
* touches is selected. Drawing it downward would be a lie about the direction
* of the dependency; hiding it entirely would be a lie about its existence.
*
* The Screens view runs the same pipeline with three options the Map leaves at
* their defaults: its own layering (distance from the entry screen), a wider
* layer gap (its edges carry labels), and `directional` ports — a link that
* points up the layering leaves the TOP of its source and arrives at the
* BOTTOM of its target, so a return trip is drawn around the boxes instead of
* through them. In a screens graph a cycle is the normal case, not the
* exception the Map hides at rest.
*/
import type { WireMapLink, WireMapModule, WireMapPayload } from './api';
// Geometry, from the design spec. Changing these changes the picture.
export const NODE_HEIGHT = 40;
export const LAYER_GAP = 74;
export const NODE_GAP = 34;
export const PADDING = 44;
/** Least horizontal room a layer gets per module, so a sparse row still spreads. */
const MIN_SLOT = 230;
/**
* Room between two ports on one side of a box, when a view asks for it
* (`portPitch`). Fifteen lines leaving a 110px box are 7px apart and read as
* one; at 12px they are a fan a reader can follow back to its box.
*/
export const PORT_PITCH = 12;
const MIN_NODE_WIDTH = 110;
/**
* IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate.
*
* The prototype drew labels as SVG text that spilled harmlessly past the
* rectangle, so 7.3 was close enough there. An HTML box clips instead, and at
* 7.3 a 27-character id like `src/resolution/(root files)` lost its last
* characters to an ellipsis — measured in the browser: 211px of text in 205px
* of box. Padding is the box's own 9px each side plus its 1px borders.
*/
const CHAR_WIDTH = 7.81;
const LABEL_PADDING = 22;
/** Links below this weight stay hidden until a module they touch is selected. */
export const MIN_WEIGHT = 4;
/** …raised when tests are included, because a test module touches everything. */
export const MIN_WEIGHT_WITH_TESTS = 6;
/**
* Share of links that must carry a declared edge for the declared basis to be
* used. Below this the declared graph is too sparse to describe the repository
* — most modules would land on layer 0 with nothing explaining why — and the
* layout falls back to raw counts, announced in the side panel, never silent.
*
* Two thirds of this repository's links are declared at every depth, and the
* same holds for any language whose imports the resolver can follow; the
* fallback exists for the ones where it cannot.
*/
const DECLARED_BASIS_COVERAGE = 0.4;
/** Approximate advance of the 11px sans meta line, measured against Archivo. */
const META_CHAR_WIDTH = 5.9;
const META_PADDING = 24;
/**
* A box wide enough for BOTH of its lines.
*
* The spec sizes a node from its label (`label.length x 7.3 + 28`); the
* prototype's SVG let the "N symbols · M files" line spill outside the
* rectangle, which an HTML box cannot do without looking broken. So the width
* is the wider of the two lines. Same formula for the label, same determinism,
* and `src/bin` now says "63 symbols · 5 files" instead of "5 fi…" — a count
* clipped to an ellipsis is worse than a slightly wider box.
*/
export function nodeWidth(label: string, meta = ''): number {
return Math.max(
MIN_NODE_WIDTH,
label.length * CHAR_WIDTH + LABEL_PADDING,
meta.length * META_CHAR_WIDTH + META_PADDING
);
}
/**
* The second line of a module box — and the string {@link nodeWidth} sizes for.
*
* An island says so INSTEAD of counting itself. "Nothing depends on this" is
* the only fact about such a module a reader needs from twenty boxes away, and
* the counts are still one click away in the side panel. Both callers — the
* width calculation and the box itself — must pass the same `island`, or the
* text will not fit the box that was sized for it.
*/
export function moduleMetaLabel(module: WireMapModule, island = false): string {
if (island) return 'nothing depends on this';
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
// How big a change here is, said in the same breath as how big the module is.
// Two boxes of 20 files are not the same box when one of them is imported by
// ninety files and the other by two, and until this line the picture had no
// channel that said so — width tracked the length of the PATH.
// `?.` because `GraphAdapter` is a public seam: a host that assembles this
// payload itself and has not caught up to the field must lose the bar, not
// the screen. Every other read of `dependents` goes through this one.
const reach = module.dependents?.files ?? 0;
const depend = reach > 0 ? ` · ${reach} depend on it` : '';
return `${symbols} · ${files}${depend}`;
}
/** One port on a box's edge: the link it belongs to, and which end of it this is. */
export interface PortRef {
id: string;
type: 'source' | 'target';
}
export interface MapNodeLayout {
id: string;
module: WireMapModule;
/**
* No link in the payload arrives here — an island (task CG-59).
*
* Computed from the WHOLE link set, not the filtered one, so hiding test
* modules or raising the weight threshold cannot manufacture an island that
* the index does not agree is one.
*/
island: boolean;
/** Every file in it is tool-generated, so it draws in ink-4. */
generated: boolean;
/**
* How much of the picture leans on this box, 0..1, as a share of the
* most-depended-on box DRAWN — the bar along the bottom of the node.
*
* Relative rather than absolute because there is no absolute scale a reader
* could calibrate against: 94 dependent files is enormous in a 377-file app
* and unremarkable in a monorepo. Relative to what is on screen, the longest
* bar always means "this is the one to be careful with, here". The absolute
* number is on the box beside it, so the bar never has to be trusted alone.
*/
weight: number;
layer: number;
x: number;
y: number;
width: number;
height: number;
/** Link ids leaving from the BOTTOM of this node, left to right — one hidden handle each. */
sourceHandles: string[];
/** Link ids arriving at the TOP of this node, left to right. */
targetHandles: string[];
/**
* Every port on the box, by side, left to right — what a node component
* draws its handles from. Under the Map's `layered` ports this is exactly
* `targetHandles` on top and `sourceHandles` below. Under `directional`
* ports a side mixes the two: an edge routed `up` leaves the top of its
* source and arrives at the bottom of its target, and a `level` edge leaves
* and arrives at the top, arching over the row.
*/
ports: { top: PortRef[]; bottom: PortRef[] };
}
export interface MapEdgeLayout {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
link: WireMapLink;
/** Stroke width, from the spec's `min(6, 1 + log2(count) x 0.7)`. */
width: number;
/** Points up the layering: a mutual dependency or a link with nothing declared. */
back: boolean;
/** Below the weight threshold — drawn only when a touching module is selected. */
thin: boolean;
/**
* Which way the link runs through the layering: `down` to a lower layer,
* `up` to a higher one, `level` along its own. Under `directional` ports
* this decides the sides the edge uses and the curve it draws.
*/
route: EdgeRoute;
}
export type EdgeRoute = 'down' | 'up' | 'level';
export interface MapLayerLayout {
index: number;
y: number;
/** Only the top and bottom layers are named. */
label: string | null;
}
export interface MutualPair {
/** The heavier direction. */
forward: WireMapLink;
/** The lighter one — the back-reference. */
back: WireMapLink;
}
export interface MapLayout {
nodes: MapNodeLayout[];
edges: MapEdgeLayout[];
layers: MapLayerLayout[];
width: number;
height: number;
/** What set the vertical order, and how thin the evidence was. */
basis: {
kind: 'declared' | 'all';
declaredLinks: number;
totalLinks: number;
};
minWeight: number;
/** Links hidden for being thin, at rest. */
hiddenLinks: number;
mutual: MutualPair[];
/** Module-level cycles of three or more, in the drawn graph. */
moduleCycles: string[][];
}
export interface MapLayoutOptions {
includeTests: boolean;
/** Override the hidden-link floor; 0 draws every link (the Screens view). */
minWeight?: number;
/**
* The two lines a box is sized for. The Map's boxes show the module id and
* its counts; a view that shows something else (a screen's path and its
* component) must size for what it draws, or an opaque id decides the width.
*/
sizing?: (module: WireMapModule, island: boolean) => { label: string; meta: string };
/**
* Replace longest-path layering. Receives every module id and the acyclic
* links (mutual pairs already broken); returns each id's layer, 0 at the
* BOTTOM. The Screens view lays out by distance from the entry screen,
* where "one layer above what it depends on" would put the head of the
* longest chain of screens above the login page.
*/
layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
/**
* A row order the view already knows — the Steps view's rows read in the
* code's order. It is the initial order, and the sweeps then move a box
* only to sit under its parents (a barycenter over parents alone, not
* children), tie-broken by this order rather than by id.
*/
order?: (id: string) => number;
/**
* Vertical room between two layers; {@link LAYER_GAP} unless a view says
* otherwise. The Screens view widens it because its edges carry labels, and
* a label needs a lane the Map's hairlines never did.
*/
layerGap?: number;
/**
* Least distance between two ports on one side of a box; a box widens to
* keep it. 0 (the default) sizes a box by its text alone.
*/
portPitch?: number;
/**
* `layered` (the default): every link leaves a bottom and arrives at a top,
* whichever way it points. `directional`: see {@link MapNodeLayout.ports}.
*/
ports?: 'layered' | 'directional';
}
export function strokeWidthFor(count: number): number {
return Math.min(6, 1 + Math.log2(Math.max(1, count)) * 0.7);
}
/**
* A link's stable identity, and the id Svelte Flow keys its edge on.
*
* NUL is the separator because a module id is a path and a path may contain
* anything else — including the spaces, arrows and colons that read nicer.
*/
export function linkId(link: { source: string; target: string }): string {
return `${link.source}\u0000${link.target}`;
}
export function buildMapLayout(
payload: Pick<WireMapPayload, 'modules' | 'links'>,
options: MapLayoutOptions
): MapLayout {
const modules = payload.modules.filter((m) => options.includeTests || !m.test);
const present = new Set(modules.map((m) => m.id));
// Islands come off the UNFILTERED link set: a module a hidden test module
// depends on is depended on, whatever this screen is currently showing.
const depended = new Set(payload.links.map((l) => l.target));
const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
const layerGap = options.layerGap ?? LAYER_GAP;
const portPitch = options.portPitch ?? 0;
const directional = options.ports === 'directional';
const declaredLinks = links.filter((l) => l.declared > 0);
const useDeclared =
links.length > 0 && declaredLinks.length >= links.length * DECLARED_BASIS_COVERAGE;
const weightOf = (link: WireMapLink): number => (useDeclared ? link.declared : link.count);
const layeringLinks = useDeclared ? declaredLinks : links;
// --- 2-cycle break, on the layering graph only ---------------------------
const byPair = new Map(layeringLinks.map((l) => [linkId(l), l]));
const acyclic: WireMapLink[] = [];
const mutual: MutualPair[] = [];
for (const link of layeringLinks) {
const back = byPair.get(linkId({ source: link.target, target: link.source }));
if (!back) {
acyclic.push(link);
continue;
}
const mine = weightOf(link);
const theirs = weightOf(back);
// Ties broken by id so two runs over one payload agree.
if (theirs > mine || (theirs === mine && link.source > link.target)) {
mutual.push({ forward: back, back: link });
continue;
}
acyclic.push(link);
}
// --- longest-path layering ----------------------------------------------
const out = new Map<string, string[]>(modules.map((m) => [m.id, []]));
for (const link of acyclic) out.get(link.source)?.push(link.target);
for (const list of out.values()) list.sort();
const layer = new Map<string, number>();
if (options.layering) {
for (const [id, value] of options.layering(modules.map((m) => m.id), acyclic)) layer.set(id, value);
for (const module of modules) if (!layer.has(module.id)) layer.set(module.id, 0);
} else {
for (const module of modules) longestPath(module.id, out, layer, new Set());
}
const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
const rows: string[][] = Array.from({ length: layerCount }, () => []);
for (const module of modules) rows[layer.get(module.id) ?? 0]!.push(module.id);
const given = options.order;
for (const row of rows) row.sort(given ? (a, b) => given(a) - given(b) || a.localeCompare(b) : undefined);
// --- barycenter ordering, three sweeps -----------------------------------
// With an order given, a box's barycenter is over its parents alone, so
// siblings under one parent keep the order they came in.
const neighbours = new Map<string, string[]>(modules.map((m) => [m.id, []]));
for (const link of acyclic) {
neighbours.get(link.target)?.push(link.source);
if (!given) neighbours.get(link.source)?.push(link.target);
}
const position = new Map<string, number>();
for (const row of rows) row.forEach((id, i) => position.set(id, i));
for (let sweep = 0; sweep < 3; sweep += 1) {
for (const row of rows) {
const bary = new Map(row.map((id) => [id, barycenter(id, neighbours, position)]));
// Sort by barycenter, then by the previous position, then by id: three
// total-order tiebreaks so the sweep cannot depend on sort stability.
// Infinity minus Infinity is NaN, so the unconnected modules — which all
// carry Infinity — are compared by the later keys instead.
row.sort((a, b) => {
const ba = bary.get(a) ?? 0;
const bb = bary.get(b) ?? 0;
if (ba !== bb && Number.isFinite(ba - bb)) return ba - bb;
if (ba !== bb) return ba < bb ? -1 : 1;
return (position.get(a) ?? 0) - (position.get(b) ?? 0) || (given ? given(a) - given(b) : 0) || a.localeCompare(b);
});
row.forEach((id, i) => position.set(id, i));
}
}
// --- placement -----------------------------------------------------------
// Which side of each box a link's two ports land on is settled by the
// layers alone, so it is known before any box has a width — and a view
// that asked for a port pitch needs it now: a hub with nineteen lines
// leaving its bottom edge is widened to hold them.
const routeOf = (link: { source: string; target: string }): EdgeRoute => {
const from = layer.get(link.source) ?? 0;
const to = layer.get(link.target) ?? 0;
return from > to ? 'down' : from < to ? 'up' : 'level';
};
const sidesOf = (route: EdgeRoute): { source: 'top' | 'bottom'; target: 'top' | 'bottom' } => {
if (!directional || route === 'down') return { source: 'bottom', target: 'top' };
if (route === 'up') return { source: 'top', target: 'bottom' };
return { source: 'top', target: 'top' };
};
const portCount = new Map<string, { top: number; bottom: number }>(
modules.map((m) => [m.id, { top: 0, bottom: 0 }])
);
for (const link of links) {
const sides = sidesOf(routeOf(link));
portCount.get(link.source)![sides.source] += 1;
portCount.get(link.target)![sides.target] += 1;
}
const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
const widths = new Map(
modules.map((m) => {
const island = islands.has(m.id);
const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
const count = portCount.get(m.id) ?? { top: 0, bottom: 0 };
const forPorts = (Math.max(count.top, count.bottom) + 1) * portPitch;
return [m.id, Math.max(nodeWidth(lines.label, lines.meta), forPorts)];
})
);
const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
// Natural span = the boxes shoulder to shoulder. The content width is the
// widest of those, and NOTHING may exceed it — a row of forty leaf modules
// must not stretch the canvas to `40 x MIN_SLOT` and shrink every other row
// to a thumbnail. MIN_SLOT only breathes a row out INSIDE that width.
const naturalSpans = rows.map(
(row, i) => (rowSums[i] ?? 0) + Math.max(0, row.length - 1) * NODE_GAP
);
const contentWidth = Math.max(1, ...naturalSpans);
const rowSpans = rows.map((row, i) =>
Math.min(contentWidth, Math.max(naturalSpans[i] ?? 0, row.length * MIN_SLOT))
);
const width = contentWidth + PADDING * 2;
const height = layerCount * (NODE_HEIGHT + layerGap) - layerGap + PADDING * 2;
const nodesById = new Map<string, MapNodeLayout>();
const byId = new Map(modules.map((m) => [m.id, m]));
// The busiest box DRAWN sets the scale — so turning tests on rescales the
// bars rather than leaving a test module's bar overflowing a hidden maximum.
const heaviest = Math.max(0, ...modules.map((m) => m.dependents?.files ?? 0));
rows.forEach((row, index) => {
const span = rowSpans[index] ?? 0;
const sum = rowSums[index] ?? 0;
const gap = row.length > 1 ? (span - sum) / (row.length - 1) : 0;
// A single box centres in the content width instead of clinging to the
// left edge — the common case for the entry point at the top.
let x = PADDING + (contentWidth - span) / 2 + (row.length === 1 ? (span - sum) / 2 : 0);
const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap);
for (const id of row) {
const w = widths.get(id) ?? MIN_NODE_WIDTH;
const module = byId.get(id)!;
nodesById.set(id, {
id,
module,
island: islands.has(id),
// Every file generated, not merely some: a module with one `.pb.go` in
// it is still a module somebody writes by hand.
generated: module.files > 0 && module.generated === module.files,
weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
layer: index,
x,
y,
width: w,
height: NODE_HEIGHT,
sourceHandles: [],
targetHandles: [],
ports: { top: [], bottom: [] },
});
x += w + gap;
}
});
// --- edges and ports -----------------------------------------------------
// EVERY link is laid out, including the ones the layering ignored: a link
// that survives the filter exists in the code, and the map's job is to say
// where it goes, not to pretend it is absent.
const edges: MapEdgeLayout[] = [];
// Every port, by box and side, with the x of the link's other end.
const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
for (const link of links) {
const from = nodesById.get(link.source);
const to = nodesById.get(link.target);
if (!from || !to) continue;
const id = linkId(link);
const route = routeOf(link);
const edge: MapEdgeLayout = {
id,
source: link.source,
target: link.target,
sourceHandle: `s:${id}`,
targetHandle: `t:${id}`,
link,
width: strokeWidthFor(link.count),
back: from.layer <= to.layer,
thin: link.count < minWeight,
route,
};
edges.push(edge);
const sides = sidesOf(route);
(sidePorts.get(link.source) ?? setDefault(sidePorts, link.source))[sides.source].push({
id,
type: 'source',
other: xOf(nodesById, link.target),
});
(sidePorts.get(link.target) ?? setDefault(sidePorts, link.target))[sides.target].push({
id,
type: 'target',
other: xOf(nodesById, link.source),
});
}
// Ports spread in the order the other end appears left-to-right, so bundles
// between two layers stay untangled instead of crossing inside the gap.
const byOther = (a: SidePort, b: SidePort) => a.other - b.other || a.id.localeCompare(b.id);
for (const [id, sides] of sidePorts) {
const node = nodesById.get(id);
if (!node) continue;
sides.top.sort(byOther);
sides.bottom.sort(byOther);
node.ports = {
top: sides.top.map((p) => ({ id: p.id, type: p.type })),
bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
};
node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
}
const layers: MapLayerLayout[] = rows.map((_, index) => ({
index,
y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap) + NODE_HEIGHT / 2,
label:
layerCount === 1
? null
: index === layerCount - 1
? 'entry points'
: index === 0
? 'foundations — depend on nothing below'
: null,
}));
return {
nodes: [...nodesById.values()],
edges,
layers,
width,
height,
basis: {
kind: useDeclared ? 'declared' : 'all',
declaredLinks: declaredLinks.length,
totalLinks: links.length,
},
minWeight,
hiddenLinks: edges.filter((e) => e.thin || e.back).length,
mutual: mutual.sort((a, b) => b.back.count - a.back.count || a.back.source.localeCompare(b.back.source)),
moduleCycles: moduleCycles(modules.map((m) => m.id), edges),
};
}
/**
* Which edges are drawn, given the selection.
*
* At rest the map shows the layering: downward links carrying real weight.
* Selecting a module says "show me everything about this one", so its thin
* links and its back-references come out — for that module only.
*/
export function isEdgeVisible(edge: MapEdgeLayout, selected: string | null): boolean {
if (selected !== null) return edge.source === selected || edge.target === selected;
return !edge.thin && !edge.back;
}
/**
* Where a link's port sits on a box: `x = left + width x (i+1)/(n+1)` along
* the side that holds it, at the top or bottom edge. The same arithmetic the
* node components place their hidden handles with, so a view that needs the
* point before anything is rendered — to put a label on the curve — gets the
* one the browser will measure.
*/
export function portPoint(node: MapNodeLayout, id: string, type: 'source' | 'target'): { x: number; y: number } {
const top = node.ports.top.findIndex((p) => p.id === id && p.type === type);
if (top >= 0) return { x: node.x + (node.width * (top + 1)) / (node.ports.top.length + 1), y: node.y };
const bottom = node.ports.bottom.findIndex((p) => p.id === id && p.type === type);
if (bottom >= 0) {
return {
x: node.x + (node.width * (bottom + 1)) / (node.ports.bottom.length + 1),
y: node.y + node.height,
};
}
return { x: node.x + node.width / 2, y: type === 'source' ? node.y + node.height : node.y };
}
interface SidePort extends PortRef {
/** Centre x of the link's other end — the sort key along the side. */
other: number;
}
function setDefault(
map: Map<string, { top: SidePort[]; bottom: SidePort[] }>,
key: string
): { top: SidePort[]; bottom: SidePort[] } {
const sides = { top: [], bottom: [] };
map.set(key, sides);
return sides;
}
function xOf(nodes: Map<string, MapNodeLayout>, id: string): number {
const node = nodes.get(id);
return node ? node.x + node.width / 2 : 0;
}
/**
* A module's horizontal pull: the mean position of everything it connects to.
*
* A module connected to nothing has no pull, and giving it its own position
* back leaves it wherever the alphabet dropped it — which on a repository with
* forty leaf directories means forty unconnected boxes interleaved through the
* drawing, pushing the parts that DO connect apart. Infinity parks them at the
* right-hand end of their layer instead, so the connected picture stays
* contiguous. They are still drawn, and still counted.
*/
function barycenter(
id: string,
neighbours: Map<string, string[]>,
position: Map<string, number>
): number {
const list = neighbours.get(id) ?? [];
if (list.length === 0) return Number.POSITIVE_INFINITY;
let sum = 0;
for (const other of list) sum += position.get(other) ?? 0;
return sum / list.length;
}
/**
* A module's layer: one above the deepest thing it depends on.
*
* `visiting` guards a cycle the two-cycle break did not catch (a three-module
* loop). Returning 0 there is not an answer, it is a floor — the module still
* gets placed above whatever else it depends on, and the loop itself is
* reported separately in {@link MapLayout.moduleCycles}.
*/
function longestPath(
id: string,
out: Map<string, string[]>,
layer: Map<string, number>,
visiting: Set<string>
): number {
const known = layer.get(id);
if (known !== undefined) return known;
if (visiting.has(id)) return 0;
visiting.add(id);
let value = 0;
for (const next of out.get(id) ?? []) {
value = Math.max(value, longestPath(next, out, layer, visiting) + 1);
}
visiting.delete(id);
layer.set(id, value);
return value;
}
/** Strongly connected components of three or more modules, in the drawn graph. */
function moduleCycles(ids: readonly string[], edges: readonly MapEdgeLayout[]): string[][] {
const out = new Map<string, string[]>(ids.map((id) => [id, []]));
for (const edge of edges) out.get(edge.source)?.push(edge.target);
for (const list of out.values()) list.sort();
const index = new Map<string, number>();
const low = new Map<string, number>();
const onStack = new Set<string>();
const stack: string[] = [];
const found: string[][] = [];
let counter = 0;
const strongconnect = (id: string): void => {
index.set(id, counter);
low.set(id, counter);
counter += 1;
stack.push(id);
onStack.add(id);
for (const next of out.get(id) ?? []) {
if (!index.has(next)) {
strongconnect(next);
low.set(id, Math.min(low.get(id) ?? 0, low.get(next) ?? 0));
} else if (onStack.has(next)) {
low.set(id, Math.min(low.get(id) ?? 0, index.get(next) ?? 0));
}
}
if (low.get(id) === index.get(id)) {
const component: string[] = [];
for (;;) {
const popped = stack.pop();
if (popped === undefined) break;
onStack.delete(popped);
component.push(popped);
if (popped === id) break;
}
if (component.length > 2) found.push(component.sort());
}
};
for (const id of [...ids].sort()) if (!index.has(id)) strongconnect(id);
return found.sort((a, b) => b.length - a.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
}