@coldtea/pr-lens-renderer 0.1.0

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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/bounds.d.ts +23 -0
  4. package/dist/bounds.d.ts.map +1 -0
  5. package/dist/bounds.js +39 -0
  6. package/dist/bounds.js.map +1 -0
  7. package/dist/corrections.d.ts +12 -0
  8. package/dist/corrections.d.ts.map +1 -0
  9. package/dist/corrections.js +122 -0
  10. package/dist/corrections.js.map +1 -0
  11. package/dist/design.d.ts +112 -0
  12. package/dist/design.d.ts.map +1 -0
  13. package/dist/design.js +112 -0
  14. package/dist/design.js.map +1 -0
  15. package/dist/errors.d.ts +11 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +9 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/geometry.d.ts +20 -0
  20. package/dist/geometry.d.ts.map +1 -0
  21. package/dist/geometry.js +15 -0
  22. package/dist/geometry.js.map +1 -0
  23. package/dist/glob.d.ts +7 -0
  24. package/dist/glob.d.ts.map +1 -0
  25. package/dist/glob.js +17 -0
  26. package/dist/glob.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/layout/architecture.d.ts +78 -0
  32. package/dist/layout/architecture.d.ts.map +1 -0
  33. package/dist/layout/architecture.js +207 -0
  34. package/dist/layout/architecture.js.map +1 -0
  35. package/dist/layout/congestion.d.ts +18 -0
  36. package/dist/layout/congestion.d.ts.map +1 -0
  37. package/dist/layout/congestion.js +50 -0
  38. package/dist/layout/congestion.js.map +1 -0
  39. package/dist/layout/dataflow.d.ts +85 -0
  40. package/dist/layout/dataflow.d.ts.map +1 -0
  41. package/dist/layout/dataflow.js +184 -0
  42. package/dist/layout/dataflow.js.map +1 -0
  43. package/dist/layout/edges.d.ts +48 -0
  44. package/dist/layout/edges.d.ts.map +1 -0
  45. package/dist/layout/edges.js +843 -0
  46. package/dist/layout/edges.js.map +1 -0
  47. package/dist/layout/labels.d.ts +9 -0
  48. package/dist/layout/labels.d.ts.map +1 -0
  49. package/dist/layout/labels.js +149 -0
  50. package/dist/layout/labels.js.map +1 -0
  51. package/dist/layout/rank.d.ts +16 -0
  52. package/dist/layout/rank.d.ts.map +1 -0
  53. package/dist/layout/rank.js +80 -0
  54. package/dist/layout/rank.js.map +1 -0
  55. package/dist/layout/seating.d.ts +29 -0
  56. package/dist/layout/seating.d.ts.map +1 -0
  57. package/dist/layout/seating.js +131 -0
  58. package/dist/layout/seating.js.map +1 -0
  59. package/dist/manifest.d.ts +39 -0
  60. package/dist/manifest.d.ts.map +1 -0
  61. package/dist/manifest.js +99 -0
  62. package/dist/manifest.js.map +1 -0
  63. package/dist/render.d.ts +49 -0
  64. package/dist/render.d.ts.map +1 -0
  65. package/dist/render.js +125 -0
  66. package/dist/render.js.map +1 -0
  67. package/dist/scope.d.ts +21 -0
  68. package/dist/scope.d.ts.map +1 -0
  69. package/dist/scope.js +57 -0
  70. package/dist/scope.js.map +1 -0
  71. package/dist/svg/architecture.d.ts +15 -0
  72. package/dist/svg/architecture.d.ts.map +1 -0
  73. package/dist/svg/architecture.js +197 -0
  74. package/dist/svg/architecture.js.map +1 -0
  75. package/dist/svg/dataflow.d.ts +9 -0
  76. package/dist/svg/dataflow.d.ts.map +1 -0
  77. package/dist/svg/dataflow.js +284 -0
  78. package/dist/svg/dataflow.js.map +1 -0
  79. package/dist/svg/document.d.ts +37 -0
  80. package/dist/svg/document.d.ts.map +1 -0
  81. package/dist/svg/document.js +148 -0
  82. package/dist/svg/document.js.map +1 -0
  83. package/dist/svg/icons.d.ts +3 -0
  84. package/dist/svg/icons.d.ts.map +1 -0
  85. package/dist/svg/icons.js +106 -0
  86. package/dist/svg/icons.js.map +1 -0
  87. package/dist/svg/primitives.d.ts +13 -0
  88. package/dist/svg/primitives.d.ts.map +1 -0
  89. package/dist/svg/primitives.js +34 -0
  90. package/dist/svg/primitives.js.map +1 -0
  91. package/dist/svg/pulse.d.ts +24 -0
  92. package/dist/svg/pulse.d.ts.map +1 -0
  93. package/dist/svg/pulse.js +33 -0
  94. package/dist/svg/pulse.js.map +1 -0
  95. package/dist/text.d.ts +18 -0
  96. package/dist/text.d.ts.map +1 -0
  97. package/dist/text.js +70 -0
  98. package/dist/text.js.map +1 -0
  99. package/dist/theme.d.ts +44 -0
  100. package/dist/theme.d.ts.map +1 -0
  101. package/dist/theme.js +72 -0
  102. package/dist/theme.js.map +1 -0
  103. package/dist/version.d.ts +9 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +9 -0
  106. package/dist/version.js.map +1 -0
  107. package/package.json +56 -0
  108. package/src/bounds.ts +50 -0
  109. package/src/corrections.ts +166 -0
  110. package/src/design.ts +124 -0
  111. package/src/errors.ts +21 -0
  112. package/src/geometry.ts +22 -0
  113. package/src/glob.ts +20 -0
  114. package/src/index.ts +30 -0
  115. package/src/layout/architecture.ts +324 -0
  116. package/src/layout/congestion.ts +76 -0
  117. package/src/layout/dataflow.ts +307 -0
  118. package/src/layout/edges.ts +1061 -0
  119. package/src/layout/labels.ts +192 -0
  120. package/src/layout/rank.ts +95 -0
  121. package/src/layout/seating.ts +189 -0
  122. package/src/manifest.ts +118 -0
  123. package/src/render.ts +190 -0
  124. package/src/scope.ts +67 -0
  125. package/src/svg/architecture.ts +293 -0
  126. package/src/svg/dataflow.ts +408 -0
  127. package/src/svg/document.ts +198 -0
  128. package/src/svg/icons.ts +121 -0
  129. package/src/svg/primitives.ts +50 -0
  130. package/src/svg/pulse.ts +48 -0
  131. package/src/text.ts +92 -0
  132. package/src/theme.ts +107 -0
  133. package/src/version.ts +9 -0
