@goodbones/browser 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/index.d.ts +12 -0
  3. package/build/dts/index.d.ts.map +1 -0
  4. package/build/dts/main.d.ts +3 -0
  5. package/build/dts/main.d.ts.map +1 -0
  6. package/build/dts/model/atlas.d.ts +157 -0
  7. package/build/dts/model/atlas.d.ts.map +1 -0
  8. package/build/dts/model/campaigns.d.ts +141 -0
  9. package/build/dts/model/campaigns.d.ts.map +1 -0
  10. package/build/dts/server/collect.d.ts +16 -0
  11. package/build/dts/server/collect.d.ts.map +1 -0
  12. package/build/dts/server/compose.d.ts +13 -0
  13. package/build/dts/server/compose.d.ts.map +1 -0
  14. package/build/dts/server/export.d.ts +3 -0
  15. package/build/dts/server/export.d.ts.map +1 -0
  16. package/build/dts/server/handler.d.ts +6 -0
  17. package/build/dts/server/handler.d.ts.map +1 -0
  18. package/build/dts/server/serve.d.ts +15 -0
  19. package/build/dts/server/serve.d.ts.map +1 -0
  20. package/build/dts/server/site.d.ts +2 -0
  21. package/build/dts/server/site.d.ts.map +1 -0
  22. package/build/dts/server/source.d.ts +19 -0
  23. package/build/dts/server/source.d.ts.map +1 -0
  24. package/build/dts/server/vite-plugin.d.ts +8 -0
  25. package/build/dts/server/vite-plugin.d.ts.map +1 -0
  26. package/build/dts/server/watch.d.ts +10 -0
  27. package/build/dts/server/watch.d.ts.map +1 -0
  28. package/build/esm/index.js +14 -0
  29. package/build/esm/index.js.map +1 -0
  30. package/build/esm/main.js +134 -0
  31. package/build/esm/main.js.map +1 -0
  32. package/build/esm/model/atlas.js +431 -0
  33. package/build/esm/model/atlas.js.map +1 -0
  34. package/build/esm/model/campaigns.js +181 -0
  35. package/build/esm/model/campaigns.js.map +1 -0
  36. package/build/esm/server/collect.js +83 -0
  37. package/build/esm/server/collect.js.map +1 -0
  38. package/build/esm/server/compose.js +50 -0
  39. package/build/esm/server/compose.js.map +1 -0
  40. package/build/esm/server/export.js +19 -0
  41. package/build/esm/server/export.js.map +1 -0
  42. package/build/esm/server/handler.js +67 -0
  43. package/build/esm/server/handler.js.map +1 -0
  44. package/build/esm/server/serve.js +80 -0
  45. package/build/esm/server/serve.js.map +1 -0
  46. package/build/esm/server/site.js +6 -0
  47. package/build/esm/server/site.js.map +1 -0
  48. package/build/esm/server/source.js +67 -0
  49. package/build/esm/server/source.js.map +1 -0
  50. package/build/esm/server/vite-plugin.js +21 -0
  51. package/build/esm/server/vite-plugin.js.map +1 -0
  52. package/build/esm/server/watch.js +56 -0
  53. package/build/esm/server/watch.js.map +1 -0
  54. package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
  55. package/build/site/_astro/client.Buyw3Q1S.js +9 -0
  56. package/build/site/_astro/index.DQrWs1cb.css +1 -0
  57. package/build/site/_astro/react.DJY1zw8Z.js +1 -0
  58. package/build/site/favicon.svg +1 -0
  59. package/build/site/index.html +8 -0
  60. package/package.json +78 -0
  61. package/src/app/components/Browser.tsx +153 -0
  62. package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
  63. package/src/app/components/architecture/Detail.tsx +655 -0
  64. package/src/app/components/architecture/ManifestPane.tsx +178 -0
  65. package/src/app/components/architecture/Tree.tsx +205 -0
  66. package/src/app/components/architecture/rows.ts +181 -0
  67. package/src/app/components/architecture/selection.ts +6 -0
  68. package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
  69. package/src/app/components/campaigns/Detail.tsx +653 -0
  70. package/src/app/components/campaigns/Ladder.tsx +326 -0
  71. package/src/app/components/campaigns/pick.ts +7 -0
  72. package/src/app/components/lib/data.ts +132 -0
  73. package/src/app/pages/index.astro +34 -0
  74. package/src/app/public/favicon.svg +1 -0
  75. package/src/app/styles/theme.css +993 -0
  76. package/src/index.ts +37 -0
  77. package/src/main.ts +155 -0
  78. package/src/model/atlas.ts +751 -0
  79. package/src/model/campaigns.ts +376 -0
  80. package/src/server/collect.ts +132 -0
  81. package/src/server/compose.ts +79 -0
  82. package/src/server/export.ts +24 -0
  83. package/src/server/handler.ts +77 -0
  84. package/src/server/serve.ts +100 -0
  85. package/src/server/site.ts +6 -0
  86. package/src/server/source.ts +89 -0
  87. package/src/server/vite-plugin.ts +32 -0
  88. package/src/server/watch.ts +65 -0
