@principal-ai/principal-view-react 0.16.58 → 0.16.60

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.
@@ -11,6 +11,7 @@ import { useState } from 'react';
11
11
  import {
12
12
  Handle,
13
13
  Position,
14
+ type Node,
14
15
  type NodeProps,
15
16
  type EdgeProps,
16
17
  } from '@xyflow/react';
@@ -21,7 +22,9 @@ import {
21
22
  ROLE_COLOR,
22
23
  ROLE_LABEL,
23
24
  deriveNameFromSymbol,
24
- type SubsystemGraphNode,
25
+ packageColor,
26
+ type SubsystemGraphNodeData,
27
+ type SubsystemGroupNodeData,
25
28
  type SubsystemGraphEdge,
26
29
  } from './model';
27
30
  import { constructColorsFromPierreTheme } from '../pierre/constructColors';
@@ -34,6 +37,7 @@ export const CONSTRUCT_LABEL: Record<string, string> = {
34
37
  interface: 'interface',
35
38
  type_alias: 'type alias',
36
39
  enum: 'enum',
40
+ react_component: 'component',
37
41
  module: 'module',
38
42
  store: 'store',
39
43
  external: 'external',
@@ -70,7 +74,7 @@ export interface SubsystemGraphCallbacks {
70
74
  /** Root callbacks carried through node data (injected by the graph component). */
71
75
  export const SUBSYSTEM_CALLBACKS: SubsystemGraphCallbacks = {};
72
76
 
73
- export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
77
+ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
74
78
  const { theme, mode } = useTheme();
75
79
  const { data, selected, width: nodeWidth, height: nodeHeight } = props;
76
80
  const c = data.component;
@@ -267,6 +271,65 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
267
271
  );
268
272
  }
269
273
 
274
+ /**
275
+ * Process boundary frame — a React Flow parent node. Members render inside
276
+ * via `parentId`; this draws the labeled container only (no handles, no
277
+ * selection). The border color derives deterministically from the process key
278
+ * so each deployment unit reads as its own region.
279
+ */
280
+ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData, 'subsystem-group'>>) {
281
+ const { theme } = useTheme();
282
+ const { data, width, height, selected } = props as unknown as {
283
+ data: SubsystemGroupNodeData;
284
+ width?: number;
285
+ height?: number;
286
+ selected?: boolean;
287
+ };
288
+ const region = data.region;
289
+ const color = packageColor(region?.key ?? 'process');
290
+ const dimmed = data.dimmed === true;
291
+ const hidden = (data as { hidden?: boolean }).hidden === true;
292
+
293
+ if (!region) return null;
294
+
295
+ return (
296
+ <div
297
+ style={{
298
+ position: 'relative',
299
+ width: width ?? 400,
300
+ height: height ?? 300,
301
+ boxSizing: 'border-box',
302
+ borderRadius: 12,
303
+ border: `2px ${selected ? 'solid' : 'dashed'} ${color}`,
304
+ background: `${color}14`,
305
+ opacity: hidden ? 0 : dimmed ? 0.35 : 1,
306
+ transition: 'opacity 150ms ease',
307
+ pointerEvents: 'none',
308
+ }}
309
+ >
310
+ <div
311
+ style={{
312
+ position: 'absolute',
313
+ top: -13,
314
+ left: 12,
315
+ fontFamily: theme.fonts.monospace,
316
+ fontSize: theme.fontSizes[0],
317
+ fontWeight: 700,
318
+ letterSpacing: 0.6,
319
+ color,
320
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
321
+ border: `1px solid ${color}`,
322
+ borderRadius: 4,
323
+ padding: '1px 7px',
324
+ whiteSpace: 'nowrap',
325
+ }}
326
+ >
327
+ {`process: ${region.label}`}
328
+ </div>
329
+ </div>
330
+ );
331
+ }
332
+
270
333
  /** `#rrggbb` + alpha → `#rrggbbaa`. Used to dim a stroke/marker by color so
271
334
  * each opacity gets its own SVG marker id — path `opacity` leaks across every
272
335
  * edge that shares a `url(#marker)` (the focused edge's arrowhead dims). */
