| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702 |
- /**
- * 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] ?? ''));
- }
|