package/src/render.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { Config, GraphDoc, Lens, RenderAsset, RenderManifest, View, ViewScope } from "@coldtea/pr-lens-schema";
2
+ import { assertNever, MAX_RENDER_ASSETS } from "@coldtea/pr-lens-schema";
3
+ import { applyCorrections } from "./corrections.js";
4
+ import { PrLensRenderError } from "./errors.js";
5
+ import { buildManifest, contentHash, renderAssetFileName, renderAssetId } from "./manifest.js";
6
+ import { findView, flattenViews, resolveScope, type ScopedGraph } from "./scope.js";
7
+ import { paletteFor, THEMES, type Theme } from "./theme.js";
8
+ import { paintArchitecture } from "./svg/architecture.js";
9
+ import { paintDataFlow } from "./svg/dataflow.js";
10
+ import { svgDocument } from "./svg/document.js";
11
+
12
+ export type RenderOptions = {
13
+ lens: Lens;
14
+ theme: Theme;
15
+ /** Id of the drill-down section to draw. Omitted renders the whole document. */
16
+ view?: string;
17
+ /** The repository's corrections, applied as an overlay before layout. */
18
+ config?: Config;
19
+ };
20
+
21
+ export type RenderedSvg = {
22
+ svg: string;
23
+ width: number;
24
+ height: number;
25
+ lens: Lens;
26
+ theme: Theme;
27
+ view: string | undefined;
28
+ animated: boolean;
29
+ };
30
+
31
+ const WHOLE_DOCUMENT: ViewScope = { kind: "all" };
32
+
33
+ const prepare = (doc: GraphDoc, config: Config | undefined): GraphDoc =>
34
+ config === undefined ? doc : applyCorrections(doc, config.map);
35
+
36
+ const paint = (
37
+ lens: Lens,
38
+ graph: ScopedGraph,
39
+ doc: GraphDoc,
40
+ theme: Theme,
41
+ ): { width: number; height: number; body: string; animated: boolean } => {
42
+ const palette = paletteFor(theme);
43
+
44
+ switch (lens) {
45
+ case "architecture": {
46
+ if (graph.nodes.length === 0)
47
+ throw new PrLensRenderError("NOTHING_TO_RENDER", "no nodes are in scope for this view");
48
+ const painting = paintArchitecture(graph, doc.layout, palette);
49
+ return { ...painting, animated: graph.edges.some((edge) => edge.animated) };
50
+ }
51
+ case "data-flow": {
52
+ if (graph.flows.length === 0)
53
+ throw new PrLensRenderError("NO_FLOW_IN_SCOPE", "the data-flow lens needs a flow to draw");
54
+ const painting = paintDataFlow(graph.flows, doc.nodes, palette);
55
+ return {
56
+ ...painting,
57
+ animated: graph.flows.some((flow) => flow.messages.some((message) => message.animated)),
58
+ };
59
+ }
60
+ default:
61
+ return assertNever(lens, "Unhandled lens");
62
+ }
63
+ };
64
+
65
+ /**
66
+ * A schema-valid document in, one self-contained SVG out.
67
+ *
68
+ * Nothing here reads a clock, a file or a random number: the same document
69
+ * and options produce the same bytes, on any machine, in any order. That is
70
+ * what lets a render be addressed by its own hash, and what keeps a diagram
71
+ * from rearranging itself between two pushes that barely changed anything.
72
+ */
73
+ export const render = (doc: GraphDoc, options: RenderOptions): RenderedSvg => {
74
+ const prepared = prepare(doc, options.config);
75
+
76
+ if (!prepared.lenses.includes(options.lens))
77
+ throw new PrLensRenderError(
78
+ "LENS_NOT_DECLARED",
79
+ `this document does not declare the '${options.lens}' lens`,
80
+ );
81
+
82
+ const view =
83
+ options.view === undefined ? undefined : requireView(prepared.views, options.view);
84
+ const scope = view?.scope ?? WHOLE_DOCUMENT;
85
+ const graph = resolveScope(prepared, scope);
86
+
87
+ const { width, height, body, animated } = paint(options.lens, graph, prepared, options.theme);
88
+
89
+ const svg = svgDocument({
90
+ width,
91
+ height,
92
+ palette: paletteFor(options.theme),
93
+ title: view?.title ?? prepared.title,
94
+ description: view?.summary ?? prepared.summary,
95
+ body,
96
+ });
97
+
98
+ return { svg, width, height, lens: options.lens, theme: options.theme, view: view?.id, animated };
99
+ };
100
+
101
+ const requireView = (views: readonly View[], id: string): View => {
102
+ const view = findView(views, id);
103
+ if (view === undefined)
104
+ throw new PrLensRenderError("UNKNOWN_VIEW", `this document has no view '${id}'`);
105
+ return view;
106
+ };
107
+
108
+ export type RenderAllOptions = {
109
+ config?: Config;
110
+ /** Which halves of the theme pair to produce. Both, by default. */
111
+ themes?: readonly Theme[];
112
+ };
113
+
114
+ export type RenderedAsset = RenderedSvg & { asset: RenderAsset };
115
+
116
+ export type RenderAllResult = {
117
+ assets: RenderedAsset[];
118
+ manifest: RenderManifest;
119
+ };
120
+
121
+ /**
122
+ * Every drill-down section a comment will show, in both themes.
123
+ *
124
+ * A document with no sections still renders: it gets one diagram per lens it
125
+ * declares, which is what a comment falls back to when extraction found
126
+ * nothing worth splitting into a tree.
127
+ */
128
+ export const renderAll = (doc: GraphDoc, options: RenderAllOptions = {}): RenderAllResult => {
129
+ const prepared = prepare(doc, options.config);
130
+ const themes = options.themes ?? THEMES;
131
+
132
+ const targets: { lens: Lens; view: View | undefined }[] =
133
+ prepared.views.length > 0
134
+ ? flattenViews(prepared.views)
135
+ .filter((view) => prepared.lenses.includes(view.lens))
136
+ .map((view) => ({ lens: view.lens, view }))
137
+ : prepared.lenses
138
+ .filter((lens) => lens !== "data-flow" || prepared.flows.length > 0)
139
+ .map((lens) => ({ lens, view: undefined }));
140
+
141
+ /**
142
+ * The contract caps a view tree at the number of views a two-theme render
143
+ * fits inside a manifest, so a parsed document cannot reach this. A
144
+ * hand-built one can: the cap lives in a refinement, and a refinement does
145
+ * not survive into the inferred type. This is a postcondition on what is
146
+ * about to be produced rather than a re-reading of what came in — the count
147
+ * depends on how many themes the caller asked for, which no document knows.
148
+ */
149
+ if (targets.length * themes.length > MAX_RENDER_ASSETS)
150
+ throw new PrLensRenderError(
151
+ "TOO_MANY_ASSETS",
152
+ `this document asks for ${targets.length * themes.length} pictures across ${targets.length} views ` +
153
+ `and ${themes.length} themes, and a render manifest carries at most ${MAX_RENDER_ASSETS}`,
154
+ );
155
+
156
+ const assets: RenderedAsset[] = [];
157
+
158
+ for (const target of targets)
159
+ for (const theme of themes) {
160
+ const rendered = render(prepared, {
161
+ lens: target.lens,
162
+ theme,
163
+ view: target.view?.id,
164
+ });
165
+ const address = { lens: target.lens, theme, view: target.view?.id };
166
+ const hash = contentHash(rendered.svg);
167
+
168
+ assets.push({
169
+ ...rendered,
170
+ asset: {
171
+ id: renderAssetId(address),
172
+ lens: target.lens,
173
+ theme,
174
+ view: target.view?.id,
175
+ mediaType: "image/svg+xml",
176
+ contentHash: hash,
177
+ bytes: Buffer.byteLength(rendered.svg, "utf8"),
178
+ width: rendered.width,
179
+ height: rendered.height,
180
+ animated: rendered.animated,
181
+ path: renderAssetFileName(address, hash),
182
+ },
183
+ });
184
+ }
185
+
186
+ if (assets.length === 0)
187
+ throw new PrLensRenderError("NOTHING_TO_RENDER", "this document declares no renderable view");
188
+
189
+ return { assets, manifest: buildManifest(prepared, assets.map(({ asset }) => asset)) };
190
+ };
package/src/scope.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Flow, GraphDoc, GraphEdge, GraphNode, Lane, View, ViewScope } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+
4
+ /** The slice of a document one SVG draws. */
5
+ export type ScopedGraph = {
6
+ lanes: Lane[];
7
+ nodes: GraphNode[];
8
+ edges: GraphEdge[];
9
+ flows: Flow[];
10
+ };
11
+
12
+ export const findView = (views: readonly View[], id: string): View | undefined => {
13
+ for (const view of views) {
14
+ if (view.id === id) return view;
15
+ const nested = findView(view.children, id);
16
+ if (nested) return nested;
17
+ }
18
+ return undefined;
19
+ };
20
+
21
+ export const flattenViews = (views: readonly View[]): View[] =>
22
+ views.flatMap((view) => [view, ...flattenViews(view.children)]);
23
+
24
+ /**
25
+ * Turns a view's scope into the elements to draw.
26
+ *
27
+ * A selection pulls in what its members need to make sense: the lanes its
28
+ * nodes sit in, and both ends of every edge it names, since half an arrow is
29
+ * worse than none. Edges are the one place the author keeps full control —
30
+ * name any and exactly those are drawn, name none and the connections between
31
+ * the selected nodes come along.
32
+ */
33
+ export const resolveScope = (doc: GraphDoc, scope: ViewScope): ScopedGraph => {
34
+ switch (scope.kind) {
35
+ case "all":
36
+ return { lanes: [...doc.lanes], nodes: [...doc.nodes], edges: [...doc.edges], flows: [...doc.flows] };
37
+ case "selection": {
38
+ const selectedLanes = new Set(scope.lanes);
39
+ const selectedEdges = new Set(scope.edges);
40
+ const selectedFlows = new Set(scope.flows);
41
+ const flows = doc.flows.filter((flow) => selectedFlows.has(flow.id));
42
+
43
+ const nodeIds = new Set<string>(scope.nodes);
44
+ for (const node of doc.nodes) if (selectedLanes.has(node.lane)) nodeIds.add(node.id);
45
+ for (const edge of doc.edges) {
46
+ if (!selectedEdges.has(edge.id)) continue;
47
+ nodeIds.add(edge.from);
48
+ nodeIds.add(edge.to);
49
+ }
50
+ for (const flow of flows)
51
+ for (const participant of flow.participants) nodeIds.add(participant.node);
52
+
53
+ const nodes = doc.nodes.filter((node) => nodeIds.has(node.id));
54
+ const edges =
55
+ scope.edges.length > 0
56
+ ? doc.edges.filter((edge) => selectedEdges.has(edge.id))
57
+ : doc.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
58
+
59
+ const laneIds = new Set<string>(scope.lanes);
60
+ for (const node of nodes) laneIds.add(node.lane);
61
+
62
+ return { lanes: doc.lanes.filter((lane) => laneIds.has(lane.id)), nodes, edges, flows };
63
+ }
64
+ default:
65
+ return assertNever(scope, "Unhandled view scope");
66
+ }
67
+ };
@@ -0,0 +1,293 @@
1
+ import type { GraphEdge, GraphNode, LayoutHints } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import {
4
+ BADGE_GAP,
5
+ BADGE_HEIGHT,
6
+ BADGE_RADIUS,
7
+ BADGE_RISE,
8
+ CARD_PADDING_X,
9
+ CARD_RADIUS,
10
+ HERO_PULSE_COUNT,
11
+ ICON_CHIP_GAP,
12
+ ICON_CHIP_RADIUS,
13
+ ICON_CHIP_SIZE,
14
+ LANE_HEADER_BASELINE,
15
+ LANE_PADDING_X,
16
+ LANE_RADIUS,
17
+ SUBTITLE_SIZE,
18
+ } from "../design.js";
19
+ import { canvasFor, union } from "../bounds.js";
20
+ import { DIAGRAM_MARGIN } from "../design.js";
21
+ import { coord, type Box } from "../geometry.js";
22
+ import {
23
+ badgeRow,
24
+ badgeWidth,
25
+ cardBadges,
26
+ deltaBadgeText,
27
+ laneHeaderText,
28
+ type PlacedNode,
29
+ } from "../layout/architecture.js";
30
+ import { relieveCongestion } from "../layout/congestion.js";
31
+ import { curveBounds, type RoutedEdge } from "../layout/edges.js";
32
+ import { placeLabelPills } from "../layout/labels.js";
33
+ import type { ScopedGraph } from "../scope.js";
34
+ import { truncate } from "../text.js";
35
+ import type { Palette } from "../theme.js";
36
+ import { markerFor, shifted, toneColour, toneFor, type Tone } from "./document.js";
37
+ import { glyphGroup } from "./icons.js";
38
+ import { lines, tag, textNode, wrap } from "./primitives.js";
39
+ import { travellingPulses } from "./pulse.js";
40
+
41
+ const badgeTone = (node: GraphNode, text: string): Tone =>
42
+ text === deltaBadgeText(node.delta) ? toneFor(node.delta) : "neutral";
43
+
44
+ /** The badge row, laid left to right across the strip the layout reserved. */
45
+ const paintBadges = (placed: PlacedNode): string => {
46
+ const row = badgeRow(placed);
47
+ if (row === undefined) return "";
48
+
49
+ let x = row.box.x;
50
+ return lines(
51
+ row.badges.map((text) => {
52
+ const width = badgeWidth(text);
53
+ const painted = wrap(
54
+ "g",
55
+ { class: `bdg bdg-${badgeTone(placed.node, text)}` },
56
+ tag("rect", {
57
+ x: coord(x),
58
+ y: coord(row.box.y),
59
+ width: coord(width),
60
+ height: BADGE_HEIGHT,
61
+ rx: BADGE_RADIUS,
62
+ }) +
63
+ textNode(
64
+ {
65
+ x: coord(x + width / 2),
66
+ y: coord(row.box.y + BADGE_HEIGHT / 2 + 3),
67
+ "text-anchor": "middle",
68
+ },
69
+ text,
70
+ ),
71
+ );
72
+ x += width + BADGE_GAP;
73
+ return painted;
74
+ }),
75
+ );
76
+ };
77
+
78
+ const cardOutlineClass = (node: GraphNode): string => {
79
+ switch (node.delta) {
80
+ case "added":
81
+ return "card card-added";
82
+ case "modified":
83
+ return "card card-modified";
84
+ case "removed":
85
+ case "unchanged":
86
+ return "card";
87
+ default:
88
+ return assertNever(node.delta, "Unhandled delta");
89
+ }
90
+ };
91
+
92
+ export const paintCard = (placed: PlacedNode): string => {
93
+ const { node, box, showIcon, titleSize } = placed;
94
+ const textX = box.x + CARD_PADDING_X + (showIcon ? ICON_CHIP_SIZE + ICON_CHIP_GAP : 0);
95
+ const textWidth = box.x + box.width - CARD_PADDING_X - textX;
96
+ const hasSubtitle = node.subtitle !== undefined;
97
+ const titleBaseline = box.y + (hasSubtitle ? 27 : 31);
98
+
99
+ const chip = showIcon
100
+ ? tag("rect", {
101
+ class: "chip",
102
+ x: coord(box.x + CARD_PADDING_X),
103
+ y: coord(box.y + (box.height - ICON_CHIP_SIZE) / 2),
104
+ width: ICON_CHIP_SIZE,
105
+ height: ICON_CHIP_SIZE,
106
+ rx: ICON_CHIP_RADIUS,
107
+ }) +
108
+ glyphGroup(
109
+ node.kind,
110
+ box.x + CARD_PADDING_X + ICON_CHIP_SIZE / 2,
111
+ box.y + box.height / 2,
112
+ )
113
+ : "";
114
+
115
+ const title = textNode(
116
+ {
117
+ class: node.delta === "removed" ? "ntitle strike" : "ntitle",
118
+ x: coord(textX),
119
+ y: coord(titleBaseline),
120
+ "font-size": titleSize,
121
+ },
122
+ truncate(node.label, "sans-bold", titleSize, textWidth),
123
+ );
124
+
125
+ const subtitle =
126
+ node.subtitle === undefined
127
+ ? ""
128
+ : textNode(
129
+ { class: "nsub", x: coord(textX), y: coord(box.y + 45) },
130
+ truncate(node.subtitle, "mono", SUBTITLE_SIZE, textWidth),
131
+ );
132
+
133
+ const groupClass =
134
+ node.delta === "removed" ? "cardsh ghost" : node.delta === "unchanged" ? "cardsh context" : "cardsh";
135
+
136
+ return wrap(
137
+ "g",
138
+ { class: groupClass },
139
+ lines([
140
+ tag("rect", {
141
+ class: cardOutlineClass(node),
142
+ x: coord(box.x),
143
+ y: coord(box.y),
144
+ width: coord(box.width),
145
+ height: coord(box.height),
146
+ rx: CARD_RADIUS,
147
+ }),
148
+ chip,
149
+ title,
150
+ subtitle,
151
+ paintBadges(placed),
152
+ ]),
153
+ );
154
+ };
155
+
156
+ const pulses = (edge: GraphEdge, path: string, palette: Palette): string =>
157
+ edge.animated
158
+ ? travellingPulses({
159
+ path,
160
+ colour: toneColour(palette, toneFor(edge.delta)),
161
+ count: edge.emphasis === "hero" ? HERO_PULSE_COUNT : 1,
162
+ lag: 0,
163
+ })
164
+ : "";
165
+
166
+ /**
167
+ * Paints one edge and the pill settled onto it. Every edge carries at most
168
+ * one label, and the layout already chose its place — on the route's longest
169
+ * straight run, nudged clear of every other pill — so a reader traces a line
170
+ * to its own words.
171
+ *
172
+ * The line and the label come back separately because they belong to
173
+ * different layers: a line passes behind a card, and a pill — which is opaque
174
+ * precisely so that it can be read wherever it lands — passes in front of one.
175
+ */
176
+ const paintEdge = (
177
+ routed: RoutedEdge,
178
+ palette: Palette,
179
+ label: Box | undefined,
180
+ ): { markup: string; pill: string } => {
181
+ const { edge, path } = routed;
182
+ const tone = toneFor(edge.delta);
183
+ const hero = edge.emphasis === "hero";
184
+
185
+ const classes = ["edge", `edge-${tone}`];
186
+ if (hero) classes.push("hero");
187
+ if (edge.emphasis === "muted") classes.push("faded");
188
+ if (edge.delta === "unchanged") classes.push("context");
189
+
190
+ const glow = hero
191
+ ? tag("path", { class: "glow", stroke: toneColour(palette, tone), d: path })
192
+ : "";
193
+
194
+ return {
195
+ markup: lines([
196
+ glow,
197
+ tag("path", { class: classes.join(" "), d: path, "marker-end": markerFor(tone) }),
198
+ pulses(edge, path, palette),
199
+ ]),
200
+ pill:
201
+ label === undefined || edge.label === undefined
202
+ ? ""
203
+ : paintLabelPill(edge.label, label, tone),
204
+ };
205
+ };
206
+
207
+ export const paintLabelPill = (text: string, box: Box, tone: Tone): string =>
208
+ wrap(
209
+ "g",
210
+ {},
211
+ tag("rect", {
212
+ class: "lpill",
213
+ x: coord(box.x),
214
+ y: coord(box.y),
215
+ width: coord(box.width),
216
+ height: coord(box.height),
217
+ rx: box.height / 2,
218
+ }) +
219
+ textNode(
220
+ {
221
+ class: tone === "neutral" ? "ltext" : `ltext ltext-${tone}`,
222
+ x: coord(box.x + box.width / 2),
223
+ y: coord(box.y + box.height / 2 + 3.5),
224
+ "text-anchor": "middle",
225
+ },
226
+ text,
227
+ ),
228
+ );
229
+
230
+ export type ArchitecturePainting = { width: number; height: number; body: string };
231
+
232
+ export const paintArchitecture = (
233
+ graph: ScopedGraph,
234
+ hints: LayoutHints | undefined,
235
+ palette: Palette,
236
+ ): ArchitecturePainting => {
237
+ const { layout, routed } = relieveCongestion(graph, hints);
238
+ const pills = placeLabelPills(routed);
239
+
240
+ const drawn: Box[] = layout.nodes.flatMap((node) => {
241
+ const row = badgeRow(node);
242
+ return row === undefined ? [node.box] : [node.box, row.box];
243
+ });
244
+ const edgeMarkup: string[] = [];
245
+ const pillMarkup: string[] = [];
246
+ for (const edge of routed) {
247
+ const label = pills.get(edge.edge.id);
248
+ const { markup, pill } = paintEdge(edge, palette, label);
249
+ edgeMarkup.push(markup);
250
+ pillMarkup.push(pill);
251
+ if (label !== undefined) drawn.push(label);
252
+ }
253
+
254
+ const lanes = layout.lanes.map(({ lane, box }) =>
255
+ lines([
256
+ tag("rect", {
257
+ class: "lanebox",
258
+ x: coord(box.x),
259
+ y: coord(box.y),
260
+ width: coord(box.width),
261
+ height: coord(box.height),
262
+ rx: LANE_RADIUS,
263
+ }),
264
+ textNode(
265
+ { class: "lanelabel", x: coord(box.x + LANE_PADDING_X), y: LANE_HEADER_BASELINE },
266
+ laneHeaderText(lane),
267
+ ),
268
+ ]),
269
+ );
270
+
271
+ const canvas = canvasFor(
272
+ layout,
273
+ union([
274
+ ...layout.lanes.map(({ box }) => box),
275
+ ...drawn,
276
+ ...routed.map(({ curve }) => curveBounds(curve)),
277
+ ]),
278
+ DIAGRAM_MARGIN,
279
+ );
280
+
281
+ const painted = lines([
282
+ wrap("g", {}, lines(lanes)),
283
+ wrap("g", {}, lines(edgeMarkup)),
284
+ wrap("g", {}, lines(layout.nodes.map(paintCard))),
285
+ wrap("g", {}, lines(pillMarkup)),
286
+ ]);
287
+
288
+ return {
289
+ width: canvas.width,
290
+ height: canvas.height,
291
+ body: shifted(canvas, painted),
292
+ };
293
+ };