@principal-ai/subsystems-react 0.35.7 → 0.36.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 (46) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
  6. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
  7. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
  8. package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
  9. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
  10. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  12. package/dist/subsystem/SubsystemComponentGraph.js +9 -5
  13. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  14. package/dist/subsystem/graphChrome.d.ts +15 -0
  15. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  16. package/dist/subsystem/graphChrome.js +17 -0
  17. package/dist/subsystem/graphChrome.js.map +1 -1
  18. package/dist/subsystem/model.d.ts +26 -0
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +90 -2
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/subsystem/nodes.d.ts.map +1 -1
  23. package/dist/subsystem/nodes.js +12 -9
  24. package/dist/subsystem/nodes.js.map +1 -1
  25. package/dist/utils/elkLayout.d.ts +45 -0
  26. package/dist/utils/elkLayout.d.ts.map +1 -1
  27. package/dist/utils/elkLayout.js +114 -84
  28. package/dist/utils/elkLayout.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/graphify/signature.test.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  33. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  34. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  35. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  36. package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
  37. package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
  38. package/src/subsystem/graphChrome.tsx +20 -0
  39. package/src/subsystem/model.test.ts +91 -0
  40. package/src/subsystem/model.ts +94 -2
  41. package/src/subsystem/nodes.tsx +12 -9
  42. package/src/utils/elkLayout.test.ts +117 -3
  43. package/src/utils/elkLayout.ts +150 -81
  44. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  45. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  46. /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
@@ -80,6 +80,16 @@ export interface ElkLayoutOptions {
80
80
  * Members reference their immediate parent via React Flow `parentId`.
81
81
  */
82
82
  groups?: Array<{ id: string; memberIds: string[]; parentId?: string; minWidth?: number }>;
83
+
84
+ /**
85
+ * Keep groups that hold a single leaf instead of dropping them and
86
+ * promoting their member to the parent. A one-child group is still a real
87
+ * boundary — a process with a single module, or a process whose components
88
+ * carry no module — so callers that own the grouping semantics opt in here.
89
+ * Callers must ensure each leaf appears in at most one group, or ELK throws
90
+ * on the duplicate. @default false
91
+ */
92
+ keepSingletonGroups?: boolean;
83
93
  }
84
94
 
85
95
  /** Result of ELK layout computation */
@@ -341,6 +351,126 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
341
351
  return baseOptions;
342
352
  }
343
353
 
354
+ /** A group definition accepted by {@link planCompoundGroups}. */
355
+ export interface CompoundGroupDef {
356
+ id: string;
357
+ memberIds: string[];
358
+ minWidth?: number;
359
+ }
360
+
361
+ /** Which groups ELK builds, and which it drops. */
362
+ export interface CompoundGroupPlan {
363
+ /**
364
+ * Built groups in build order — a group's children always appear before it,
365
+ * so callers can map ids to shells in one forward pass.
366
+ */
367
+ built: Array<{ id: string; childIds: string[]; minWidth?: number }>;
368
+ /**
369
+ * Dropped groups. Their members are promoted into the nearest built
370
+ * ancestor, so callers must clear those members' `parentId`.
371
+ */
372
+ skipped: string[];
373
+ }
374
+
375
+ /**
376
+ * Decide which compound groups survive, independently of the ELK runtime.
377
+ *
378
+ * Groups resolve bottom-up: a group is ready once every member is a leaf or an
379
+ * already-resolved group. A group is dropped when it would hold nothing, or —
380
+ * unless `keepSingletonGroups` — when it holds a single leaf, since a frame
381
+ * wrapped around one box reads worse than the box alone. Callers that keep
382
+ * singletons own the grouping semantics and must guarantee each leaf appears
383
+ * in at most one group, or ELK throws on the duplicate.
384
+ *
385
+ * Pure so the skip rules are testable without an ELK worker.
386
+ */
387
+ export function planCompoundGroups(
388
+ groupDefs: CompoundGroupDef[],
389
+ leafIds: Iterable<string>,
390
+ keepSingletonGroups = false,
391
+ ): CompoundGroupPlan {
392
+ const leaves = new Set(leafIds);
393
+ const byId = new Map(groupDefs.map((g) => [g.id, g]));
394
+ const built: CompoundGroupPlan['built'] = [];
395
+ const skipped = new Set<string>();
396
+ const pending = [...groupDefs];
397
+
398
+ /** Leaves below a group, counting through nested groups. */
399
+ const leafDescendantCount = (memberIds: string[]): number => {
400
+ let n = 0;
401
+ for (const mid of memberIds) {
402
+ if (leaves.has(mid)) n += 1;
403
+ else {
404
+ const g = byId.get(mid);
405
+ if (g) n += leafDescendantCount(g.memberIds);
406
+ }
407
+ }
408
+ return n;
409
+ };
410
+
411
+ while (pending.length > 0) {
412
+ let progress = false;
413
+ for (let i = pending.length - 1; i >= 0; i--) {
414
+ const g = pending[i]!;
415
+ const childIds: string[] = [];
416
+ let ready = true;
417
+ for (const mid of g.memberIds) {
418
+ if (leaves.has(mid)) {
419
+ childIds.push(mid);
420
+ continue;
421
+ }
422
+ if (skipped.has(mid)) {
423
+ // Promote a dropped group's members into this parent.
424
+ const dropped = byId.get(mid);
425
+ if (!dropped) {
426
+ ready = false;
427
+ break;
428
+ }
429
+ for (const sm of dropped.memberIds) {
430
+ if (leaves.has(sm) || built.some((b) => b.id === sm)) childIds.push(sm);
431
+ else if (!skipped.has(sm)) {
432
+ ready = false;
433
+ break;
434
+ }
435
+ }
436
+ if (!ready) break;
437
+ continue;
438
+ }
439
+ if (built.some((b) => b.id === mid)) {
440
+ childIds.push(mid);
441
+ continue;
442
+ }
443
+ if (byId.has(mid)) {
444
+ ready = false;
445
+ break;
446
+ }
447
+ // Unknown id — ignored (external stubs etc. may be absent).
448
+ }
449
+ if (!ready) continue;
450
+
451
+ pending.splice(i, 1);
452
+ progress = true;
453
+
454
+ if (
455
+ childIds.length < 1 ||
456
+ (!keepSingletonGroups && leafDescendantCount(g.memberIds) < 2)
457
+ ) {
458
+ skipped.add(g.id);
459
+ continue;
460
+ }
461
+
462
+ built.push({ id: g.id, childIds, minWidth: g.minWidth });
463
+ }
464
+ if (!progress) {
465
+ // Cycle or unresolved refs — leave the rest unbuilt.
466
+ for (const g of pending) skipped.add(g.id);
467
+ break;
468
+ }
469
+ }
470
+
471
+ return { built, skipped: [...skipped] };
472
+ }
473
+
344
474
  /**
345
475
  * Compute ELK layout for nodes and edges
346
476
  *
@@ -354,7 +484,7 @@ export async function computeElkLayout(
354
484
  edges: Edge[],
355
485
  options: ElkLayoutOptions = {}
356
486
  ): Promise<ElkLayoutResult> {
357
- const { preserveNodePositions = true } = options;
487
+ const { preserveNodePositions = true, keepSingletonGroups = false } = options;
358
488
  const edgeLabels = options.edgeLabels;
359
489
  const direction = options.direction ?? 'RIGHT';
360
490
 
@@ -516,89 +646,28 @@ export async function computeElkLayout(
516
646
  'elk.spacing.nodeNode': '40',
517
647
  };
518
648
 
519
- /** Descendant leaf count for singleton checks (nested groups count through). */
520
- const leafDescendantCount = (memberIds: string[]): number => {
521
- let n = 0;
522
- for (const mid of memberIds) {
523
- if (elkById.has(mid)) n += 1;
524
- else {
525
- const g = groupById.get(mid);
526
- if (g) n += leafDescendantCount(g.memberIds);
527
- }
528
- }
529
- return n;
530
- };
531
-
649
+ const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
650
+ const skippedGroups = new Set(plan.skipped);
532
651
  const builtGroups = new Map<string, ElkNode>();
