@principal-ai/principal-view-react 0.15.5 → 0.15.7

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.
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * React hook for sequence diagram layout
3
3
  *
4
- * Computes swimlane-based positioning for events based on their namespaces.
4
+ * Computes swimlane-based positioning for events. Lanes default to the first
5
+ * dotted segment of each event name. Callers can drill deeper by passing
6
+ * `openedNamespaces`: any prefix listed there has its events pushed one level
7
+ * deeper, so its children appear as their own lanes instead of being grouped
8
+ * under the parent.
5
9
  */
6
10
 
7
11
  import { useMemo } from 'react';
@@ -55,40 +59,36 @@ export interface SequenceEdge {
55
59
  * Swimlane information computed from events
56
60
  */
57
61
  export interface Swimlane {
58
- /** Namespace identifier */
62
+ /** Namespace identifier for this lane */
59
63
  namespace: string;
60
- /** Display label for the lane header */
64
+ /** Display label for the lane header (last segment of the namespace) */
61
65
  label: string;
62
66
  /** X position of the lane center */
63
67
  x: number;
64
- /** Parent namespace (for hierarchical lanes) */
68
+ /** Parent namespace (one segment shallower), if any */
65
69
  parentNamespace?: string;
66
- /** Whether this lane is collapsed */
67
- isCollapsed: boolean;
68
- /** Child namespaces */
69
- children: string[];
70
- /** Events in this lane */
70
+ /** Whether this lane's namespace is in `openedNamespaces` (its events have been drilled deeper) */
71
+ isOpened: boolean;
72
+ /** Whether the immediate parent namespace is opened (this lane only exists because its parent was drilled into) */
73
+ isParentOpened: boolean;
74
+ /** Whether any event extends strictly past this lane's namespace (so opening would split it further) */
75
+ canExpand: boolean;
76
+ /** Events directly assigned to this lane */
71
77
  eventIds: string[];
72
78
  }
73
79
 
74
- /**
75
- * Namespace extraction strategy
76
- */
77
- export type NamespaceStrategy =
78
- | 'first' // First segment only (auth.validation.started -> auth)
79
- | 'all-but-last' // All but last segment (auth.validation.started -> auth.validation)
80
- | number // Specific depth (2 -> auth.validation)
81
- | ((name: string) => string); // Custom function
82
-
83
80
  /**
84
81
  * Options for sequence layout
85
82
  */
86
83
  export interface UseSequenceLayoutOptions {
87
84
  /**
88
- * How to extract namespace from event name
89
- * @default 'all-but-last'
85
+ * Namespace prefixes whose events should be drilled one segment deeper.
86
+ * Pass an array or a Set. Each entry is a dotted prefix (e.g. `auth` or
87
+ * `auth.user`). When listed, events under that prefix land in lanes one
88
+ * level deeper than the prefix, instead of all sharing the prefix lane.
89
+ * Drilling is recursive: list both `auth` and `auth.user` to drill twice.
90
90
  */
91
- namespaceStrategy?: NamespaceStrategy;
91
+ openedNamespaces?: string[] | Set<string>;
92
92
 
93
93
  /**
94
94
  * Width of each swimlane
@@ -125,11 +125,23 @@ export interface UseSequenceLayoutOptions {
125
125
  * @default 14
126
126
  */
127
127
  nodeHeight?: number;
128
+ }
128
129
 
129
- /**
130
- * Namespaces to collapse (show as single lane)
131
- */
132
- collapsedNamespaces?: string[];
130
+ /**
131
+ * A header cell for an opened ancestor namespace, sitting above the leaf
132
+ * lanes it groups. Rendered as a row in the header strip.
133
+ */
134
+ export interface ParentHeader {
135
+ /** Full ancestor namespace (always in `openedNamespaces`) */
136
+ namespace: string;
137
+ /** Last segment, for display */
138
+ label: string;
139
+ /** Center x of the cell */
140
+ x: number;
141
+ /** Total span width across the leaf lanes underneath */
142
+ width: number;
143
+ /** 1-based depth in the header strip (1 = topmost row) */
144
+ depth: number;
133
145
  }
134
146
 
135
147
  /**
@@ -140,8 +152,15 @@ export interface UseSequenceLayoutResult {
140
152
  nodes: Node[];
141
153
  /** Edges for React Flow */
142
154
  edges: Edge[];
143
- /** Computed swimlane information */
155
+ /** Leaf swimlanes each gets a lifeline and a leaf header row */
144
156
  swimlanes: Swimlane[];
157
+ /**
158
+ * Header cells for opened ancestor namespaces. Stack above the leaf
159
+ * headers; depth 1 sits at the top of the header strip.
160
+ */
161
+ parentHeaders: ParentHeader[];
162
+ /** Number of header rows (= max leaf depth). At least 1. */
163
+ headerRows: number;
145
164
  /** Total width of the diagram */
146
165
  totalWidth: number;
147
166
  /** Total height of the diagram */
@@ -149,52 +168,30 @@ export interface UseSequenceLayoutResult {
149
168
  }
150
169
 
151
170
  /**
152
- * Extract namespace from event name based on strategy
153
- */
154
- function extractNamespace(name: string, strategy: NamespaceStrategy): string {
155
- if (typeof strategy === 'function') {
156
- return strategy(name);
157
- }
158
-
159
- const segments = name.split('.');
160
-
161
- if (segments.length <= 1) {
162
- return name;
163
- }
164
-
165
- if (strategy === 'first') {
166
- return segments[0];
167
- }
168
-
169
- if (strategy === 'all-but-last') {
170
- return segments.slice(0, -1).join('.');
171
- }
172
-
173
- if (typeof strategy === 'number') {
174
- return segments.slice(0, strategy).join('.');
175
- }
176
-
177
- return segments.slice(0, -1).join('.');
178
- }
179
-
180
- /**
181
- * Get parent namespace (one level up)
171
+ * Resolve the lane (namespace prefix) for a given event name, given the set
172
+ * of opened namespaces. Walks segments from depth 1 outward, descending while
173
+ * the current prefix is opened, and stopping at the first prefix that isn't.
182
174
  */
183
- function getParentNamespace(namespace: string): string | undefined {
184
- const segments = namespace.split('.');
185
- if (segments.length <= 1) {
186
- return undefined;
175
+ function resolveLane(name: string, opened: Set<string>): string {
176
+ const segs = name.split('.');
177
+ let depth = 1;
178
+ while (depth < segs.length) {
179
+ const prefix = segs.slice(0, depth).join('.');
180
+ if (opened.has(prefix)) {
181
+ depth++;
182
+ } else {
183
+ break;
184
+ }
187
185
  }
188
- return segments.slice(0, -1).join('.');
186
+ return segs.slice(0, depth).join('.');
189
187
  }
190
188
 
191
189
  /**
192
- * Hook for computing sequence diagram layout
190
+ * useSequenceLayout
193
191
  *
194
- * @param events - Events to layout
195
- * @param edges - Edges between events
196
- * @param options - Layout options
197
- * @returns Layout result with positioned nodes, edges, and swimlane info
192
+ * @param events - Events to lay out
193
+ * @param sequenceEdges - Edges connecting events
194
+ * @param options - Layout options (lane sizing, openedNamespaces)
198
195
  *
199
196
  * @example
200
197
  * ```tsx
@@ -207,7 +204,8 @@ function getParentNamespace(namespace: string): string | undefined {
207
204
  * [
208
205
  * { id: 'e1', fromEvent: '1', toEvent: '2' },
209
206
  * { id: 'e2', fromEvent: '2', toEvent: '3' },
210
- * ]
207
+ * ],
208
+ * { openedNamespaces: ['auth'] }, // drill `auth` to show validation/token as separate lanes
211
209
  * );
212
210
  * ```
213
211
  */
@@ -217,129 +215,162 @@ export function useSequenceLayout(
217
215
  options: UseSequenceLayoutOptions = {}
218
216
  ): UseSequenceLayoutResult {
219
217
  const {
220
- namespaceStrategy = 'all-but-last',
218
+ openedNamespaces,
221
219
  laneWidth = 250,
222
220
  laneGap = 0,
223
221
  eventSpacing = 80,
224
222
  headerHeight = 60,
225
- collapsedNamespaces = [],
226
223
  nodeWidth = 14,
227
224
  nodeHeight = 14,
228
225
  } = options;
229
226
 
227
+ // Normalize openedNamespaces into a stable string for memo dependency
228
+ const openedKey = useMemo(() => {
229
+ if (!openedNamespaces) return '';
230
+ const arr = Array.from(openedNamespaces);
231
+ arr.sort();
232
+ return arr.join('|');
233
+ }, [openedNamespaces]);
234
+
230
235
  return useMemo(() => {
231
236
  if (events.length === 0) {
232
237
  return {
233
238
  nodes: [],
234
239
  edges: [],
235
240
  swimlanes: [],
241
+ parentHeaders: [],
242
+ headerRows: 1,
236
243
  totalWidth: 0,
237
244
  totalHeight: 0,
238
245
  };
239
246
  }
240
247
 
241
- // Step 1: Extract namespaces and group events
242
- const eventNamespaces = new Map<string, string>();
243
- const namespaceEvents = new Map<string, string[]>();
248
+ const opened = new Set<string>(
249
+ openedKey ? openedKey.split('|') : []
250
+ );
244
251
 
245
- for (const event of events) {
246
- const namespace = extractNamespace(event.name, namespaceStrategy);
247
- eventNamespaces.set(event.id, namespace);
252
+ // Step 1: Resolve each event to a lane prefix
253
+ const eventLane = new Map<string, string>();
254
+ const laneEvents = new Map<string, string[]>();
248
255
 
249
- if (!namespaceEvents.has(namespace)) {
250
- namespaceEvents.set(namespace, []);
256
+ for (const event of events) {
257
+ const lane = resolveLane(event.name, opened);
258
+ eventLane.set(event.id, lane);
259
+ if (!laneEvents.has(lane)) {
260
+ laneEvents.set(lane, []);
251
261
  }
252
- namespaceEvents.get(namespace)!.push(event.id);
262
+ laneEvents.get(lane)!.push(event.id);
253
263
  }
254
264
 
255
- // Step 2: Build namespace hierarchy and determine visible lanes
256
- const allNamespaces = Array.from(namespaceEvents.keys()).sort();
257
- const collapsedSet = new Set(collapsedNamespaces);
258
-
259
- // For collapsed namespaces, merge children into parent
260
- const visibleNamespaces: string[] = [];
261
- const namespaceToVisible = new Map<string, string>();
262
-
263
- for (const ns of allNamespaces) {
264
- // Check if any ancestor is collapsed
265
- let visibleNs = ns;
266
- let current: string | undefined = ns;
267
-
268
- while (current) {
269
- if (collapsedSet.has(current)) {
270
- visibleNs = current;
271
- }
272
- current = getParentNamespace(current);
265
+ // Step 2: Order lanes alphabetically (parents naturally sort before children)
266
+ const laneNames = Array.from(laneEvents.keys()).sort();
267
+
268
+ // canExpand is meaningful only when opening would actually fork the lane
269
+ // into multiple child lanes. If every event in the lane would drill into
270
+ // the same child, drilling is a relabel — hide the chevron.
271
+ const eventsById = new Map<string, SequenceEvent>();
272
+ for (const event of events) eventsById.set(event.id, event);
273
+
274
+ const canExpandLane = (laneNs: string, eventIds: string[]): boolean => {
275
+ const augmented = new Set(opened);
276
+ augmented.add(laneNs);
277
+ const seen = new Set<string>();
278
+ for (const eid of eventIds) {
279
+ const event = eventsById.get(eid);
280
+ if (!event) continue;
281
+ seen.add(resolveLane(event.name, augmented));
282
+ if (seen.size > 1) return true;
273
283
  }
284
+ return false;
285
+ };
274
286
 
275
- namespaceToVisible.set(ns, visibleNs);
276
-
277
- if (!visibleNamespaces.includes(visibleNs)) {
278
- visibleNamespaces.push(visibleNs);
279
- }
280
- }
281
-
282
- // Step 3: Create swimlanes with positions
283
- const swimlanes: Swimlane[] = visibleNamespaces.map((namespace, index) => {
287
+ // Step 3: Build Swimlane records
288
+ const swimlanes: Swimlane[] = laneNames.map((namespace, index) => {
284
289
  const x = index * (laneWidth + laneGap) + laneWidth / 2;
285
-
286
- // Find all events that map to this visible namespace
287
- const eventIds: string[] = [];
288
- for (const [eventId, eventNs] of eventNamespaces) {
289
- if (namespaceToVisible.get(eventNs) === namespace) {
290
- eventIds.push(eventId);
291
- }
292
- }
293
-
294
- // Find children (namespaces that have this as parent)
295
- const children = allNamespaces.filter(
296
- (ns) => getParentNamespace(ns) === namespace
297
- );
290
+ const segs = namespace.split('.');
291
+ const parentNamespace =
292
+ segs.length > 1 ? segs.slice(0, -1).join('.') : undefined;
293
+ const eventIds = laneEvents.get(namespace)!;
298
294
 
299
295
  return {
300
296
  namespace,
301
- label: namespace.split('.').pop() || namespace,
297
+ label: segs[segs.length - 1] || namespace,
302
298
  x,
303
- parentNamespace: getParentNamespace(namespace),
304
- isCollapsed: collapsedSet.has(namespace),
305
- children,
299
+ parentNamespace,
300
+ isOpened: opened.has(namespace),
301
+ isParentOpened: parentNamespace ? opened.has(parentNamespace) : false,
302
+ canExpand: canExpandLane(namespace, eventIds),
306
303
  eventIds,
307
304
  };
308
305
  });
309
306
 
310
- // Create lookup for swimlane by namespace
311
- const swimlaneByNamespace = new Map<string, Swimlane>();
307
+ const laneByNamespace = new Map<string, Swimlane>();
312
308
  for (const lane of swimlanes) {
313
- swimlaneByNamespace.set(lane.namespace, lane);
309
+ laneByNamespace.set(lane.namespace, lane);
314
310
  }
315
311
 
316
- // Step 4: Position events using global time layers
317
- // Each event gets a Y position based on its index in the overall sequence
318
- // This creates horizontal "time layers" across all swimlanes
319
- const nodes: Node[] = [];
312
+ // Step 3b: Build parent header cells for opened ancestors. For every leaf
313
+ // lane at depth > 1, walk depths 1..d-1 and aggregate the leaf x-extent
314
+ // under each ancestor.
315
+ const ancestorBounds = new Map<
316
+ string,
317
+ { xMin: number; xMax: number; depth: number }
318
+ >();
319
+ for (const lane of swimlanes) {
320
+ const segs = lane.namespace.split('.');
321
+ const left = lane.x - laneWidth / 2;
322
+ const right = lane.x + laneWidth / 2;
323
+ for (let d = 1; d < segs.length; d++) {
324
+ const ancestorNs = segs.slice(0, d).join('.');
325
+ const existing = ancestorBounds.get(ancestorNs);
326
+ if (existing) {
327
+ existing.xMin = Math.min(existing.xMin, left);
328
+ existing.xMax = Math.max(existing.xMax, right);
329
+ } else {
330
+ ancestorBounds.set(ancestorNs, { xMin: left, xMax: right, depth: d });
331
+ }
332
+ }
333
+ }
320
334
 
335
+ const parentHeaders: ParentHeader[] = Array.from(ancestorBounds.entries())
336
+ .map(([namespace, { xMin, xMax, depth }]) => {
337
+ const segs = namespace.split('.');
338
+ return {
339
+ namespace,
340
+ label: segs[segs.length - 1] || namespace,
341
+ x: (xMin + xMax) / 2,
342
+ width: xMax - xMin,
343
+ depth,
344
+ };
345
+ })
346
+ .sort((a, b) => a.depth - b.depth || a.x - b.x);
347
+
348
+ const headerRows = swimlanes.reduce(
349
+ (max, lane) => Math.max(max, lane.namespace.split('.').length),
350
+ 1
351
+ );
352
+ const totalHeaderHeight = headerHeight * headerRows;
353
+
354
+ // Step 4: Position events on global time layers, below the full header strip
355
+ const nodes: Node[] = [];
321
356
  for (let i = 0; i < events.length; i++) {
322
357
  const event = events[i];
323
- const originalNamespace = eventNamespaces.get(event.id)!;
324
- const visibleNamespace = namespaceToVisible.get(originalNamespace)!;
325
- const lane = swimlaneByNamespace.get(visibleNamespace)!;
358
+ const laneNs = eventLane.get(event.id)!;
359
+ const lane = laneByNamespace.get(laneNs)!;
326
360
 
327
- // Global Y position based on event order (time layer)
328
- // Start first event closer to header with small offset
329
- const y = headerHeight + 40 + i * eventSpacing;
361
+ const y = totalHeaderHeight + 40 + i * eventSpacing;
330
362
 
331
363
  nodes.push({
332
364
  id: event.id,
333
365
  type: 'sequenceMarker',
334
366
  position: {
335
367
  x: lane.x - nodeWidth / 2,
336
- y: y - nodeHeight / 2, // Center vertically on the time layer
368
+ y: y - nodeHeight / 2,
337
369
  },
338
370
  data: {
339
371
  label: event.label || event.name.split('.').pop() || event.name,
340
372
  fullName: event.name,
341
- namespace: originalNamespace,
342
- visibleNamespace,
373
+ namespace: laneNs,
343
374
  timeLayer: i,
344
375
  isMoveEvent: event.moveEvent === true,
345
376
  sourcePath: event.sourcePath,
@@ -352,27 +383,24 @@ export function useSequenceLayout(
352
383
  });
353
384
  }
354
385
 
355
- // Step 5: Create edges - one per event, showing how to get to the NEXT event
356
- // Each edge looks forward to determine what to render
386
+ // Step 5: Edges one per event, looking ahead to the next
357
387
  const edges: Edge[] = [];
358
-
359
388
  for (let i = 0; i < events.length; i++) {
360
389
  const currentEvent = events[i];
361
- const currentNamespace = eventNamespaces.get(currentEvent.id)!;
362
- const currentVisibleNs = namespaceToVisible.get(currentNamespace)!;
363
- const currentLane = swimlaneByNamespace.get(currentVisibleNs)!;
390
+ const currentLaneNs = eventLane.get(currentEvent.id)!;
391
+ const currentLane = laneByNamespace.get(currentLaneNs)!;
364
392
 
365
- // Look at the next event (if any)
366
393
  if (i < events.length - 1) {
367
394
  const nextEvent = events[i + 1];
368
- const nextNamespace = eventNamespaces.get(nextEvent.id)!;
369
- const nextVisibleNs = namespaceToVisible.get(nextNamespace)!;
370
- const nextLane = swimlaneByNamespace.get(nextVisibleNs)!;
395
+ const nextLaneNs = eventLane.get(nextEvent.id)!;
396
+ const nextLane = laneByNamespace.get(nextLaneNs)!;
371
397
  const nextIsMoveEvent = nextEvent.moveEvent === true;
372
- const crossesLanes = currentVisibleNs !== nextVisibleNs;
398
+ const crossesLanes = currentLaneNs !== nextLaneNs;
373
399
 
374
- // Label is from the CURRENT event (the one creating this edge)
375
- const edgeLabel = currentEvent.label || currentEvent.name.split('.').pop() || currentEvent.name;
400
+ const edgeLabel =
401
+ currentEvent.label ||
402
+ currentEvent.name.split('.').pop() ||
403
+ currentEvent.name;
376
404
 
377
405
  edges.push({
378
406
  id: `edge-${currentEvent.id}-to-${nextEvent.id}`,
@@ -384,8 +412,8 @@ export function useSequenceLayout(
384
412
  labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
385
413
  data: {
386
414
  crossesLanes,
387
- sourceNamespace: currentNamespace,
388
- targetNamespace: nextNamespace,
415
+ sourceNamespace: currentLaneNs,
416
+ targetNamespace: nextLaneNs,
389
417
  isMoveEvent: nextIsMoveEvent,
390
418
  sourceEvent: currentEvent,
391
419
  targetEvent: nextEvent,
@@ -394,9 +422,11 @@ export function useSequenceLayout(
394
422
  },
395
423
  });
396
424
  } else {
397
- // Last event - render small activation bar to show it exists
398
425
  const currentIsMoveEvent = currentEvent.moveEvent === true;
399
- const edgeLabel = currentEvent.label || currentEvent.name.split('.').pop() || currentEvent.name;
426
+ const edgeLabel =
427
+ currentEvent.label ||
428
+ currentEvent.name.split('.').pop() ||
429
+ currentEvent.name;
400
430
 
401
431
  edges.push({
402
432
  id: `edge-${currentEvent.id}-end`,
@@ -408,8 +438,8 @@ export function useSequenceLayout(
408
438
  labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
409
439
  data: {
410
440
  crossesLanes: false,
411
- sourceNamespace: currentNamespace,
412
- targetNamespace: currentNamespace,
441
+ sourceNamespace: currentLaneNs,
442
+ targetNamespace: currentLaneNs,
413
443
  isMoveEvent: currentIsMoveEvent,
414
444
  sourceEvent: currentEvent,
415
445
  targetEvent: currentEvent,
@@ -425,15 +455,13 @@ export function useSequenceLayout(
425
455
  // Step 6: Compute total dimensions
426
456
  const totalWidth =
427
457
  swimlanes.length * laneWidth + (swimlanes.length - 1) * laneGap;
428
- // Total height based on number of events (time layers)
429
- const totalHeight = headerHeight + 40 + events.length * eventSpacing;
458
+ const totalHeight = totalHeaderHeight + 40 + events.length * eventSpacing;
430
459
 
431
- // Step 7: Add invisible boundary nodes to ensure fitView includes full diagram width
460
+ // Step 7: Boundary nodes so React Flow's fitView covers full width
432
461
  if (swimlanes.length > 0) {
433
462
  const leftmostLane = swimlanes[0];
434
463
  const rightmostLane = swimlanes[swimlanes.length - 1];
435
464
 
436
- // Add boundary nodes at the corners
437
465
  nodes.push(
438
466
  {
439
467
  id: '__boundary_left__',
@@ -460,19 +488,20 @@ export function useSequenceLayout(
460
488
  nodes,
461
489
  edges,
462
490
  swimlanes,
491
+ parentHeaders,
492
+ headerRows,
463
493
  totalWidth,
464
494
  totalHeight,
465
495
  };
466
496
  }, [
467
497
  events,
468
498
  sequenceEdges,
469
- namespaceStrategy,
499
+ openedKey,
470
500
  laneWidth,
471
501
  laneGap,
472
502
  eventSpacing,
473
503
  headerHeight,
474
504
  nodeWidth,
475
505
  nodeHeight,
476
- collapsedNamespaces,
477
506
  ]);
478
507
  }
package/src/index.ts CHANGED
@@ -46,7 +46,6 @@ export type {
46
46
  SequenceEvent,
47
47
  SequenceEdge,
48
48
  Swimlane,
49
- NamespaceStrategy,
50
49
  UseSequenceLayoutOptions,
51
50
  UseSequenceLayoutResult,
52
51
  } from './hooks/useSequenceLayout';