@principal-ai/subsystems-react 0.53.0 → 0.53.2

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 (34) hide show
  1. package/dist/subsystem/C4Graph.d.ts +91 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +672 -180
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +36 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +45 -10
  8. package/dist/subsystem/C4NodeCard.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts.map +1 -1
  10. package/dist/subsystem/IssueList.js +14 -5
  11. package/dist/subsystem/IssueList.js.map +1 -1
  12. package/dist/subsystem/c4.d.ts +27 -3
  13. package/dist/subsystem/c4.d.ts.map +1 -1
  14. package/dist/subsystem/c4.js +48 -0
  15. package/dist/subsystem/c4.js.map +1 -1
  16. package/dist/utils/elkLayout.d.ts +68 -8
  17. package/dist/utils/elkLayout.d.ts.map +1 -1
  18. package/dist/utils/elkLayout.js +95 -14
  19. package/dist/utils/elkLayout.js.map +1 -1
  20. package/package.json +2 -1
  21. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  22. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  23. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  24. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +132 -0
  25. package/src/subsystem/C4Graph.groups.test.ts +217 -0
  26. package/src/subsystem/C4Graph.morph.test.tsx +208 -0
  27. package/src/subsystem/C4Graph.tsx +803 -226
  28. package/src/subsystem/C4NodeCard.test.ts +6 -3
  29. package/src/subsystem/C4NodeCard.tsx +92 -20
  30. package/src/subsystem/IssueList.category.test.ts +39 -0
  31. package/src/subsystem/IssueList.tsx +16 -5
  32. package/src/subsystem/c4.ts +74 -3
  33. package/src/utils/elkLayout.test.ts +131 -0
  34. package/src/utils/elkLayout.ts +133 -16