@@ -71,6 +71,14 @@ export interface ElkLayoutOptions {
71
71
  * @default 'RIGHT'
72
72
  */
73
73
  direction?: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP';
74
+
75
+ /**
76
+ * Compound groups — each becomes a nested ELK parent whose `memberIds`
77
+ * are laid out inside it. Members reference the group via React Flow
78
+ * `parentId`; ELK returns parent-relative child positions which this
79
+ * module flattens back to absolute flow coordinates.
80
+ */
81
+ groups?: Array<{ id: string; memberIds: string[] }>;
74
82
  }
75
83
 
76
84
  /** Result of ELK layout computation */
@@ -83,6 +91,8 @@ export interface ElkLayoutResult {
83
91
  edgeLabelPositions: Map<string, { x: number; y: number }>;
84
92
  /** Raw ELK path points per edge (for debugging). */
85
93
  edgePathPoints: Map<string, Point[]>;
94
+ /** Compound parent bounds from ELK (absolute flow coords), keyed by group id. */
95
+ groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
86
96
  }
87
97
 
88
98
  /** Point in 2D space */
@@ -102,6 +112,8 @@ interface ElkEdgeSection {
102
112
  /** Extended ELK edge with sections */
103
113
  interface ElkEdgeWithSections extends ElkExtendedEdge {
104
114
  sections?: ElkEdgeSection[];
115
+ /** Id of the compound node whose coordinate frame sections/labels use. */
116
+ container?: string;
105
117
  }
106
118
 
107
119
  // Create ELK instance lazily to avoid issues in test environments
@@ -228,6 +240,47 @@ export function calculatePathMidpoint(points: Point[]): Point {
228
240
  return points[points.length - 1];
229
241
  }
230
242
 
243
+ /**
244
+ * Closest point on a polyline to a target (for snapping labels onto the stroke).
245
+ */
246
+ export function closestPointOnPath(points: Point[], target: Point): Point {
247
+ if (points.length === 0) return { x: 0, y: 0 };
248
+ if (points.length === 1) return points[0];
249
+
250
+ let best = points[0];
251
+ let bestDist = Infinity;
252
+
253
+ for (let i = 1; i < points.length; i++) {
254
+ const a = points[i - 1];
255
+ const b = points[i];
256
+ const dx = b.x - a.x;
257
+ const dy = b.y - a.y;
258
+ const lenSq = dx * dx + dy * dy;
259
+ const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((target.x - a.x) * dx + (target.y - a.y) * dy) / lenSq));
260
+ const px = a.x + dx * t;
261
+ const py = a.y + dy * t;
262
+ const dist = Math.hypot(target.x - px, target.y - py);
263
+ if (dist < bestDist) {
264
+ bestDist = dist;
265
+ best = { x: px, y: py };
266
+ }
267
+ }
268
+
269
+ return best;
270
+ }
271
+
272
+ /**
273
+ * Polyline length in flow-space units (sum of segment lengths).
274
+ * @public Exported for testing
275
+ */
276
+ export function calculatePathLength(points: Point[]): number {
277
+ let total = 0;
278
+ for (let i = 1; i < points.length; i++) {
279
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
280
+ }
281
+ return total;
282
+ }
283
+
231
284
  /**
232
285
  * Get ELK layout options based on configuration
233
286
  */
@@ -447,22 +500,103 @@ export async function computeElkLayout(
447
500
  return elkEdge;
448
501
  });
449
502
 
