@principal-ai/subsystems-react 0.51.4 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +3 -7
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +159 -45
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +26 -26
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -30
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts +1 -1
  14. package/dist/subsystem/IssueList.d.ts.map +1 -1
  15. package/dist/subsystem/IssueList.js +20 -1
  16. package/dist/subsystem/IssueList.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +42 -19
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/c4.d.ts +48 -49
  21. package/dist/subsystem/c4.d.ts.map +1 -1
  22. package/dist/subsystem/c4.js +50 -0
  23. package/dist/subsystem/c4.js.map +1 -1
  24. package/dist/subsystem/c4Evidence.d.ts +88 -84
  25. package/dist/subsystem/c4Evidence.d.ts.map +1 -1
  26. package/dist/subsystem/c4Evidence.js +113 -166
  27. package/dist/subsystem/c4Evidence.js.map +1 -1
  28. package/dist/subsystem/nodes.js +5 -5
  29. package/dist/subsystem/nodes.js.map +1 -1
  30. package/dist/subsystem/techIcons/index.d.ts +5 -0
  31. package/dist/subsystem/techIcons/index.d.ts.map +1 -1
  32. package/dist/subsystem/techIcons/index.js +3 -0
  33. package/dist/subsystem/techIcons/index.js.map +1 -1
  34. package/dist/utils/edgeLabel.d.ts +15 -1
  35. package/dist/utils/edgeLabel.d.ts.map +1 -1
  36. package/dist/utils/edgeLabel.js +25 -1
  37. package/dist/utils/edgeLabel.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts +47 -0
  39. package/dist/utils/elkLayout.d.ts.map +1 -1
  40. package/dist/utils/elkLayout.js +71 -7
  41. package/dist/utils/elkLayout.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/index.ts +8 -6
  44. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
  45. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
  46. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
  47. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
  48. package/src/subsystem/C4Graph.tsx +242 -82
  49. package/src/subsystem/C4NodeCard.test.ts +32 -22
  50. package/src/subsystem/C4NodeCard.tsx +67 -39
  51. package/src/subsystem/IssueList.tsx +23 -0
  52. package/src/subsystem/SubsystemComponentGraph.tsx +38 -16
  53. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  54. package/src/subsystem/c4.ts +76 -52
  55. package/src/subsystem/c4Evidence.test.ts +177 -237
  56. package/src/subsystem/c4Evidence.ts +160 -235
  57. package/src/subsystem/nodes.group.test.tsx +15 -0
  58. package/src/subsystem/nodes.tsx +7 -7
  59. package/src/subsystem/techIcons/index.tsx +8 -0
  60. package/src/utils/edgeLabel.test.ts +21 -2
  61. package/src/utils/edgeLabel.ts +29 -1
  62. package/src/utils/elkLayout.test.ts +16 -0
  63. package/src/utils/elkLayout.ts +119 -7
  64. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -10,7 +10,7 @@ import type { Node, Edge } from '@xyflow/react';
10
10
  import {
11
11
  EDGE_LABEL_WIDTH,
12
12
  EDGE_LABEL_HEIGHT,
13
- EDGE_LABEL_EDGE_GAP,
13
+ EDGE_LABEL_SIDE_PADDING,
14
14
  } from './edgeLabel';
15
15
 
16
16
  /** ELK layout options for different routing styles */
@@ -76,6 +76,22 @@ export interface ElkLayoutOptions {
76
76
  enabled?: boolean;
77
77
  /** Placement of inline labels. @default 'CENTER' */
78
78
  placement?: 'CENTER' | 'TAIL' | 'HEAD';
79
+ /**
80
+ * Width of the reserved label box, in flow px. This is what actually sets
81
+ * the between-layer run for a labelled edge — ELK's CENTER_LAYER creates a
82
+ * label layer of this width, which dominates the layering spacing. Defaults
83
+ * to the shared `EDGE_LABEL_WIDTH`; a caller with shorter labels passes a
84
+ * smaller value to tighten its edges.
85
+ */
86
+ width?: number;
87
+ /**
88
+ * Per-label width, computed from the label text. When supplied, wins over
89
+ * `width` for each edge, so the reserved box tracks content instead of one
90
+ * fixed width. The overlay must use the same function.
91
+ */
92
+ measure?: (text: string) => number;
93
+ /** Height of the reserved label box. Defaults to `EDGE_LABEL_HEIGHT`. */
94
+ height?: number;
79
95
  };
80
96
 
81
97
  /**
@@ -121,6 +137,34 @@ export interface ElkLayoutOptions {
121
137
  * on the duplicate. @default false
122
138
  */
123
139
  keepSingletonGroups?: boolean;
