@principal-ai/subsystems-react 0.51.3 → 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 (70) 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 +202 -63
  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 +74 -26
  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/model.d.ts +6 -0
  29. package/dist/subsystem/model.d.ts.map +1 -1
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.d.ts.map +1 -1
  32. package/dist/subsystem/nodes.js +9 -7
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/techIcons/index.d.ts +5 -0
  35. package/dist/subsystem/techIcons/index.d.ts.map +1 -1
  36. package/dist/subsystem/techIcons/index.js +3 -0
  37. package/dist/subsystem/techIcons/index.js.map +1 -1
  38. package/dist/utils/edgeLabel.d.ts +14 -0
  39. package/dist/utils/edgeLabel.d.ts.map +1 -1
  40. package/dist/utils/edgeLabel.js +24 -0
  41. package/dist/utils/edgeLabel.js.map +1 -1
  42. package/dist/utils/elkLayout.d.ts +65 -0
  43. package/dist/utils/elkLayout.d.ts.map +1 -1
  44. package/dist/utils/elkLayout.js +145 -11
  45. package/dist/utils/elkLayout.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/index.ts +8 -6
  48. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +198 -0
  49. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +42 -6
  50. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +9 -7
  51. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +686 -2
  52. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  53. package/src/subsystem/C4Graph.tsx +296 -99
  54. package/src/subsystem/C4NodeCard.test.ts +32 -22
  55. package/src/subsystem/C4NodeCard.tsx +67 -39
  56. package/src/subsystem/IssueList.tsx +23 -0
  57. package/src/subsystem/SubsystemComponentGraph.tsx +79 -23
  58. package/src/subsystem/c4.protocolColor.test.ts +26 -0
  59. package/src/subsystem/c4.ts +76 -52
  60. package/src/subsystem/c4Evidence.test.ts +177 -237
  61. package/src/subsystem/c4Evidence.ts +160 -235
  62. package/src/subsystem/model.ts +6 -0
  63. package/src/subsystem/nodes.group.test.tsx +15 -0
  64. package/src/subsystem/nodes.tsx +12 -9
  65. package/src/subsystem/techIcons/index.tsx +8 -0
  66. package/src/utils/edgeLabel.test.ts +21 -2
  67. package/src/utils/edgeLabel.ts +28 -0
  68. package/src/utils/elkLayout.test.ts +50 -0
  69. package/src/utils/elkLayout.ts +191 -11
  70. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +0 -602
@@ -12,9 +12,13 @@ import {
12
12
  pointsToPath,
13
13
  pointsToSmoothPath,
14
14
  calculatePathMidpoint,
15
+ pointAlongPath,
16
+ fractionAlongPath,
15
17
  planCompoundGroups,
18
+ getElkOptions,
16
19
  type Point,
17
20
  } from './elkLayout';
21
+ import { EDGE_LABEL_WIDTH, EDGE_LABEL_SIDE_PADDING } from './edgeLabel';
18
22
 