@@ -0,0 +1,178 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import type { Atlas, AtlasNode, AtlasPosition } from "../../../model/atlas.js";
4
+
5
+ // The manifest, rendered as it was written — every file `include` pulled in
6
+ // under its own tab — with each node's lines addressable: the line a node
7
+ // starts on is its anchor, the lines down to the next node are its own. A
8
+ // node's allowance no import uses is marked where it was written.
9
+
10
+ type Props = {
11
+ readonly atlas: Atlas;
12
+ readonly activeNode: string | null;
13
+ readonly chain: ReadonlyArray<string>;
14
+ readonly scrollTo: AtlasPosition | null;
15
+ readonly selectedNode: string | null;
16
+ readonly onHoverNode: (id: string | null) => void;
17
+ readonly onSelectNode: (id: string | null) => void;
18
+ };
19
+
20
+ type Anchored = {
21
+ readonly node: AtlasNode;
22
+ readonly line: number;
23
+ // The last line that is this node's own, before the next node begins.
24
+ readonly end: number;
25
+ };
26
+
27
+ const anchorsOf = (
28
+ nodes: ReadonlyArray<AtlasNode>,
29
+ file: string,
30
+ lines: number,
31
+ ): ReadonlyArray<Anchored> => {
32
+ const here = nodes
33
+ .filter((node) => node.position?.file === file)
34
+ .map((node) => ({ node, line: node.position?.line ?? 0 }))
35
+ .sort((a, b) => (a.line !== b.line ? a.line - b.line : a.node.depth - b.node.depth));
36
+ return here.map((one, index) => {
37
+ const next = here.slice(index + 1).find((other) => other.line > one.line);
38
+ return { node: one.node, line: one.line, end: next === undefined ? lines : next.line - 1 };
39
+ });
40
+ };
41
+
42
+ const escapeRegExp = (literal: string): string => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43
+
44
+ export const ManifestPane = ({
45
+ activeNode,
46
+ atlas,
47
+ chain,
48
+ onHoverNode,
49
+ onSelectNode,
50
+ scrollTo,
51
+ selectedNode,
52
+ }: Props): React.JSX.Element => {
53
+ const files = atlas.manifest.files;
54
+ const [tab, setTab] = useState(files[0]?.path ?? "");
55
+ const wanted = scrollTo?.file ?? null;
56
+ useEffect(() => {
57
+ if (wanted !== null && files.some((file) => file.path === wanted)) setTab(wanted);
58
+ }, [wanted, files]);
59
+ useEffect(() => {
60
+ if (!files.some((file) => file.path === tab)) setTab(files[0]?.path ?? "");
61
+ }, [files, tab]);
62
+
63
+ const active = files.find((file) => file.path === tab) ?? files[0];
64
+ const lines = useMemo(() => (active === undefined ? [] : active.text.split("\n")), [active]);
65
+ const anchors = useMemo(
66
+ () => (active === undefined ? [] : anchorsOf(atlas.manifest.nodes, active.path, lines.length)),
67
+ [active, atlas, lines.length],
68
+ );
69
+ const byLine = useMemo(() => new Map(anchors.map((one) => [one.line, one] as const)), [anchors]);
70
+ const own = anchors.find((one) => one.node.id === activeNode) ?? null;
71
+ const chainLines = useMemo(
72
+ () => new Set(anchors.filter((one) => chain.includes(one.node.id)).map((one) => one.line)),
73
+ [anchors, chain],
74
+ );
75
+
76
+ // The unused allowances, found where they were written.
77
+ const slackLines = useMemo(() => {
78
+ const found = new Map<number, string>();
79
+ for (const anchor of anchors) {
80
+ const unused = (anchor.node.imports?.allowances ?? []).filter((one) => !one.used);
81
+ if (unused.length === 0) continue;
82
+ for (let line = anchor.line; line <= anchor.end; line += 1) {
83
+ const text = lines[line - 1] ?? "";
84
+ for (const allowance of unused) {
85
+ if (
86
+ new RegExp(`(^|[\\s\\[,"'])${escapeRegExp(allowance.entry)}(["',\\]\\s]|$)`).test(text)
87
+ ) {
88
+ found.set(line, allowance.entry);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return found;
94
+ }, [anchors, lines]);
95
+
96
+ // Bring the focused node's anchor into view when the focus moves.
97
+ const listRef = useRef<HTMLOListElement | null>(null);
98
+ const targetLine = scrollTo !== null && scrollTo.file === active?.path ? scrollTo.line : null;
99
+ useEffect(() => {
100
+ if (targetLine === null || listRef.current === null) return;
101
+ const element = listRef.current.querySelector<HTMLElement>(
102
+ `[data-line="${String(targetLine)}"]`,
103
+ );
104
+ element?.scrollIntoView({ block: "center", behavior: "smooth" });
105
+ }, [targetLine, tab]);
106
+
107
+ // The innermost node whose lines include this one.
108
+ const nodeAtLine = (line: number): AtlasNode | null => {
109
+ let best: Anchored | null = null;
110
+ for (const anchor of anchors) {
111
+ if (anchor.line <= line && (best === null || anchor.line >= best.line)) best = anchor;
112
+ }
113
+ return best?.node ?? null;
114
+ };
115
+
116
+ return (
117
+ <div
118
+ className="manifest"
119
+ onMouseLeave={() => {
120
+ onHoverNode(null);
121
+ }}
122
+ >
123
+ <div className="pane-head tabs-row">
124
+ {files.map((file) => (
125
+ <button
126
+ key={file.path}
127
+ type="button"
128
+ className={file.path === active?.path ? "tab active" : "tab"}
129
+ onClick={() => {
130
+ setTab(file.path);
131
+ }}
132
+ title={file.path}
133
+ >
134
+ {file.path.split("/").slice(-2).join("/")}
135
+ </button>
136
+ ))}
137
+ </div>
138
+ <ol className="source" ref={listRef}>
139
+ {lines.map((text, index) => {
140
+ const line = index + 1;
141
+ const anchor = byLine.get(line);
142
+ const classes = ["line"];
143
+ if (anchor !== undefined) classes.push("anchor");
144
+ if (own !== null && line >= own.line && line <= own.end) classes.push("own");
145
+ if (chainLines.has(line)) classes.push("chain");
146
+ if (anchor?.node.id === selectedNode) classes.push("selected");
147
+ const slack = slackLines.get(line);
148
+ if (slack !== undefined) classes.push("slack");
149
+ return (
150
+ <li
151
+ key={line}
152
+ data-line={line}
153
+ className={classes.join(" ")}
154
+ onMouseEnter={() => {
155
+ onHoverNode(nodeAtLine(line)?.id ?? null);
156
+ }}
157
+ onClick={() => {
158
+ const node = nodeAtLine(line);
159
+ onSelectNode(node === null ? null : node.id);
160
+ }}
161
+ title={
162
+ slack !== undefined
163
+ ? `No import uses "${slack}": this allowance is slack.`
164
+ : anchor === undefined
165
+ ? undefined
166
+ : `${anchor.node.id} — ${String(anchor.node.files)} file(s) governed`
167
+ }
168
+ >
169
+ <span className="ln">{line}</span>
170
+ <span className="code">{text === "" ? " " : text}</span>
171
+ {anchor !== undefined ? <span className="node-tag">{anchor.node.id}</span> : null}
172
+ </li>
173
+ );
174
+ })}
175
+ </ol>
176
+ </div>
177
+ );
178
+ };
@@ -0,0 +1,205 @@
1
+ import type { AtlasEdge } from "../../../model/atlas.js";
2
+ import type { Arc, Relation, Row } from "./rows.js";
3
+
4
+ // The tree and its arcs: one row per visible file or folder, the edges drawn
5
+ // in a lane beside them. Every arc is there, greyed; the ones touching the
6
+ // focus come forward — out of it in the accent, into it in ink, refused in
7
+ // red — so a file's whole neighbourhood reads at a glance.
8
+
9
+ export const ROW = 24;
10
+ const LANE = 150;
11
+
12
+ type Props = {
13
+ readonly rows: ReadonlyArray<Row>;
14
+ readonly arcs: ReadonlyArray<Arc>;
15
+ readonly focus: string | null;
16
+ readonly focusIsFolder: boolean;
17
+ readonly relations: ReadonlyMap<string, Relation>;
18
+ readonly governedBy: string | null;
19
+ readonly selected: string | null;
20
+ readonly onHover: (path: string | null) => void;
21
+ readonly onToggle: (folder: string) => void;
22
+ readonly onSelect: (row: Row) => void;
23
+ };
24
+
25
+ type ArcClass = "out" | "in" | "within" | "dim";
26
+
27
+ const classify = (arc: Arc, focus: string | null, focusIsFolder: boolean): ArcClass => {
28
+ if (focus === null) return "dim";
29
+ const inside = (path: string): boolean =>
30
+ focusIsFolder ? path === focus || path.startsWith(`${focus}/`) : path === focus;
31
+ let out = false;
32
+ let into = false;
33
+ let within = false;
34
+ for (const edge of arc.edges) {
35
+ const fromIn = inside(edge.from);
36
+ const toIn = inside(edge.to);
37
+ if (fromIn && toIn) within = true;
38
+ else if (fromIn) out = true;
39
+ else if (toIn) into = true;
40
+ }
41
+ if (out) return "out";
42
+ if (into) return "in";
43
+ if (within) return "within";
44
+ return "dim";
45
+ };
46
+
47
+ const ORDER: Readonly<Record<ArcClass, number>> = { dim: 0, within: 1, in: 2, out: 3 };
48
+
49
+ const arcPath = (from: number, to: number): string => {
50
+ const y1 = from * ROW + ROW / 2;
51
+ const y2 = to * ROW + ROW / 2;
52
+ const span = Math.abs(from - to);
53
+ const bulge = Math.min(LANE - 12, 18 + span * 5);
54
+ return `M6 ${String(y1)} C ${String(6 + bulge)} ${String(y1)}, ${String(6 + bulge)} ${String(y2)}, 10 ${String(y2)}`;
55
+ };
56
+
57
+ const rowRelation = (row: Row, relations: ReadonlyMap<string, Relation>): Relation => {
58
+ const own = relations.get(row.path);
59
+ if (own !== undefined) return own;
60
+ if (row.kind === "folder" && !row.expanded) {
61
+ // A collapsed folder carries the strongest relation of what it hides.
62
+ let best: Relation = null;
63
+ for (const [path, relation] of relations) {
64
+ if (!path.startsWith(`${row.path}/`)) continue;
65
+ if (relation === "dep" || relation === "importer") return relation;
66
+ best ??= relation;
67
+ }
68
+ return best;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ const isGoverned = (row: Row, governedBy: string | null): boolean =>
74
+ governedBy !== null &&
75
+ (row.kind === "file"
76
+ ? row.file.chain.includes(governedBy)
77
+ : row.folder.node === governedBy || row.folder.declares.includes(governedBy));
78
+
79
+ export const Tree = ({
80
+ arcs,
81
+ focus,
82
+ focusIsFolder,
83
+ governedBy,
84
+ onHover,
85
+ onSelect,
86
+ onToggle,
87
+ relations,
88
+ rows,
89
+ selected,
90
+ }: Props): React.JSX.Element => {
91
+ const height = Math.max(rows.length, 1) * ROW;
92
+ const classified = arcs
93
+ .map((arc) => ({ arc, kind: classify(arc, focus, focusIsFolder) }))
94
+ .sort((a, b) => ORDER[a.kind] - ORDER[b.kind]);
95
+ return (
96
+ <div
97
+ className="tree"
98
+ onMouseLeave={() => {
99
+ onHover(null);
100
+ }}
101
+ >
102
+ <ol className="rows" style={{ height }}>
103
+ {rows.map((row) => {
104
+ const relation = rowRelation(row, relations);
105
+ const classes = ["row", row.kind];
106
+ if (relation !== null) classes.push(relation);
107
+ if (isGoverned(row, governedBy)) classes.push("governed");
108
+ if (selected === row.path) classes.push("selected");
109
+ const violations = row.kind === "file" ? row.file.violations : row.violations;
110
+ return (
111
+ <li
112
+ key={row.path}
113
+ className={classes.join(" ")}
114
+ style={{ paddingLeft: 8 + row.depth * 14, height: ROW }}
115
+ onMouseEnter={() => {
116
+ onHover(row.path);
117
+ }}
118
+ onClick={() => {
119
+ onSelect(row);
120
+ }}
121
+ title={row.path}
122
+ >
123
+ {row.kind === "folder" ? (
124
+ <button
125
+ type="button"
126
+ className="chevron"
127
+ aria-label={row.expanded ? "collapse" : "expand"}
128
+ onClick={(event) => {
129
+ event.stopPropagation();
130
+ onToggle(row.path);
131
+ }}
132
+ >
133
+ {row.expanded ? "▾" : "▸"}
134
+ </button>
135
+ ) : (
136
+ <span className="chevron spacer" />
137
+ )}
138
+ <span className={`name ${row.kind === "file" && !row.file.walked ? "outside" : ""}`}>
139
+ {row.name}
140
+ {row.kind === "folder" ? "/" : ""}
141
+ </span>
142
+ {row.kind === "folder" && row.folder.declares.length > 0 ? (
143
+ <span className="node-mark" title={`node: ${row.folder.declares.join(", ")}`}>
144
+ §
145
+ </span>
146
+ ) : null}
147
+ {row.kind === "folder" && !row.expanded ? (
148
+ <span className="meta">{row.files}</span>
149
+ ) : null}
150
+ {violations > 0 ? (
151
+ <span className="badge refused" title={`${String(violations)} violation(s)`}>
152
+ {violations}
153
+ </span>
154
+ ) : null}
155
+ </li>
156
+ );
157
+ })}
158
+ </ol>
159
+ <svg
160
+ className="arcs"
161
+ width={LANE}
162
+ height={height}
163
+ viewBox={`0 0 ${String(LANE)} ${String(height)}`}
164
+ aria-hidden="true"
165
+ >
166
+ <defs>
167
+ {(["dim", "within", "in", "out", "refused"] as const).map((kind) => (
168
+ <marker
169
+ key={kind}
170
+ id={`head-${kind}`}
171
+ viewBox="0 0 10 10"
172
+ refX="9"
173
+ refY="5"
174
+ markerWidth="6"
175
+ markerHeight="6"
176
+ orient="auto-start-reverse"
177
+ >
178
+ <path d="M0,0 L10,5 L0,10 z" className={`head ${kind}`} />
179
+ </marker>
180
+ ))}
181
+ </defs>
182
+ {classified.map(({ arc, kind }) => {
183
+ const refused = arc.refused && kind !== "dim";
184
+ const marker = refused ? "refused" : kind;
185
+ return (
186
+ <path
187
+ key={`${String(arc.from)}>${String(arc.to)}`}
188
+ d={arcPath(arc.from, arc.to)}
189
+ className={`arc ${kind} ${arc.refused ? "refused" : ""}`}
190
+ markerEnd={`url(#head-${marker})`}
191
+ >
192
+ <title>{describe(arc.edges)}</title>
193
+ </path>
194
+ );
195
+ })}
196
+ </svg>
197
+ </div>
198
+ );
199
+ };
200
+
201
+ const describe = (edges: ReadonlyArray<AtlasEdge>): string =>
202
+ edges
203
+ .slice(0, 6)
204
+ .map((edge) => `${edge.from} → ${edge.to}${edge.status === "refused" ? " (refused)" : ""}`)
205
+ .join("\n") + (edges.length > 6 ? `\n… ${String(edges.length - 6)} more` : "");
@@ -0,0 +1,181 @@
1
+ import type { Atlas, AtlasEdge, AtlasFile, AtlasFolder } from "../../../model/atlas.js";
2
+
3
+ // The tree as rows, and the edges as arcs between rows. A folder collapsed
4
+ // stands in for everything under it: an edge into a hidden file lands on the
5
+ // nearest visible ancestor, and edges between two files of one collapsed
6
+ // folder are not drawn. Pure, so the drawing can be tested without a DOM.
7
+
8
+ export type Row =
9
+ | {
10
+ readonly kind: "folder";
11
+ readonly path: string;
12
+ readonly name: string;
13
+ readonly depth: number;
14
+ readonly expanded: boolean;
15
+ readonly folder: AtlasFolder;
16
+ readonly files: number;
17
+ readonly violations: number;
18
+ }
19
+ | {
20
+ readonly kind: "file";
21
+ readonly path: string;
22
+ readonly name: string;
23
+ readonly depth: number;
24
+ readonly file: AtlasFile;
25
+ };
26
+
27
+ export type Arc = {
28
+ readonly from: number;
29
+ readonly to: number;
30
+ readonly edges: ReadonlyArray<AtlasEdge>;
31
+ readonly refused: boolean;
32
+ };
33
+
34
+ type Tree = {
35
+ readonly folders: ReadonlyMap<string, ReadonlyArray<AtlasFolder>>;
36
+ readonly files: ReadonlyMap<string, ReadonlyArray<AtlasFile>>;
37
+ readonly filesUnder: ReadonlyMap<string, number>;
38
+ readonly violationsUnder: ReadonlyMap<string, number>;
39
+ };
40
+
41
+ const parentOf = (path: string): string => {
42
+ const at = path.lastIndexOf("/");
43
+ return at === -1 ? "" : path.slice(0, at);
44
+ };
45
+
46
+ export const treeOf = (atlas: Atlas): Tree => {
47
+ const folders = new Map<string, Array<AtlasFolder>>();
48
+ for (const folder of atlas.folders) {
49
+ const key = folder.parent ?? "";
50
+ const held = folders.get(key) ?? [];
51
+ held.push(folder);
52
+ folders.set(key, held);
53
+ }
54
+ const files = new Map<string, Array<AtlasFile>>();
55
+ const filesUnder = new Map<string, number>();
56
+ const violationsUnder = new Map<string, number>();
57
+ for (const file of atlas.files) {
58
+ const held = files.get(file.folder) ?? [];
59
+ held.push(file);
60
+ files.set(file.folder, held);
61
+ let folder = file.folder;
62
+ while (folder !== "") {
63
+ filesUnder.set(folder, (filesUnder.get(folder) ?? 0) + 1);
64
+ violationsUnder.set(folder, (violationsUnder.get(folder) ?? 0) + file.violations);
65
+ folder = parentOf(folder);
66
+ }
67
+ }
68
+ for (const held of folders.values()) held.sort((a, b) => a.name.localeCompare(b.name));
69
+ for (const held of files.values()) held.sort((a, b) => a.name.localeCompare(b.name));
70
+ return { folders, files, filesUnder, violationsUnder };
71
+ };
72
+
73
+ // Folders first, then files, each alphabetically; a folder is walked into
74
+ // only when expanded.
75
+ export const rowsOf = (atlas: Atlas, expanded: ReadonlySet<string>): ReadonlyArray<Row> => {
76
+ const tree = treeOf(atlas);
77
+ const rows: Array<Row> = [];
78
+ const visit = (folder: string, depth: number): void => {
79
+ for (const child of tree.folders.get(folder) ?? []) {
80
+ const isExpanded = expanded.has(child.path);
81
+ rows.push({
82
+ kind: "folder",
83
+ path: child.path,
84
+ name: child.name,
85
+ depth,
86
+ expanded: isExpanded,
87
+ folder: child,
88
+ files: tree.filesUnder.get(child.path) ?? 0,
89
+ violations: tree.violationsUnder.get(child.path) ?? 0,
90
+ });
91
+ if (isExpanded) visit(child.path, depth + 1);
92
+ }
93
+ for (const file of tree.files.get(folder) ?? []) {
94
+ rows.push({ kind: "file", path: file.path, name: file.name, depth, file });
95
+ }
96
+ };
97
+ visit("", 0);
98
+ return rows;
99
+ };
100
+
101
+ // The row a file lands on: its own, or the nearest visible ancestor's.
102
+ export const rowIndexOf = (
103
+ rows: ReadonlyArray<Row>,
104
+ expanded: ReadonlySet<string>,
105
+ ): ((file: string) => number) => {
106
+ const index = new Map(rows.map((row, i) => [row.path, i] as const));
107
+ return (file) => {
108
+ let path = file;
109
+ while (path !== "") {
110
+ const found = index.get(path);
111
+ if (found !== undefined && (path === file || !expanded.has(path))) return found;
112
+ path = parentOf(path);
113
+ }
114
+ return -1;
115
+ };
116
+ };
117
+
118
+ export const arcsOf = (
119
+ atlas: Atlas,
120
+ rows: ReadonlyArray<Row>,
121
+ expanded: ReadonlySet<string>,
122
+ ): ReadonlyArray<Arc> => {
123
+ const at = rowIndexOf(rows, expanded);
124
+ const arcs = new Map<string, { from: number; to: number; edges: Array<AtlasEdge> }>();
125
+ for (const edge of atlas.edges) {
126
+ const from = at(edge.from);
127
+ const to = at(edge.to);
128
+ if (from === -1 || to === -1 || from === to) continue;
129
+ const key = `${String(from)}>${String(to)}`;
130
+ const held = arcs.get(key);
131
+ if (held === undefined) arcs.set(key, { from, to, edges: [edge] });
132
+ else held.edges.push(edge);
133
+ }
134
+ return [...arcs.values()].map((arc) => ({
135
+ ...arc,
136
+ refused: arc.edges.some((edge) => edge.status === "refused"),
137
+ }));
138
+ };
139
+
140
+ // Every folder, for a tree that opens fully; or the folders down to a depth,
141
+ // for one too large to.
142
+ export const initiallyExpanded = (atlas: Atlas): ReadonlySet<string> => {
143
+ const open = atlas.files.length <= 600;
144
+ return new Set(
145
+ atlas.folders.filter((folder) => open || folder.depth < 2).map((folder) => folder.path),
146
+ );
147
+ };
148
+
149
+ // What a focus on one row means for every other: the files it reaches, the
150
+ // files that reach it, the siblings it owes, and the files under it.
151
+ export type Relation = "focus" | "dep" | "importer" | "sibling" | "within" | null;
152
+
153
+ export const relationsOf = (
154
+ atlas: Atlas,
155
+ focus: string | null,
156
+ isFolder: boolean,
157
+ ): ReadonlyMap<string, Relation> => {
158
+ const relations = new Map<string, Relation>();
159
+ if (focus === null) return relations;
160
+ const inside = (path: string): boolean =>
161
+ isFolder ? path.startsWith(`${focus}/`) : path === focus;
162
+ relations.set(focus, "focus");
163
+ for (const edge of atlas.edges) {
164
+ const fromIn = inside(edge.from);
165
+ const toIn = inside(edge.to);
166
+ if (fromIn && toIn) {
167
+ if (isFolder) {
168
+ relations.set(edge.from, "within");
169
+ relations.set(edge.to, "within");
170
+ }
171
+ } else if (fromIn && !relations.has(edge.to)) relations.set(edge.to, "dep");
172
+ else if (toIn && !relations.has(edge.from)) relations.set(edge.from, "importer");
173
+ }
174
+ if (!isFolder) {
175
+ const file = atlas.files.find((one) => one.path === focus);
176
+ for (const owed of file?.requires ?? []) {
177
+ if (!relations.has(owed.sibling)) relations.set(owed.sibling, "sibling");
178
+ }
179
+ }
180
+ return relations;
181
+ };
@@ -0,0 +1,6 @@
1
+ // What the Architecture Browser can hold: a file or folder of the tree, or a
2
+ // node of the manifest. In the hash as the path, or `node:<id>`.
3
+ export type Selection =
4
+ | { readonly kind: "file"; readonly path: string }
5
+ | { readonly kind: "folder"; readonly path: string }
6
+ | { readonly kind: "node"; readonly id: string };