140
+
141
+ /**
142
+ * Lay a group's members out at the root instead of nesting them inside an
143
+ * ELK parent, then synthesize the frame's bounds as the bounding box of its
144
+ * members.
145
+ *
146
+ * A nested frame is a real ELK parent, so edges wholly inside it inherit the
147
+ * frame's padding and its own between-layer spacing on top — which makes an
148
+ * in-frame edge run longer than a root-level one touching the same nodes.
149
+ * Flattening removes that: every edge uses one spacing, and the frame is pure
150
+ * decoration drawn around wherever the members landed.
151
+ *
152
+ * Only for groups whose members need no parent-relative positioning (C4's
153
+ * system frame). @default false
154
+ */
155
+ flatGroups?: boolean;
156
+
157
+ /**
158
+ * ELK partition per node id. Nodes are banded by `partition` — ELK places all
159
+ * of partition 0, then partition 1, etc., in the layout direction — so a
160
+ * group of nodes can be kept apart from the rest without nesting them in a
161
+ * parent. C4 uses this to push out-of-boundary nodes (external systems,
162
+ * people) into their own band, away from the framed containers.
163
+ *
164
+ * Requires every laid-out node to have a partition; unknown ids are ignored,
165
+ * unlisted nodes get partition 0. @default undefined (partitioning off)
166
+ */
167
+ partitionByNode?: ReadonlyMap<string, number>;
124
168
  }
125
169
 
126
170
  /** Result of ELK layout computation */
@@ -397,7 +441,7 @@ export function fractionAlongPath(points: Point[], at: Point): number {
397
441
  /**
398
442
  * Get ELK layout options based on configuration
399
443
  */
400
- function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
444
+ export function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
401
445
  const {
402
446
  routingStyle = 'orthogonal',
403
447
  nodeSpacing = 50,
@@ -421,6 +465,12 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
421
465
  'elk.spacing.edgeNode': String(edgeNodeSpacing),
422
466
  'elk.layered.spacing.edgeEdgeBetweenLayers': String(edgeSpacing),
423
467
  'elk.layered.spacing.edgeNodeBetweenLayers': String(edgeNodeSpacing),
468
+ // Edges host inline labels and `CENTER_LAYER` already reserves a label
469
+ // layer, so this is EXTRA space stacked on top of that layer — not a
470
+ // replacement for it. Reserving the full label box here double-counts and
471
+ // the run balloons (measured: w=140 + 204 here = 548px between two nodes).
472
+ // `interLayerSpacing` is the only caller-set value; otherwise this is the
473
+ // small clearance the label layer can't express.
424
474
  'elk.layered.spacing.nodeNodeBetweenLayers': String(interLayerSpacing),
425
475
  // Port constraints - edges connect at specific sides
426
476
  'elk.portConstraints': 'FIXED_SIDE',
@@ -598,7 +648,12 @@ export async function computeElkLayout(
598
648
  edges: Edge[],
599
649
  options: ElkLayoutOptions = {}
600
650
  ): Promise<ElkLayoutResult> {
601
- const { preserveNodePositions = true, keepSingletonGroups = false } = options;
651
+ const {
652
+ preserveNodePositions = true,
653
+ keepSingletonGroups = false,
654
+ flatGroups = false,
655
+ partitionByNode,
656
+ } = options;
602
657
  const edgeLabels = options.edgeLabels;
603
658
  const endpointInset = options.endpointInset ?? 0;
604
659
  const direction = options.direction ?? 'RIGHT';
@@ -648,6 +703,7 @@ export async function computeElkLayout(
648
703
  properties: {
649
704
  'portConstraints': 'FIXED_SIDE',
650
705
  ...(layer !== undefined ? { 'layering.layer': String(layer) } : {}),
706
+ ...(partitionByNode ? { partition: String(partitionByNode.get(node.id) ?? 0) } : {}),
651
707
  },
652
708
  };
653
709
  });
@@ -745,11 +801,20 @@ export async function computeElkLayout(
745
801
  sources: [sourcePort],
746
802
  targets: [targetPort],
747
803
  };
748
- // Reserve the fixed label box so ELK leaves the same room for every label,
749
- // regardless of text length. The overlay renders into this exact box.
804
+ // Reserve the label box. ELK's `CENTER_LAYER` sizes a dedicated label layer
805
+ // from this and adds no clearance of its own, so the box carries the chip
806
+ // width PLUS the clearance we want on each side — that is the only place
807
+ // the side padding can take effect. The overlay draws the chip at the
808
+ // narrower `edgeLabels.width`, centred in this reserved run.
750
809
  if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
810
+ const chipWidth =
811
+ edgeLabels?.measure?.(edge.label) ?? edgeLabels?.width ?? EDGE_LABEL_WIDTH;
751
812
  elkEdge.labels = [
752
- { text: edge.label, width: EDGE_LABEL_WIDTH, height: EDGE_LABEL_HEIGHT },
813
+ {
814
+ text: edge.label,
815
+ width: chipWidth + EDGE_LABEL_SIDE_PADDING * 2,
816
+ height: edgeLabels?.height ?? EDGE_LABEL_HEIGHT,
817
+ },
753
818
  ];
754
819
  }
