@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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/dist/bounds.d.ts +6 -1
  3. package/dist/bounds.d.ts.map +1 -1
  4. package/dist/bounds.js +21 -0
  5. package/dist/bounds.js.map +1 -1
  6. package/dist/design.d.ts +5 -0
  7. package/dist/design.d.ts.map +1 -1
  8. package/dist/design.js +5 -0
  9. package/dist/design.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/layout/architecture.d.ts +2 -0
  15. package/dist/layout/architecture.d.ts.map +1 -1
  16. package/dist/layout/architecture.js +5 -0
  17. package/dist/layout/architecture.js.map +1 -1
  18. package/dist/layout/congestion.d.ts +5 -0
  19. package/dist/layout/congestion.d.ts.map +1 -1
  20. package/dist/layout/congestion.js +50 -12
  21. package/dist/layout/congestion.js.map +1 -1
  22. package/dist/layout/edges.d.ts +18 -3
  23. package/dist/layout/edges.d.ts.map +1 -1
  24. package/dist/layout/edges.js +93 -23
  25. package/dist/layout/edges.js.map +1 -1
  26. package/dist/layout/labels.d.ts +15 -4
  27. package/dist/layout/labels.d.ts.map +1 -1
  28. package/dist/layout/labels.js +404 -119
  29. package/dist/layout/labels.js.map +1 -1
  30. package/dist/manifest.d.ts +1 -1
  31. package/dist/manifest.d.ts.map +1 -1
  32. package/dist/manifest.js +3 -1
  33. package/dist/manifest.js.map +1 -1
  34. package/dist/render.d.ts +12 -2
  35. package/dist/render.d.ts.map +1 -1
  36. package/dist/render.js +2 -2
  37. package/dist/render.js.map +1 -1
  38. package/dist/svg/architecture.d.ts.map +1 -1
  39. package/dist/svg/architecture.js +3 -8
  40. package/dist/svg/architecture.js.map +1 -1
  41. package/dist/theme.d.ts +9 -8
  42. package/dist/theme.d.ts.map +1 -1
  43. package/dist/theme.js +61 -8
  44. package/dist/theme.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +2 -2
  48. package/src/bounds.ts +29 -1
  49. package/src/design.ts +5 -0
  50. package/src/index.ts +2 -1
  51. package/src/layout/architecture.ts +7 -0
  52. package/src/layout/congestion.ts +71 -12
  53. package/src/layout/edges.ts +126 -21
  54. package/src/layout/labels.ts +495 -136
  55. package/src/manifest.ts +3 -2
  56. package/src/render.ts +19 -4
  57. package/src/svg/architecture.ts +3 -7
  58. package/src/theme.ts +62 -8
  59. package/src/version.ts +1 -1
@@ -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 this edge's label pill sits. */
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 never threads the living graph: it
158
- * exits past the last lane and comes back. Only a connection wholly between
159
- * dead cards keeps its short run inside the dead band — that is the internal
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 isExiled = (edge: GraphEdge, from: PlacedNode, to: PlacedNode): boolean =>
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
- ): RoutedEdge[] => finalPass(edges, layout).routed;
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
- ): ChannelTraffic => {
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
- for (const route of finalPass(edges, layout).plans)
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 = (edges: readonly GraphEdge[], layout: ArchitectureLayout): Pass => {
641
- const first = routePass(edges, layout, new Set());
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) || isExiled(edge, from, to) || isDead(from)) continue;
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 (isExiled(edge, from, to))
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
- ...planExile(from, to, grid, laneCount, blocked),
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 polylinesCross = (a: readonly Point[], b: readonly Point[]): boolean => {
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)) return true;
1118
+ if (segmentsCross(a1, a2, b1, b2)) count += 1;
1024
1119
  }
1025
- return false;
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. */