@coldtea/pr-lens-renderer 0.1.2 → 0.2.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.
package/README.md CHANGED
@@ -58,6 +58,24 @@ pnpm test # compares against the goldens
58
58
  UPDATE_GOLDENS=1 pnpm test # records them, for a human to read the diff of
59
59
  ```
60
60
 
61
+ ## The atlas
62
+
63
+ Every render comes back with an `atlas`: where each lane, node, edge and flow step of that picture landed, in the same viewBox units the file is written in.
64
+
65
+ ```ts
66
+ const { svg, atlas } = render(doc, { lens: "architecture", theme: "dark" });
67
+ atlas.nodes["send-broadcast-bulk"]; // { x, y, width, height }
68
+ atlas.messages["send-pipeline"]?.["batch-post"];
69
+ ```
70
+
71
+ A surface that plays a document's walkthrough has to put a rim around what a step points at, and a step names its elements by id and nothing else. Only the renderer knows where any of them ended up, so the geometry travels beside the picture rather than being measured back out of it by a reader that would have to guess.
72
+
73
+ Flow steps are keyed by flow first, because a step's id is unique only inside its own flow: two flows may each carry one called `retry`, and neither document is wrong for it.
74
+
75
+ The atlas is as deterministic as the SVG. Same document, same boxes, in the same key order, each coordinate rounded exactly as the one written into the file beside it, so a box and the rectangle it stands for cannot disagree in the last place. The theme never moves anything, so one atlas answers for both halves of a `<picture>` pair.
76
+
77
+ Each lens fills in what it draws. The architecture lens places lanes, cards and edges and no flow step; the data-flow lens places the cards heading its columns and the steps of each flow, and no lane or edge. A node the same render draws twice, heading a column in two stacked flows, gets the box covering both of them: a reader sent to that node is being sent to all of it. A card's box is the card, not the badge strip above it, which is context a veil may dim.
78
+
61
79
  ## Corrections
62
80
 
63
81
  A repository's `.github/pr-lens.yml` is an overlay, applied here before layout and never written back into inference, so a correction keeps holding as the code moves:
@@ -68,7 +86,7 @@ render(doc, { lens: "architecture", theme: "dark", config });
68
86
 
69
87
  `rename`, `lane` and `group` address nodes by `id:<node-id>` or by a glob over the paths backing them. A `lane` correction may name a band the document never declared; it gets created, with the id for a label, so write `lane: infrastructure` rather than `lane: l3`.
70
88
 
71
- `exclude` takes with it every edge and flow step that touched what it removed, every view that pointed at nothing else, and every layout hint left naming something that is gone. Half an arrow is worse than none, and the corrected document is a document like any other, so it still parses.
89
+ `exclude` takes with it every edge and flow step that touched what it removed, every view that pointed at nothing else, every layout hint left naming something that is gone, and every walkthrough step whose diagram or last focused element went with it. A walkthrough cut below two steps goes too, because one step is a caption. Half an arrow is worse than none, and the corrected document is a document like any other, so it still parses.
72
90
 
73
91
  ## Addresses
74
92
 
package/dist/version.d.ts CHANGED
@@ -5,5 +5,5 @@
5
5
  * not otherwise need.
6
6
  */
7
7
  export declare const RENDERER_NAME = "@coldtea/pr-lens-renderer";
8
- export declare const RENDERER_VERSION = "0.1.2";
8
+ export declare const RENDERER_VERSION = "0.1.3";
9
9
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -5,5 +5,5 @@
5
5
  * not otherwise need.
6
6
  */
7
7
  export const RENDERER_NAME = "@coldtea/pr-lens-renderer";
8
- export const RENDERER_VERSION = "0.1.2";
8
+ export const RENDERER_VERSION = "0.1.3";
9
9
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldtea/pr-lens-renderer",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The PR Lens renderer: a schema-valid graph document in, a self-contained animated SVG out.",
5
5
  "license": "MIT",
6
6
  "author": "Coldtea AI",
@@ -40,7 +40,7 @@
40
40
  "node": ">=20.11"
41
41
  },
42
42
  "dependencies": {
43
- "@coldtea/pr-lens-schema": "0.1.2"
43
+ "@coldtea/pr-lens-schema": "0.2.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^20.19.0",
package/src/atlas.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { covering, type Canvas } from "./bounds.js";
2
+ import { roundCoord, type Box } from "./geometry.js";
3
+
4
+ /**
5
+ * Where everything a render drew ended up, in the viewBox units of the file
6
+ * beside it.
7
+ *
8
+ * A surface that plays a walkthrough has to put a rim around what a step
9
+ * points at, and a step points at lanes, nodes, edges and flow steps by id
10
+ * alone. Only the renderer knows where any of them landed, and it knows it
11
+ * while it is drawing them, so the geometry travels with the picture rather
12
+ * than being measured back out of it by something that would have to guess.
13
+ */
14
+ export type RenderAtlas = {
15
+ lanes: Record<string, Box>;
16
+ nodes: Record<string, Box>;
17
+ edges: Record<string, Box>;
18
+ /** Keyed by flow, then by step: a flow step's id is unique only inside its own flow. */
19
+ messages: Record<string, Record<string, Box>>;
20
+ };
21
+
22
+ /** An atlas for a drawing that has placed nothing. */
23
+ export const emptyAtlas = (): RenderAtlas => ({ lanes: {}, nodes: {}, edges: {}, messages: {} });
24
+
25
+ export type AtlasEntry = { id: string; box: Box };
26
+
27
+ /**
28
+ * Boxes onto the canvas and into a record, in the order they were drawn.
29
+ *
30
+ * Each coordinate is rounded on its own, exactly as the one written into the
31
+ * file beside it is, so a box here and the rectangle it stands for cannot
32
+ * disagree in the last place. An id drawn more than once — a participant
33
+ * heading a column in two stacked flows — keeps the box covering every
34
+ * drawing of it, because a reader sent to that node is being sent to all of
35
+ * them.
36
+ */
37
+ export const atlasBoxes = (
38
+ entries: readonly AtlasEntry[],
39
+ canvas: Canvas,
40
+ ): Record<string, Box> => {
41
+ const grown = new Map<string, Box>();
42
+ for (const { id, box } of entries) {
43
+ const current = grown.get(id);
44
+ grown.set(id, current === undefined ? box : covering(current, box));
45
+ }
46
+
47
+ const boxes: Record<string, Box> = {};
48
+ for (const [id, box] of grown)
49
+ boxes[id] = {
50
+ x: roundCoord(box.x + canvas.shiftX),
51
+ y: roundCoord(box.y + canvas.shiftY),
52
+ width: roundCoord(box.width),
53
+ height: roundCoord(box.height),
54
+ };
55
+ return boxes;
56
+ };
package/src/bounds.ts CHANGED
@@ -1,25 +1,22 @@
1
1
  import type { Box } from "./geometry.js";
2
2
 
3
- /** The smallest box containing all of them, or nothing when there are none. */
4
- export const union = (boxes: readonly Box[]): Box | undefined => {
5
- const first = boxes[0];
6
- if (first === undefined) return undefined;
7
-
8
- let left = first.x;
9
- let top = first.y;
10
- let right = first.x + first.width;
11
- let bottom = first.y + first.height;
12
-
13
- for (const box of boxes) {
14
- left = Math.min(left, box.x);
15
- top = Math.min(top, box.y);
16
- right = Math.max(right, box.x + box.width);
17
- bottom = Math.max(bottom, box.y + box.height);
18
- }
3
+ /** The smallest box containing both of them. */
4
+ export const covering = (a: Box, b: Box): Box => {
5
+ const left = Math.min(a.x, b.x);
6
+ const top = Math.min(a.y, b.y);
7
+ const right = Math.max(a.x + a.width, b.x + b.width);
8
+ const bottom = Math.max(a.y + a.height, b.y + b.height);
19
9
 
20
10
  return { x: left, y: top, width: right - left, height: bottom - top };
21
11
  };
22
12
 
13
+ /** The smallest box containing all of them, or nothing when there are none. */
14
+ export const union = (boxes: readonly Box[]): Box | undefined =>
15
+ boxes.reduce<Box | undefined>(
16
+ (grown, box) => (grown === undefined ? box : covering(grown, box)),
17
+ undefined,
18
+ );
19
+
23
20
  export type Canvas = { width: number; height: number; shiftX: number; shiftY: number };
24
21
 
25
22
  /**
@@ -9,7 +9,7 @@ import type {
9
9
  View,
10
10
  ViewScope,
11
11
  } from "@coldtea/pr-lens-schema";
12
- import { assertNever } from "@coldtea/pr-lens-schema";
12
+ import { assertNever, pruneWalkthrough } from "@coldtea/pr-lens-schema";
13
13
  import { PrLensRenderError } from "./errors.js";
14
14
  import { matchesGlob } from "./glob.js";
15
15
 
@@ -129,13 +129,20 @@ export const applyCorrections = (doc: GraphDoc, corrections: MapCorrections): Gr
129
129
  flow: (id: string) => flows.some((flow) => flow.id === id),
130
130
  };
131
131
 
132
+ const views = narrowViews(doc.views, survives);
133
+
132
134
  return {
133
135
  ...doc,
134
136
  lanes,
135
137
  nodes,
136
138
  edges,
137
139
  flows,
138
- views: narrowViews(doc.views, survives),
140
+ views,
141
+ /**
142
+ * Narrowed against the document that came through, views included: a step
143
+ * can play over a view the overlay has just emptied out.
144
+ */
145
+ walkthrough: pruneWalkthrough(doc.walkthrough, { lanes, nodes, edges, flows, views }),
139
146
  layout: narrowLayout(doc.layout, survives),
140
147
  };
141
148
  };
package/src/geometry.ts CHANGED
@@ -10,12 +10,13 @@ export type Side = "top" | "right" | "bottom" | "left";
10
10
  * arithmetic that differs in the last bit would otherwise change the bytes of
11
11
  * the document without changing the picture, and the render hash with it.
12
12
  */
13
- export const coord = (value: number): string => {
13
+ export const roundCoord = (value: number): number => {
14
14
  const rounded = Math.round(value * 100) / 100;
15
- const normalised = Object.is(rounded, -0) ? 0 : rounded;
16
- return String(normalised);
15
+ return Object.is(rounded, -0) ? 0 : rounded;
17
16
  };
18
17
 
18
+ export const coord = (value: number): string => String(roundCoord(value));
19
+
19
20
  export const boxCentre = (box: Box): Point => ({
20
21
  x: box.x + box.width / 2,
21
22
  y: box.y + box.height / 2,
package/src/index.ts CHANGED
@@ -10,8 +10,12 @@ export {
10
10
  type RenderOptions,
11
11
  } from "./render.js";
12
12
 
13
+ export { emptyAtlas, type RenderAtlas } from "./atlas.js";
14
+
13
15
  export { PrLensRenderError, type RenderErrorCode } from "./errors.js";
14
16
 
17
+ export type { Box } from "./geometry.js";
18
+
15
19
  export { paletteFor, THEMES, type Palette, type Theme } from "./theme.js";
16
20
 
17
21
  export {
@@ -85,7 +85,7 @@ export type DataFlowLayout = {
85
85
  slotCount: number;
86
86
  };
87
87
 
88
- const messagePitch = (message: FlowMessage): number =>
88
+ export const messagePitch = (message: FlowMessage): number =>
89
89
  message.kind === "self" ? SELF_MESSAGE_PITCH : MESSAGE_PITCH;
90
90
 
91
91
  /**
package/src/render.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Config, GraphDoc, Lens, RenderAsset, RenderManifest, View, ViewScope } from "@coldtea/pr-lens-schema";
2
2
  import { assertNever, MAX_RENDER_ASSETS } from "@coldtea/pr-lens-schema";
3
+ import type { RenderAtlas } from "./atlas.js";
3
4
  import { applyCorrections } from "./corrections.js";
4
5
  import { PrLensRenderError } from "./errors.js";
5
6
  import { buildManifest, contentHash, renderAssetFileName, renderAssetId } from "./manifest.js";
@@ -26,6 +27,8 @@ export type RenderedSvg = {
26
27
  theme: Theme;
27
28
  view: string | undefined;
28
29
  animated: boolean;
30
+ /** Where every lane, node, edge and flow step of this picture landed. */
31
+ atlas: RenderAtlas;
29
32
  };
30
33
 
31
34
  const WHOLE_DOCUMENT: ViewScope = { kind: "all" };
@@ -38,7 +41,7 @@ const paint = (
38
41
  graph: ScopedGraph,
39
42
  doc: GraphDoc,
40
43
  theme: Theme,
41
- ): { width: number; height: number; body: string; animated: boolean } => {
44
+ ): { width: number; height: number; body: string; animated: boolean; atlas: RenderAtlas } => {
42
45
  const palette = paletteFor(theme);
43
46
 
44
47
  switch (lens) {
@@ -84,7 +87,12 @@ export const render = (doc: GraphDoc, options: RenderOptions): RenderedSvg => {
84
87
  const scope = view?.scope ?? WHOLE_DOCUMENT;
85
88
  const graph = resolveScope(prepared, scope);
86
89
 
87
- const { width, height, body, animated } = paint(options.lens, graph, prepared, options.theme);
90
+ const { width, height, body, animated, atlas } = paint(
91
+ options.lens,
92
+ graph,
93
+ prepared,
94
+ options.theme,
95
+ );
88
96
 
89
97
  const svg = svgDocument({
90
98
  width,
@@ -95,7 +103,16 @@ export const render = (doc: GraphDoc, options: RenderOptions): RenderedSvg => {
95
103
  body,
96
104
  });
97
105
 
98
- return { svg, width, height, lens: options.lens, theme: options.theme, view: view?.id, animated };
106
+ return {
107
+ svg,
108
+ width,
109
+ height,
110
+ lens: options.lens,
111
+ theme: options.theme,
112
+ view: view?.id,
113
+ animated,
114
+ atlas,
115
+ };
99
116
  };
100
117
 
101
118
  const requireView = (views: readonly View[], id: string): View => {
@@ -16,6 +16,7 @@ import {
16
16
  LANE_RADIUS,
17
17
  SUBTITLE_SIZE,
18
18
  } from "../design.js";
19
+ import { atlasBoxes, emptyAtlas, type RenderAtlas } from "../atlas.js";
19
20
  import { canvasFor, union } from "../bounds.js";
20
21
  import { DIAGRAM_MARGIN } from "../design.js";
21
22
  import { coord, type Box } from "../geometry.js";
@@ -228,7 +229,12 @@ export const paintLabelPill = (text: string, box: Box, tone: Tone): string =>
228
229
  ),
229
230
  );
230
231
 
231
- export type ArchitecturePainting = { width: number; height: number; body: string };
232
+ export type ArchitecturePainting = {
233
+ width: number;
234
+ height: number;
235
+ body: string;
236
+ atlas: RenderAtlas;
237
+ };
232
238
 
233
239
  export const paintArchitecture = (
234
240
  graph: ScopedGraph,
@@ -290,5 +296,21 @@ export const paintArchitecture = (
290
296
  width: canvas.width,
291
297
  height: canvas.height,
292
298
  body: shifted(canvas, painted),
299
+ atlas: {
300
+ ...emptyAtlas(),
301
+ lanes: atlasBoxes(
302
+ layout.lanes.map(({ lane, box }) => ({ id: lane.id, box })),
303
+ canvas,
304
+ ),
305
+ /** The card, not the badge strip above it: a badge is context the veil may dim. */
306
+ nodes: atlasBoxes(
307
+ layout.nodes.map(({ node, box }) => ({ id: node.id, box })),
308
+ canvas,
309
+ ),
310
+ edges: atlasBoxes(
311
+ routed.map(({ edge, curve }) => ({ id: edge.id, box: curveBounds(curve) })),
312
+ canvas,
313
+ ),
314
+ },
293
315
  };
294
316
  };
@@ -12,7 +12,8 @@ import {
12
12
  PILL_TEXT_SIZE,
13
13
  TITLE_SIZE,
14
14
  } from "../design.js";
15
- import { canvasFor, union } from "../bounds.js";
15
+ import { atlasBoxes, emptyAtlas, type RenderAtlas } from "../atlas.js";
16
+ import { canvasFor, covering, union, type Canvas } from "../bounds.js";
16
17
  import type { Box } from "../geometry.js";
17
18
  import { measure } from "../text.js";
18
19
  import { coord } from "../geometry.js";
@@ -22,6 +23,7 @@ import {
22
23
  FLOW_BAND_PAD_Y,
23
24
  layoutDataFlow,
24
25
  MARKER_INSET,
26
+ messagePitch,
25
27
  PARTICIPANT_TOP,
26
28
  SELF_LOOP_CORNER,
27
29
  SELF_LOOP_DROP,
@@ -378,7 +380,68 @@ const flowBounds = (layout: FlowLayout, columnWidth: number): Box[] => {
378
380
  return [...bands, ...pills, ...loops];
379
381
  };
380
382
 
381
- export type DataFlowPainting = { width: number; height: number; body: string };
383
+ /**
384
+ * The row a step owns: what it draws, grown to the pitch the layout gave it.
385
+ *
386
+ * An arrow is a line and a loop is barely taller, so the tight bounds of
387
+ * either make a poor thing to put a rim around. The row is the honest unit —
388
+ * it is the space the layout set aside for this step and no other, so two
389
+ * neighbouring steps can never claim the same band. The label comes with it:
390
+ * a step lit without its own words is a step a reader cannot name.
391
+ */
392
+ const messageBox = (placed: PlacedMessage, activeAt: ActiveAt): Box => {
393
+ const direction = travelDirection(placed.message.kind, placed.fromX, placed.toX);
394
+
395
+ const drawn =
396
+ direction === 0
397
+ ? selfDrawn(placed, activeAt(placed.message.from, placed.y))
398
+ : covering(straightDrawn(placed), pillBox(placed, endsFor(placed, activeAt, direction)));
399
+
400
+ const pitch = messagePitch(placed.message);
401
+ return {
402
+ x: drawn.x,
403
+ y: drawn.y + drawn.height / 2 - pitch / 2,
404
+ width: drawn.width,
405
+ height: pitch,
406
+ };
407
+ };
408
+
409
+ const straightDrawn = (placed: PlacedMessage): Box => ({
410
+ x: Math.min(placed.fromX, placed.toX),
411
+ y: placed.y,
412
+ width: Math.abs(placed.toX - placed.fromX),
413
+ height: 0,
414
+ });
415
+
416
+ const selfDrawn = (placed: PlacedMessage, activated: boolean): Box =>
417
+ covering(
418
+ {
419
+ x: placed.fromX,
420
+ y: placed.y,
421
+ width:
422
+ (activated ? ACTIVATION_HALF_WIDTH : 0) + SELF_LOOP_REACH + SELF_LOOP_CORNER,
423
+ height: SELF_LOOP_EXTENT,
424
+ },
425
+ selfPillBox(placed, activated),
426
+ );
427
+
428
+ const flowAtlas = (layout: FlowLayout, canvas: Canvas): Record<string, Box> => {
429
+ const activeAt = activationLookup(layout);
430
+ return atlasBoxes(
431
+ layout.messages.map((placed) => ({
432
+ id: placed.message.id,
433
+ box: messageBox(placed, activeAt),
434
+ })),
435
+ canvas,
436
+ );
437
+ };
438
+
439
+ export type DataFlowPainting = {
440
+ width: number;
441
+ height: number;
442
+ body: string;
443
+ atlas: RenderAtlas;
444
+ };
382
445
 
383
446
  export const paintDataFlow = (
384
447
  flows: readonly Flow[],
@@ -404,5 +467,21 @@ export const paintDataFlow = (
404
467
  ),
405
468
  ),
406
469
  ),
470
+ atlas: {
471
+ ...emptyAtlas(),
472
+ /** The card heading a column, which is where this lens draws a node. */
473
+ nodes: atlasBoxes(
474
+ layout.flows.flatMap((flow) =>
475
+ flow.participants.map((participant) => ({
476
+ id: participant.node.id,
477
+ box: participant.card,
478
+ })),
479
+ ),
480
+ canvas,
481
+ ),
482
+ messages: Object.fromEntries(
483
+ layout.flows.map((flow) => [flow.flow.id, flowAtlas(flow, canvas)]),
484
+ ),
485
+ },
407
486
  };
408
487
  };
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  */
7
7
  export const RENDERER_NAME = "@coldtea/pr-lens-renderer";
8
8
 
9
- export const RENDERER_VERSION = "0.1.2";
9
+ export const RENDERER_VERSION = "0.2.0";