@coldtea/pr-lens-renderer 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/bounds.d.ts +6 -1
- package/dist/bounds.d.ts.map +1 -1
- package/dist/bounds.js +21 -0
- package/dist/bounds.js.map +1 -1
- package/dist/design.d.ts +5 -0
- package/dist/design.d.ts.map +1 -1
- package/dist/design.js +5 -0
- package/dist/design.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/architecture.d.ts +2 -0
- package/dist/layout/architecture.d.ts.map +1 -1
- package/dist/layout/architecture.js +5 -0
- package/dist/layout/architecture.js.map +1 -1
- package/dist/layout/congestion.d.ts +5 -0
- package/dist/layout/congestion.d.ts.map +1 -1
- package/dist/layout/congestion.js +50 -12
- package/dist/layout/congestion.js.map +1 -1
- package/dist/layout/edges.d.ts +18 -3
- package/dist/layout/edges.d.ts.map +1 -1
- package/dist/layout/edges.js +93 -23
- package/dist/layout/edges.js.map +1 -1
- package/dist/layout/labels.d.ts +15 -4
- package/dist/layout/labels.d.ts.map +1 -1
- package/dist/layout/labels.js +404 -119
- package/dist/layout/labels.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +3 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render.d.ts +12 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +2 -2
- package/dist/render.js.map +1 -1
- package/dist/svg/architecture.d.ts.map +1 -1
- package/dist/svg/architecture.js +3 -8
- package/dist/svg/architecture.js.map +1 -1
- package/dist/theme.d.ts +9 -8
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +61 -8
- package/dist/theme.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/bounds.ts +29 -1
- package/src/design.ts +5 -0
- package/src/index.ts +2 -1
- package/src/layout/architecture.ts +7 -0
- package/src/layout/congestion.ts +71 -12
- package/src/layout/edges.ts +126 -21
- package/src/layout/labels.ts +495 -136
- package/src/manifest.ts +3 -2
- package/src/render.ts +19 -4
- package/src/svg/architecture.ts +3 -7
- package/src/theme.ts +62 -8
- package/src/version.ts +1 -1
package/src/layout/edges.ts
CHANGED
|
@@ -2,13 +2,17 @@ import type { GraphEdge } from "@coldtea/pr-lens-schema";
|
|
|
2
2
|
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
3
3
|
import {
|
|
4
4
|
BEND_RADIUS_MAX,
|
|
5
|
+
CARD_HEIGHT,
|
|
6
|
+
PILL_CARD_CLEARANCE,
|
|
5
7
|
PORT_INSET,
|
|
6
8
|
PORT_PITCH,
|
|
9
|
+
ROW_GAP,
|
|
7
10
|
TRACK_CLEARANCE,
|
|
8
11
|
TRACK_PITCH_MAX,
|
|
9
12
|
} from "../design.js";
|
|
10
13
|
import { boxCentre, coord, type Box, type Point, type Side } from "../geometry.js";
|
|
11
14
|
import type { ArchitectureLayout, LayoutGrid, PlacedLane, PlacedNode } from "./architecture.js";
|
|
15
|
+
import { pillSize } from "./labels.js";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* A route, kept as its own segments rather than as a path string, because the
|
|
@@ -24,7 +28,7 @@ export type RoutedEdge = {
|
|
|
24
28
|
edge: GraphEdge;
|
|
25
29
|
path: string;
|
|
26
30
|
curve: Curve;
|
|
27
|
-
/** Centre of the longest straight run — where
|
|
31
|
+
/** Centre of the longest straight run — where a pill with no room on its route starts looking. */
|
|
28
32
|
labelAnchor: Point | undefined;
|
|
29
33
|
};
|
|
30
34
|
|
|
@@ -154,12 +158,11 @@ type Route = {
|
|
|
154
158
|
const isDead = (placed: PlacedNode): boolean => placed.node.delta === "removed";
|
|
155
159
|
|
|
156
160
|
/**
|
|
157
|
-
* A retired connection with a living end
|
|
158
|
-
*
|
|
159
|
-
* dead cards keeps its short run
|
|
160
|
-
* structure of the retired path.
|
|
161
|
+
* A retired connection with a living end goes directly or into exile, out
|
|
162
|
+
* past the last lane and back.
|
|
163
|
+
* One wholly between dead cards always keeps its short run in the dead band.
|
|
161
164
|
*/
|
|
162
|
-
const
|
|
165
|
+
const isRetiredWithLivingEnd = (edge: GraphEdge, from: PlacedNode, to: PlacedNode): boolean =>
|
|
163
166
|
!(isDead(from) && isDead(to)) && (edge.delta === "removed" || isDead(from) || isDead(to));
|
|
164
167
|
|
|
165
168
|
const clampBand = (index: number, grid: LayoutGrid): number =>
|
|
@@ -600,14 +603,80 @@ const labelAnchorOf = (curve: Curve): Point => {
|
|
|
600
603
|
return best?.middle ?? pointAt(curve, 0.5);
|
|
601
604
|
};
|
|
602
605
|
|
|
606
|
+
export type RetiredRoutes = ReadonlyMap<string, "direct" | "exile">;
|
|
607
|
+
|
|
608
|
+
/** A crossing of a living line is worth one row's pitch of extra travel. */
|
|
609
|
+
const CROSSING_COST = CARD_HEIGHT + ROW_GAP;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Direct or exile for each retired connection, whichever costs less in
|
|
613
|
+
* length plus crossings. Exile keeps retired paths out of the living graph,
|
|
614
|
+
* but in a wide diagram it sends the reader around everything. Decided on
|
|
615
|
+
* the first layout and held, so widening a gap can never flip a route.
|
|
616
|
+
*/
|
|
617
|
+
export const chooseRetiredRoutes = (
|
|
618
|
+
edges: readonly GraphEdge[],
|
|
619
|
+
layout: ArchitectureLayout,
|
|
620
|
+
): RetiredRoutes => {
|
|
621
|
+
const nodes = new Map(layout.nodes.map((node) => [node.node.id, node]));
|
|
622
|
+
const retired = edges.filter((edge) => {
|
|
623
|
+
const from = nodes.get(edge.from);
|
|
624
|
+
const to = nodes.get(edge.to);
|
|
625
|
+
if (from === undefined || to === undefined || edge.from === edge.to) return false;
|
|
626
|
+
return isRetiredWithLivingEnd(edge, from, to);
|
|
627
|
+
});
|
|
628
|
+
if (retired.length === 0) return new Map();
|
|
629
|
+
|
|
630
|
+
const everyone = (way: "direct" | "exile"): RetiredRoutes =>
|
|
631
|
+
new Map(retired.map(({ id }) => [id, way]));
|
|
632
|
+
const retiredIds = new Set(retired.map(({ id }) => id));
|
|
633
|
+
const costs = (pass: Pass): Map<string, number> => {
|
|
634
|
+
const living = [...pass.waypoints]
|
|
635
|
+
.filter(([id]) => !retiredIds.has(id))
|
|
636
|
+
.map(([, points]) => points);
|
|
637
|
+
return new Map(
|
|
638
|
+
retired.map(({ id }) => {
|
|
639
|
+
const points = pass.waypoints.get(id) ?? [];
|
|
640
|
+
const crossings = living.reduce((sum, other) => sum + crossingCount(points, other), 0);
|
|
641
|
+
return [id, polylineLength(points) + crossings * CROSSING_COST];
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const directPass = finalPass(edges, layout, everyone("direct"));
|
|
647
|
+
const direct = costs(directPass);
|
|
648
|
+
const exile = costs(finalPass(edges, layout, everyone("exile")));
|
|
649
|
+
return new Map(
|
|
650
|
+
retired.map(({ id, label }) => {
|
|
651
|
+
const points = directPass.waypoints.get(id) ?? [];
|
|
652
|
+
const fits = label === undefined || holdsPill(points, pillSize(label));
|
|
653
|
+
const cheaper = (direct.get(id) ?? Number.POSITIVE_INFINITY) < (exile.get(id) ?? 0);
|
|
654
|
+
return [id, fits && cheaper ? "direct" : "exile"];
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/** No label fits on the direct line between a pair's halves; exile gives it room. */
|
|
660
|
+
const holdsPill = (points: readonly Point[], pill: { width: number; height: number }): boolean =>
|
|
661
|
+
points.some((point, index) => {
|
|
662
|
+
const previous = points[index - 1];
|
|
663
|
+
if (previous === undefined) return false;
|
|
664
|
+
const horizontal = Math.abs(point.y - previous.y) < EPSILON;
|
|
665
|
+
const length = Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
666
|
+
return length >= (horizontal ? pill.width : pill.height) + PILL_CARD_CLEARANCE * 2;
|
|
667
|
+
});
|
|
668
|
+
|
|
603
669
|
export const routeEdges = (
|
|
604
670
|
edges: readonly GraphEdge[],
|
|
605
671
|
layout: ArchitectureLayout,
|
|
606
|
-
|
|
672
|
+
retired: RetiredRoutes,
|
|
673
|
+
): RoutedEdge[] => finalPass(edges, layout, retired).routed;
|
|
607
674
|
|
|
608
675
|
export type ChannelTraffic = {
|
|
609
676
|
corridors: ReadonlyMap<number, number>;
|
|
610
677
|
bands: ReadonlyMap<number, number>;
|
|
678
|
+
/** Where room for each edge's label would have to come from. */
|
|
679
|
+
corridorsByEdge: ReadonlyMap<string, readonly number[]>;
|
|
611
680
|
};
|
|
612
681
|
|
|
613
682
|
/**
|
|
@@ -619,28 +688,46 @@ export type ChannelTraffic = {
|
|
|
619
688
|
export const channelTraffic = (
|
|
620
689
|
edges: readonly GraphEdge[],
|
|
621
690
|
layout: ArchitectureLayout,
|
|
622
|
-
|
|
691
|
+
retired: RetiredRoutes,
|
|
692
|
+
): ChannelTraffic => routeAndCount(edges, layout, retired).traffic;
|
|
693
|
+
|
|
694
|
+
/** One routing pass, shared by drawing and gap sizing. */
|
|
695
|
+
export const routeAndCount = (
|
|
696
|
+
edges: readonly GraphEdge[],
|
|
697
|
+
layout: ArchitectureLayout,
|
|
698
|
+
retired: RetiredRoutes,
|
|
699
|
+
): { routed: RoutedEdge[]; traffic: ChannelTraffic } => {
|
|
700
|
+
const pass = finalPass(edges, layout, retired);
|
|
623
701
|
const corridors = new Map<number, number>();
|
|
624
702
|
const bands = new Map<number, number>();
|
|
625
|
-
|
|
703
|
+
const corridorsByEdge = new Map<string, number[]>();
|
|
704
|
+
for (const route of pass.plans)
|
|
626
705
|
for (const channel of route.channels)
|
|
627
706
|
switch (channel.kind) {
|
|
628
|
-
case "corridor":
|
|
707
|
+
case "corridor": {
|
|
629
708
|
corridors.set(channel.index, (corridors.get(channel.index) ?? 0) + 1);
|
|
709
|
+
const through = corridorsByEdge.get(route.edge.id) ?? [];
|
|
710
|
+
through.push(channel.index);
|
|
711
|
+
corridorsByEdge.set(route.edge.id, through);
|
|
630
712
|
break;
|
|
713
|
+
}
|
|
631
714
|
case "band":
|
|
632
715
|
bands.set(channel.index, (bands.get(channel.index) ?? 0) + 1);
|
|
633
716
|
break;
|
|
634
717
|
default:
|
|
635
718
|
assertNever(channel, "Unhandled channel");
|
|
636
719
|
}
|
|
637
|
-
return { corridors, bands };
|
|
720
|
+
return { routed: pass.routed, traffic: { corridors, bands, corridorsByEdge } };
|
|
638
721
|
};
|
|
639
722
|
|
|
640
|
-
const finalPass = (
|
|
641
|
-
|
|
723
|
+
const finalPass = (
|
|
724
|
+
edges: readonly GraphEdge[],
|
|
725
|
+
layout: ArchitectureLayout,
|
|
726
|
+
retired: RetiredRoutes,
|
|
727
|
+
): Pass => {
|
|
728
|
+
const first = routePass(edges, layout, new Set(), retired);
|
|
642
729
|
const braiding = braidingTrunks(first.branches);
|
|
643
|
-
return braiding.size === 0 ? first : routePass(edges, layout, braiding);
|
|
730
|
+
return braiding.size === 0 ? first : routePass(edges, layout, braiding, retired);
|
|
644
731
|
};
|
|
645
732
|
|
|
646
733
|
type Pass = {
|
|
@@ -648,12 +735,15 @@ type Pass = {
|
|
|
648
735
|
plans: Route[];
|
|
649
736
|
/** Per trunk group, each member's waypoints minus the shared head segment. */
|
|
650
737
|
branches: Map<string, Point[][]>;
|
|
738
|
+
/** Every planned route's waypoints, before its corners are rounded. */
|
|
739
|
+
waypoints: Map<string, Point[]>;
|
|
651
740
|
};
|
|
652
741
|
|
|
653
742
|
const routePass = (
|
|
654
743
|
edges: readonly GraphEdge[],
|
|
655
744
|
layout: ArchitectureLayout,
|
|
656
745
|
blockedTrunks: ReadonlySet<string>,
|
|
746
|
+
retired: RetiredRoutes,
|
|
657
747
|
): Pass => {
|
|
658
748
|
const nodes = new Map(layout.nodes.map((node) => [node.node.id, node]));
|
|
659
749
|
const blocked = blockedFaces(layout.nodes);
|
|
@@ -671,7 +761,7 @@ const routePass = (
|
|
|
671
761
|
|
|
672
762
|
const trunkCounts = new Map<string, number>();
|
|
673
763
|
for (const { edge, from, to } of drawable) {
|
|
674
|
-
if (loops.has(edge.id) ||
|
|
764
|
+
if (loops.has(edge.id) || isRetiredWithLivingEnd(edge, from, to) || isDead(from)) continue;
|
|
675
765
|
const key = trunkKey(edge, from, to);
|
|
676
766
|
if (key === undefined || blockedTrunks.has(key)) continue;
|
|
677
767
|
trunkCounts.set(key, (trunkCounts.get(key) ?? 0) + 1);
|
|
@@ -680,14 +770,16 @@ const routePass = (
|
|
|
680
770
|
const routes: Route[] = drawable
|
|
681
771
|
.filter(({ edge }) => !loops.has(edge.id))
|
|
682
772
|
.map(({ edge, order, from, to }) => {
|
|
683
|
-
if (
|
|
773
|
+
if (isRetiredWithLivingEnd(edge, from, to))
|
|
684
774
|
return {
|
|
685
775
|
edge,
|
|
686
776
|
order,
|
|
687
777
|
from,
|
|
688
778
|
to,
|
|
689
779
|
trunk: undefined,
|
|
690
|
-
...
|
|
780
|
+
...(retired.get(edge.id) === "direct"
|
|
781
|
+
? planRoute(from, to, layout.lanes, grid, blocked)
|
|
782
|
+
: planExile(from, to, grid, laneCount, blocked)),
|
|
691
783
|
};
|
|
692
784
|
|
|
693
785
|
const key = isDead(from) ? undefined : trunkKey(edge, from, to);
|
|
@@ -703,6 +795,7 @@ const routePass = (
|
|
|
703
795
|
const tracks = allocateTracks(routes, ports, grid);
|
|
704
796
|
|
|
705
797
|
const branches = new Map<string, Point[][]>();
|
|
798
|
+
const waypoints = new Map<string, Point[]>();
|
|
706
799
|
const routedById = new Map<string, RoutedEdge>();
|
|
707
800
|
|
|
708
801
|
for (const route of routes) {
|
|
@@ -713,6 +806,7 @@ const routePass = (
|
|
|
713
806
|
(index) => tracks.get(`${route.edge.id}#${index}`) ?? 0,
|
|
714
807
|
);
|
|
715
808
|
|
|
809
|
+
waypoints.set(route.edge.id, points);
|
|
716
810
|
if (route.trunk !== undefined) {
|
|
717
811
|
const list = branches.get(route.trunk) ?? [];
|
|
718
812
|
list.push(points.slice(1));
|
|
@@ -740,7 +834,7 @@ const routePass = (
|
|
|
740
834
|
};
|
|
741
835
|
});
|
|
742
836
|
|
|
743
|
-
return { routed, plans: routes, branches };
|
|
837
|
+
return { routed, plans: routes, branches, waypoints };
|
|
744
838
|
};
|
|
745
839
|
|
|
746
840
|
type Port = { along: number };
|
|
@@ -1012,7 +1106,8 @@ const braidingTrunks = (branches: ReadonlyMap<string, Point[][]>): Set<string> =
|
|
|
1012
1106
|
return braiding;
|
|
1013
1107
|
};
|
|
1014
1108
|
|
|
1015
|
-
const
|
|
1109
|
+
const crossingCount = (a: readonly Point[], b: readonly Point[]): number => {
|
|
1110
|
+
let count = 0;
|
|
1016
1111
|
for (let i = 0; i + 1 < a.length; i += 1)
|
|
1017
1112
|
for (let j = 0; j + 1 < b.length; j += 1) {
|
|
1018
1113
|
const a1 = a[i];
|
|
@@ -1020,11 +1115,21 @@ const polylinesCross = (a: readonly Point[], b: readonly Point[]): boolean => {
|
|
|
1020
1115
|
const b1 = b[j];
|
|
1021
1116
|
const b2 = b[j + 1];
|
|
1022
1117
|
if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined) continue;
|
|
1023
|
-
if (segmentsCross(a1, a2, b1, b2))
|
|
1118
|
+
if (segmentsCross(a1, a2, b1, b2)) count += 1;
|
|
1024
1119
|
}
|
|
1025
|
-
return
|
|
1120
|
+
return count;
|
|
1026
1121
|
};
|
|
1027
1122
|
|
|
1123
|
+
const polylineLength = (points: readonly Point[]): number =>
|
|
1124
|
+
points.reduce((sum, point, index) => {
|
|
1125
|
+
const previous = points[index - 1];
|
|
1126
|
+
if (previous === undefined) return sum;
|
|
1127
|
+
return sum + Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
1128
|
+
}, 0);
|
|
1129
|
+
|
|
1130
|
+
const polylinesCross = (a: readonly Point[], b: readonly Point[]): boolean =>
|
|
1131
|
+
crossingCount(a, b) > 0;
|
|
1132
|
+
|
|
1028
1133
|
const EPSILON = 0.01;
|
|
1029
1134
|
|
|
1030
1135
|
/** Proper crossing or collinear overlap of two axis-aligned segments. */
|