@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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/bounds.d.ts +23 -0
- package/dist/bounds.d.ts.map +1 -0
- package/dist/bounds.js +39 -0
- package/dist/bounds.js.map +1 -0
- package/dist/corrections.d.ts +12 -0
- package/dist/corrections.d.ts.map +1 -0
- package/dist/corrections.js +122 -0
- package/dist/corrections.js.map +1 -0
- package/dist/design.d.ts +112 -0
- package/dist/design.d.ts.map +1 -0
- package/dist/design.js +112 -0
- package/dist/design.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/geometry.d.ts +20 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +15 -0
- package/dist/geometry.js.map +1 -0
- package/dist/glob.d.ts +7 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +17 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/architecture.d.ts +78 -0
- package/dist/layout/architecture.d.ts.map +1 -0
- package/dist/layout/architecture.js +207 -0
- package/dist/layout/architecture.js.map +1 -0
- package/dist/layout/congestion.d.ts +18 -0
- package/dist/layout/congestion.d.ts.map +1 -0
- package/dist/layout/congestion.js +50 -0
- package/dist/layout/congestion.js.map +1 -0
- package/dist/layout/dataflow.d.ts +85 -0
- package/dist/layout/dataflow.d.ts.map +1 -0
- package/dist/layout/dataflow.js +184 -0
- package/dist/layout/dataflow.js.map +1 -0
- package/dist/layout/edges.d.ts +48 -0
- package/dist/layout/edges.d.ts.map +1 -0
- package/dist/layout/edges.js +843 -0
- package/dist/layout/edges.js.map +1 -0
- package/dist/layout/labels.d.ts +9 -0
- package/dist/layout/labels.d.ts.map +1 -0
- package/dist/layout/labels.js +149 -0
- package/dist/layout/labels.js.map +1 -0
- package/dist/layout/rank.d.ts +16 -0
- package/dist/layout/rank.d.ts.map +1 -0
- package/dist/layout/rank.js +80 -0
- package/dist/layout/rank.js.map +1 -0
- package/dist/layout/seating.d.ts +29 -0
- package/dist/layout/seating.d.ts.map +1 -0
- package/dist/layout/seating.js +131 -0
- package/dist/layout/seating.js.map +1 -0
- package/dist/manifest.d.ts +39 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +99 -0
- package/dist/manifest.js.map +1 -0
- package/dist/render.d.ts +49 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +125 -0
- package/dist/render.js.map +1 -0
- package/dist/scope.d.ts +21 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +57 -0
- package/dist/scope.js.map +1 -0
- package/dist/svg/architecture.d.ts +15 -0
- package/dist/svg/architecture.d.ts.map +1 -0
- package/dist/svg/architecture.js +197 -0
- package/dist/svg/architecture.js.map +1 -0
- package/dist/svg/dataflow.d.ts +9 -0
- package/dist/svg/dataflow.d.ts.map +1 -0
- package/dist/svg/dataflow.js +284 -0
- package/dist/svg/dataflow.js.map +1 -0
- package/dist/svg/document.d.ts +37 -0
- package/dist/svg/document.d.ts.map +1 -0
- package/dist/svg/document.js +148 -0
- package/dist/svg/document.js.map +1 -0
- package/dist/svg/icons.d.ts +3 -0
- package/dist/svg/icons.d.ts.map +1 -0
- package/dist/svg/icons.js +106 -0
- package/dist/svg/icons.js.map +1 -0
- package/dist/svg/primitives.d.ts +13 -0
- package/dist/svg/primitives.d.ts.map +1 -0
- package/dist/svg/primitives.js +34 -0
- package/dist/svg/primitives.js.map +1 -0
- package/dist/svg/pulse.d.ts +24 -0
- package/dist/svg/pulse.d.ts.map +1 -0
- package/dist/svg/pulse.js +33 -0
- package/dist/svg/pulse.js.map +1 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +70 -0
- package/dist/text.js.map +1 -0
- package/dist/theme.d.ts +44 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +72 -0
- package/dist/theme.js.map +1 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/package.json +56 -0
- package/src/bounds.ts +50 -0
- package/src/corrections.ts +166 -0
- package/src/design.ts +124 -0
- package/src/errors.ts +21 -0
- package/src/geometry.ts +22 -0
- package/src/glob.ts +20 -0
- package/src/index.ts +30 -0
- package/src/layout/architecture.ts +324 -0
- package/src/layout/congestion.ts +76 -0
- package/src/layout/dataflow.ts +307 -0
- package/src/layout/edges.ts +1061 -0
- package/src/layout/labels.ts +192 -0
- package/src/layout/rank.ts +95 -0
- package/src/layout/seating.ts +189 -0
- package/src/manifest.ts +118 -0
- package/src/render.ts +190 -0
- package/src/scope.ts +67 -0
- package/src/svg/architecture.ts +293 -0
- package/src/svg/dataflow.ts +408 -0
- package/src/svg/document.ts +198 -0
- package/src/svg/icons.ts +121 -0
- package/src/svg/primitives.ts +50 -0
- package/src/svg/pulse.ts +48 -0
- package/src/text.ts +92 -0
- package/src/theme.ts +107 -0
- 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
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -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);
|