533
- const skippedGroups = new Set<string>();
534
- const pending = [...groupDefs];
535
- // Build bottom-up: a group is ready when every member is a leaf or an
536
- // already-built / skipped group. Skipped groups promote their members.
537
- while (pending.length > 0) {
538
- let progress = false;
539
- for (let i = pending.length - 1; i >= 0; i--) {
540
- const g = pending[i]!;
541
- const childNodes: ElkNode[] = [];
542
- let ready = true;
543
- for (const mid of g.memberIds) {
544
- if (elkById.has(mid)) {
545
- childNodes.push(elkById.get(mid)!);
546
- continue;
547
- }
548
- if (skippedGroups.has(mid)) {
549
- // Promote skipped group's members into this parent.
550
- const skipped = groupById.get(mid);
551
- if (!skipped) {
552
- ready = false;
553
- break;
554
- }
555
- for (const sm of skipped.memberIds) {
556
- if (elkById.has(sm)) childNodes.push(elkById.get(sm)!);
557
- else if (builtGroups.has(sm)) childNodes.push(builtGroups.get(sm)!);
558
- else if (!skippedGroups.has(sm)) {
559
- ready = false;
560
- break;
561
- }
562
- }
563
- if (!ready) break;
564
- continue;
565
- }
566
- if (builtGroups.has(mid)) {
567
- childNodes.push(builtGroups.get(mid)!);
568
- continue;
569
- }
570
- if (groupById.has(mid)) {
571
- ready = false;
572
- break;
573
- }
574
- // Unknown id — ignore (external stubs etc. may be absent).
575
- }
576
- if (!ready) continue;
577
-
578
- pending.splice(i, 1);
579
- progress = true;
580
-
581
- // Skip frames with fewer than 2 leaf descendants — promote children up.
582
- if (leafDescendantCount(g.memberIds) < 2 || childNodes.length < 1) {
583
- skippedGroups.add(g.id);
584
- continue;
652
+ for (const g of plan.built) {
653
+ const children: ElkNode[] = [];
654
+ for (const cid of g.childIds) {
655
+ const leaf = elkById.get(cid);
656
+ if (leaf) children.push(leaf);
657
+ else {
658
+ const nested = builtGroups.get(cid);
659
+ if (nested) children.push(nested);
585
660
  }
586
-
587
- builtGroups.set(g.id, {
588
- id: g.id,
589
- children: childNodes,
590
- layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
591
- ...compoundLayoutOptions,
592
- 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
593
- 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
594
- },
595
- });
596
- }
597
- if (!progress) {
598
- // Cycle or unresolved refs — leave remaining groups unbuilt.
599
- for (const g of pending) skippedGroups.add(g.id);
600
- break;
601
661
  }
662
+ builtGroups.set(g.id, {
663
+ id: g.id,
664
+ children,
665
+ layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
666
+ ...compoundLayoutOptions,
667
+ 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
668
+ 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
669
+ },
670
+ });
602
671
  }
603
672
 
604
673
  const groupedLeafIds = new Set<string>();