@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,34 @@
1
+ /**
2
+ * Code points XML 1.0 has no spelling for: most control characters, the
3
+ * surrogate halves, and the two non-characters at the end of the basic plane.
4
+ * They are dropped rather than escaped, because there is no escape — a
5
+ * document containing one is not merely ugly, it does not parse.
6
+ *
7
+ * Labels reach here from an extraction model by way of the schema, which
8
+ * constrains their length but not their alphabet, so this is the last place
9
+ * such a character can be stopped. Matching by code point rather than by code
10
+ * unit is what keeps a well-formed astral character — which is a pair of
11
+ * surrogates in memory — from being mistaken for two lone halves.
12
+ */
13
+ const FORBIDDEN_IN_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu;
14
+ /**
15
+ * XML escaping for text nodes and attribute values alike. Attributes are
16
+ * always written in double quotes, so the single quote does not need an
17
+ * entity, but `<` and `&` do in both positions.
18
+ */
19
+ export const escapeXml = (value) => value
20
+ .replace(FORBIDDEN_IN_XML, "")
21
+ .replace(/&/g, "&amp;")
22
+ .replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;")
24
+ .replace(/"/g, "&quot;");
25
+ const serialiseAttributes = (attributes) => Object.entries(attributes)
26
+ .filter((entry) => entry[1] !== undefined)
27
+ .map(([name, value]) => ` ${name}="${escapeXml(String(value))}"`)
28
+ .join("");
29
+ export const tag = (name, attributes) => `<${name}${serialiseAttributes(attributes)}/>`;
30
+ export const wrap = (name, attributes, children) => `<${name}${serialiseAttributes(attributes)}>${children}</${name}>`;
31
+ export const textNode = (attributes, content) => wrap("text", attributes, escapeXml(content));
32
+ /** Joins emitted fragments with newlines, dropping the ones that produced nothing. */
33
+ export const lines = (fragments) => fragments.filter((fragment) => fragment.length > 0).join("\n");
34
+ //# sourceMappingURL=primitives.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"primitives.js","sourceRoot":"","sources":["../../src/svg/primitives.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,gBAAgB,GACpB,qEAAqE,CAAC;AAExE;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,KAAa,EAAU,EAAE,CACjD,KAAK;KACF,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC;KAC7B,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;KACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;KACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;KACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAI7B,MAAM,mBAAmB,GAAG,CAAC,UAAsB,EAAU,EAAE,CAC7D,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC;KACvB,MAAM,CAAC,CAAC,KAAK,EAAsC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC;KAC7E,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,IAAI,IAAI,KAAK,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC;KAChE,IAAI,CAAC,EAAE,CAAC,CAAC;AAEd,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,IAAY,EAAE,UAAsB,EAAU,EAAE,CAClE,IAAI,IAAI,GAAG,mBAAmB,CAAC,UAAU,CAAC,IAAI,CAAC;AAEjD,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,UAAsB,EAAE,QAAgB,EAAU,EAAE,CACrF,IAAI,IAAI,GAAG,mBAAmB,CAAC,UAAU,CAAC,IAAI,QAAQ,KAAK,IAAI,GAAG,CAAC;AAErE,MAAM,CAAC,MAAM,QAAQ,GAAG,CACtB,UAAsB,EACtB,OAAe,EACP,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;AAE1D,sFAAsF;AACtF,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,SAA4B,EAAU,EAAE,CAC5D,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The travelling pulse: the mark that says a connection carries traffic
3
+ * rather than merely existing. Both lenses draw it from here, because a
4
+ * reader moving between them is reading one language — an architecture edge
5
+ * and a sequence message differ in what they connect, not in how they live.
6
+ *
7
+ * A line running behind the drawing's clock says so with a negative `begin`,
8
+ * which starts its motion that far into its own turn. A positive delay would
9
+ * describe the same steady state and lie for the seconds after load: an
10
+ * animation has no effect before it begins, so the dot waiting for its turn
11
+ * would sit at the canvas origin, in the corner, in full view.
12
+ */
13
+ /** The dot itself, so both lenses draw the same mark whatever clock moves it. */
14
+ export declare const PULSE_RADIUS = 2.6;
15
+ export declare const TRAIN_RADIUS = 3;
16
+ export declare const travellingPulses: (pulse: {
17
+ path: string;
18
+ colour: string;
19
+ /** Dots riding this line at once, spread evenly around the turn. */
20
+ count: number;
21
+ /** How far this line runs behind the drawing's clock, in seconds. */
22
+ lag: number;
23
+ }) => string;
24
+ //# sourceMappingURL=pulse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pulse.d.ts","sourceRoot":"","sources":["../../src/svg/pulse.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AACH,iFAAiF;AACjF,eAAO,MAAM,YAAY,MAAM,CAAC;AAChC,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,eAAO,MAAM,gBAAgB,UAAW;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,GAAG,EAAE,MAAM,CAAC;CACb,KAAG,MAoBH,CAAC"}
@@ -0,0 +1,33 @@
1
+ import { HERO_PULSE_DURATION, PULSE_DURATION } from "../design.js";
2
+ import { coord } from "../geometry.js";
3
+ import { lines, tag, wrap } from "./primitives.js";
4
+ /**
5
+ * The travelling pulse: the mark that says a connection carries traffic
6
+ * rather than merely existing. Both lenses draw it from here, because a
7
+ * reader moving between them is reading one language — an architecture edge
8
+ * and a sequence message differ in what they connect, not in how they live.
9
+ *
10
+ * A line running behind the drawing's clock says so with a negative `begin`,
11
+ * which starts its motion that far into its own turn. A positive delay would
12
+ * describe the same steady state and lie for the seconds after load: an
13
+ * animation has no effect before it begins, so the dot waiting for its turn
14
+ * would sit at the canvas origin, in the corner, in full view.
15
+ */
16
+ /** The dot itself, so both lenses draw the same mark whatever clock moves it. */
17
+ export const PULSE_RADIUS = 2.6;
18
+ export const TRAIN_RADIUS = 3;
19
+ export const travellingPulses = (pulse) => {
20
+ const { path, colour, count, lag } = pulse;
21
+ const train = count > 1;
22
+ const duration = train ? HERO_PULSE_DURATION : PULSE_DURATION;
23
+ return lines(Array.from({ length: count }, (_, index) => {
24
+ const behind = (lag + (duration / count) * index) % duration;
25
+ return wrap("circle", { r: train ? TRAIN_RADIUS : PULSE_RADIUS, fill: colour }, tag("animateMotion", {
26
+ dur: `${coord(duration)}s`,
27
+ begin: behind === 0 ? undefined : `${coord(behind - duration)}s`,
28
+ repeatCount: "indefinite",
29
+ path,
30
+ }));
31
+ }));
32
+ };
33
+ //# sourceMappingURL=pulse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pulse.js","sourceRoot":"","sources":["../../src/svg/pulse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnE,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AACvC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,iFAAiF;AACjF,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAC;AAChC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,CAAC;AAE9B,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,KAOhC,EAAU,EAAE;IACX,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC;IAC3C,MAAM,KAAK,GAAG,KAAK,GAAG,CAAC,CAAC;IACxB,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,cAAc,CAAC;IAE9D,OAAO,KAAK,CACV,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE;QACzC,MAAM,MAAM,GAAG,CAAC,GAAG,GAAG,CAAC,QAAQ,GAAG,KAAK,CAAC,GAAG,KAAK,CAAC,GAAG,QAAQ,CAAC;QAC7D,OAAO,IAAI,CACT,QAAQ,EACR,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,EACxD,GAAG,CAAC,eAAe,EAAE;YACnB,GAAG,EAAE,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG;YAC1B,KAAK,EAAE,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,QAAQ,CAAC,GAAG;YAChE,WAAW,EAAE,YAAY;YACzB,IAAI;SACL,CAAC,CACH,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;AACJ,CAAC,CAAC"}
package/dist/text.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The renderer never asks a font engine how wide a string is: it must produce
3
+ * the same geometry on a CI runner with no fonts installed as on a laptop
4
+ * with all of them. Widths come from a table instead, and every box is sized
5
+ * with enough slack that the small error against a real face stays invisible.
6
+ */
7
+ export type Face = "sans" | "sans-bold" | "mono";
8
+ export declare const SANS_STACK = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif";
9
+ export declare const MONO_STACK = "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace";
10
+ /** Width of `text` in user units at `fontSize`, to a hundredth of a unit. */
11
+ export declare const measure: (text: string, face: Face, fontSize: number) => number;
12
+ /**
13
+ * Shortens `text` until it fits `maxWidth`, ellipsis included. Cutting by
14
+ * code point rather than by UTF-16 unit keeps a surrogate pair from being
15
+ * split into a replacement character.
16
+ */
17
+ export declare const truncate: (text: string, face: Face, fontSize: number, maxWidth: number) => string;
18
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;AAEjD,eAAO,MAAM,UAAU,iGACqE,CAAC;AAC7F,eAAO,MAAM,UAAU,0EACgD,CAAC;AA0CxE,6EAA6E;AAC7E,eAAO,MAAM,OAAO,SAAU,MAAM,QAAQ,IAAI,YAAY,MAAM,KAAG,MAIpE,CAAC;AAIF;;;;GAIG;AACH,eAAO,MAAM,QAAQ,SACb,MAAM,QACN,IAAI,YACA,MAAM,YACN,MAAM,KACf,MAiBF,CAAC"}
package/dist/text.js ADDED
@@ -0,0 +1,70 @@
1
+ import { assertNever } from "@coldtea/pr-lens-schema";
2
+ export const SANS_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif';
3
+ export const MONO_STACK = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
4
+ /** Advance widths per 1000 units of em, for the regular sans face. */
5
+ const SANS_ADVANCE = {
6
+ " ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667, "'": 191,
7
+ "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333, ".": 278, "/": 278,
8
+ "0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556, "7": 556,
9
+ "8": 556, "9": 556, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584, "?": 556,
10
+ "@": 1015, A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722,
11
+ I: 278, J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
12
+ S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611, "[": 278,
13
+ "\\": 278, "]": 278, "^": 469, _: 556, "`": 333, a: 556, b: 556, c: 500, d: 556,
14
+ e: 556, f: 278, g: 556, h: 556, i: 222, j: 222, k: 500, l: 222, m: 833, n: 556,
15
+ o: 556, p: 556, q: 556, r: 333, s: 500, t: 278, u: 556, v: 500, w: 722, x: 500,
16
+ y: 500, z: 500, "{": 334, "|": 260, "}": 334, "~": 584,
17
+ };
18
+ /** What a character outside the table is assumed to cost, in the same units. */
19
+ const FALLBACK_ADVANCE = 600;
20
+ /**
21
+ * The bold face of the same family runs a little wider at every weight step.
22
+ * One factor over the regular table is close enough for boxes that already
23
+ * carry padding, and it keeps a second table from drifting out of sync.
24
+ */
25
+ const BOLD_WIDENING = 1.06;
26
+ const MONO_ADVANCE = 600;
27
+ const advanceFor = (face, character) => {
28
+ switch (face) {
29
+ case "sans":
30
+ return SANS_ADVANCE[character] ?? FALLBACK_ADVANCE;
31
+ case "sans-bold":
32
+ return (SANS_ADVANCE[character] ?? FALLBACK_ADVANCE) * BOLD_WIDENING;
33
+ case "mono":
34
+ return MONO_ADVANCE;
35
+ default:
36
+ return assertNever(face, "Unhandled font face");
37
+ }
38
+ };
39
+ /** Width of `text` in user units at `fontSize`, to a hundredth of a unit. */
40
+ export const measure = (text, face, fontSize) => {
41
+ let thousandths = 0;
42
+ for (const character of text)
43
+ thousandths += advanceFor(face, character);
44
+ return Math.round((thousandths / 1000) * fontSize * 100) / 100;
45
+ };
46
+ const ELLIPSIS = "…";
47
+ /**
48
+ * Shortens `text` until it fits `maxWidth`, ellipsis included. Cutting by
49
+ * code point rather than by UTF-16 unit keeps a surrogate pair from being
50
+ * split into a replacement character.
51
+ */
52
+ export const truncate = (text, face, fontSize, maxWidth) => {
53
+ if (measure(text, face, fontSize) <= maxWidth)
54
+ return text;
55
+ const characters = [...text];
56
+ const ellipsisWidth = measure(ELLIPSIS, face, fontSize);
57
+ let width = 0;
58
+ let kept = 0;
59
+ for (const character of characters) {
60
+ const next = width + measure(character, face, fontSize);
61
+ if (next + ellipsisWidth > maxWidth)
62
+ break;
63
+ width = next;
64
+ kept += 1;
65
+ }
66
+ if (kept === 0)
67
+ return ELLIPSIS;
68
+ return `${characters.slice(0, kept).join("").trimEnd()}${ELLIPSIS}`;
69
+ };
70
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.js","sourceRoot":"","sources":["../src/text.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ import { THEMES, type Theme } from "@coldtea/pr-lens-schema";
2
+ /**
3
+ * The two halves of a GitHub `<picture>` pair. A comment ships both and the
4
+ * client picks; neither half may depend on the page it lands in, because an
5
+ * SVG served as an image cannot see it.
6
+ *
7
+ * The contract owns the pair, because the number of themes is one half of the
8
+ * arithmetic that bounds a document's view tree. Re-exported here so a caller
9
+ * rendering a diagram does not have to reach into the schema for it.
10
+ */
11
+ export { THEMES, type Theme };
12
+ /**
13
+ * Every colour the renderer can paint, resolved to a literal before it
14
+ * reaches the document. CSS custom properties would be shorter, but GitHub
15
+ * serves these files through an image proxy where nothing outside the file
16
+ * exists, so the palette is baked in per theme.
17
+ */
18
+ export type Palette = {
19
+ background: string;
20
+ dot: string;
21
+ lane: string;
22
+ card: string;
23
+ cardBorder: string;
24
+ foreground: string;
25
+ muted: string;
26
+ edge: string;
27
+ chip: string;
28
+ pill: string;
29
+ pillBorder: string;
30
+ lifeline: string;
31
+ added: string;
32
+ addedFill: string;
33
+ addedBorder: string;
34
+ modified: string;
35
+ modifiedFill: string;
36
+ modifiedBorder: string;
37
+ removed: string;
38
+ removedFill: string;
39
+ removedBorder: string;
40
+ neutralFill: string;
41
+ shadow: string;
42
+ };
43
+ export declare const paletteFor: (theme: Theme) => Palette;
44
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,MAAM,EAAE,KAAK,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAE1E;;;;;;;;GAQG;AACH,OAAO,EAAE,MAAM,EAAE,KAAK,KAAK,EAAE,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAsDF,eAAO,MAAM,UAAU,UAAW,KAAK,KAAG,OASzC,CAAC"}
package/dist/theme.js ADDED
@@ -0,0 +1,72 @@
1
+ import { assertNever, THEMES } from "@coldtea/pr-lens-schema";
2
+ /**
3
+ * The two halves of a GitHub `<picture>` pair. A comment ships both and the
4
+ * client picks; neither half may depend on the page it lands in, because an
5
+ * SVG served as an image cannot see it.
6
+ *
7
+ * The contract owns the pair, because the number of themes is one half of the
8
+ * arithmetic that bounds a document's view tree. Re-exported here so a caller
9
+ * rendering a diagram does not have to reach into the schema for it.
10
+ */
11
+ export { THEMES };
12
+ const LIGHT = {
13
+ background: "#ffffff",
14
+ dot: "rgba(140,149,159,.35)",
15
+ lane: "#f6f8fa",
16
+ card: "#ffffff",
17
+ cardBorder: "#d1d9e0",
18
+ foreground: "#1f2328",
19
+ muted: "#59636e",
20
+ edge: "#8c959f",
21
+ chip: "#f6f8fa",
22
+ pill: "#ffffff",
23
+ pillBorder: "#d8dee4",
24
+ lifeline: "#d1d9e0",
25
+ added: "#1a7f37",
26
+ addedFill: "#dafbe1",
27
+ addedBorder: "rgba(31,136,61,.4)",
28
+ modified: "#9a6700",
29
+ modifiedFill: "#fff8c5",
30
+ modifiedBorder: "rgba(154,103,0,.35)",
31
+ removed: "#d1242f",
32
+ removedFill: "#ffebe9",
33
+ removedBorder: "rgba(209,36,47,.35)",
34
+ neutralFill: "#f6f8fa",
35
+ shadow: "rgba(31,35,40,.14)",
36
+ };
37
+ const DARK = {
38
+ background: "#0d1117",
39
+ dot: "rgba(110,118,129,.22)",
40
+ lane: "rgba(110,118,129,.07)",
41
+ card: "#1c2128",
42
+ cardBorder: "#3d444d",
43
+ foreground: "#e6edf3",
44
+ muted: "#9198a1",
45
+ edge: "#6e7681",
46
+ chip: "rgba(110,118,129,.18)",
47
+ pill: "#0d1117",
48
+ pillBorder: "#21262d",
49
+ lifeline: "#30363d",
50
+ added: "#3fb950",
51
+ addedFill: "rgba(46,160,67,.15)",
52
+ addedBorder: "rgba(63,185,80,.4)",
53
+ modified: "#d29922",
54
+ modifiedFill: "rgba(187,128,9,.15)",
55
+ modifiedBorder: "rgba(210,153,34,.4)",
56
+ removed: "#f85149",
57
+ removedFill: "rgba(248,81,73,.12)",
58
+ removedBorder: "rgba(248,81,73,.4)",
59
+ neutralFill: "rgba(110,118,129,.18)",
60
+ shadow: "rgba(0,0,0,.28)",
61
+ };
62
+ export const paletteFor = (theme) => {
63
+ switch (theme) {
64
+ case "light":
65
+ return LIGHT;
66
+ case "dark":
67
+ return DARK;
68
+ default:
69
+ return assertNever(theme, "Unhandled theme");
70
+ }
71
+ };
72
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,EAAc,MAAM,yBAAyB,CAAC;AAE1E;;;;;;;;GAQG;AACH,OAAO,EAAE,MAAM,EAAc,CAAC;AAkC9B,MAAM,KAAK,GAAY;IACrB,UAAU,EAAE,SAAS;IACrB,GAAG,EAAE,uBAAuB;IAC5B,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,UAAU,EAAE,SAAS;IACrB,UAAU,EAAE,SAAS;IACrB,KAAK,EAAE,SAAS;IAChB,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,UAAU,EAAE,SAAS;IACrB,QAAQ,EAAE,SAAS;IACnB,KAAK,EAAE,SAAS;IAChB,SAAS,EAAE,SAAS;IACpB,WAAW,EAAE,oBAAoB;IACjC,QAAQ,EAAE,SAAS;IACnB,YAAY,EAAE,SAAS;IACvB,cAAc,EAAE,qBAAqB;IACrC,OAAO,EAAE,SAAS;IAClB,WAAW,EAAE,SAAS;IACtB,aAAa,EAAE,qBAAqB;IACpC,WAAW,EAAE,SAAS;IACtB,MAAM,EAAE,oBAAoB;CAC7B,CAAC;AAEF,MAAM,IAAI,GAAY;IACpB,UAAU,EAAE,SAAS;IACrB,GAAG,EAAE,uBAAuB;IAC5B,IAAI,EAAE,uBAAuB;IAC7B,IAAI,EAAE,SAAS;IACf,UAAU,EAAE,SAAS;IACrB,UAAU,EAAE,SAAS;IACrB,KAAK,EAAE,SAAS;IAChB,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,uBAAuB;IAC7B,IAAI,EAAE,SAAS;IACf,UAAU,EAAE,SAAS;IACrB,QAAQ,EAAE,SAAS;IACnB,KAAK,EAAE,SAAS;IAChB,SAAS,EAAE,qBAAqB;IAChC,WAAW,EAAE,oBAAoB;IACjC,QAAQ,EAAE,SAAS;IACnB,YAAY,EAAE,qBAAqB;IACnC,cAAc,EAAE,qBAAqB;IACrC,OAAO,EAAE,SAAS;IAClB,WAAW,EAAE,qBAAqB;IAClC,aAAa,EAAE,oBAAoB;IACnC,WAAW,EAAE,uBAAuB;IACpC,MAAM,EAAE,iBAAiB;CAC1B,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,KAAY,EAAW,EAAE;IAClD,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,OAAO;YACV,OAAO,KAAK,CAAC;QACf,KAAK,MAAM;YACT,OAAO,IAAI,CAAC;QACd;YACE,OAAO,WAAW,CAAC,KAAK,EAAE,iBAAiB,CAAC,CAAC;IACjD,CAAC;AACH,CAAC,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stamped into every render manifest so a stored diagram can be traced to the
3
+ * code that drew it. Kept in step with package.json by a test, since a
4
+ * package cannot read its own manifest without doing I/O the renderer does
5
+ * not otherwise need.
6
+ */
7
+ export declare const RENDERER_NAME = "@coldtea/pr-lens-renderer";
8
+ export declare const RENDERER_VERSION = "0.1.0";
9
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,aAAa,8BAA8B,CAAC;AAEzD,eAAO,MAAM,gBAAgB,UAAU,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stamped into every render manifest so a stored diagram can be traced to the
3
+ * code that drew it. Kept in step with package.json by a test, since a
4
+ * package cannot read its own manifest without doing I/O the renderer does
5
+ * not otherwise need.
6
+ */
7
+ export const RENDERER_NAME = "@coldtea/pr-lens-renderer";
8
+ export const RENDERER_VERSION = "0.1.0";
9
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,2BAA2B,CAAC;AAEzD,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@coldtea/pr-lens-renderer",
3
+ "version": "0.1.0",
4
+ "description": "The PR Lens renderer: a schema-valid graph document in, a self-contained animated SVG out.",
5
+ "license": "MIT",
6
+ "author": "Ohans Emmanuel",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/coldteadotai/pr-lens.git",
10
+ "directory": "packages/renderer"
11
+ },
12
+ "homepage": "https://github.com/coldteadotai/pr-lens/tree/main/packages/renderer#readme",
13
+ "keywords": [
14
+ "pr-lens",
15
+ "code-review",
16
+ "architecture-diagram",
17
+ "data-flow",
18
+ "svg",
19
+ "diagram"
20
+ ],
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=20.11"
41
+ },
42
+ "dependencies": {
43
+ "@coldtea/pr-lens-schema": "0.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^20.19.0",
47
+ "typescript": "7.0.2",
48
+ "vitest": "4.1.11"
49
+ },
50
+ "scripts": {
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest"
55
+ }
56
+ }
package/src/bounds.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { Box } from "./geometry.js";
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
+ }
19
+
20
+ return { x: left, y: top, width: right - left, height: bottom - top };
21
+ };
22
+
23
+ export type Canvas = { width: number; height: number; shiftX: number; shiftY: number };
24
+
25
+ /**
26
+ * A canvas big enough for everything that was drawn.
27
+ *
28
+ * Edge routes deliberately leave their lane, and a label pill can be pushed
29
+ * past the last card in search of clear air, so the space the lanes occupy is
30
+ * a floor rather than the answer. The canvas only ever grows: the margins the
31
+ * design leaves above and beside the lanes are part of the design, not slack
32
+ * to be reclaimed.
33
+ */
34
+ export const canvasFor = (
35
+ laid: { width: number; height: number },
36
+ drawn: Box | undefined,
37
+ margin: number,
38
+ ): Canvas => {
39
+ if (drawn === undefined) return { width: laid.width, height: laid.height, shiftX: 0, shiftY: 0 };
40
+
41
+ const shiftX = Math.max(0, margin - drawn.x);
42
+ const shiftY = Math.max(0, margin - drawn.y);
43
+
44
+ return {
45
+ width: Math.ceil(Math.max(laid.width, drawn.x + drawn.width + margin) + shiftX),
46
+ height: Math.ceil(Math.max(laid.height, drawn.y + drawn.height + margin) + shiftY),
47
+ shiftX,
48
+ shiftY,
49
+ };
50
+ };
@@ -0,0 +1,166 @@
1
+ import type {
2
+ Flow,
3
+ FlowMessage,
4
+ GraphDoc,
5
+ GraphEdge,
6
+ GraphNode,
7
+ Lane,
8
+ MapCorrections,
9
+ View,
10
+ ViewScope,
11
+ } from "@coldtea/pr-lens-schema";
12
+ import { assertNever } from "@coldtea/pr-lens-schema";
13
+ import { PrLensRenderError } from "./errors.js";
14
+ import { matchesGlob } from "./glob.js";
15
+
16
+ const ID_SELECTOR = "id:";
17
+
18
+ const selects = (selector: string, node: GraphNode): boolean => {
19
+ if (selector.startsWith(ID_SELECTOR)) return node.id === selector.slice(ID_SELECTOR.length);
20
+ return node.files.some((file) => matchesGlob(selector, file.path));
21
+ };
22
+
23
+ /**
24
+ * A view of a selection with the ids the overlay removed taken out, or
25
+ * `undefined` when nothing it pointed at survived. A selection is never
26
+ * allowed to empty out into "show everything": that is a different view.
27
+ */
28
+ const narrowScope = (
29
+ scope: ViewScope,
30
+ survives: { lane: (id: string) => boolean; node: (id: string) => boolean; edge: (id: string) => boolean; flow: (id: string) => boolean },
31
+ ): ViewScope | undefined => {
32
+ switch (scope.kind) {
33
+ case "all":
34
+ return scope;
35
+ case "selection": {
36
+ const narrowed = {
37
+ kind: "selection" as const,
38
+ lanes: scope.lanes.filter(survives.lane),
39
+ nodes: scope.nodes.filter(survives.node),
40
+ edges: scope.edges.filter(survives.edge),
41
+ flows: scope.flows.filter(survives.flow),
42
+ };
43
+ const total =
44
+ narrowed.lanes.length + narrowed.nodes.length + narrowed.edges.length + narrowed.flows.length;
45
+ return total > 0 ? narrowed : undefined;
46
+ }
47
+ default:
48
+ return assertNever(scope, "Unhandled view scope");
49
+ }
50
+ };
51
+
52
+ const narrowViews = (
53
+ views: readonly View[],
54
+ survives: Parameters<typeof narrowScope>[1],
55
+ ): View[] =>
56
+ views.flatMap((view) => {
57
+ const scope = narrowScope(view.scope, survives);
58
+ if (scope === undefined) return [];
59
+ return [{ ...view, scope, children: narrowViews(view.children, survives) }];
60
+ });
61
+
62
+ /**
63
+ * Applies a repository's corrections over an inferred document.
64
+ *
65
+ * The overlay is re-applied to fresh inference on every run, so a correction
66
+ * keeps holding as the code moves and inference never learns to agree with
67
+ * it. Everything a removed node touched goes with it: an edge to a node that
68
+ * is no longer drawn would point at empty space, and a flow step from one
69
+ * would animate out of nowhere.
70
+ */
71
+ export const applyCorrections = (doc: GraphDoc, corrections: MapCorrections): GraphDoc => {
72
+ const excluded = new Set(
73
+ doc.nodes.filter((node) => corrections.exclude.some((selector) => selects(selector, node))).map((node) => node.id),
74
+ );
75
+
76
+ const nodes: GraphNode[] = doc.nodes
77
+ .filter((node) => !excluded.has(node.id))
78
+ .map((node) => {
79
+ const rename = corrections.rename.find((rule) => selects(rule.match, node));
80
+ const lane = corrections.lane.find((rule) => selects(rule.match, node));
81
+ const group = corrections.group.find((rule) => selects(rule.match, node));
82
+ return {
83
+ ...node,
84
+ label: rename ? rename.to : node.label,
85
+ lane: lane ? lane.lane : node.lane,
86
+ group: group ? group.group : node.group,
87
+ };
88
+ });
89
+
90
+ if (nodes.length === 0)
91
+ throw new PrLensRenderError(
92
+ "NOTHING_TO_RENDER",
93
+ "the exclude rules in this repository's config removed every node",
94
+ );
95
+
96
+ /**
97
+ * A `lane` correction may name a band the document never declared, which is
98
+ * how a repository adds one of its own. It gets the id for a label, since
99
+ * that is the only name the config carries.
100
+ */
101
+ const declared = new Set(doc.lanes.map((lane) => lane.id));
102
+ const invented: Lane[] = [...new Set(nodes.map((node) => node.lane))]
103
+ .filter((id) => !declared.has(id))
104
+ .map((id) => ({ id, label: id }));
105
+
106
+ const lanes = [...doc.lanes, ...invented];
107
+
108
+ const edges: GraphEdge[] = doc.edges.filter(
109
+ (edge) => !excluded.has(edge.from) && !excluded.has(edge.to),
110
+ );
111
+
112
+ const flows: Flow[] = doc.flows.flatMap((flow) => {
113
+ const participants = flow.participants.filter((participant) => !excluded.has(participant.node));
114
+ if (participants.length < 2) return [];
115
+
116
+ const kept = new Set(participants.map((participant) => participant.node));
117
+ const messages: FlowMessage[] = flow.messages.filter(
118
+ (message) => kept.has(message.from) && kept.has(message.to),
119
+ );
120
+ if (messages.length === 0) return [];
121
+
122
+ return [{ ...flow, participants, messages }];
123
+ });
124
+
125
+ const survives = {
126
+ lane: (id: string) => lanes.some((lane) => lane.id === id),
127
+ node: (id: string) => !excluded.has(id),
128
+ edge: (id: string) => edges.some((edge) => edge.id === id),
129
+ flow: (id: string) => flows.some((flow) => flow.id === id),
130
+ };
131
+
132
+ return {
133
+ ...doc,
134
+ lanes,
135
+ nodes,
136
+ edges,
137
+ flows,
138
+ views: narrowViews(doc.views, survives),
139
+ layout: narrowLayout(doc.layout, survives),
140
+ };
141
+ };
142
+
143
+ /**
144
+ * Layout hints name nodes and lanes, so an exclusion can leave one pointing at
145
+ * something that is no longer there. The renderer would not notice — a hint
146
+ * for a node it is not drawing simply goes unread — but the corrected document
147
+ * is a document like any other, and a surface that writes it out and parses it
148
+ * back is entitled to have it still be valid.
149
+ */
150
+ const narrowLayout = (
151
+ layout: GraphDoc["layout"],
152
+ survives: Parameters<typeof narrowScope>[1],
153
+ ): GraphDoc["layout"] => {
154
+ if (layout === undefined) return undefined;
155
+
156
+ const rank =
157
+ layout.rank === undefined
158
+ ? undefined
159
+ : Object.fromEntries(Object.entries(layout.rank).filter(([id]) => survives.node(id)));
160
+
161
+ return {
162
+ ...layout,
163
+ laneOrder: layout.laneOrder.filter(survives.lane),
164
+ ...(rank === undefined ? {} : { rank }),
165
+ };
166
+ };