@@ -126,7 +126,7 @@ export interface ElkLayoutOptions {
126
126
  * nests this group under another group; omit for a root-level frame.
127
127
  * Members reference their immediate parent via React Flow `parentId`.
128
128
  */
129
- groups?: Array<{ id: string; memberIds: string[]; parentId?: string; minWidth?: number }>;
129
+ groups?: CompoundGroupDef[];
130
130
 
131
131
  /**
132
132
  * Keep groups that hold a single leaf instead of dropping them and
@@ -150,9 +150,16 @@ export interface ElkLayoutOptions {
150
150
  * decoration drawn around wherever the members landed.
151
151
  *
152
152
  * Only for groups whose members need no parent-relative positioning (C4's
153
- * system frame). @default false
153
+ * system frame).
154
+ *
155
+ * A set names specific groups to flatten, leaving the rest as real ELK
156
+ * parents — the drill-down needs this, because the opened container must be
157
+ * *sized by ELK to fit its nested components* while the system frame around
158
+ * it stays decorative. Descendants of a flattened group are flattened too.
159
+ *
160
+ * @default false
154
161
  */
155
- flatGroups?: boolean;
162
+ flatGroups?: boolean | ReadonlySet<string>;
156
163
 
157
164
  /**
158
165
  * ELK partition per node id. Nodes are banded by `partition` — ELK places all
@@ -188,6 +195,46 @@ export interface ElkLayoutResult {
188
195
  absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
189
196
  }
190
197
 
198
+ /**
199
+ * A node's rect in absolute (root) coordinates — the space `edgePathPoints` and
200
+ * the synthesized frame bounds are in.
201
+ *
202
+ * `absoluteRects` is the only map that is absolute for *every* node ELK placed.
203
+ * The alternatives each have a hole: `result.nodes` is parent-relative for a real
204
+ * group's children and has **no entry at all** for the group itself, and
205
+ * `groupBounds` is parent-relative for a nested group (and is the only source for
206
+ * a flattened group, which never enters the ELK tree).
207
+ */
208
+ export function absoluteBoundsOf(
209
+ result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
210
+ id: string,
211
+ ): { x: number; y: number; width: number; height: number } | undefined {
212
+ return result.absoluteRects.get(id) ?? result.groupBounds.get(id);
213
+ }
214
+
215
+ /**
216
+ * Where a node belongs in React Flow coordinates: a root node keeps its absolute
217
+ * position, a child is expressed relative to its parent.
218
+ *
219
+ * Convert in exactly one place, from the absolute space, subtracting the parent's
220
+ * absolute origin **once**. The two failure modes here are silent — a child
221
+ * double-offset by its parent's origin lands outside its own card, and a compound
222
+ * parent with no `result.nodes` entry silently keeps the authored position it was
223
+ * built with, so the container drifts away from its siblings, its frame and its
224
+ * edges while everything around it uses ELK's answer.
225
+ */
226
+ export function flowPositionOf(
227
+ result: Pick<ElkLayoutResult, 'absoluteRects' | 'groupBounds'>,
228
+ id: string,
229
+ parentId?: string,
230
+ ): { x: number; y: number } | undefined {
231
+ const abs = result.absoluteRects.get(id);
232
+ if (!abs) return undefined;
233
+ if (!parentId) return { x: abs.x, y: abs.y };
234
+ const origin = absoluteBoundsOf(result, parentId);
235
+ return origin ? { x: abs.x - origin.x, y: abs.y - origin.y } : { x: abs.x, y: abs.y };
236
+ }
237
+
191
238
  /** Point in 2D space */
192
239
  export interface Point {
193
240
  x: number;
@@ -519,7 +566,33 @@ export function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
519
566
  export interface CompoundGroupDef {
520
567
  id: string;
521
568
  memberIds: string[];
569
+ /** Nested inside another group. Omitted for a root-level group. */
570
+ parentId?: string;
522
571
  minWidth?: number;
572
+ /**
573
+ * This id is *also* a drawn node — a card that hosts a group. Set it when the
574
+ * element is drawn as a card but must be laid out as a parent: the drill-down's
575
+ * opened container.
576
+ *
577
+ * The node and the group are not alternatives. The node is how the group is
578
+ * *drawn*, and the group is how it is *laid out* — so this says which one wins
579
+ * when a member id could be either. Without it the frame takes the bare leaf box
580
+ * and the group is built but never referenced, leaving its components laid out
581
+ * nowhere: the container reads as closed, at its 250x150 card size, whatever
582
+ * ELK was told about its children.
583
+ */
584
+ drawnAsCard?: boolean;
585
+ /**
586
+ * Space reserved above the first child, in px. Set it when the parent draws
587
+ * its own chrome at the top (C4's grown container draws a label slot and a
588
+ * technology row) so ELK still owns the fit — it pads the parent by this and
589
+ * the children land below the chrome rather than under it.
590
+ *
591
+ * Note: there is deliberately no per-group partition. `partitionByNode` bands
592
+ * leaves, and ELK derives a compound node's band from its children, so a
593
+ * partition set on the parent itself is ignored.
594
+ */
595
+ padTop?: number;
523
596
  }
524
597
 
525
598
  /** Which groups ELK builds, and which it drops. */
@@ -528,7 +601,7 @@ export interface CompoundGroupPlan {
528
601
  * Built groups in build order — a group's children always appear before it,
529
602
  * so callers can map ids to shells in one forward pass.
530
603
  */
531
- built: Array<{ id: string; childIds: string[]; minWidth?: number }>;
604
+ built: Array<{ id: string; childIds: string[]; minWidth?: number; padTop?: number }>;
532
605
  /**
533
606
  * Dropped groups. Their members are promoted into the nearest built
534
607
  * ancestor, so callers must clear those members' `parentId`.
@@ -623,7 +696,14 @@ export function planCompoundGroups(
623
696
  continue;
624
697
  }
625
698
 
626
- built.push({ id: g.id, childIds, minWidth: g.minWidth });
699
+ built.push({
700
+ id: g.id,
701
+ childIds,
702
+ minWidth: g.minWidth,
703
+ // Carried through, not just validated here: the caller turns each of
704
+ // these into ELK layout options on the parent node.
705
+ padTop: g.padTop,
706
+ });
627
707
  }
628
708
  if (!progress) {
629
709
  // Cycle or unresolved refs — leave the rest unbuilt.
@@ -860,32 +940,69 @@ export async function computeElkLayout(
860
940
  const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
861
941
  const skippedGroups = new Set(plan.skipped);
862
942
  const builtGroups = new Map<string, ElkNode>();
943
+ // Ids that are drawn as a card *and* laid out as a group. Such an id is in
944
+ // `elkById` too, so this is what says which of the two an enclosing frame takes.
945
+ const drawnAsCardIds = new Set(groupDefs.filter((g) => g.drawnAsCard).map((g) => g.id));
863
946
  // Flat groups are not ELK parents at all: their members lay out at the root,
864
947
  // and the frame is synthesized from the members' bounds after layout. This
865
948
  // keeps every edge on one spacing instead of inheriting a nested frame's.
866
949
  const flatGroupIds = new Set<string>();
867
- if (flatGroups) {
950
+ if (flatGroups === true) {
868
951
  for (const g of plan.built) flatGroupIds.add(g.id);
952
+ } else if (flatGroups) {
953
+ for (const g of plan.built) if (flatGroups.has(g.id)) flatGroupIds.add(g.id);
954
+ // A group inside a flat frame can't be an ELK parent either — its members
955
+ // lay out at the root alongside the frame's other members.
956
+ let grew = true;
957
+ while (grew) {
958
+ grew = false;
959
+ for (const g of plan.built) {
960
+ if (flatGroupIds.has(g.id)) continue;
961
+ const parentId = groupById.get(g.id)?.parentId;
962
+ if (parentId && flatGroupIds.has(parentId)) {
963
+ flatGroupIds.add(g.id);
964
+ grew = true;
965
+ }
966
+ }
967
+ }
869
968
  }
870
969
  for (const g of plan.built) {
871
- if (flatGroups) break;
970
+ if (flatGroupIds.has(g.id)) continue;
872
971
  const children: ElkNode[] = [];
873
972
  for (const cid of g.childIds) {
874
- const leaf = elkById.get(cid);
875
- if (leaf) children.push(leaf);
973
+ // A member id can be a drawn node *and* a built group. Preferring the leaf —
974
+ // which is what this did unconditionally — silently drops the group: the
975
+ // frame ends up holding a bare 250x150 box, nothing references the group, and
976
+ // its components are laid out nowhere. So the card ids take the group, and
977
+ // everything else keeps the leaf. (For those, `builtGroups` has no entry
978
+ // anyway: flat groups are skipped above, and a nested group has no node.)
979
+ const nested = builtGroups.get(cid);
980
+ if (nested && drawnAsCardIds.has(cid)) children.push(nested);
876
981
  else {
877
- const nested = builtGroups.get(cid);
878
- if (nested) children.push(nested);
982
+ const leaf = elkById.get(cid);
983
+ if (leaf) children.push(leaf);
984
+ else if (nested) children.push(nested);
879
985
  }
880
986
  }
987
+ const sizeOptions: Record<string, string> = {};
988
+ if (g.minWidth != null) {
989
+ // MINIMUM_SIZE, not a forced size: ELK grows the parent past it when the
990
+ // children need more, but never shrinks it below it.
991
+ sizeOptions['elk.nodeSize.constraints'] = 'MINIMUM_SIZE';
992
+ sizeOptions['elk.nodeSize.minimum'] = `(${g.minWidth},0)`;
993
+ }
994
+ if (g.padTop != null) {
995
+ // Reserving the parent's own chrome. Children then start below it, and
996
+ // the parent's height still comes from ELK's fit.
997
+ sizeOptions['elk.padding'] = `[top=${g.padTop},left=12,bottom=12,right=12]`;
998
+ }
881
999
  builtGroups.set(g.id, {
882
1000
  id: g.id,
883
1001
  children,
884
- layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
885
- ...compoundLayoutOptions,
886
- 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
887
- 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
888
- },
1002
+ layoutOptions:
1003
+ Object.keys(sizeOptions).length > 0
1004
+ ? { ...compoundLayoutOptions, ...sizeOptions }
1005
+ : compoundLayoutOptions,
889
1006
  });
890
1007
  }
891
1008