755
820
  return elkEdge;
@@ -778,7 +843,9 @@ export async function computeElkLayout(
778
843
  // the full reserved width (label box + clearance at each end); labels off
779
844
  // falls back to a plain between-layer gap.
780
845
  'elk.layered.spacing.nodeNodeBetweenLayers': String(
781
- edgeLabels?.enabled === false ? 40 : EDGE_LABEL_EDGE_GAP,
846
+ edgeLabels?.enabled === false
847
+ ? 40
848
+ : (edgeLabels?.width ?? EDGE_LABEL_WIDTH) + EDGE_LABEL_SIDE_PADDING * 2,
782
849
  ),
783
850
  };
784
851
  // Match the root's inline-label reservation, so ELK inside the frame leaves a
@@ -793,7 +860,15 @@ export async function computeElkLayout(
793
860
  const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
794
861
  const skippedGroups = new Set(plan.skipped);
795
862
  const builtGroups = new Map<string, ElkNode>();
863
+ // Flat groups are not ELK parents at all: their members lay out at the root,
864
+ // and the frame is synthesized from the members' bounds after layout. This
865
+ // keeps every edge on one spacing instead of inheriting a nested frame's.
866
+ const flatGroupIds = new Set<string>();
867
+ if (flatGroups) {
868
+ for (const g of plan.built) flatGroupIds.add(g.id);
869
+ }
796
870
  for (const g of plan.built) {
871
+ if (flatGroups) break;
797
872
  const children: ElkNode[] = [];
798
873
  for (const cid of g.childIds) {
799
874
  const leaf = elkById.get(cid);
@@ -857,6 +932,9 @@ export async function computeElkLayout(
857
932
 
858
933
  // Create ELK graph
859
934
  const rootOptions = getElkOptions(options);
935
+ if (partitionByNode) {
936
+ rootOptions['elk.partitioning.activate'] = 'true';
937
+ }
860
938
  if (elkParents.length > 0 || builtGroups.size > 0) {
861
939
  rootOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
862
940
  }
@@ -929,6 +1007,40 @@ export async function computeElkLayout(
929
1007
  }
930
1008
  }
931
1009
 
1010
+ // Flat groups: synthesize each frame from the bounding box of its members.
1011
+ // `plan.built` is bottom-up, so a nested frame's bounds already exist when its
1012
+ // parent is computed — a frame containing frames unions the child frames, not
1013
+ // just leaves. Without that, an outer frame (the system) would omit the
1014
+ // container frames inside it and mis-size.
1015
+ if (flatGroupIds.size > 0) {
1016
+ const FLAT_PAD = 24;
1017
+ const FLAT_TOP = 64;
1018
+ /** Absolute rect of a group member: a leaf rect, or an already-built frame. */
1019
+ const rectOf = (id: string) =>
1020
+ absoluteRects.get(id) ??
1021
+ (() => {
1022
+ const b = groupBounds.get(id);
1023
+ return b ? { x: b.x, y: b.y, width: b.width, height: b.height } : undefined;
1024
+ })();
1025
+ for (const g of plan.built) {
1026
+ if (!flatGroupIds.has(g.id)) continue;
1027
+ const rects = g.childIds
1028
+ .map(rectOf)
1029
+ .filter((r): r is { x: number; y: number; width: number; height: number } => !!r);
1030
+ if (rects.length === 0) continue;
1031
+ const minX = Math.min(...rects.map((r) => r.x));
1032
+ const minY = Math.min(...rects.map((r) => r.y));
1033
+ const maxX = Math.max(...rects.map((r) => r.x + r.width));
1034
+ const maxY = Math.max(...rects.map((r) => r.y + r.height));
1035
+ groupBounds.set(g.id, {
1036
+ x: minX - FLAT_PAD,
1037
+ y: minY - FLAT_TOP,
1038
+ width: maxX - minX + FLAT_PAD * 2,
1039
+ height: maxY - minY + FLAT_TOP + FLAT_PAD,
1040
+ });
1041
+ }
1042
+ }
1043
+
932
1044
  // Extract results
933
1045
  const edgePaths = new Map<string, string>();
934
1046
  const edgeLabelPositions = new Map<string, { x: number; y: number }>();