19
23
  describe('planCompoundGroups', () => {
20
24
  test('drops a single-leaf group by default and promotes its member', () => {
@@ -129,6 +133,52 @@ describe('planCompoundGroups', () => {
129
133
  });
130
134
 
131
135
  describe('elkLayout helper functions', () => {
136
+ describe('getElkOptions — label-aware between-layer spacing', () => {
137
+ test('does not stack spacing on top of ELK’s reserved label layer', () => {
138
+ // CENTER_LAYER already reserves the label; adding the box width here
139
+ // double-counts and balloons the run (measured 548px for 140 + 204).
140
+ const opts = getElkOptions({ edgeLabels: { enabled: true, placement: 'CENTER' } });
141
+ expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('0');
142
+ });
143
+
144
+ test('an explicit interLayerSpacing still wins', () => {
145
+ const opts = getElkOptions({ interLayerSpacing: 120, edgeLabels: { enabled: true } });
146
+ expect(opts['elk.layered.spacing.nodeNodeBetweenLayers']).toBe('120');
147
+ });
148
+ });
149
+
150
+ describe('pointAlongPath / fractionAlongPath', () => {
151
+ const bent: Point[] = [
152
+ { x: 0, y: 0 },
153
+ { x: 100, y: 0 },
154
+ { x: 100, y: 100 },
155
+ ];
156
+
157
+ test('half is the arc-length midpoint, not a corner', () => {
158
+ // Total length 200; half is 100 along, which is the corner (100,0).
159
+ const mid = pointAlongPath(bent, 0.5);
160
+ expect(mid).toEqual({ x: 100, y: 0 });
161
+ });
162
+
163
+ test('a fraction lands on the correct segment', () => {
164
+ // 0.75 of 200 = 150 → 50 down the second segment.
165
+ expect(pointAlongPath(bent, 0.75)).toEqual({ x: 100, y: 50 });
166
+ // 0.25 of 200 = 50 → half along the first segment.
167
+ expect(pointAlongPath(bent, 0.25)).toEqual({ x: 50, y: 0 });
168
+ });
169
+
170
+ test('clamps and handles degenerate input', () => {
171
+ expect(pointAlongPath(bent, -1)).toEqual({ x: 0, y: 0 });
172
+ expect(pointAlongPath(bent, 2)).toEqual({ x: 100, y: 100 });
173
+ expect(pointAlongPath([{ x: 5, y: 5 }], 0.5)).toEqual({ x: 5, y: 5 });
174
+ });
175
+
176
+ test('fractionAlongPath inverts pointAlongPath', () => {
177
+ const at = pointAlongPath(bent, 0.75);
178
+ expect(fractionAlongPath(bent, at)).toBeCloseTo(0.75, 5);
179
+ });
180
+ });
181
+
132
182
  describe('pointsToPath', () => {
133
183
  test('should return empty string for empty array', () => {
134
184
  expect(pointsToPath([])).toBe('');
@@ -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 */
@@ -330,10 +374,74 @@ export function calculatePathLength(points: Point[]): number {
330
374
  return total;
331
375
  }
332
376
 
377
+ /**
378
+ * Point a given fraction along a polyline, by arc length.
379
+ *
380
+ * Using arc length, not the nearest-point snap, is what keeps a label chip on
381
+ * the visual middle of a *bent* route. Nearest-point (`closestPointOnPath`)
382
+ * jumps to whichever segment happens to be nearest the target, so a fanned-out
383
+ * orthogonal route put the chip on a corner near the source node.
384
+ *
385
+ * @public Exported for testing
386
+ */
387
+ export function pointAlongPath(points: Point[], fraction: number): Point {
388
+ if (points.length === 0) return { x: 0, y: 0 };
389
+ if (points.length === 1) return points[0];
390
+ const total = calculatePathLength(points);
391
+ if (total === 0) return points[0];
392
+
393
+ let remaining = Math.max(0, Math.min(1, fraction)) * total;
394
+ for (let i = 1; i < points.length; i++) {
395
+ const a = points[i - 1];
396
+ const b = points[i];
397
+ const seg = Math.hypot(b.x - a.x, b.y - a.y);
398
+ if (seg === 0) continue;
399
+ if (remaining <= seg) {
400
+ const t = remaining / seg;
401
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
402
+ }
403
+ remaining -= seg;
404
+ }
405
+ return points[points.length - 1];
406
+ }
407
+
408
+ /**
409
+ * How far along a polyline a point lies, as a 0…1 fraction of arc length.
410
+ * The point is assumed to lie on (or near) the path.
411
+ *
412
+ * @public Exported for testing
413
+ */
414
+ export function fractionAlongPath(points: Point[], at: Point): number {
415
+ if (points.length < 2) return 0.5;
416
+ const total = calculatePathLength(points);
417
+ if (total === 0) return 0.5;
418
+
419
+ let travelled = 0;
420
+ let bestDist = Infinity;
421
+ let bestTravelled = 0;
422
+ for (let i = 1; i < points.length; i++) {
423
+ const a = points[i - 1];
424
+ const b = points[i];
425
+ const seg = Math.hypot(b.x - a.x, b.y - a.y);
426
+ const dx = b.x - a.x;
427
+ const dy = b.y - a.y;
428
+ const t = seg === 0 ? 0 : Math.max(0, Math.min(1, ((at.x - a.x) * dx + (at.y - a.y) * dy) / (seg * seg)));
429
+ const px = a.x + dx * t;
430
+ const py = a.y + dy * t;
431
+ const dist = Math.hypot(at.x - px, at.y - py);
432
+ if (dist < bestDist) {
433
+ bestDist = dist;
434
+ bestTravelled = travelled + seg * t;
435
+ }
436
+ travelled += seg;
437
+ }
438
+ return bestTravelled / total;
439
+ }
440
+
333
441
  /**
334
442
  * Get ELK layout options based on configuration
335
443
  */
336
- function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
444
+ export function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
337
445
  const {
338
446
  routingStyle = 'orthogonal',
339
447
  nodeSpacing = 50,
@@ -357,6 +465,12 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
357
465
  'elk.spacing.edgeNode': String(edgeNodeSpacing),
358
466
  'elk.layered.spacing.edgeEdgeBetweenLayers': String(edgeSpacing),
359
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.
360
474
  'elk.layered.spacing.nodeNodeBetweenLayers': String(interLayerSpacing),
361
475
  // Port constraints - edges connect at specific sides
362
476
  'elk.portConstraints': 'FIXED_SIDE',
@@ -534,7 +648,12 @@ export async function computeElkLayout(
534
648
  edges: Edge[],
535
649
  options: ElkLayoutOptions = {}
536
650
  ): Promise<ElkLayoutResult> {
537
- const { preserveNodePositions = true, keepSingletonGroups = false } = options;
651
+ const {
652
+ preserveNodePositions = true,
653
+ keepSingletonGroups = false,
654
+ flatGroups = false,
655
+ partitionByNode,
656
+ } = options;
538
657
  const edgeLabels = options.edgeLabels;
539
658
  const endpointInset = options.endpointInset ?? 0;
540
659
  const direction = options.direction ?? 'RIGHT';
@@ -584,6 +703,7 @@ export async function computeElkLayout(
584
703
  properties: {
585
704
  'portConstraints': 'FIXED_SIDE',
586
705
  ...(layer !== undefined ? { 'layering.layer': String(layer) } : {}),
706
+ ...(partitionByNode ? { partition: String(partitionByNode.get(node.id) ?? 0) } : {}),
587
707
  },
588
708
  };
589
709
  });
@@ -681,11 +801,20 @@ export async function computeElkLayout(
681
801
  sources: [sourcePort],
682
802
  targets: [targetPort],
683
803
  };
684
- // Reserve the fixed label box so ELK leaves the same room for every label,
685
- // 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.
686
809
  if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
810
+ const chipWidth =
811
+ edgeLabels?.measure?.(edge.label) ?? edgeLabels?.width ?? EDGE_LABEL_WIDTH;
687
812
  elkEdge.labels = [
688
- { 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
+ },
689
818
  ];
690
819
  }
691
820
  return elkEdge;
@@ -714,7 +843,9 @@ export async function computeElkLayout(
714
843
  // the full reserved width (label box + clearance at each end); labels off
715
844
  // falls back to a plain between-layer gap.
716
845
  'elk.layered.spacing.nodeNodeBetweenLayers': String(
717
- 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,
718
849
  ),
719
850
  };
720
851
  // Match the root's inline-label reservation, so ELK inside the frame leaves a
@@ -729,7 +860,15 @@ export async function computeElkLayout(
729
860
  const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
730
861
  const skippedGroups = new Set(plan.skipped);
731
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
+ }
732
870
  for (const g of plan.built) {
871
+ if (flatGroups) break;
733
872
  const children: ElkNode[] = [];
734
873
  for (const cid of g.childIds) {
735
874
  const leaf = elkById.get(cid);
@@ -793,6 +932,9 @@ export async function computeElkLayout(
793
932
 
794
933
  // Create ELK graph
795
934
  const rootOptions = getElkOptions(options);
935
+ if (partitionByNode) {
936
+ rootOptions['elk.partitioning.activate'] = 'true';
937
+ }
796
938
  if (elkParents.length > 0 || builtGroups.size > 0) {
797
939
  rootOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
798
940
  }
@@ -865,6 +1007,40 @@ export async function computeElkLayout(
865
1007
  }
866
1008
  }
867
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
+
868
1044
  // Extract results
869
1045
  const edgePaths = new Map<string, string>();
870
1046
  const edgeLabelPositions = new Map<string, { x: number; y: number }>();
@@ -958,10 +1134,14 @@ export async function computeElkLayout(
958
1134
  lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
959
1135
  ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
960
1136
  }
961
- // Snap onto the polyline stroke. ELK's label box can sit slightly
962
- // off the route (side selection / reserved label space); we keep
963
- // its along-edge placement but center on the actual path.
964
- const onPath = closestPointOnPath(allPoints, { x: lx, y: ly });
1137
+ // Place the chip by arc length, using ELK's label position only as a
1138
+ // hint for *how far along* the route it belongs. Snapping to the
1139
+ // nearest point (the old behaviour) jumped to whichever segment was
1140
+ // nearest the target, which put fanned-out orthogonal labels on a
1141
+ // corner near the source instead of the middle of the line.
1142
+ const hint = closestPointOnPath(allPoints, { x: lx, y: ly });
1143
+ const hintFraction = fractionAlongPath(allPoints, hint);
1144
+ const onPath = pointAlongPath(allPoints, hintFraction);
965
1145
  edgeLabelPositions.set(edge.id, onPath);
966
1146
  }
967
1147