map-model.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. /**
  2. * The Map's layout — deterministic, and computed here rather than by a physics
  3. * simulation (design spec §3.6, epic rule 2).
  4. *
  5. * Everything in this file is a pure function of the `/api/map` payload plus two
  6. * switches (include tests, which module is selected). That is what lets the
  7. * canvas re-render on a toggle without a round-trip, and what lets the layout
  8. * be unit-tested — a force-directed graph settles somewhere slightly different
  9. * every time you open it, and a diagram you cannot recognise between two visits
  10. * is not a map of anything.
  11. *
  12. * The pipeline, in order:
  13. *
  14. * 1. **Filter.** Drop test modules unless asked for; drop links whose ends went
  15. * with them.
  16. * 2. **Pick a layering basis.** Prefer each link's `declared` weight — the
  17. * edges resolved through an import, a qualified name, an inheritance clause
  18. * or a typed receiver. Bare name matching resolves calls to `run`, `push`
  19. * and `finish` across unrelated directories, and letting those set the
  20. * vertical order puts the storage layer under the CLI. When too few links
  21. * carry a declared edge to describe the repository (a language whose
  22. * imports the resolver cannot follow), fall back to raw counts and say so.
  23. * 3. **Break two-cycles.** Keep the heavier direction; the lighter one becomes
  24. * a mutual dependency, drawn only when one of its modules is selected.
  25. * 4. **Layer.** Longest path: a module sits one layer above everything it
  26. * depends on. Layer 0 is the foundations, at the bottom.
  27. * 5. **Order.** Barycenter, three sweeps, from a stable alphabetical start.
  28. * 6. **Place, then port.** Boxes get x/y; each edge gets a distinct port along
  29. * its endpoints' edges so a bundle fans out instead of knotting at a corner.
  30. *
  31. * An edge that points *up* after all that — a broken two-cycle, or a link with
  32. * no declared edge behind it — is marked `back` and drawn only when a module it
  33. * touches is selected. Drawing it downward would be a lie about the direction
  34. * of the dependency; hiding it entirely would be a lie about its existence.
  35. *
  36. * The Screens view runs the same pipeline with three options the Map leaves at
  37. * their defaults: its own layering (distance from the entry screen), a wider
  38. * layer gap (its edges carry labels), and `directional` ports — a link that
  39. * points up the layering leaves the TOP of its source and arrives at the
  40. * BOTTOM of its target, so a return trip is drawn around the boxes instead of
  41. * through them. In a screens graph a cycle is the normal case, not the
  42. * exception the Map hides at rest.
  43. */
  44. import type { WireMapLink, WireMapModule, WireMapPayload } from './api';
  45. // Geometry, from the design spec. Changing these changes the picture.
  46. export const NODE_HEIGHT = 40;
  47. export const LAYER_GAP = 74;
  48. export const NODE_GAP = 34;
  49. export const PADDING = 44;
  50. /** Least horizontal room a layer gets per module, so a sparse row still spreads. */
  51. const MIN_SLOT = 230;
  52. /**
  53. * Room between two ports on one side of a box, when a view asks for it
  54. * (`portPitch`). Fifteen lines leaving a 110px box are 7px apart and read as
  55. * one; at 12px they are a fan a reader can follow back to its box.
  56. */
  57. export const PORT_PITCH = 12;
  58. const MIN_NODE_WIDTH = 110;
  59. /**
  60. * IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate.
  61. *
  62. * The prototype drew labels as SVG text that spilled harmlessly past the
  63. * rectangle, so 7.3 was close enough there. An HTML box clips instead, and at
  64. * 7.3 a 27-character id like `src/resolution/(root files)` lost its last
  65. * characters to an ellipsis — measured in the browser: 211px of text in 205px
  66. * of box. Padding is the box's own 9px each side plus its 1px borders.
  67. */
  68. const CHAR_WIDTH = 7.81;
  69. const LABEL_PADDING = 22;
  70. /** Links below this weight stay hidden until a module they touch is selected. */
  71. export const MIN_WEIGHT = 4;
  72. /** …raised when tests are included, because a test module touches everything. */
  73. export const MIN_WEIGHT_WITH_TESTS = 6;
  74. /**
  75. * Share of links that must carry a declared edge for the declared basis to be
  76. * used. Below this the declared graph is too sparse to describe the repository
  77. * — most modules would land on layer 0 with nothing explaining why — and the
  78. * layout falls back to raw counts, announced in the side panel, never silent.
  79. *
  80. * Two thirds of this repository's links are declared at every depth, and the
  81. * same holds for any language whose imports the resolver can follow; the
  82. * fallback exists for the ones where it cannot.
  83. */
  84. const DECLARED_BASIS_COVERAGE = 0.4;
  85. /** Approximate advance of the 11px sans meta line, measured against Archivo. */
  86. const META_CHAR_WIDTH = 5.9;
  87. const META_PADDING = 24;
  88. /**
  89. * A box wide enough for BOTH of its lines.
  90. *
  91. * The spec sizes a node from its label (`label.length x 7.3 + 28`); the
  92. * prototype's SVG let the "N symbols · M files" line spill outside the
  93. * rectangle, which an HTML box cannot do without looking broken. So the width
  94. * is the wider of the two lines. Same formula for the label, same determinism,
  95. * and `src/bin` now says "63 symbols · 5 files" instead of "5 fi…" — a count
  96. * clipped to an ellipsis is worse than a slightly wider box.
  97. */
  98. export function nodeWidth(label: string, meta = ''): number {
  99. return Math.max(
  100. MIN_NODE_WIDTH,
  101. label.length * CHAR_WIDTH + LABEL_PADDING,
  102. meta.length * META_CHAR_WIDTH + META_PADDING
  103. );
  104. }
  105. /**
  106. * The second line of a module box — and the string {@link nodeWidth} sizes for.
  107. *
  108. * An island says so INSTEAD of counting itself. "Nothing depends on this" is
  109. * the only fact about such a module a reader needs from twenty boxes away, and
  110. * the counts are still one click away in the side panel. Both callers — the
  111. * width calculation and the box itself — must pass the same `island`, or the
  112. * text will not fit the box that was sized for it.
  113. */
  114. export function moduleMetaLabel(module: WireMapModule, island = false): string {
  115. if (island) return 'nothing depends on this';
  116. const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
  117. const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
  118. // How big a change here is, said in the same breath as how big the module is.
  119. // Two boxes of 20 files are not the same box when one of them is imported by
  120. // ninety files and the other by two, and until this line the picture had no
  121. // channel that said so — width tracked the length of the PATH.
  122. // `?.` because `GraphAdapter` is a public seam: a host that assembles this
  123. // payload itself and has not caught up to the field must lose the bar, not
  124. // the screen. Every other read of `dependents` goes through this one.
  125. const reach = module.dependents?.files ?? 0;
  126. const depend = reach > 0 ? ` · ${reach} depend on it` : '';
  127. return `${symbols} · ${files}${depend}`;
  128. }
  129. /** One port on a box's edge: the link it belongs to, and which end of it this is. */
  130. export interface PortRef {
  131. id: string;
  132. type: 'source' | 'target';
  133. }
  134. export interface MapNodeLayout {
  135. id: string;
  136. module: WireMapModule;
  137. /**
  138. * No link in the payload arrives here — an island (task CG-59).
  139. *
  140. * Computed from the WHOLE link set, not the filtered one, so hiding test
  141. * modules or raising the weight threshold cannot manufacture an island that
  142. * the index does not agree is one.
  143. */
  144. island: boolean;
  145. /** Every file in it is tool-generated, so it draws in ink-4. */
  146. generated: boolean;
  147. /**
  148. * How much of the picture leans on this box, 0..1, as a share of the
  149. * most-depended-on box DRAWN — the bar along the bottom of the node.
  150. *
  151. * Relative rather than absolute because there is no absolute scale a reader
  152. * could calibrate against: 94 dependent files is enormous in a 377-file app
  153. * and unremarkable in a monorepo. Relative to what is on screen, the longest
  154. * bar always means "this is the one to be careful with, here". The absolute
  155. * number is on the box beside it, so the bar never has to be trusted alone.
  156. */
  157. weight: number;
  158. layer: number;
  159. x: number;
  160. y: number;
  161. width: number;
  162. height: number;
  163. /** Link ids leaving from the BOTTOM of this node, left to right — one hidden handle each. */
  164. sourceHandles: string[];
  165. /** Link ids arriving at the TOP of this node, left to right. */
  166. targetHandles: string[];
  167. /**
  168. * Every port on the box, by side, left to right — what a node component
  169. * draws its handles from. Under the Map's `layered` ports this is exactly
  170. * `targetHandles` on top and `sourceHandles` below. Under `directional`
  171. * ports a side mixes the two: an edge routed `up` leaves the top of its
  172. * source and arrives at the bottom of its target, and a `level` edge leaves
  173. * and arrives at the top, arching over the row.
  174. */
  175. ports: { top: PortRef[]; bottom: PortRef[] };
  176. }
  177. export interface MapEdgeLayout {
  178. id: string;
  179. source: string;
  180. target: string;
  181. sourceHandle: string;
  182. targetHandle: string;
  183. link: WireMapLink;
  184. /** Stroke width, from the spec's `min(6, 1 + log2(count) x 0.7)`. */
  185. width: number;
  186. /** Points up the layering: a mutual dependency or a link with nothing declared. */
  187. back: boolean;
  188. /** Below the weight threshold — drawn only when a touching module is selected. */
  189. thin: boolean;
  190. /**
  191. * Which way the link runs through the layering: `down` to a lower layer,
  192. * `up` to a higher one, `level` along its own. Under `directional` ports
  193. * this decides the sides the edge uses and the curve it draws.
  194. */
  195. route: EdgeRoute;
  196. }
  197. export type EdgeRoute = 'down' | 'up' | 'level';
  198. export interface MapLayerLayout {
  199. index: number;
  200. y: number;
  201. /** Only the top and bottom layers are named. */
  202. label: string | null;
  203. }
  204. export interface MutualPair {
  205. /** The heavier direction. */
  206. forward: WireMapLink;
  207. /** The lighter one — the back-reference. */
  208. back: WireMapLink;
  209. }
  210. export interface MapLayout {
  211. nodes: MapNodeLayout[];
  212. edges: MapEdgeLayout[];
  213. layers: MapLayerLayout[];
  214. width: number;
  215. height: number;
  216. /** What set the vertical order, and how thin the evidence was. */
  217. basis: {
  218. kind: 'declared' | 'all';
  219. declaredLinks: number;
  220. totalLinks: number;
  221. };
  222. minWeight: number;
  223. /** Links hidden for being thin, at rest. */
  224. hiddenLinks: number;
  225. mutual: MutualPair[];
  226. /** Module-level cycles of three or more, in the drawn graph. */
  227. moduleCycles: string[][];
  228. }
  229. export interface MapLayoutOptions {
  230. includeTests: boolean;
  231. /** Override the hidden-link floor; 0 draws every link (the Screens view). */
  232. minWeight?: number;
  233. /**
  234. * The two lines a box is sized for. The Map's boxes show the module id and
  235. * its counts; a view that shows something else (a screen's path and its
  236. * component) must size for what it draws, or an opaque id decides the width.
  237. */
  238. sizing?: (module: WireMapModule, island: boolean) => { label: string; meta: string };
  239. /**
  240. * Replace longest-path layering. Receives every module id and the acyclic
  241. * links (mutual pairs already broken); returns each id's layer, 0 at the
  242. * BOTTOM. The Screens view lays out by distance from the entry screen,
  243. * where "one layer above what it depends on" would put the head of the
  244. * longest chain of screens above the login page.
  245. */
  246. layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
  247. /**
  248. * A row order the view already knows — the Steps view's rows read in the
  249. * code's order. It is the initial order, and the sweeps then move a box
  250. * only to sit under its parents (a barycenter over parents alone, not
  251. * children), tie-broken by this order rather than by id.
  252. */
  253. order?: (id: string) => number;
  254. /**
  255. * Vertical room between two layers; {@link LAYER_GAP} unless a view says
  256. * otherwise. The Screens view widens it because its edges carry labels, and
  257. * a label needs a lane the Map's hairlines never did.
  258. */
  259. layerGap?: number;
  260. /**
  261. * Least distance between two ports on one side of a box; a box widens to
  262. * keep it. 0 (the default) sizes a box by its text alone.
  263. */
  264. portPitch?: number;
  265. /**
  266. * `layered` (the default): every link leaves a bottom and arrives at a top,
  267. * whichever way it points. `directional`: see {@link MapNodeLayout.ports}.
  268. */
  269. ports?: 'layered' | 'directional';
  270. }
  271. export function strokeWidthFor(count: number): number {
  272. return Math.min(6, 1 + Math.log2(Math.max(1, count)) * 0.7);
  273. }
  274. /**
  275. * A link's stable identity, and the id Svelte Flow keys its edge on.
  276. *
  277. * NUL is the separator because a module id is a path and a path may contain
  278. * anything else — including the spaces, arrows and colons that read nicer.
  279. */
  280. export function linkId(link: { source: string; target: string }): string {
  281. return `${link.source}\u0000${link.target}`;
  282. }
  283. export function buildMapLayout(
  284. payload: Pick<WireMapPayload, 'modules' | 'links'>,
  285. options: MapLayoutOptions
  286. ): MapLayout {
  287. const modules = payload.modules.filter((m) => options.includeTests || !m.test);
  288. const present = new Set(modules.map((m) => m.id));
  289. // Islands come off the UNFILTERED link set: a module a hidden test module
  290. // depends on is depended on, whatever this screen is currently showing.
  291. const depended = new Set(payload.links.map((l) => l.target));
  292. const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
  293. const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
  294. const layerGap = options.layerGap ?? LAYER_GAP;
  295. const portPitch = options.portPitch ?? 0;
  296. const directional = options.ports === 'directional';
  297. const declaredLinks = links.filter((l) => l.declared > 0);
  298. const useDeclared =
  299. links.length > 0 && declaredLinks.length >= links.length * DECLARED_BASIS_COVERAGE;
  300. const weightOf = (link: WireMapLink): number => (useDeclared ? link.declared : link.count);
  301. const layeringLinks = useDeclared ? declaredLinks : links;
  302. // --- 2-cycle break, on the layering graph only ---------------------------
  303. const byPair = new Map(layeringLinks.map((l) => [linkId(l), l]));
  304. const acyclic: WireMapLink[] = [];
  305. const mutual: MutualPair[] = [];
  306. for (const link of layeringLinks) {
  307. const back = byPair.get(linkId({ source: link.target, target: link.source }));
  308. if (!back) {
  309. acyclic.push(link);
  310. continue;
  311. }
  312. const mine = weightOf(link);
  313. const theirs = weightOf(back);
  314. // Ties broken by id so two runs over one payload agree.
  315. if (theirs > mine || (theirs === mine && link.source > link.target)) {
  316. mutual.push({ forward: back, back: link });
  317. continue;
  318. }
  319. acyclic.push(link);
  320. }
  321. // --- longest-path layering ----------------------------------------------
  322. const out = new Map<string, string[]>(modules.map((m) => [m.id, []]));
  323. for (const link of acyclic) out.get(link.source)?.push(link.target);
  324. for (const list of out.values()) list.sort();
  325. const layer = new Map<string, number>();
  326. if (options.layering) {
  327. for (const [id, value] of options.layering(modules.map((m) => m.id), acyclic)) layer.set(id, value);
  328. for (const module of modules) if (!layer.has(module.id)) layer.set(module.id, 0);
  329. } else {
  330. for (const module of modules) longestPath(module.id, out, layer, new Set());
  331. }
  332. const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
  333. const rows: string[][] = Array.from({ length: layerCount }, () => []);
  334. for (const module of modules) rows[layer.get(module.id) ?? 0]!.push(module.id);
  335. const given = options.order;
  336. for (const row of rows) row.sort(given ? (a, b) => given(a) - given(b) || a.localeCompare(b) : undefined);
  337. // --- barycenter ordering, three sweeps -----------------------------------
  338. // With an order given, a box's barycenter is over its parents alone, so
  339. // siblings under one parent keep the order they came in.
  340. const neighbours = new Map<string, string[]>(modules.map((m) => [m.id, []]));
  341. for (const link of acyclic) {
  342. neighbours.get(link.target)?.push(link.source);
  343. if (!given) neighbours.get(link.source)?.push(link.target);
  344. }
  345. const position = new Map<string, number>();
  346. for (const row of rows) row.forEach((id, i) => position.set(id, i));
  347. for (let sweep = 0; sweep < 3; sweep += 1) {
  348. for (const row of rows) {
  349. const bary = new Map(row.map((id) => [id, barycenter(id, neighbours, position)]));
  350. // Sort by barycenter, then by the previous position, then by id: three
  351. // total-order tiebreaks so the sweep cannot depend on sort stability.
  352. // Infinity minus Infinity is NaN, so the unconnected modules — which all
  353. // carry Infinity — are compared by the later keys instead.
  354. row.sort((a, b) => {
  355. const ba = bary.get(a) ?? 0;
  356. const bb = bary.get(b) ?? 0;
  357. if (ba !== bb && Number.isFinite(ba - bb)) return ba - bb;
  358. if (ba !== bb) return ba < bb ? -1 : 1;
  359. return (position.get(a) ?? 0) - (position.get(b) ?? 0) || (given ? given(a) - given(b) : 0) || a.localeCompare(b);
  360. });
  361. row.forEach((id, i) => position.set(id, i));
  362. }
  363. }
  364. // --- placement -----------------------------------------------------------
  365. // Which side of each box a link's two ports land on is settled by the
  366. // layers alone, so it is known before any box has a width — and a view
  367. // that asked for a port pitch needs it now: a hub with nineteen lines
  368. // leaving its bottom edge is widened to hold them.
  369. const routeOf = (link: { source: string; target: string }): EdgeRoute => {
  370. const from = layer.get(link.source) ?? 0;
  371. const to = layer.get(link.target) ?? 0;
  372. return from > to ? 'down' : from < to ? 'up' : 'level';
  373. };
  374. const sidesOf = (route: EdgeRoute): { source: 'top' | 'bottom'; target: 'top' | 'bottom' } => {
  375. if (!directional || route === 'down') return { source: 'bottom', target: 'top' };
  376. if (route === 'up') return { source: 'top', target: 'bottom' };
  377. return { source: 'top', target: 'top' };
  378. };
  379. const portCount = new Map<string, { top: number; bottom: number }>(
  380. modules.map((m) => [m.id, { top: 0, bottom: 0 }])
  381. );
  382. for (const link of links) {
  383. const sides = sidesOf(routeOf(link));
  384. portCount.get(link.source)![sides.source] += 1;
  385. portCount.get(link.target)![sides.target] += 1;
  386. }
  387. const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
  388. const widths = new Map(
  389. modules.map((m) => {
  390. const island = islands.has(m.id);
  391. const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
  392. const count = portCount.get(m.id) ?? { top: 0, bottom: 0 };
  393. const forPorts = (Math.max(count.top, count.bottom) + 1) * portPitch;
  394. return [m.id, Math.max(nodeWidth(lines.label, lines.meta), forPorts)];
  395. })
  396. );
  397. const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
  398. // Natural span = the boxes shoulder to shoulder. The content width is the
  399. // widest of those, and NOTHING may exceed it — a row of forty leaf modules
  400. // must not stretch the canvas to `40 x MIN_SLOT` and shrink every other row
  401. // to a thumbnail. MIN_SLOT only breathes a row out INSIDE that width.
  402. const naturalSpans = rows.map(
  403. (row, i) => (rowSums[i] ?? 0) + Math.max(0, row.length - 1) * NODE_GAP
  404. );
  405. const contentWidth = Math.max(1, ...naturalSpans);
  406. const rowSpans = rows.map((row, i) =>
  407. Math.min(contentWidth, Math.max(naturalSpans[i] ?? 0, row.length * MIN_SLOT))
  408. );
  409. const width = contentWidth + PADDING * 2;
  410. const height = layerCount * (NODE_HEIGHT + layerGap) - layerGap + PADDING * 2;
  411. const nodesById = new Map<string, MapNodeLayout>();
  412. const byId = new Map(modules.map((m) => [m.id, m]));
  413. // The busiest box DRAWN sets the scale — so turning tests on rescales the
  414. // bars rather than leaving a test module's bar overflowing a hidden maximum.
  415. const heaviest = Math.max(0, ...modules.map((m) => m.dependents?.files ?? 0));
  416. rows.forEach((row, index) => {
  417. const span = rowSpans[index] ?? 0;
  418. const sum = rowSums[index] ?? 0;
  419. const gap = row.length > 1 ? (span - sum) / (row.length - 1) : 0;
  420. // A single box centres in the content width instead of clinging to the
  421. // left edge — the common case for the entry point at the top.
  422. let x = PADDING + (contentWidth - span) / 2 + (row.length === 1 ? (span - sum) / 2 : 0);
  423. const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap);
  424. for (const id of row) {
  425. const w = widths.get(id) ?? MIN_NODE_WIDTH;
  426. const module = byId.get(id)!;
  427. nodesById.set(id, {
  428. id,
  429. module,
  430. island: islands.has(id),
  431. // Every file generated, not merely some: a module with one `.pb.go` in
  432. // it is still a module somebody writes by hand.
  433. generated: module.files > 0 && module.generated === module.files,
  434. weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
  435. layer: index,
  436. x,
  437. y,
  438. width: w,
  439. height: NODE_HEIGHT,
  440. sourceHandles: [],
  441. targetHandles: [],
  442. ports: { top: [], bottom: [] },
  443. });
  444. x += w + gap;
  445. }
  446. });
  447. // --- edges and ports -----------------------------------------------------
  448. // EVERY link is laid out, including the ones the layering ignored: a link
  449. // that survives the filter exists in the code, and the map's job is to say
  450. // where it goes, not to pretend it is absent.
  451. const edges: MapEdgeLayout[] = [];
  452. // Every port, by box and side, with the x of the link's other end.
  453. const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
  454. for (const link of links) {
  455. const from = nodesById.get(link.source);
  456. const to = nodesById.get(link.target);
  457. if (!from || !to) continue;
  458. const id = linkId(link);
  459. const route = routeOf(link);
  460. const edge: MapEdgeLayout = {
  461. id,
  462. source: link.source,
  463. target: link.target,
  464. sourceHandle: `s:${id}`,
  465. targetHandle: `t:${id}`,
  466. link,
  467. width: strokeWidthFor(link.count),
  468. back: from.layer <= to.layer,
  469. thin: link.count < minWeight,
  470. route,
  471. };
  472. edges.push(edge);
  473. const sides = sidesOf(route);
  474. (sidePorts.get(link.source) ?? setDefault(sidePorts, link.source))[sides.source].push({
  475. id,
  476. type: 'source',
  477. other: xOf(nodesById, link.target),
  478. });
  479. (sidePorts.get(link.target) ?? setDefault(sidePorts, link.target))[sides.target].push({
  480. id,
  481. type: 'target',
  482. other: xOf(nodesById, link.source),
  483. });
  484. }
  485. // Ports spread in the order the other end appears left-to-right, so bundles
  486. // between two layers stay untangled instead of crossing inside the gap.
  487. const byOther = (a: SidePort, b: SidePort) => a.other - b.other || a.id.localeCompare(b.id);
  488. for (const [id, sides] of sidePorts) {
  489. const node = nodesById.get(id);
  490. if (!node) continue;
  491. sides.top.sort(byOther);
  492. sides.bottom.sort(byOther);
  493. node.ports = {
  494. top: sides.top.map((p) => ({ id: p.id, type: p.type })),
  495. bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
  496. };
  497. node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
  498. node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
  499. }
  500. const layers: MapLayerLayout[] = rows.map((_, index) => ({
  501. index,
  502. y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap) + NODE_HEIGHT / 2,
  503. label:
  504. layerCount === 1
  505. ? null
  506. : index === layerCount - 1
  507. ? 'entry points'
  508. : index === 0
  509. ? 'foundations — depend on nothing below'
  510. : null,
  511. }));
  512. return {
  513. nodes: [...nodesById.values()],
  514. edges,
  515. layers,
  516. width,
  517. height,
  518. basis: {
  519. kind: useDeclared ? 'declared' : 'all',
  520. declaredLinks: declaredLinks.length,
  521. totalLinks: links.length,
  522. },
  523. minWeight,
  524. hiddenLinks: edges.filter((e) => e.thin || e.back).length,
  525. mutual: mutual.sort((a, b) => b.back.count - a.back.count || a.back.source.localeCompare(b.back.source)),
  526. moduleCycles: moduleCycles(modules.map((m) => m.id), edges),
  527. };
  528. }
  529. /**
  530. * Which edges are drawn, given the selection.
  531. *
  532. * At rest the map shows the layering: downward links carrying real weight.
  533. * Selecting a module says "show me everything about this one", so its thin
  534. * links and its back-references come out — for that module only.
  535. */
  536. export function isEdgeVisible(edge: MapEdgeLayout, selected: string | null): boolean {
  537. if (selected !== null) return edge.source === selected || edge.target === selected;
  538. return !edge.thin && !edge.back;
  539. }
  540. /**
  541. * Where a link's port sits on a box: `x = left + width x (i+1)/(n+1)` along
  542. * the side that holds it, at the top or bottom edge. The same arithmetic the
  543. * node components place their hidden handles with, so a view that needs the
  544. * point before anything is rendered — to put a label on the curve — gets the
  545. * one the browser will measure.
  546. */
  547. export function portPoint(node: MapNodeLayout, id: string, type: 'source' | 'target'): { x: number; y: number } {
  548. const top = node.ports.top.findIndex((p) => p.id === id && p.type === type);
  549. if (top >= 0) return { x: node.x + (node.width * (top + 1)) / (node.ports.top.length + 1), y: node.y };
  550. const bottom = node.ports.bottom.findIndex((p) => p.id === id && p.type === type);
  551. if (bottom >= 0) {
  552. return {
  553. x: node.x + (node.width * (bottom + 1)) / (node.ports.bottom.length + 1),
  554. y: node.y + node.height,
  555. };
  556. }
  557. return { x: node.x + node.width / 2, y: type === 'source' ? node.y + node.height : node.y };
  558. }
  559. interface SidePort extends PortRef {
  560. /** Centre x of the link's other end — the sort key along the side. */
  561. other: number;
  562. }
  563. function setDefault(
  564. map: Map<string, { top: SidePort[]; bottom: SidePort[] }>,
  565. key: string
  566. ): { top: SidePort[]; bottom: SidePort[] } {
  567. const sides = { top: [], bottom: [] };
  568. map.set(key, sides);
  569. return sides;
  570. }
  571. function xOf(nodes: Map<string, MapNodeLayout>, id: string): number {
  572. const node = nodes.get(id);
  573. return node ? node.x + node.width / 2 : 0;
  574. }
  575. /**
  576. * A module's horizontal pull: the mean position of everything it connects to.
  577. *
  578. * A module connected to nothing has no pull, and giving it its own position
  579. * back leaves it wherever the alphabet dropped it — which on a repository with
  580. * forty leaf directories means forty unconnected boxes interleaved through the
  581. * drawing, pushing the parts that DO connect apart. Infinity parks them at the
  582. * right-hand end of their layer instead, so the connected picture stays
  583. * contiguous. They are still drawn, and still counted.
  584. */
  585. function barycenter(
  586. id: string,
  587. neighbours: Map<string, string[]>,
  588. position: Map<string, number>
  589. ): number {
  590. const list = neighbours.get(id) ?? [];
  591. if (list.length === 0) return Number.POSITIVE_INFINITY;
  592. let sum = 0;
  593. for (const other of list) sum += position.get(other) ?? 0;
  594. return sum / list.length;
  595. }
  596. /**
  597. * A module's layer: one above the deepest thing it depends on.
  598. *
  599. * `visiting` guards a cycle the two-cycle break did not catch (a three-module
  600. * loop). Returning 0 there is not an answer, it is a floor — the module still
  601. * gets placed above whatever else it depends on, and the loop itself is
  602. * reported separately in {@link MapLayout.moduleCycles}.
  603. */
  604. function longestPath(
  605. id: string,
  606. out: Map<string, string[]>,
  607. layer: Map<string, number>,
  608. visiting: Set<string>
  609. ): number {
  610. const known = layer.get(id);
  611. if (known !== undefined) return known;
  612. if (visiting.has(id)) return 0;
  613. visiting.add(id);
  614. let value = 0;
  615. for (const next of out.get(id) ?? []) {
  616. value = Math.max(value, longestPath(next, out, layer, visiting) + 1);
  617. }
  618. visiting.delete(id);
  619. layer.set(id, value);
  620. return value;
  621. }
  622. /** Strongly connected components of three or more modules, in the drawn graph. */
  623. function moduleCycles(ids: readonly string[], edges: readonly MapEdgeLayout[]): string[][] {
  624. const out = new Map<string, string[]>(ids.map((id) => [id, []]));
  625. for (const edge of edges) out.get(edge.source)?.push(edge.target);
  626. for (const list of out.values()) list.sort();
  627. const index = new Map<string, number>();
  628. const low = new Map<string, number>();
  629. const onStack = new Set<string>();
  630. const stack: string[] = [];
  631. const found: string[][] = [];
  632. let counter = 0;
  633. const strongconnect = (id: string): void => {
  634. index.set(id, counter);
  635. low.set(id, counter);
  636. counter += 1;
  637. stack.push(id);
  638. onStack.add(id);
  639. for (const next of out.get(id) ?? []) {
  640. if (!index.has(next)) {
  641. strongconnect(next);
  642. low.set(id, Math.min(low.get(id) ?? 0, low.get(next) ?? 0));
  643. } else if (onStack.has(next)) {
  644. low.set(id, Math.min(low.get(id) ?? 0, index.get(next) ?? 0));
  645. }
  646. }
  647. if (low.get(id) === index.get(id)) {
  648. const component: string[] = [];
  649. for (;;) {
  650. const popped = stack.pop();
  651. if (popped === undefined) break;
  652. onStack.delete(popped);
  653. component.push(popped);
  654. if (popped === id) break;
  655. }
  656. if (component.length > 2) found.push(component.sort());
  657. }
  658. };
  659. for (const id of [...ids].sort()) if (!index.has(id)) strongconnect(id);
  660. return found.sort((a, b) => b.length - a.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
  661. }