@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/design.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The measurements of the validated prototype, in one place. These are the
3
+ * design system: change a number here and every diagram moves with it.
4
+ */
5
+ export const DIAGRAM_MARGIN = 16;
6
+
7
+ export const LANE_TOP = 44;
8
+ export const LANE_HEADER_BASELINE = 68;
9
+ export const LANE_PADDING_X = 16;
10
+ export const LANE_GAP = 20;
11
+ export const LANE_BOTTOM_PADDING = 20;
12
+
13
+ /**
14
+ * Every lane is the same width, and that width is a constant rather than
15
+ * anything derived from what the lanes hold.
16
+ *
17
+ * A lane's width decides where the lane after it starts, so a width drawn
18
+ * from content couples every column to the contents of the ones before it:
19
+ * adding one node with a long name to the first lane would slide every card
20
+ * in every later lane sideways, which is exactly the teleport a reviewer
21
+ * comparing two pushes must never be shown. The cost is that a lane holding
22
+ * something narrow is wider than it needs to be. Stability is worth more.
23
+ *
24
+ * Sized so that two cards sharing a row still each get a readable width.
25
+ */
26
+ export const LANE_CONTENT_WIDTH = 372;
27
+ export const LANE_RADIUS = 12;
28
+ export const LANE_LABEL_SIZE = 10;
29
+ export const LANE_LABEL_TRACKING = 0.12;
30
+
31
+ export const CONTENT_TOP = 92;
32
+ export const ROW_GAP = 52;
33
+
34
+ export const CARD_RADIUS = 10;
35
+ export const CARD_GAP_X = 12;
36
+ export const CARD_HEIGHT = 52;
37
+ export const CARD_HEIGHT_WITH_SUBTITLE = 62;
38
+ export const CARD_PADDING_X = 14;
39
+
40
+ export const ICON_CHIP_SIZE = 26;
41
+ export const ICON_CHIP_GAP = 10;
42
+ export const ICON_CHIP_RADIUS = 7;
43
+ /** Below this a card's title steps down a size to keep its room. */
44
+ export const ICON_MIN_CARD_WIDTH = 200;
45
+
46
+ export const TITLE_SIZE = 13;
47
+ export const TITLE_SIZE_SMALL = 11.5;
48
+ export const SUBTITLE_SIZE = 9.5;
49
+
50
+ export const BADGE_HEIGHT = 16;
51
+ export const BADGE_PADDING_X = 9;
52
+ export const BADGE_TEXT_SIZE = 8.5;
53
+ export const BADGE_TRACKING = 0.06;
54
+ export const BADGE_GAP = 6;
55
+ export const BADGE_RADIUS = 8;
56
+ /** How far the badge row rides above the top edge of the card it labels. */
57
+ export const BADGE_RISE = 8;
58
+
59
+ export const PILL_HEIGHT = 15;
60
+ export const PILL_PADDING_X = 8;
61
+ export const PILL_TEXT_SIZE = 9.5;
62
+ /** Two label pills never sit closer than this, in either direction. */
63
+ export const PILL_CLEARANCE = 2;
64
+
65
+ /**
66
+ * Routes travel in the gaps of the grid: vertical corridors beside lanes and
67
+ * horizontal bands between rows. Both gaps are physically 52px across — lane
68
+ * padding + lane gap + lane padding one way, ROW_GAP the other — and a track
69
+ * keeps this clearance from the cards on either side, leaving 40px of room.
70
+ */
71
+ export const TRACK_CLEARANCE = 6;
72
+ /**
73
+ * Neighbouring tracks in one gap sit this far apart at most; when a gap
74
+ * carries more traffic than the room allows, the pitch shrinks to fit. An
75
+ * added route therefore nudges its gap-mates proportionately — an accepted
76
+ * trade, and one that never moves a card.
77
+ */
78
+ export const TRACK_PITCH_MAX = 16;
79
+ /**
80
+ * And never closer than this: below it, parallel runs stop reading as
81
+ * separate lines. A gap asked to carry more traffic than the floor allows
82
+ * does not compress further — it widens instead, corridors pushing the lanes
83
+ * beside them apart and row gaps pushing the rows, in proportion to the
84
+ * traffic and to nothing else.
85
+ */
86
+ export const TRACK_PITCH_MIN = 10;
87
+
88
+ /** Step between neighbouring arrow ports along one card face. */
89
+ export const PORT_PITCH = 16;
90
+ /** Ports keep clear of the card's rounded corners. */
91
+ export const PORT_INSET = 14;
92
+
93
+ /**
94
+ * A turn's bend radius comes from the shorter of its two legs, capped here.
95
+ * Deriving it from the longer leg balloons a route with one short leg and one
96
+ * long one — the shape every corridor run has — clear out of its corridor.
97
+ */
98
+ export const BEND_RADIUS_MAX = 34;
99
+
100
+ /** One turn of the travelling pulse, and of the staggered train. */
101
+ export const PULSE_DURATION = 1.6;
102
+ export const HERO_PULSE_DURATION = 2.1;
103
+ export const HERO_PULSE_COUNT = 3;
104
+
105
+ /**
106
+ * A sequence animates one step at a time, in order, because that is the one
107
+ * thing a sequence has to say. Each pulse owns a slot of the cycle outright
108
+ * and crosses its arrow for the whole of it, so the drawing always has
109
+ * exactly one dot in flight — the next arrow lights as the last dot lands.
110
+ *
111
+ * This is how long one crossing takes when the flow is short enough to
112
+ * afford it: brisk enough to read as travelling, slow enough to follow.
113
+ */
114
+ export const FLOW_STEP_TRAVEL = 1.4;
115
+ /**
116
+ * A whole sequence should play through in one sitting. Past this the steps
117
+ * share the ceiling instead, which speeds up a long flow rather than asking
118
+ * a reader to wait a minute for its last arrow.
119
+ */
120
+ export const FLOW_CYCLE_MAX = 16;
121
+ /** How much of its own slot a pulse spends fading in, and again fading out. */
122
+ export const FLOW_PULSE_RAMP = 0.08;
123
+ /** More repeats than this and the arrows stop reading as separate calls. */
124
+ export const FLOW_MAX_PULSES_PER_MESSAGE = 3;
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Codes are the stable half of a failure; the message is for a human. Every
3
+ * one of these describes a document the renderer cannot draw, never an
4
+ * internal fault.
5
+ */
6
+ export type RenderErrorCode =
7
+ | "UNKNOWN_VIEW"
8
+ | "LENS_NOT_DECLARED"
9
+ | "NOTHING_TO_RENDER"
10
+ | "NO_FLOW_IN_SCOPE"
11
+ | "TOO_MANY_ASSETS";
12
+
13
+ export class PrLensRenderError extends Error {
14
+ readonly code: RenderErrorCode;
15
+
16
+ constructor(code: RenderErrorCode, message: string) {
17
+ super(message);
18
+ this.name = "PrLensRenderError";
19
+ this.code = code;
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ export type Point = { x: number; y: number };
2
+
3
+ export type Box = { x: number; y: number; width: number; height: number };
4
+
5
+ /** Which face of a box a line meets. */
6
+ export type Side = "top" | "right" | "bottom" | "left";
7
+
8
+ /**
9
+ * Every coordinate the renderer writes goes through here. Floating point
10
+ * arithmetic that differs in the last bit would otherwise change the bytes of
11
+ * the document without changing the picture, and the render hash with it.
12
+ */
13
+ export const coord = (value: number): string => {
14
+ const rounded = Math.round(value * 100) / 100;
15
+ const normalised = Object.is(rounded, -0) ? 0 : rounded;
16
+ return String(normalised);
17
+ };
18
+
19
+ export const boxCentre = (box: Box): Point => ({
20
+ x: box.x + box.width / 2,
21
+ y: box.y + box.height / 2,
22
+ });
package/src/glob.ts ADDED
@@ -0,0 +1,20 @@
1
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2
+
3
+ /**
4
+ * Path matching for config selectors, with the subset of glob a person
5
+ * actually writes in one: `**` crosses directory boundaries, `*` and `?` do
6
+ * not. Every other character is literal.
7
+ */
8
+ export const matchesGlob = (pattern: string, path: string): boolean => {
9
+ const source = pattern
10
+ .split("**")
11
+ .map((crossing) =>
12
+ crossing
13
+ .split("*")
14
+ .map((segment) => segment.split("?").map(escapeRegExp).join("[^/]"))
15
+ .join("[^/]*"),
16
+ )
17
+ .join(".*");
18
+
19
+ return new RegExp(`^${source}$`).test(path);
20
+ };
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export { RENDERER_NAME, RENDERER_VERSION } from "./version.js";
2
+
3
+ export {
4
+ render,
5
+ renderAll,
6
+ type RenderAllOptions,
7
+ type RenderAllResult,
8
+ type RenderedAsset,
9
+ type RenderedSvg,
10
+ type RenderOptions,
11
+ } from "./render.js";
12
+
13
+ export { PrLensRenderError, type RenderErrorCode } from "./errors.js";
14
+
15
+ export { paletteFor, THEMES, type Palette, type Theme } from "./theme.js";
16
+
17
+ export {
18
+ buildManifest,
19
+ canonicalJson,
20
+ contentHash,
21
+ CONTENT_HASH_LENGTH,
22
+ graphContentHash,
23
+ renderAssetFileName,
24
+ renderAssetId,
25
+ type AssetAddress,
26
+ } from "./manifest.js";
27
+
28
+ export { applyCorrections } from "./corrections.js";
29
+
30
+ export { findView, flattenViews, resolveScope, type ScopedGraph } from "./scope.js";
@@ -0,0 +1,324 @@
1
+ import type { Delta, GraphNode, Lane, LayoutHints } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import {
4
+ BADGE_GAP,
5
+ BADGE_HEIGHT,
6
+ BADGE_PADDING_X,
7
+ BADGE_RISE,
8
+ BADGE_TEXT_SIZE,
9
+ BADGE_TRACKING,
10
+ CARD_GAP_X,
11
+ CARD_HEIGHT,
12
+ CARD_HEIGHT_WITH_SUBTITLE,
13
+ CARD_PADDING_X,
14
+ CONTENT_TOP,
15
+ DIAGRAM_MARGIN,
16
+ ICON_MIN_CARD_WIDTH,
17
+ LANE_BOTTOM_PADDING,
18
+ LANE_GAP,
19
+ LANE_LABEL_SIZE,
20
+ LANE_LABEL_TRACKING,
21
+ LANE_CONTENT_WIDTH,
22
+ LANE_PADDING_X,
23
+ LANE_TOP,
24
+ ROW_GAP,
25
+ TITLE_SIZE,
26
+ TITLE_SIZE_SMALL,
27
+ } from "../design.js";
28
+ import type { Box } from "../geometry.js";
29
+ import type { ScopedGraph } from "../scope.js";
30
+ import { measure, type Face } from "../text.js";
31
+ import { seatNodes, type SeatedRow } from "./seating.js";
32
+
33
+ export const deltaBadgeText = (delta: Delta): string | undefined => {
34
+ switch (delta) {
35
+ case "added":
36
+ return "NEW";
37
+ case "modified":
38
+ return "CHANGED";
39
+ case "removed":
40
+ return "REMOVED";
41
+ case "unchanged":
42
+ return undefined;
43
+ default:
44
+ return assertNever(delta, "Unhandled delta");
45
+ }
46
+ };
47
+
48
+ /** Width of tracked text: letter-spacing adds a fixed step after every glyph. */
49
+ const trackedWidth = (
50
+ text: string,
51
+ face: Face,
52
+ fontSize: number,
53
+ tracking: number,
54
+ ): number => measure(text, face, fontSize) + [...text].length * tracking * fontSize;
55
+
56
+ export const badgeWidth = (text: string): number =>
57
+ trackedWidth(text, "sans-bold", BADGE_TEXT_SIZE, BADGE_TRACKING) + BADGE_PADDING_X * 2;
58
+
59
+ /** Every badge a card carries, its own first and the delta badge last. */
60
+ export const cardBadges = (node: GraphNode): string[] => {
61
+ const delta = deltaBadgeText(node.delta);
62
+ return delta === undefined ? [...node.badges] : [...node.badges, delta];
63
+ };
64
+
65
+ export type PlacedNode = {
66
+ node: GraphNode;
67
+ box: Box;
68
+ /** Wide enough for the kind glyph to earn its place. */
69
+ showIcon: boolean;
70
+ titleSize: number;
71
+ row: number;
72
+ laneIndex: number;
73
+ };
74
+
75
+ export type PlacedLane = { lane: Lane; box: Box };
76
+
77
+ /**
78
+ * The badges a card actually shows and the strip they occupy above it.
79
+ *
80
+ * When the row is wider than the card the badges the author added give way
81
+ * first: the delta badge is the one a reviewer is scanning for. Nothing is
82
+ * returned when the card carries no badge, and the strip counts as occupied
83
+ * space so an edge label does not settle on top of it.
84
+ */
85
+ export const badgeRow = (placed: PlacedNode): { badges: string[]; box: Box } | undefined => {
86
+ const all = cardBadges(placed.node);
87
+ if (all.length === 0) return undefined;
88
+
89
+ const room = placed.box.width - CARD_PADDING_X;
90
+ const shown: string[] = [];
91
+ let used = 0;
92
+ for (const text of [...all].reverse()) {
93
+ const next = used + badgeWidth(text) + (shown.length > 0 ? BADGE_GAP : 0);
94
+ if (next > room) break;
95
+ used = next;
96
+ shown.unshift(text);
97
+ }
98
+ if (shown.length === 0) return undefined;
99
+
100
+ const right = placed.box.x + placed.box.width - CARD_PADDING_X / 2;
101
+ return {
102
+ badges: shown,
103
+ box: {
104
+ x: right - used,
105
+ y: placed.box.y - BADGE_RISE,
106
+ width: used,
107
+ height: BADGE_HEIGHT,
108
+ },
109
+ };
110
+ };
111
+
112
+ /**
113
+ * The gaps of the grid, for the router: the vertical corridors beside each
114
+ * lane's cards and the horizontal extents of every row. Corridor `i` runs to
115
+ * the left of lane `i`; the extra corridor after the last lane is where
116
+ * retired pathways are exiled to.
117
+ */
118
+ export type LayoutGrid = {
119
+ rows: { top: number; height: number }[];
120
+ corridors: { left: number; right: number }[];
121
+ /** Where lane content ends — the floor of the last band under the last row. */
122
+ laneBottom: number;
123
+ /** First row of the dead band; absent when nothing was removed. */
124
+ deadFromRow: number | undefined;
125
+ };
126
+
127
+ /**
128
+ * Extra room granted to individual gaps, keyed by corridor or band index —
129
+ * how the layout widens where traffic would otherwise compress track pitch
130
+ * through the floor. Expanding a corridor moves every lane after it sideways
131
+ * by the same amount; expanding a band moves every row after it down. Cards
132
+ * travel with their lane and row — nothing re-seats, nothing reorders.
133
+ */
134
+ export type GapExpansions = {
135
+ corridors: ReadonlyMap<number, number>;
136
+ bands: ReadonlyMap<number, number>;
137
+ };
138
+
139
+ export type ArchitectureLayout = {
140
+ width: number;
141
+ height: number;
142
+ lanes: PlacedLane[];
143
+ nodes: PlacedNode[];
144
+ grid: LayoutGrid;
145
+ };
146
+
147
+ const cardHeight = (node: GraphNode): number =>
148
+ node.subtitle === undefined ? CARD_HEIGHT : CARD_HEIGHT_WITH_SUBTITLE;
149
+
150
+ /**
151
+ * Lane order: the document's explicit list first, for the lanes it names,
152
+ * then whatever is left by declared order and finally by the order they were
153
+ * written in. `order` may be absent, and two lanes may share one, so the
154
+ * document's own array is the tie-break that keeps this stable.
155
+ */
156
+ const orderLanes = (lanes: readonly Lane[], hints: LayoutHints | undefined): Lane[] => {
157
+ const named = hints?.laneOrder ?? [];
158
+ const byId = new Map(lanes.map((lane) => [lane.id, lane]));
159
+ const ordered: Lane[] = [];
160
+ const taken = new Set<string>();
161
+
162
+ for (const id of named) {
163
+ const lane = byId.get(id);
164
+ if (lane === undefined || taken.has(id)) continue;
165
+ ordered.push(lane);
166
+ taken.add(id);
167
+ }
168
+
169
+ const rest = lanes
170
+ .map((lane, index) => ({ lane, index }))
171
+ .filter(({ lane }) => !taken.has(lane.id))
172
+ .sort((a, b) => (a.lane.order ?? Number.MAX_SAFE_INTEGER) - (b.lane.order ?? Number.MAX_SAFE_INTEGER) || a.index - b.index)
173
+ .map(({ lane }) => lane);
174
+
175
+ return [...ordered, ...rest];
176
+ };
177
+
178
+ /**
179
+ * A pair divides its row into equal halves. Splitting in proportion to what
180
+ * each card's text asked for would mean a rename moves its neighbour — and a
181
+ * rename moving anything is exactly what the seating guarantee rules out.
182
+ */
183
+ const rowWidths = (contentWidth: number, row: SeatedRow): number[] => {
184
+ if (row.nodes.length < 2) return [contentWidth];
185
+ const half = Math.round((contentWidth - CARD_GAP_X) / 2);
186
+ return [half, contentWidth - CARD_GAP_X - half];
187
+ };
188
+
189
+ export const layoutArchitecture = (
190
+ graph: ScopedGraph,
191
+ hints: LayoutHints | undefined,
192
+ expansions?: GapExpansions,
193
+ ): ArchitectureLayout => {
194
+ const corridorExtra = (index: number): number => expansions?.corridors.get(index) ?? 0;
195
+ const bandExtra = (index: number): number => expansions?.bands.get(index) ?? 0;
196
+
197
+ const ordered = orderLanes(graph.lanes, hints);
198
+ const seating = seatNodes(ordered, graph.nodes, graph.edges, hints?.rank ?? {});
199
+
200
+ const lanes = ordered.flatMap((lane) => {
201
+ const rows = seating.rowsByLane.get(lane.id);
202
+ return rows === undefined ? [] : [{ lane, rows }];
203
+ });
204
+
205
+ const laneBoxWidth = LANE_CONTENT_WIDTH + LANE_PADDING_X * 2;
206
+
207
+ const gridHeights = new Map<number, number>();
208
+ for (const { rows } of lanes)
209
+ for (const { grid, nodes } of rows) {
210
+ const height = Math.max(...nodes.map(cardHeight));
211
+ gridHeights.set(grid, Math.max(gridHeights.get(grid) ?? 0, height));
212
+ }
213
+
214
+ // Every row of the shared grid is occupied by whichever card created it, so
215
+ // walking 0..rowCount visits exactly the keys collected above.
216
+ const gridRows: { top: number; height: number }[] = [];
217
+ let cursor = CONTENT_TOP;
218
+ for (let grid = 0; grid < seating.rowCount; grid += 1) {
219
+ if (grid > 0) cursor += bandExtra(grid);
220
+ const height = gridHeights.get(grid) ?? 0;
221
+ gridRows.push({ top: cursor, height });
222
+ cursor += height + ROW_GAP;
223
+ }
224
+ const contentBottom = cursor - ROW_GAP;
225
+
226
+ const laneBottom = contentBottom + LANE_BOTTOM_PADDING + bandExtra(seating.rowCount);
227
+ const placedLanes: PlacedLane[] = [];
228
+ const placedNodes: PlacedNode[] = [];
229
+ let laneX = DIAGRAM_MARGIN;
230
+
231
+ lanes.forEach(({ lane, rows }, laneIndex) => {
232
+ laneX += corridorExtra(laneIndex);
233
+ const contentX = laneX + LANE_PADDING_X;
234
+ const contentWidth = LANE_CONTENT_WIDTH;
235
+
236
+ placedLanes.push({
237
+ lane,
238
+ box: { x: laneX, y: LANE_TOP, width: laneBoxWidth, height: laneBottom - LANE_TOP },
239
+ });
240
+
241
+ for (const row of rows) {
242
+ const top = gridRows[row.grid]?.top ?? CONTENT_TOP;
243
+ const widths = rowWidths(contentWidth, row);
244
+
245
+ let x = contentX;
246
+ row.nodes.forEach((node, index) => {
247
+ const width = widths[index] ?? contentWidth;
248
+ placedNodes.push({
249
+ node,
250
+ box: { x, y: top, width, height: cardHeight(node) },
251
+ // Every kind draws something, on every card: the chip is reserved
252
+ // space and the title truncates, so recognition wins at any width.
253
+ showIcon: true,
254
+ titleSize: width >= ICON_MIN_CARD_WIDTH ? TITLE_SIZE : TITLE_SIZE_SMALL,
255
+ row: row.grid,
256
+ laneIndex,
257
+ });
258
+ x += width + CARD_GAP_X;
259
+ });
260
+ }
261
+
262
+ laneX += laneBoxWidth + LANE_GAP;
263
+ });
264
+
265
+ const gutter = LANE_PADDING_X * 2 + LANE_GAP;
266
+ const corridors = placedLanes.map(({ box }, index) => ({
267
+ left: box.x + LANE_PADDING_X - gutter - corridorExtra(index),
268
+ right: box.x + LANE_PADDING_X,
269
+ }));
270
+ const lastContentRight =
271
+ (placedLanes[placedLanes.length - 1]?.box.x ?? DIAGRAM_MARGIN) +
272
+ LANE_PADDING_X +
273
+ LANE_CONTENT_WIDTH;
274
+ corridors.push({
275
+ left: lastContentRight,
276
+ right: lastContentRight + gutter + corridorExtra(placedLanes.length),
277
+ });
278
+
279
+ return {
280
+ // Whole numbers: the canvas is reported to the comment composer as pixels,
281
+ // and half a pixel of diagram is not a thing a reviewer can be shown.
282
+ width: Math.ceil(laneX - LANE_GAP + DIAGRAM_MARGIN),
283
+ height: Math.ceil(laneBottom + DIAGRAM_MARGIN),
284
+ lanes: placedLanes,
285
+ nodes: placedNodes,
286
+ grid: { rows: gridRows, corridors, laneBottom, deadFromRow: seating.deadFromRow },
287
+ };
288
+ };
289
+
290
+ /**
291
+ * The lane's own name over its band. Lanes never widen to fit their
292
+ * headers — that would put content in charge of where the next lane
293
+ * starts — so a header longer than the band gives up its tail instead.
294
+ */
295
+ export const laneHeaderText = (lane: Lane): string => {
296
+ const full = (
297
+ lane.subtitle === undefined ? lane.label : `${lane.label} · ${lane.subtitle}`
298
+ ).toUpperCase();
299
+
300
+ return truncateTracked(full, LANE_LABEL_SIZE, LANE_LABEL_TRACKING, LANE_CONTENT_WIDTH);
301
+ };
302
+
303
+ const ELLIPSIS = "…";
304
+
305
+ /** `truncate`, but counting the extra step letter-spacing puts after each glyph. */
306
+ const truncateTracked = (
307
+ text: string,
308
+ fontSize: number,
309
+ tracking: number,
310
+ maxWidth: number,
311
+ ): string => {
312
+ const width = (value: string) => trackedWidth(value, "sans-bold", fontSize, tracking);
313
+ if (width(text) <= maxWidth) return text;
314
+
315
+ const characters = [...text];
316
+ let kept = 0;
317
+ while (kept < characters.length) {
318
+ const next = characters.slice(0, kept + 1).join("") + ELLIPSIS;
319
+ if (width(next) > maxWidth) break;
320
+ kept += 1;
321
+ }
322
+
323
+ return kept === 0 ? ELLIPSIS : characters.slice(0, kept).join("").trimEnd() + ELLIPSIS;
324
+ };
@@ -0,0 +1,76 @@
1
+ import type { LayoutHints } from "@coldtea/pr-lens-schema";
2
+ import {
3
+ LANE_BOTTOM_PADDING,
4
+ LANE_GAP,
5
+ LANE_PADDING_X,
6
+ ROW_GAP,
7
+ TRACK_CLEARANCE,
8
+ TRACK_PITCH_MIN,
9
+ } from "../design.js";
10
+ import type { ScopedGraph } from "../scope.js";
11
+ import {
12
+ layoutArchitecture,
13
+ type ArchitectureLayout,
14
+ type GapExpansions,
15
+ type LayoutGrid,
16
+ } from "./architecture.js";
17
+ import { channelTraffic, routeEdges, type ChannelTraffic, type RoutedEdge } from "./edges.js";
18
+
19
+ /**
20
+ * Layout and routing with the pitch floor held: any gap whose traffic would
21
+ * compress its tracks below TRACK_PITCH_MIN is widened to exactly what that
22
+ * traffic needs, and the graph is laid out again around the wider gap. The
23
+ * extra room is a pure function of the traffic count, so one added route
24
+ * moves the lanes or rows beside a saturated gap by one pitch step — a small
25
+ * change staying a small move — and an uncrowded document is laid out
26
+ * exactly as if this pass did not exist.
27
+ */
28
+ export const relieveCongestion = (
29
+ graph: ScopedGraph,
30
+ hints: LayoutHints | undefined,
31
+ ): { layout: ArchitectureLayout; routed: RoutedEdge[] } => {
32
+ let expansions: GapExpansions = { corridors: new Map(), bands: new Map() };
33
+ let layout = layoutArchitecture(graph, hints, expansions);
34
+
35
+ // Widening never changes which gaps the routes choose — plans are made of
36
+ // lane and row indices, and cards keep their in-lane positions — so the
37
+ // second round sees the same traffic and settles. The bound is a backstop.
38
+ for (let round = 0; round < 3; round += 1) {
39
+ const needed = expansionsFor(channelTraffic(graph.edges, layout), layout.grid);
40
+ if (sameExpansions(needed, expansions)) break;
41
+ expansions = needed;
42
+ layout = layoutArchitecture(graph, hints, expansions);
43
+ }
44
+
45
+ return { layout, routed: routeEdges(graph.edges, layout) };
46
+ };
47
+
48
+ /** Room for this many tracks at the floor pitch, plus clearance to the cards. */
49
+ const widthNeeded = (traffic: number): number =>
50
+ (traffic - 1) * TRACK_PITCH_MIN + TRACK_CLEARANCE * 2;
51
+
52
+ const CORRIDOR_WIDTH = LANE_PADDING_X * 2 + LANE_GAP;
53
+
54
+ const expansionsFor = (traffic: ChannelTraffic, grid: LayoutGrid): GapExpansions => {
55
+ const corridors = new Map<number, number>();
56
+ for (const [index, count] of traffic.corridors) {
57
+ const extra = widthNeeded(count) - CORRIDOR_WIDTH;
58
+ if (extra > 0) corridors.set(index, extra);
59
+ }
60
+
61
+ const bands = new Map<number, number>();
62
+ for (const [index, count] of traffic.bands) {
63
+ // The band after the last row is the sliver of lane bottom padding.
64
+ const width = index === grid.rows.length ? LANE_BOTTOM_PADDING : ROW_GAP;
65
+ const extra = widthNeeded(count) - width;
66
+ if (extra > 0) bands.set(index, extra);
67
+ }
68
+
69
+ return { corridors, bands };
70
+ };
71
+
72
+ const sameExpansions = (a: GapExpansions, b: GapExpansions): boolean =>
73
+ sameEntries(a.corridors, b.corridors) && sameEntries(a.bands, b.bands);
74
+
75
+ const sameEntries = (a: ReadonlyMap<number, number>, b: ReadonlyMap<number, number>): boolean =>
76
+ a.size === b.size && [...a].every(([key, value]) => b.get(key) === value);