@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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { PILL_CLEARANCE, PILL_HEIGHT, PILL_PADDING_X, PILL_TEXT_SIZE } from "../design.js";
|
|
3
|
+
import type { Box, Point } from "../geometry.js";
|
|
4
|
+
import { measure } from "../text.js";
|
|
5
|
+
import type { Curve, RoutedEdge } from "./edges.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks separate the lines, but nothing in the router separates the pills
|
|
9
|
+
* riding them: when several labelled runs share a gap, track pitch is far
|
|
10
|
+
* finer than a pill, and the pills stack. This pass settles every pill so
|
|
11
|
+
* that no two intersect, preferring a nudge along the pill's own run — the
|
|
12
|
+
* label stays on its own line, just off the crowded spot — over anything
|
|
13
|
+
* that would loosen the tie between a label and its line.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type Size = { width: number; height: number };
|
|
17
|
+
|
|
18
|
+
/** A straight run of a route, described by its slide axis. */
|
|
19
|
+
type Run = {
|
|
20
|
+
axis: "x" | "y";
|
|
21
|
+
/** The coordinate the run holds fixed — y of a horizontal run, x of a vertical one. */
|
|
22
|
+
cross: number;
|
|
23
|
+
lo: number;
|
|
24
|
+
hi: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const EPSILON = 0.01;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The longest straight run of a route — the run the router pins the label to,
|
|
31
|
+
* and the only one collision handling may slide it along. Earlier segments
|
|
32
|
+
* win a tie, matching how the anchor itself is chosen.
|
|
33
|
+
*/
|
|
34
|
+
const longestRun = (curve: Curve): Run | undefined => {
|
|
35
|
+
let best: { run: Run; length: number } | undefined;
|
|
36
|
+
let start = curve.from;
|
|
37
|
+
for (const segment of curve.segments) {
|
|
38
|
+
const end = segment.to;
|
|
39
|
+
if (segment.kind === "line") {
|
|
40
|
+
const horizontal = Math.abs(end.y - start.y) < EPSILON;
|
|
41
|
+
const [lo, hi] = horizontal
|
|
42
|
+
? [Math.min(start.x, end.x), Math.max(start.x, end.x)]
|
|
43
|
+
: [Math.min(start.y, end.y), Math.max(start.y, end.y)];
|
|
44
|
+
if (best === undefined || hi - lo > best.length)
|
|
45
|
+
best = {
|
|
46
|
+
run: { axis: horizontal ? "x" : "y", cross: horizontal ? start.y : start.x, lo, hi },
|
|
47
|
+
length: hi - lo,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
start = end;
|
|
51
|
+
}
|
|
52
|
+
return best?.run;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const centred = (centre: Point, size: Size): Box => ({
|
|
56
|
+
x: centre.x - size.width / 2,
|
|
57
|
+
y: centre.y - size.height / 2,
|
|
58
|
+
width: size.width,
|
|
59
|
+
height: size.height,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const centreOn = (run: Run, along: number): Point => {
|
|
63
|
+
switch (run.axis) {
|
|
64
|
+
case "x":
|
|
65
|
+
return { x: along, y: run.cross };
|
|
66
|
+
case "y":
|
|
67
|
+
return { x: run.cross, y: along };
|
|
68
|
+
default:
|
|
69
|
+
return assertNever(run.axis, "Unhandled run axis");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Exactly PILL_CLEARANCE of air is enough, so the comparisons are strict. */
|
|
74
|
+
const collides = (a: Box, b: Box): boolean =>
|
|
75
|
+
a.x < b.x + b.width + PILL_CLEARANCE &&
|
|
76
|
+
b.x < a.x + a.width + PILL_CLEARANCE &&
|
|
77
|
+
a.y < b.y + b.height + PILL_CLEARANCE &&
|
|
78
|
+
b.y < a.y + a.height + PILL_CLEARANCE;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The position along the run closest to its midpoint where the pill clears
|
|
82
|
+
* everything already settled, or undefined when the run offers no room. A
|
|
83
|
+
* pill wider than a short run may only take the midpoint — overhanging both
|
|
84
|
+
* ends evenly, which is what the router always did with short runs. `reach`
|
|
85
|
+
* lets a second, more desperate pass push the pill up to that far past the
|
|
86
|
+
* run's ends — still square on its own line, at the line's foot.
|
|
87
|
+
*/
|
|
88
|
+
const slideAlong = (
|
|
89
|
+
run: Run,
|
|
90
|
+
size: Size,
|
|
91
|
+
settled: readonly Box[],
|
|
92
|
+
reach: number,
|
|
93
|
+
): number | undefined => {
|
|
94
|
+
const halfAlong = (run.axis === "x" ? size.width : size.height) / 2;
|
|
95
|
+
const preferred = (run.lo + run.hi) / 2;
|
|
96
|
+
const lo = Math.min(run.lo + halfAlong - reach, preferred);
|
|
97
|
+
const hi = Math.max(run.hi - halfAlong + reach, preferred);
|
|
98
|
+
|
|
99
|
+
const blocked = settled
|
|
100
|
+
.flatMap((box) => {
|
|
101
|
+
const [alongLo, alongHi, crossLo, crossHi] =
|
|
102
|
+
run.axis === "x"
|
|
103
|
+
? [box.x, box.x + box.width, box.y, box.y + box.height]
|
|
104
|
+
: [box.y, box.y + box.height, box.x, box.x + box.width];
|
|
105
|
+
const halfCross = (run.axis === "x" ? size.height : size.width) / 2;
|
|
106
|
+
if (run.cross - halfCross >= crossHi + PILL_CLEARANCE) return [];
|
|
107
|
+
if (run.cross + halfCross <= crossLo - PILL_CLEARANCE) return [];
|
|
108
|
+
return [{ from: alongLo - PILL_CLEARANCE - halfAlong, to: alongHi + PILL_CLEARANCE + halfAlong }];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let best: number | undefined;
|
|
112
|
+
const consider = (candidate: number) => {
|
|
113
|
+
if (candidate < lo || candidate > hi) return;
|
|
114
|
+
if (blocked.some((b) => b.from < candidate && candidate < b.to)) return;
|
|
115
|
+
if (
|
|
116
|
+
best === undefined ||
|
|
117
|
+
Math.abs(candidate - preferred) < Math.abs(best - preferred) ||
|
|
118
|
+
(Math.abs(candidate - preferred) === Math.abs(best - preferred) && candidate < best)
|
|
119
|
+
)
|
|
120
|
+
best = candidate;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
consider(preferred);
|
|
124
|
+
for (const b of blocked) {
|
|
125
|
+
consider(b.from);
|
|
126
|
+
consider(b.to);
|
|
127
|
+
}
|
|
128
|
+
return best;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The tiebreak for a pill none of its runs can host: hold the anchor's
|
|
133
|
+
* position along the line and step square off it, nearer side first, until
|
|
134
|
+
* the pill clears — it ends up beside its own line rather than on it, but
|
|
135
|
+
* never more than a step or two away, and always in the same place for the
|
|
136
|
+
* same document.
|
|
137
|
+
*/
|
|
138
|
+
const stepAside = (anchor: Point, axis: "x" | "y", size: Size, settled: readonly Box[]): Box => {
|
|
139
|
+
const step = (axis === "x" ? size.height : size.width) + PILL_CLEARANCE;
|
|
140
|
+
for (let k = 1; ; k += 1)
|
|
141
|
+
for (const sign of [-1, 1]) {
|
|
142
|
+
const centre =
|
|
143
|
+
axis === "x"
|
|
144
|
+
? { x: anchor.x, y: anchor.y + sign * k * step }
|
|
145
|
+
: { x: anchor.x + sign * k * step, y: anchor.y };
|
|
146
|
+
const box = centred(centre, size);
|
|
147
|
+
if (!settled.some((other) => collides(box, other))) return box;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const settle = (
|
|
152
|
+
curve: Curve,
|
|
153
|
+
anchor: Point,
|
|
154
|
+
size: Size,
|
|
155
|
+
settled: readonly Box[],
|
|
156
|
+
): Box => {
|
|
157
|
+
const run = longestRun(curve);
|
|
158
|
+
if (run !== undefined)
|
|
159
|
+
for (const reach of [0, PILL_HEIGHT]) {
|
|
160
|
+
const along = slideAlong(run, size, settled, reach);
|
|
161
|
+
if (along !== undefined) return centred(centreOn(run, along), size);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A route that only bends — a self-loop — has no run to slide along; its
|
|
165
|
+
// anchor still stands wherever it is clear.
|
|
166
|
+
const atAnchor = centred(anchor, size);
|
|
167
|
+
if (!settled.some((other) => collides(atAnchor, other))) return atAnchor;
|
|
168
|
+
return stepAside(anchor, run?.axis ?? "x", size, settled);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A pill box for every labelled route, none intersecting any other. Routes
|
|
173
|
+
* settle in document order, and a pill whose anchor is already clear stays
|
|
174
|
+
* exactly where the router put it, so an uncrowded diagram is untouched.
|
|
175
|
+
*/
|
|
176
|
+
export const placeLabelPills = (routed: readonly RoutedEdge[]): Map<string, Box> => {
|
|
177
|
+
const settled: Box[] = [];
|
|
178
|
+
const boxes = new Map<string, Box>();
|
|
179
|
+
|
|
180
|
+
for (const { edge, curve, labelAnchor } of routed) {
|
|
181
|
+
if (edge.label === undefined || labelAnchor === undefined) continue;
|
|
182
|
+
const size = {
|
|
183
|
+
width: measure(edge.label, "sans-bold", PILL_TEXT_SIZE) + PILL_PADDING_X * 2,
|
|
184
|
+
height: PILL_HEIGHT,
|
|
185
|
+
};
|
|
186
|
+
const box = settle(curve, labelAnchor, size, settled);
|
|
187
|
+
settled.push(box);
|
|
188
|
+
boxes.set(edge.id, box);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return boxes;
|
|
192
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { GraphEdge, GraphNode } from "@coldtea/pr-lens-schema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Layer index per node: how far down the diagram it sits.
|
|
5
|
+
*
|
|
6
|
+
* Longest path from a source, so an arrow always points at a node below the
|
|
7
|
+
* one it left. Real dependency graphs contain cycles, and a cycle has no
|
|
8
|
+
* such ordering, so the edges that close one are dropped first — the arrow
|
|
9
|
+
* still gets drawn, it just runs back up the page.
|
|
10
|
+
*
|
|
11
|
+
* `hints` are the document's preferences, applied as a floor rather than an
|
|
12
|
+
* answer: a hint can push a node further down, never above something that
|
|
13
|
+
* feeds it. A stale hint from an extraction model therefore cannot invert an
|
|
14
|
+
* edge, only leave a gap.
|
|
15
|
+
*/
|
|
16
|
+
export const rankNodes = (
|
|
17
|
+
nodes: readonly GraphNode[],
|
|
18
|
+
edges: readonly GraphEdge[],
|
|
19
|
+
hints: Readonly<Record<string, number>>,
|
|
20
|
+
): Map<string, number> => {
|
|
21
|
+
const forward = new Map<string, string[]>();
|
|
22
|
+
const backward = new Map<string, string[]>();
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
forward.set(node.id, []);
|
|
25
|
+
backward.set(node.id, []);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const withinGraph = edges.filter(
|
|
29
|
+
(edge) => edge.from !== edge.to && forward.has(edge.from) && forward.has(edge.to),
|
|
30
|
+
);
|
|
31
|
+
for (const edge of withinGraph) forward.get(edge.from)?.push(edge.to);
|
|
32
|
+
|
|
33
|
+
for (const [from, to] of forwardEdgesWithoutCycles(nodes, forward)) backward.get(to)?.push(from);
|
|
34
|
+
|
|
35
|
+
const ranks = new Map<string, number>();
|
|
36
|
+
const settle = (id: string): number => {
|
|
37
|
+
const known = ranks.get(id);
|
|
38
|
+
if (known !== undefined) return known;
|
|
39
|
+
|
|
40
|
+
let rank = hints[id] ?? 0;
|
|
41
|
+
for (const predecessor of backward.get(id) ?? []) rank = Math.max(rank, settle(predecessor) + 1);
|
|
42
|
+
ranks.set(id, rank);
|
|
43
|
+
return rank;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const node of nodes) settle(node.id);
|
|
47
|
+
return ranks;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Depth-first from every node in document order, dropping any edge that
|
|
52
|
+
* reaches a node still open on the stack. Which edge closes a cycle depends
|
|
53
|
+
* on where the walk started, so the walk order is fixed by the document
|
|
54
|
+
* rather than by iteration order of a map.
|
|
55
|
+
*/
|
|
56
|
+
const forwardEdgesWithoutCycles = (
|
|
57
|
+
nodes: readonly GraphNode[],
|
|
58
|
+
forward: ReadonlyMap<string, readonly string[]>,
|
|
59
|
+
): [string, string][] => {
|
|
60
|
+
const kept: [string, string][] = [];
|
|
61
|
+
const open = new Set<string>();
|
|
62
|
+
const closed = new Set<string>();
|
|
63
|
+
|
|
64
|
+
for (const root of nodes) {
|
|
65
|
+
if (closed.has(root.id)) continue;
|
|
66
|
+
|
|
67
|
+
const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];
|
|
68
|
+
open.add(root.id);
|
|
69
|
+
|
|
70
|
+
while (stack.length > 0) {
|
|
71
|
+
const frame = stack[stack.length - 1];
|
|
72
|
+
if (frame === undefined) break;
|
|
73
|
+
|
|
74
|
+
const successors = forward.get(frame.id) ?? [];
|
|
75
|
+
if (frame.next >= successors.length) {
|
|
76
|
+
open.delete(frame.id);
|
|
77
|
+
closed.add(frame.id);
|
|
78
|
+
stack.pop();
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const successor = successors[frame.next];
|
|
83
|
+
frame.next += 1;
|
|
84
|
+
if (successor === undefined || open.has(successor)) continue;
|
|
85
|
+
|
|
86
|
+
kept.push([frame.id, successor]);
|
|
87
|
+
if (closed.has(successor)) continue;
|
|
88
|
+
|
|
89
|
+
open.add(successor);
|
|
90
|
+
stack.push({ id: successor, next: 0 });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return kept;
|
|
95
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { GraphEdge, GraphNode, Lane } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { rankNodes } from "./rank.js";
|
|
3
|
+
|
|
4
|
+
/** One occupied row of one lane: a single card, or two side by side. */
|
|
5
|
+
export type SeatedRow = { grid: number; nodes: GraphNode[] };
|
|
6
|
+
|
|
7
|
+
export type Seating = {
|
|
8
|
+
rowsByLane: Map<string, SeatedRow[]>;
|
|
9
|
+
rowCount: number;
|
|
10
|
+
/** First row of the dead band; absent when nothing was removed. */
|
|
11
|
+
deadFromRow: number | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where every card sits: a row grid shared across all lanes.
|
|
16
|
+
*
|
|
17
|
+
* Ranks are compressed globally rather than per lane, so a card and the card
|
|
18
|
+
* it converses with across a lane boundary land on comparable rows — that is
|
|
19
|
+
* what lets their connection run straight. The cost is honest: a lane that
|
|
20
|
+
* enters the story late leaves its upper rows empty, and the diagram gets
|
|
21
|
+
* taller. Stability is the invariant this trades nothing of: seating reads
|
|
22
|
+
* connections and document order, never a label, so a rename moves nothing.
|
|
23
|
+
*
|
|
24
|
+
* What was removed drops out of the living graph entirely, into a band of
|
|
25
|
+
* rows below everything alive, keeping its internal structure. Living ranks
|
|
26
|
+
* are computed over living connections only, so a retired pathway cannot
|
|
27
|
+
* push a living card down the page.
|
|
28
|
+
*/
|
|
29
|
+
export const seatNodes = (
|
|
30
|
+
orderedLanes: readonly Lane[],
|
|
31
|
+
nodes: readonly GraphNode[],
|
|
32
|
+
edges: readonly GraphEdge[],
|
|
33
|
+
rankHints: Readonly<Record<string, number>>,
|
|
34
|
+
): Seating => {
|
|
35
|
+
const laneIndex = new Map(orderedLanes.map((lane, index) => [lane.id, index]));
|
|
36
|
+
const docIndex = new Map(nodes.map((node, index) => [node.id, index]));
|
|
37
|
+
|
|
38
|
+
const living = nodes.filter((node) => node.delta !== "removed");
|
|
39
|
+
const dead = nodes.filter((node) => node.delta === "removed");
|
|
40
|
+
const livingEdges = edges.filter((edge) => edge.delta !== "removed");
|
|
41
|
+
|
|
42
|
+
const livingRanks = rankNodes(living, livingEdges, rankHints);
|
|
43
|
+
const livingRowOfRank = compressRanks(living, livingRanks);
|
|
44
|
+
const keys = barycenters(living, livingEdges, livingRanks, laneIndex);
|
|
45
|
+
|
|
46
|
+
const rowsByLane = new Map<string, SeatedRow[]>();
|
|
47
|
+
let lastRow = -1;
|
|
48
|
+
|
|
49
|
+
for (const lane of orderedLanes) {
|
|
50
|
+
const members = living.filter((node) => node.lane === lane.id);
|
|
51
|
+
if (members.length === 0) continue;
|
|
52
|
+
const rows = seatLane(members, livingRanks, livingRowOfRank, -1, keys, docIndex);
|
|
53
|
+
rowsByLane.set(lane.id, rows);
|
|
54
|
+
lastRow = Math.max(lastRow, rows[rows.length - 1]?.grid ?? -1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const deadFromRow = dead.length === 0 ? undefined : lastRow + 1;
|
|
58
|
+
if (deadFromRow !== undefined) {
|
|
59
|
+
const deadRanks = rankNodes(dead, edges, {});
|
|
60
|
+
const deadRowOfRank = compressRanks(dead, deadRanks);
|
|
61
|
+
const deadKeys = barycenters(dead, edges, deadRanks, laneIndex);
|
|
62
|
+
|
|
63
|
+
for (const lane of orderedLanes) {
|
|
64
|
+
const members = dead.filter((node) => node.lane === lane.id);
|
|
65
|
+
if (members.length === 0) continue;
|
|
66
|
+
const rows = seatLane(members, deadRanks, deadRowOfRank, deadFromRow - 1, deadKeys, docIndex);
|
|
67
|
+
const seated = rowsByLane.get(lane.id);
|
|
68
|
+
if (seated === undefined) rowsByLane.set(lane.id, rows);
|
|
69
|
+
else seated.push(...rows);
|
|
70
|
+
lastRow = Math.max(lastRow, rows[rows.length - 1]?.grid ?? -1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { rowsByLane, rowCount: lastRow + 1, deadFromRow };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Rank values in use, in order, mapped onto contiguous rows. */
|
|
78
|
+
const compressRanks = (
|
|
79
|
+
nodes: readonly GraphNode[],
|
|
80
|
+
ranks: ReadonlyMap<string, number>,
|
|
81
|
+
): Map<number, number> => {
|
|
82
|
+
const used = [...new Set(nodes.map((node) => ranks.get(node.id) ?? 0))].sort((a, b) => a - b);
|
|
83
|
+
return new Map(used.map((rank, index) => [rank, index]));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Barycenters = {
|
|
87
|
+
/** Mean rank of a card's partners: decides who falls when a rank collides. */
|
|
88
|
+
fall: Map<string, number>;
|
|
89
|
+
/** Mean lane of a card's partners: decides who sits left in a shared row. */
|
|
90
|
+
side: Map<string, number>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const barycenters = (
|
|
94
|
+
nodes: readonly GraphNode[],
|
|
95
|
+
edges: readonly GraphEdge[],
|
|
96
|
+
ranks: ReadonlyMap<string, number>,
|
|
97
|
+
laneIndex: ReadonlyMap<string, number>,
|
|
98
|
+
): Barycenters => {
|
|
99
|
+
const laneOf = new Map(nodes.map((node) => [node.id, laneIndex.get(node.lane) ?? 0]));
|
|
100
|
+
const partners = new Map<string, string[]>(nodes.map((node) => [node.id, []]));
|
|
101
|
+
for (const edge of edges) {
|
|
102
|
+
if (edge.from === edge.to || !laneOf.has(edge.from) || !laneOf.has(edge.to)) continue;
|
|
103
|
+
partners.get(edge.from)?.push(edge.to);
|
|
104
|
+
partners.get(edge.to)?.push(edge.from);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fall = new Map<string, number>();
|
|
108
|
+
const side = new Map<string, number>();
|
|
109
|
+
for (const node of nodes) {
|
|
110
|
+
const linked = partners.get(node.id) ?? [];
|
|
111
|
+
fall.set(node.id, mean(linked.map((id) => ranks.get(id) ?? 0)) ?? ranks.get(node.id) ?? 0);
|
|
112
|
+
side.set(
|
|
113
|
+
node.id,
|
|
114
|
+
mean(linked.map((id) => laneOf.get(id) ?? 0)) ?? laneOf.get(node.id) ?? 0,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return { fall, side };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const mean = (values: readonly number[]): number | undefined =>
|
|
121
|
+
values.length === 0
|
|
122
|
+
? undefined
|
|
123
|
+
: values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One lane's members onto the shared grid.
|
|
127
|
+
*
|
|
128
|
+
* A card sits at its rank's row unless an earlier card of the same lane
|
|
129
|
+
* already claimed it, in which case it falls to the next free one — a lane
|
|
130
|
+
* never overlaps itself. Two cards share a row only at the same rank in the
|
|
131
|
+
* same sub-group, so a pair always reads as "these happen alongside each
|
|
132
|
+
* other". Who falls, and who sits left in a pair, is decided by where each
|
|
133
|
+
* card's partners are — the card leaning toward its neighbours stays put —
|
|
134
|
+
* with document order as the stable tiebreak.
|
|
135
|
+
*/
|
|
136
|
+
const seatLane = (
|
|
137
|
+
members: readonly GraphNode[],
|
|
138
|
+
ranks: ReadonlyMap<string, number>,
|
|
139
|
+
rowOfRank: ReadonlyMap<number, number>,
|
|
140
|
+
floor: number,
|
|
141
|
+
keys: Barycenters,
|
|
142
|
+
docIndex: ReadonlyMap<string, number>,
|
|
143
|
+
): SeatedRow[] => {
|
|
144
|
+
const doc = (node: GraphNode): number => docIndex.get(node.id) ?? 0;
|
|
145
|
+
const sorted = [...members].sort((a, b) => {
|
|
146
|
+
const row =
|
|
147
|
+
(rowOfRank.get(ranks.get(a.id) ?? 0) ?? 0) - (rowOfRank.get(ranks.get(b.id) ?? 0) ?? 0);
|
|
148
|
+
if (row !== 0) return row;
|
|
149
|
+
const fall = (keys.fall.get(a.id) ?? 0) - (keys.fall.get(b.id) ?? 0);
|
|
150
|
+
if (fall !== 0) return fall;
|
|
151
|
+
return doc(a) - doc(b);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const rows: { grid: number; rank: number; nodes: GraphNode[] }[] = [];
|
|
155
|
+
let previous = floor;
|
|
156
|
+
|
|
157
|
+
for (const node of sorted) {
|
|
158
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
159
|
+
const open = rows[rows.length - 1];
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
open !== undefined &&
|
|
163
|
+
open.nodes.length === 1 &&
|
|
164
|
+
open.rank === rank &&
|
|
165
|
+
(open.nodes[0]?.group ?? "") === (node.group ?? "")
|
|
166
|
+
) {
|
|
167
|
+
const first = open.nodes[0];
|
|
168
|
+
if (first !== undefined && leansLeft(node, first, keys, doc)) open.nodes.unshift(node);
|
|
169
|
+
else open.nodes.push(node);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
previous = Math.max(previous + 1, floor + 1 + (rowOfRank.get(rank) ?? 0));
|
|
174
|
+
rows.push({ grid: previous, rank, nodes: [node] });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return rows.map(({ grid, nodes }) => ({ grid, nodes }));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const leansLeft = (
|
|
181
|
+
node: GraphNode,
|
|
182
|
+
other: GraphNode,
|
|
183
|
+
keys: Barycenters,
|
|
184
|
+
doc: (node: GraphNode) => number,
|
|
185
|
+
): boolean => {
|
|
186
|
+
const side = (keys.side.get(node.id) ?? 0) - (keys.side.get(other.id) ?? 0);
|
|
187
|
+
if (side !== 0) return side < 0;
|
|
188
|
+
return doc(node) < doc(other);
|
|
189
|
+
};
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { GraphDoc, Lens, RenderAsset, RenderManifest } from "@coldtea/pr-lens-schema";
|
|
3
|
+
import { SCHEMA_VERSION } from "@coldtea/pr-lens-schema";
|
|
4
|
+
import type { Theme } from "./theme.js";
|
|
5
|
+
import { RENDERER_NAME, RENDERER_VERSION } from "./version.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How many hex characters of the digest an address carries. Long enough that
|
|
9
|
+
* two different renders will not collide inside one repository's history,
|
|
10
|
+
* short enough to read in a URL.
|
|
11
|
+
*/
|
|
12
|
+
export const CONTENT_HASH_LENGTH = 32;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The one place render bytes become an address.
|
|
16
|
+
*
|
|
17
|
+
* GitHub proxies comment images and caches them hard, so a changed diagram
|
|
18
|
+
* has to arrive as a different URL rather than as new bytes at the old one.
|
|
19
|
+
* Every surface that builds such a URL — the action writing files, the hosted
|
|
20
|
+
* app uploading them — must hash through here, or the two will disagree about
|
|
21
|
+
* what the same render is called.
|
|
22
|
+
*/
|
|
23
|
+
export const contentHash = (bytes: string): string =>
|
|
24
|
+
createHash("sha256").update(bytes, "utf8").digest("hex").slice(0, CONTENT_HASH_LENGTH);
|
|
25
|
+
|
|
26
|
+
/** JSON with object keys in sorted order, so equal documents hash equal. */
|
|
27
|
+
export const canonicalJson = (value: unknown): string => {
|
|
28
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
29
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
30
|
+
|
|
31
|
+
const entries = Object.entries(value)
|
|
32
|
+
.filter(([, nested]) => nested !== undefined)
|
|
33
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
34
|
+
|
|
35
|
+
return `{${entries.map(([key, nested]) => `${JSON.stringify(key)}:${canonicalJson(nested)}`).join(",")}}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const graphContentHash = (doc: GraphDoc): string => contentHash(canonicalJson(doc));
|
|
39
|
+
|
|
40
|
+
export type AssetAddress = { lens: Lens; theme: Theme; view: string | undefined };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How the characters a view id may contain but a file name may not are spelled.
|
|
44
|
+
*
|
|
45
|
+
* View ids are authored by an extraction model, and the contract lets one
|
|
46
|
+
* contain `/`, `:` and `.` — so `a/../../elsewhere` is a perfectly valid id.
|
|
47
|
+
* Surfaces join an asset's path to an output directory, and that id would walk
|
|
48
|
+
* straight out of it. The mapping is one-to-one rather than a scrub, so two
|
|
49
|
+
* different views can never be handed the same address: `_` names itself
|
|
50
|
+
* first, which is what keeps the other three from colliding with text that
|
|
51
|
+
* was already there.
|
|
52
|
+
*/
|
|
53
|
+
const FILE_NAME_ESCAPES: readonly [string, string][] = [
|
|
54
|
+
["_", "_u"],
|
|
55
|
+
["/", "_s"],
|
|
56
|
+
[":", "_c"],
|
|
57
|
+
[".", "_d"],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const escapeForFileName = (id: string): string =>
|
|
61
|
+
FILE_NAME_ESCAPES.reduce((text, [character, escape]) => text.split(character).join(escape), id);
|
|
62
|
+
|
|
63
|
+
/** The contract's ceiling on an `Id`, which an asset id has to stay under. */
|
|
64
|
+
const ASSET_ID_MAX = 128;
|
|
65
|
+
const THEME_SUFFIX_MAX = "-light".length;
|
|
66
|
+
const STEM_MAX = ASSET_ID_MAX - THEME_SUFFIX_MAX;
|
|
67
|
+
const DIGEST_LENGTH = 12;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `_h` cannot occur in an escaped id — every `_` there opens one of the four
|
|
71
|
+
* escapes above — so it can mark the digest without ever being mistaken for
|
|
72
|
+
* text that was already in the id.
|
|
73
|
+
*/
|
|
74
|
+
const DIGEST_MARKER = "_h";
|
|
75
|
+
const PREFIX_MAX = STEM_MAX - DIGEST_MARKER.length - DIGEST_LENGTH;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A view id spelled so that it is both a legal file name and a legal `Id`.
|
|
79
|
+
*
|
|
80
|
+
* Escaping can double the length of an id that is already near the contract's
|
|
81
|
+
* limit, so a stem that would overflow keeps a readable prefix and ends in a
|
|
82
|
+
* digest of the whole original. The digest is what preserves the one property
|
|
83
|
+
* that matters: two different views can never be handed the same address.
|
|
84
|
+
*/
|
|
85
|
+
const fileNameSafe = (id: string): string => {
|
|
86
|
+
const escaped = escapeForFileName(id);
|
|
87
|
+
if (escaped.length <= STEM_MAX) return escaped;
|
|
88
|
+
|
|
89
|
+
const prefix = escaped.slice(0, PREFIX_MAX).replace(/_$/, "");
|
|
90
|
+
return `${prefix}${DIGEST_MARKER}${contentHash(id).slice(0, DIGEST_LENGTH)}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The identity of one rendered SVG within a render: the drill-down section it
|
|
95
|
+
* belongs to and the half of the theme pair it is. A render with no sections
|
|
96
|
+
* falls back to the lens, which is unique across the same render.
|
|
97
|
+
*
|
|
98
|
+
* This doubles as the stem of the file name, so it is spelled in the alphabet
|
|
99
|
+
* a file name can carry.
|
|
100
|
+
*/
|
|
101
|
+
export const renderAssetId = ({ lens, theme, view }: AssetAddress): string =>
|
|
102
|
+
`${fileNameSafe(view ?? lens)}-${theme}`;
|
|
103
|
+
|
|
104
|
+
/** The file name an asset is written or published under. */
|
|
105
|
+
export const renderAssetFileName = (address: AssetAddress, hash: string): string =>
|
|
106
|
+
`${renderAssetId(address)}-${hash}.svg`;
|
|
107
|
+
|
|
108
|
+
export const buildManifest = (doc: GraphDoc, assets: readonly RenderAsset[]): RenderManifest => ({
|
|
109
|
+
schemaVersion: SCHEMA_VERSION,
|
|
110
|
+
kind: "render-manifest",
|
|
111
|
+
graph: {
|
|
112
|
+
id: doc.id,
|
|
113
|
+
headSha: doc.provenance.head.sha,
|
|
114
|
+
contentHash: graphContentHash(doc),
|
|
115
|
+
},
|
|
116
|
+
renderer: { name: RENDERER_NAME, version: RENDERER_VERSION },
|
|
117
|
+
assets: [...assets],
|
|
118
|
+
});
|