503
+ // Partition leaf nodes into compound parents when groups are given.
504
+ // Group shells themselves are NOT part of `nodes` — they are reconstructed
505
+ // by the caller from `groupBounds`. Only leaf ids in `memberIds` nest.
506
+ const groupDefs = (options.groups ?? []).filter((g) => g.memberIds.length > 0);
507
+ const memberToGroup = new Map<string, string>();
508
+ const groupedLeafIds = new Set<string>();
509
+ for (const g of groupDefs) {
510
+ for (const mid of g.memberIds) {
511
+ if (!memberToGroup.has(mid)) {
512
+ memberToGroup.set(mid, g.id);
513
+ groupedLeafIds.add(mid);
514
+ }
515
+ }
516
+ }
517
+ const elkById = new Map(elkNodes.map((n) => [n.id, n]));
518
+ const ungroupedElkNodes: ElkNode[] = [];
519
+ for (const n of elkNodes) {
520
+ if (!groupedLeafIds.has(n.id)) ungroupedElkNodes.push(n);
521
+ }
522
+ const elkParents: ElkNode[] = [];
523
+ for (const g of groupDefs) {
524
+ const children = g.memberIds
525
+ .map((mid) => elkById.get(mid))
526
+ .filter((n): n is ElkNode => !!n);
527
+ // Skip groups with <2 real members — a single-child frame adds noise;
528
+ // the caller drops the shell and leaves the node top-level.
529
+ if (children.length < 2) {
530
+ for (const c of children) ungroupedElkNodes.push(c);
531
+ memberToGroup.delete(g.memberIds[0]);
532
+ groupedLeafIds.delete(g.memberIds[0]);
533
+ continue;
534
+ }
535
+ elkParents.push({
536
+ id: g.id,
537
+ children,
538
+ layoutOptions: {
539
+ 'elk.algorithm': 'layered',
540
+ 'elk.direction': direction,
541
+ 'elk.padding': '[top=48,left=24,bottom=24,right=24]',
542
+ 'elk.spacing.nodeNode': '40',
543
+ },
544
+ });
545
+ }
546
+
450
547
  // Create ELK graph
548
+ const rootOptions = getElkOptions(options);
549
+ if (elkParents.length > 0) {
550
+ rootOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
551
+ }
451
552
  const elkGraph: ElkNode = {
452
553
  id: 'root',
453
- layoutOptions: getElkOptions(options),
454
- children: elkNodes,
554
+ layoutOptions: rootOptions,
555
+ children: [...ungroupedElkNodes, ...elkParents],
455
556
  edges: elkEdges,
456
557
  };
457
558
 
458
559
  // Run ELK layout
459
560
  const layoutedGraph = await getElkInstance().layout(elkGraph);
460
561
 
461
- // Build a map of ELK-computed node positions
562
+ // Build maps of ELK-computed positions. Nested children report
563
+ // parent-relative coords — flatten to absolute for edges, and keep the
564
+ // relative form for React Flow children (whose position is parent-relative).
565
+ // Absolute offset of every ELK node (parents included), accumulated down
566
+ // the ancestor chain — edge sections/labels are relative to their
567
+ // `container`, so each edge needs its container's absolute offset.
568
+ const elkAbsOffsets = new Map<string, { x: number; y: number }>();
462
569
  const elkPositions = new Map<string, { x: number; y: number }>();
570
+ const elkRelativePositions = new Map<string, { x: number; y: number }>();
571
+ const groupBounds = new Map<string, { x: number; y: number; width: number; height: number }>();
572
+ const walkElk = (n: ElkNode, ox: number, oy: number) => {
573
+ const ax = ox + (n.x ?? 0);
574
+ const ay = oy + (n.y ?? 0);
575
+ elkAbsOffsets.set(n.id, { x: ax, y: ay });
576
+ for (const c of n.children ?? []) walkElk(c, ax, ay);
577
+ };
578
+ walkElk(layoutedGraph, 0, 0);
463
579
  if (layoutedGraph.children) {
464
580
  for (const child of layoutedGraph.children) {
465
- elkPositions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
581
+ if (child.children && child.children.length > 0 && groupDefs.some((g) => g.id === child.id)) {
582
+ const gx = child.x ?? 0;
583
+ const gy = child.y ?? 0;
584
+ groupBounds.set(child.id, {
585
+ x: gx,
586
+ y: gy,
587
+ width: child.width ?? 0,
588
+ height: child.height ?? 0,
589
+ });
590
+ for (const grand of child.children) {
591
+ const rx = grand.x ?? 0;
592
+ const ry = grand.y ?? 0;
593
+ elkRelativePositions.set(grand.id, { x: rx, y: ry });
594
+ elkPositions.set(grand.id, { x: gx + rx, y: gy + ry });
595
+ }
596
+ } else {
597
+ elkPositions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
598
+ elkRelativePositions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
599
+ }
466
600
  }
467
601
  }
