@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
@@ -0,0 +1,408 @@
1
+ import type { Flow, FlowMessage, GraphNode, MessageKind } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import {
4
+ DIAGRAM_MARGIN,
5
+ FLOW_CYCLE_MAX,
6
+ FLOW_MAX_PULSES_PER_MESSAGE,
7
+ FLOW_PULSE_RAMP,
8
+ FLOW_STEP_TRAVEL,
9
+ LANE_RADIUS,
10
+ PILL_HEIGHT,
11
+ PILL_PADDING_X,
12
+ PILL_TEXT_SIZE,
13
+ TITLE_SIZE,
14
+ } from "../design.js";
15
+ import { canvasFor, union } from "../bounds.js";
16
+ import type { Box } from "../geometry.js";
17
+ import { measure } from "../text.js";
18
+ import { coord } from "../geometry.js";
19
+ import {
20
+ ACTIVATION_HALF_WIDTH,
21
+ FLOW_BAND_PAD_X,
22
+ FLOW_BAND_PAD_Y,
23
+ layoutDataFlow,
24
+ MARKER_INSET,
25
+ PARTICIPANT_TOP,
26
+ SELF_LOOP_CORNER,
27
+ SELF_LOOP_DROP,
28
+ SELF_LOOP_EXTENT,
29
+ SELF_LOOP_REACH,
30
+ type FlowLayout,
31
+ type PlacedMessage,
32
+ } from "../layout/dataflow.js";
33
+ import type { Palette } from "../theme.js";
34
+ import { paintCard, paintLabelPill } from "./architecture.js";
35
+ import { markerFor, openMarkerFor, shifted, toneColour, toneFor, type Tone } from "./document.js";
36
+ import { lines, tag, wrap } from "./primitives.js";
37
+ import { PULSE_RADIUS, TRAIN_RADIUS } from "./pulse.js";
38
+
39
+ /** A ratio inside the animation cycle, written to a fixed number of places. */
40
+ const ratio = (value: number): string => String(Math.round(value * 10000) / 10000);
41
+
42
+ /**
43
+ * How long the whole sequence takes. Every step would rather have a full
44
+ * crossing to itself; a flow long enough to overrun the ceiling shares it
45
+ * instead, and its steps go by faster.
46
+ */
47
+ const cycleFor = (slotCount: number): number =>
48
+ Math.min(FLOW_CYCLE_MAX, slotCount * FLOW_STEP_TRAVEL);
49
+
50
+ /** Whether a message crosses to another column, and which way. */
51
+ const travelDirection = (kind: MessageKind, fromX: number, toX: number): -1 | 0 | 1 => {
52
+ switch (kind) {
53
+ case "self":
54
+ return 0;
55
+ case "sync":
56
+ case "async":
57
+ case "return":
58
+ return toX >= fromX ? 1 : -1;
59
+ default:
60
+ return assertNever(kind, "Unhandled message kind");
61
+ }
62
+ };
63
+
64
+ const messageClasses = (message: FlowMessage, tone: Tone): string => {
65
+ const classes = ["msg", `edge-${tone}`];
66
+ if (message.kind === "return") classes.push("msg-return");
67
+ if (message.delta === "added") classes.push("msg-strong");
68
+ return classes.join(" ");
69
+ };
70
+
71
+ /**
72
+ * Fire-and-forget gets the open head; everything else keeps the filled one.
73
+ *
74
+ * Deliberate deviation from strict UML 2, which draws replies with an open
75
+ * head as well: here each signal carries exactly one meaning, so a reader
76
+ * needs no legend. The dashed stroke is already the whole mark of "this is
77
+ * an answer", and the open head stays the exclusive mark of "nobody waits on
78
+ * this". Open-headed returns would put the async signature on every reply
79
+ * and dilute the one distinction the head shape exists to draw.
80
+ */
81
+ const headFor = (kind: MessageKind, tone: Tone): string => {
82
+ switch (kind) {
83
+ case "async":
84
+ return openMarkerFor(tone);
85
+ case "sync":
86
+ case "return":
87
+ case "self":
88
+ return markerFor(tone);
89
+ default:
90
+ return assertNever(kind, "Unhandled message kind");
91
+ }
92
+ };
93
+
94
+ /** Whether an activation bar covers this column at this height. */
95
+ type ActiveAt = (node: string, y: number) => boolean;
96
+
97
+ const activationLookup = (layout: FlowLayout): ActiveAt => {
98
+ const byNode = new Map(
99
+ layout.participants.map((participant) => [participant.node.id, participant.activations]),
100
+ );
101
+ return (node, y) =>
102
+ (byNode.get(node) ?? []).some((bar) => bar.top <= y && y <= bar.bottom);
103
+ };
104
+
105
+ type Ends = { start: number; end: number };
106
+
107
+ /**
108
+ * Where an arrow starts and stops horizontally. An activated column is a bar,
109
+ * not a line, so an arrow that touches one has to stop at its edge, and the
110
+ * arrowhead needs room of its own on top of that.
111
+ */
112
+ const endsFor = (
113
+ placed: PlacedMessage,
114
+ activeAt: ActiveAt,
115
+ direction: -1 | 1,
116
+ ): Ends => ({
117
+ start:
118
+ placed.fromX +
119
+ direction * (activeAt(placed.message.from, placed.y) ? ACTIVATION_HALF_WIDTH : 0),
120
+ end:
121
+ placed.toX -
122
+ direction *
123
+ ((activeAt(placed.message.to, placed.y) ? ACTIVATION_HALF_WIDTH : 0) + MARKER_INSET),
124
+ });
125
+
126
+ const selfPath = (x: number, y: number, activated: boolean): string => {
127
+ const start = x + (activated ? ACTIVATION_HALF_WIDTH : 0);
128
+ return (
129
+ `M${coord(start)},${coord(y)} h${coord(SELF_LOOP_REACH)} ` +
130
+ `a${SELF_LOOP_CORNER},${SELF_LOOP_CORNER} 0 0 1 ${SELF_LOOP_CORNER},${SELF_LOOP_CORNER} ` +
131
+ `v${coord(SELF_LOOP_DROP)} ` +
132
+ `a${SELF_LOOP_CORNER},${SELF_LOOP_CORNER} 0 0 1 -${SELF_LOOP_CORNER},${SELF_LOOP_CORNER} ` +
133
+ `h-${coord(SELF_LOOP_REACH - MARKER_INSET)}`
134
+ );
135
+ };
136
+
137
+ /** How far a self message's pill stands off the loop it names. */
138
+ const SELF_PILL_GAP = 8;
139
+
140
+ const pillWidth = (label: string): number =>
141
+ measure(label, "sans-bold", PILL_TEXT_SIZE) + PILL_PADDING_X * 2;
142
+
143
+ /** The pill of a straight message, settled onto the middle of its arrow. */
144
+ const pillBox = (placed: PlacedMessage, ends: Ends): Box => {
145
+ const width = pillWidth(placed.label);
146
+ return {
147
+ x: (ends.start + ends.end) / 2 - width / 2,
148
+ y: placed.y - PILL_HEIGHT / 2,
149
+ width,
150
+ height: PILL_HEIGHT,
151
+ };
152
+ };
153
+
154
+ /** A self message's pill, beside the loop and centred on its height. */
155
+ const selfPillBox = (placed: PlacedMessage, activated: boolean): Box => ({
156
+ x:
157
+ placed.fromX +
158
+ (activated ? ACTIVATION_HALF_WIDTH : 0) +
159
+ SELF_LOOP_REACH +
160
+ SELF_LOOP_CORNER +
161
+ SELF_PILL_GAP,
162
+ y: placed.y + SELF_LOOP_EXTENT / 2 - PILL_HEIGHT / 2,
163
+ width: pillWidth(placed.label),
164
+ height: PILL_HEIGHT,
165
+ });
166
+
167
+ /**
168
+ * Pulses for one message: the architecture lens's dot, on a sequence's own
169
+ * clock. Every pulse in the drawing shares one cycle and owns a slot of it
170
+ * outright, so the steps light in the order they happen, one at a time.
171
+ *
172
+ * A slot is spent entirely on the crossing — the dot enters as the previous
173
+ * one lands and leaves as the next departs. Nothing waits in the dark for its
174
+ * turn, which is the whole difference between a sequence that reads as a
175
+ * relay and one that reads as a still picture with an occasional blink.
176
+ */
177
+ const pulsesFor = (
178
+ placed: PlacedMessage,
179
+ path: string,
180
+ slotCount: number,
181
+ palette: Palette,
182
+ ): string => {
183
+ if (slotCount === 0) return "";
184
+ const colour = toneColour(palette, toneFor(placed.message.delta));
185
+ // A repeated step keeps the heavier mark it has always carried; only when
186
+ // its crossings happen changed here, not what they look like.
187
+ const radius = placed.slot.count > 1 ? TRAIN_RADIUS : PULSE_RADIUS;
188
+ const width = 1 / slotCount;
189
+ const ramp = width * FLOW_PULSE_RAMP;
190
+ const duration = `${coord(cycleFor(slotCount))}s`;
191
+
192
+ return lines(
193
+ Array.from({ length: placed.slot.count }, (_, index) => {
194
+ const start = (placed.slot.start + index) * width;
195
+ const finish = start + width;
196
+
197
+ return wrap(
198
+ "circle",
199
+ { r: radius, fill: colour, opacity: 0 },
200
+ tag("animateMotion", {
201
+ dur: duration,
202
+ repeatCount: "indefinite",
203
+ keyPoints: "0;0;1;1",
204
+ keyTimes: `0;${ratio(start)};${ratio(finish)};1`,
205
+ calcMode: "linear",
206
+ path,
207
+ }) +
208
+ tag("animate", {
209
+ attributeName: "opacity",
210
+ dur: duration,
211
+ repeatCount: "indefinite",
212
+ values: "0;0;1;1;0;0",
213
+ keyTimes:
214
+ `0;${ratio(start)};${ratio(start + ramp)};` +
215
+ `${ratio(finish - ramp)};${ratio(finish)};1`,
216
+ }),
217
+ );
218
+ }),
219
+ );
220
+ };
221
+
222
+ /**
223
+ * Paints one message and the pill that names it. Like an architecture edge
224
+ * and its label, the two come back separately because they belong to
225
+ * different layers: the pill is opaque precisely so it can be read wherever
226
+ * it lands, so it passes in front of everything the arrows drew.
227
+ */
228
+ const paintMessage = (
229
+ placed: PlacedMessage,
230
+ activeAt: ActiveAt,
231
+ slotCount: number,
232
+ palette: Palette,
233
+ ): { line: string; pill: string } => {
234
+ const tone = toneFor(placed.message.delta);
235
+ const direction = travelDirection(placed.message.kind, placed.fromX, placed.toX);
236
+ const head = headFor(placed.message.kind, tone);
237
+
238
+ if (direction === 0) {
239
+ const activated = activeAt(placed.message.from, placed.y);
240
+ const path = selfPath(placed.fromX, placed.y, activated);
241
+ return {
242
+ line: lines([
243
+ tag("path", { class: messageClasses(placed.message, tone), d: path, "marker-end": head }),
244
+ pulsesFor(placed, path, slotCount, palette),
245
+ ]),
246
+ pill: paintLabelPill(placed.label, selfPillBox(placed, activated), tone),
247
+ };
248
+ }
249
+
250
+ const ends = endsFor(placed, activeAt, direction);
251
+ const path = `M${coord(ends.start)},${coord(placed.y)} L${coord(ends.end)},${coord(placed.y)}`;
252
+
253
+ return {
254
+ line: lines([
255
+ tag("path", { class: messageClasses(placed.message, tone), d: path, "marker-end": head }),
256
+ pulsesFor(placed, path, slotCount, palette),
257
+ ]),
258
+ pill: paintLabelPill(placed.label, pillBox(placed, ends), tone),
259
+ };
260
+ };
261
+
262
+ /**
263
+ * The ground under one column: a band in the lane language, holding the
264
+ * card, its lifeline and its activation bars with the same breathing room a
265
+ * lane keeps around its cards.
266
+ */
267
+ const bandBox = (centreX: number, columnWidth: number, layout: FlowLayout): Box => {
268
+ const top = layout.top + PARTICIPANT_TOP - FLOW_BAND_PAD_Y;
269
+ return {
270
+ x: centreX - columnWidth / 2 - FLOW_BAND_PAD_X,
271
+ y: top,
272
+ width: columnWidth + FLOW_BAND_PAD_X * 2,
273
+ height: layout.top + layout.height + FLOW_BAND_PAD_Y - top,
274
+ };
275
+ };
276
+
277
+ const paintFlow = (
278
+ layout: FlowLayout,
279
+ columnWidth: number,
280
+ slotCount: number,
281
+ palette: Palette,
282
+ ): string => {
283
+ const activeAt = activationLookup(layout);
284
+
285
+ const lifelineBottom = layout.top + layout.height;
286
+
287
+ const bands = layout.participants.map((participant) => {
288
+ const box = bandBox(participant.centreX, columnWidth, layout);
289
+ return tag("rect", {
290
+ class: "lanebox",
291
+ x: coord(box.x),
292
+ y: coord(box.y),
293
+ width: coord(box.width),
294
+ height: coord(box.height),
295
+ rx: LANE_RADIUS,
296
+ });
297
+ });
298
+
299
+ const columns = layout.participants.map((participant) =>
300
+ lines([
301
+ tag("line", {
302
+ class: "lifeline",
303
+ x1: coord(participant.centreX),
304
+ y1: coord(layout.lifelineTop),
305
+ x2: coord(participant.centreX),
306
+ y2: coord(lifelineBottom),
307
+ }),
308
+ ...participant.activations.map((bar) =>
309
+ tag("rect", {
310
+ class: "actbar",
311
+ x: coord(participant.centreX - ACTIVATION_HALF_WIDTH),
312
+ y: coord(bar.top),
313
+ width: ACTIVATION_HALF_WIDTH * 2,
314
+ height: coord(bar.bottom - bar.top),
315
+ rx: 4,
316
+ }),
317
+ ),
318
+ ]),
319
+ );
320
+
321
+ const cards = layout.participants.map((participant, index) =>
322
+ paintCard({
323
+ node: participant.node,
324
+ box: participant.card,
325
+ showIcon: true,
326
+ titleSize: TITLE_SIZE,
327
+ row: 0,
328
+ laneIndex: index,
329
+ }),
330
+ );
331
+
332
+ const lineMarkup: string[] = [];
333
+ const pillMarkup: string[] = [];
334
+ for (const message of layout.messages) {
335
+ const { line, pill } = paintMessage(message, activeAt, slotCount, palette);
336
+ lineMarkup.push(line);
337
+ pillMarkup.push(pill);
338
+ }
339
+
340
+ return lines([
341
+ wrap("g", {}, lines(bands)),
342
+ wrap("g", {}, lines(columns)),
343
+ wrap("g", {}, lines(cards)),
344
+ wrap("g", {}, lines(lineMarkup)),
345
+ wrap("g", {}, lines(pillMarkup)),
346
+ ]);
347
+ };
348
+
349
+ /**
350
+ * The room a flow's own drawing takes. The bands already hold the cards and
351
+ * columns, but a pill is centred on its arrow and a self message's sits off
352
+ * to the right of one, so either can reach past what the layout sized the
353
+ * canvas from.
354
+ */
355
+ const flowBounds = (layout: FlowLayout, columnWidth: number): Box[] => {
356
+ const activeAt = activationLookup(layout);
357
+
358
+ const bands = layout.participants.map((participant) =>
359
+ bandBox(participant.centreX, columnWidth, layout),
360
+ );
361
+
362
+ const pills = layout.messages.map((placed) => {
363
+ const direction = travelDirection(placed.message.kind, placed.fromX, placed.toX);
364
+
365
+ if (direction === 0) return selfPillBox(placed, activeAt(placed.message.from, placed.y));
366
+ return pillBox(placed, endsFor(placed, activeAt, direction));
367
+ });
368
+
369
+ const loops = layout.messages
370
+ .filter((placed) => placed.message.kind === "self")
371
+ .map((placed) => ({
372
+ x: placed.fromX,
373
+ y: placed.y,
374
+ width: SELF_LOOP_REACH + SELF_LOOP_CORNER + ACTIVATION_HALF_WIDTH,
375
+ height: SELF_LOOP_DROP + SELF_LOOP_CORNER * 2,
376
+ }));
377
+
378
+ return [...bands, ...pills, ...loops];
379
+ };
380
+
381
+ export type DataFlowPainting = { width: number; height: number; body: string };
382
+
383
+ export const paintDataFlow = (
384
+ flows: readonly Flow[],
385
+ nodes: readonly GraphNode[],
386
+ palette: Palette,
387
+ ): DataFlowPainting => {
388
+ const layout = layoutDataFlow(flows, nodes, FLOW_MAX_PULSES_PER_MESSAGE);
389
+
390
+ const canvas = canvasFor(
391
+ layout,
392
+ union(layout.flows.flatMap((flow) => flowBounds(flow, layout.columnWidth))),
393
+ DIAGRAM_MARGIN,
394
+ );
395
+
396
+ return {
397
+ width: canvas.width,
398
+ height: canvas.height,
399
+ body: shifted(
400
+ canvas,
401
+ lines(
402
+ layout.flows.map((flow) =>
403
+ paintFlow(flow, layout.columnWidth, layout.slotCount, palette),
404
+ ),
405
+ ),
406
+ ),
407
+ };
408
+ };
@@ -0,0 +1,198 @@
1
+ import type { Delta } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import type { Canvas } from "../bounds.js";
4
+ import { coord } from "../geometry.js";
5
+ import type { Palette } from "../theme.js";
6
+ import { MONO_STACK, SANS_STACK } from "../text.js";
7
+ import { escapeXml, lines, tag, wrap } from "./primitives.js";
8
+
9
+ /**
10
+ * The colour family an element is drawn in. It follows the delta rather than
11
+ * the element type, because the one question a reviewer asks of a diagram is
12
+ * what changed.
13
+ */
14
+ export type Tone = "added" | "modified" | "removed" | "neutral";
15
+
16
+ export const toneFor = (delta: Delta): Tone => {
17
+ switch (delta) {
18
+ case "added":
19
+ return "added";
20
+ case "modified":
21
+ return "modified";
22
+ case "removed":
23
+ return "removed";
24
+ case "unchanged":
25
+ return "neutral";
26
+ default:
27
+ return assertNever(delta, "Unhandled delta");
28
+ }
29
+ };
30
+
31
+ export const toneColour = (palette: Palette, tone: Tone): string => {
32
+ switch (tone) {
33
+ case "added":
34
+ return palette.added;
35
+ case "modified":
36
+ return palette.modified;
37
+ case "removed":
38
+ return palette.removed;
39
+ case "neutral":
40
+ return palette.edge;
41
+ default:
42
+ return assertNever(tone, "Unhandled tone");
43
+ }
44
+ };
45
+
46
+ const TONES = ["added", "modified", "removed", "neutral"] as const satisfies readonly Tone[];
47
+
48
+ const stylesheet = (palette: Palette): string =>
49
+ [
50
+ `text{font-family:${SANS_STACK}}`,
51
+ `.lanebox{fill:${palette.lane}}`,
52
+ `.lanelabel{font-size:10px;font-weight:700;letter-spacing:.12em;fill:${palette.muted}}`,
53
+ `.card{fill:${palette.card};stroke:${palette.cardBorder};stroke-width:1}`,
54
+ `.card-added{stroke:${palette.added};stroke-opacity:.55}`,
55
+ `.card-modified{stroke:${palette.modified};stroke-opacity:.5}`,
56
+ `.ghost{opacity:.55}`,
57
+ `.ghost .card{stroke:${palette.removed};stroke-dasharray:4 3;stroke-opacity:.6}`,
58
+ `.context{opacity:.82}`,
59
+ `.ntitle{font-weight:600;fill:${palette.foreground}}`,
60
+ `.strike{text-decoration:line-through}`,
61
+ `.nsub{font-size:9.5px;fill:${palette.muted};font-family:${MONO_STACK}}`,
62
+ `.chip{fill:${palette.chip}}`,
63
+ `.glyph{fill:${palette.muted}}`,
64
+ `.glyph-stroke{stroke:${palette.muted};stroke-width:1.4;fill:none}`,
65
+ `.bdg text{font-size:8.5px;font-weight:700;letter-spacing:.06em}`,
66
+ `.bdg rect{stroke-width:1}`,
67
+ `.bdg-added rect{fill:${palette.addedFill};stroke:${palette.addedBorder}}`,
68
+ `.bdg-added text{fill:${palette.added}}`,
69
+ `.bdg-modified rect{fill:${palette.modifiedFill};stroke:${palette.modifiedBorder}}`,
70
+ `.bdg-modified text{fill:${palette.modified}}`,
71
+ `.bdg-removed rect{fill:${palette.removedFill};stroke:${palette.removedBorder}}`,
72
+ `.bdg-removed text{fill:${palette.removed}}`,
73
+ `.bdg-neutral rect{fill:${palette.neutralFill};stroke:${palette.cardBorder}}`,
74
+ `.bdg-neutral text{fill:${palette.muted}}`,
75
+ `.edge{fill:none;stroke-width:1.5}`,
76
+ `.edge-added{stroke:${palette.added}}`,
77
+ `.edge-modified{stroke:${palette.modified}}`,
78
+ `.edge-removed{stroke:${palette.removed};stroke-dasharray:5 4;opacity:.7}`,
79
+ `.edge-neutral{stroke:${palette.edge}}`,
80
+ `.hero{stroke-width:2.25}`,
81
+ `.faded{opacity:.45}`,
82
+ `.glow{fill:none;stroke-width:7;opacity:.14}`,
83
+ `.msg-self{font-size:11px;fill:${palette.foreground}}`,
84
+ `.lpill{fill:${palette.pill};stroke:${palette.pillBorder};stroke-width:1}`,
85
+ `.ltext{font-size:9.5px;font-weight:600;fill:${palette.muted}}`,
86
+ `.ltext-added{fill:${palette.added}}`,
87
+ `.ltext-modified{fill:${palette.modified}}`,
88
+ `.ltext-removed{fill:${palette.removed}}`,
89
+ `.cardsh{filter:drop-shadow(0 1px 2px ${palette.shadow})}`,
90
+ `.lifeline{stroke:${palette.lifeline};stroke-width:1;stroke-dasharray:3 4}`,
91
+ `.actbar{fill:${palette.addedFill};stroke:${palette.addedBorder}}`,
92
+ `.msg{fill:none;stroke-width:1.5}`,
93
+ `.msg-return{stroke-dasharray:4 3;opacity:.8}`,
94
+ `.msg-strong{stroke-width:2.25}`,
95
+ ].join("");
96
+
97
+ /**
98
+ * Two arrowhead forms per tone, the classic sequence-diagram pair: a filled
99
+ * head for a message the sender waits on, an open line-form head for one it
100
+ * fires and forgets. The open head anchors at its tip so the line runs all
101
+ * the way into the point, where a filled head covers its own line end.
102
+ */
103
+ const markers = (palette: Palette): string =>
104
+ TONES.map(
105
+ (tone) =>
106
+ wrap(
107
+ "marker",
108
+ {
109
+ id: `mk-${tone}`,
110
+ viewBox: "0 0 10 10",
111
+ refX: 8,
112
+ refY: 5,
113
+ markerWidth: 6.5,
114
+ markerHeight: 6.5,
115
+ orient: "auto-start-reverse",
116
+ },
117
+ tag("path", { d: "M0,0 L10,5 L0,10 z", fill: toneColour(palette, tone) }),
118
+ ) +
119
+ wrap(
120
+ "marker",
121
+ {
122
+ id: `mko-${tone}`,
123
+ viewBox: "0 0 10 10",
124
+ refX: 10,
125
+ refY: 5,
126
+ markerWidth: 6.5,
127
+ markerHeight: 6.5,
128
+ orient: "auto-start-reverse",
129
+ },
130
+ tag("path", {
131
+ d: "M2,1 L10,5 L2,9",
132
+ fill: "none",
133
+ stroke: toneColour(palette, tone),
134
+ "stroke-width": 1.6,
135
+ "stroke-linecap": "round",
136
+ "stroke-linejoin": "round",
137
+ }),
138
+ ),
139
+ ).join("");
140
+
141
+ export const markerFor = (tone: Tone): string => `url(#mk-${tone})`;
142
+
143
+ export const openMarkerFor = (tone: Tone): string => `url(#mko-${tone})`;
144
+
145
+ const DOT_PITCH = 18;
146
+
147
+ /**
148
+ * Wraps painted content in a standalone SVG file.
149
+ *
150
+ * Nothing outside the file is referenced: it is served through an image proxy
151
+ * where the page it lands in does not exist, and it is served as an image, so
152
+ * script would not run even if it were there. Colours are literal for the
153
+ * same reason — the theme is chosen by picking a file, not by asking the page.
154
+ */
155
+ export const svgDocument = (input: {
156
+ width: number;
157
+ height: number;
158
+ palette: Palette;
159
+ title: string;
160
+ description?: string;
161
+ body: string;
162
+ }): string => {
163
+ const { width, height, palette, title, description, body } = input;
164
+
165
+ const defs = wrap(
166
+ "defs",
167
+ {},
168
+ wrap(
169
+ "pattern",
170
+ { id: "dots", width: DOT_PITCH, height: DOT_PITCH, patternUnits: "userSpaceOnUse" },
171
+ tag("circle", { cx: 1.5, cy: 1.5, r: 1, fill: palette.dot }),
172
+ ) + markers(palette),
173
+ );
174
+
175
+ return lines([
176
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${coord(width)} ${coord(height)}" ` +
177
+ `width="${coord(width)}" height="${coord(height)}" role="img" aria-label="${escapeXml(title)}">`,
178
+ wrap("title", {}, escapeXml(title)),
179
+ description === undefined ? "" : wrap("desc", {}, escapeXml(description)),
180
+ wrap("style", {}, stylesheet(palette)),
181
+ defs,
182
+ tag("rect", { x: 0, y: 0, width: coord(width), height: coord(height), rx: 12, fill: palette.background }),
183
+ tag("rect", { x: 0, y: 0, width: coord(width), height: coord(height), rx: 12, fill: "url(#dots)" }),
184
+ body,
185
+ "</svg>",
186
+ ]);
187
+ };
188
+
189
+ /**
190
+ * Moves painted content clear of the canvas edge when something was drawn
191
+ * above or to the left of the origin. Wrapping rather than re-deriving every
192
+ * coordinate keeps the geometry — and so the bytes — unchanged whenever the
193
+ * shift is zero, which is the ordinary case.
194
+ */
195
+ export const shifted = (canvas: Canvas, body: string): string =>
196
+ canvas.shiftX === 0 && canvas.shiftY === 0
197
+ ? body
198
+ : wrap("g", { transform: `translate(${coord(canvas.shiftX)},${coord(canvas.shiftY)})` }, body);