468
602
 
@@ -486,7 +620,10 @@ export async function computeElkLayout(
486
620
  const targetOriginal = targetId ? originalPositions.get(targetId) : null;
487
621
  const targetElk = targetId ? elkPositions.get(targetId) : null;
488
622
 
489
- // Collect all points from sections
623
+ // Collect all points from sections. Sections (and labels) are
624
+ // relative to the edge's `container` — intra-group edges live in
625
+ // the parent's frame, so translate to root-absolute flow coords.
626
+ const containerOffset = elkAbsOffsets.get(edge.container ?? 'root') ?? { x: 0, y: 0 };
490
627
  const allPoints: Point[] = [];
491
628
 
492
629
  for (const section of edge.sections) {
@@ -496,6 +633,12 @@ export async function computeElkLayout(
496
633
  }
497
634
  allPoints.push(section.endPoint);
498
635
  }
636
+ if (containerOffset.x !== 0 || containerOffset.y !== 0) {
637
+ for (const p of allPoints) {
638
+ p.x += containerOffset.x;
639
+ p.y += containerOffset.y;
640
+ }
641
+ }
499
642
 
500
643
  // If preserving positions, we need to offset the edge points
501
644
  // The edge path is relative to ELK's layout, so we translate it
@@ -531,8 +674,8 @@ export async function computeElkLayout(
531
674
  const elkLabel = edge.labels[0];
532
675
  // ELK reports the label's top-left; convert to center so screen-space
533
676
  // overlays can anchor with translate(-50%, -50%) at any zoom.
534
- let lx = (elkLabel.x ?? 0) + (elkLabel.width ?? 0) / 2;
535
- let ly = (elkLabel.y ?? 0) + (elkLabel.height ?? 0) / 2;
677
+ let lx = (elkLabel.x ?? 0) + (elkLabel.width ?? 0) / 2 + containerOffset.x;
678
+ let ly = (elkLabel.y ?? 0) + (elkLabel.height ?? 0) / 2 + containerOffset.y;
536
679
  if (preserveNodePositions && sourceOriginal && sourceElk && targetOriginal && targetElk) {
537
680
  const sourceOffset = {
538
681
  x: sourceOriginal.x - sourceElk.x,
@@ -550,7 +693,11 @@ export async function computeElkLayout(
550
693
  lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
551
694
  ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
552
695
  }
553
- edgeLabelPositions.set(edge.id, { x: lx, y: ly });
696
+ // Snap onto the polyline stroke. ELK's label box can sit slightly
697
+ // off the route (side selection / reserved label space); we keep
698
+ // its along-edge placement but center on the actual path.
699
+ const onPath = closestPointOnPath(allPoints, { x: lx, y: ly });
700
+ edgeLabelPositions.set(edge.id, onPath);
554
701
  }
555
702
 
556
703
  // For orthogonal routing with preserved positions, the offset can distort
@@ -599,15 +746,17 @@ export async function computeElkLayout(
599
746
  }
600
747
  }
601
748
 
602
- // Process nodes (update positions if not preserving)
749
+ // Process nodes (update positions if not preserving). Grouped children use
750
+ // parent-relative coords (React Flow child semantics); everything else uses
751
+ // absolute coords.
603
752
  const resultNodes = preserveNodePositions
604
753
  ? nodes
605
754
  : nodes.map((node) => {
606
- const elkNode = layoutedGraph.children?.find((n: ElkNode) => n.id === node.id);
607
- if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) {
755
+ const rel = elkRelativePositions.get(node.id);
756
+ if (rel) {
608
757
  return {
609
758
  ...node,
610
- position: { x: elkNode.x, y: elkNode.y },
759
+ position: { x: rel.x, y: rel.y },
611
760
  };
612
761
  }
613
762
  return node;
@@ -618,6 +767,7 @@ export async function computeElkLayout(
618
767
  edgePaths,
619
768
  edgeLabelPositions,
620
769
  edgePathPoints,
770
+ groupBounds,
621
771
  };
622
772
  }
623
773