@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.
- package/dist/components/SequenceDiagramRenderer.d.ts +6 -2
- package/dist/components/SequenceDiagramRenderer.d.ts.map +1 -1
- package/dist/components/SequenceDiagramRenderer.js +253 -35
- package/dist/components/SequenceDiagramRenderer.js.map +1 -1
- package/dist/components/WorkflowSequenceDiagram.d.ts +1 -4
- package/dist/components/WorkflowSequenceDiagram.d.ts.map +1 -1
- package/dist/components/WorkflowSequenceDiagram.js +3 -6
- package/dist/components/WorkflowSequenceDiagram.js.map +1 -1
- package/dist/hooks/useSequenceLayout.d.ts +51 -27
- package/dist/hooks/useSequenceLayout.d.ts.map +1 -1
- package/dist/hooks/useSequenceLayout.js +140 -118
- package/dist/hooks/useSequenceLayout.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/SequenceDiagramRenderer.tsx +395 -48
- package/src/components/WorkflowSequenceDiagram.tsx +3 -6
- package/src/hooks/useSequenceLayout.ts +192 -163
- package/src/index.ts +0 -1
- package/src/stories/FileCitySequence.stories.tsx +22 -50
- package/src/stories/SequenceDiagram.stories.tsx +12 -29
- package/src/stories/WorkflowSequenceDiagram.stories.tsx +14 -26
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Uses namespaces to determine swimlanes and event order for vertical positioning.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useCallback, useMemo } from 'react';
|
|
8
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
9
9
|
import {
|
|
10
10
|
ReactFlow,
|
|
11
11
|
Background,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type SequenceEdge,
|
|
34
34
|
type UseSequenceLayoutOptions,
|
|
35
35
|
type Swimlane,
|
|
36
|
+
type ParentHeader,
|
|
36
37
|
} from '../hooks/useSequenceLayout';
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -250,7 +251,6 @@ function SequenceArrowParticipantEdge({
|
|
|
250
251
|
width={barWidth}
|
|
251
252
|
height={barHeight}
|
|
252
253
|
fill={strokeColor}
|
|
253
|
-
fillOpacity={0.15}
|
|
254
254
|
stroke={strokeColor}
|
|
255
255
|
strokeWidth={2}
|
|
256
256
|
rx={2}
|
|
@@ -261,15 +261,21 @@ function SequenceArrowParticipantEdge({
|
|
|
261
261
|
<div
|
|
262
262
|
style={{
|
|
263
263
|
position: 'absolute',
|
|
264
|
-
transform: `translate(
|
|
264
|
+
transform: `translate(-100%, -50%) translate(${sourceX - 7}px,${barY + barHeight / 2}px)`,
|
|
265
265
|
background: isSourceSelected ? strokeColor : theme.colors.background,
|
|
266
|
-
padding:
|
|
267
|
-
|
|
266
|
+
padding: '2px 8px',
|
|
267
|
+
borderTopLeftRadius: 4,
|
|
268
|
+
borderTopRightRadius: 0,
|
|
269
|
+
borderBottomLeftRadius: 4,
|
|
270
|
+
borderBottomRightRadius: 0,
|
|
268
271
|
fontSize: theme.fontSizes[0],
|
|
269
272
|
fontWeight: isSourceSelected ? theme.fontWeights.bold : theme.fontWeights.medium,
|
|
270
273
|
fontFamily: theme.fonts.body,
|
|
271
274
|
color: isSourceSelected ? theme.colors.background : strokeColor,
|
|
272
|
-
|
|
275
|
+
borderTop: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
|
|
276
|
+
borderLeft: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
|
|
277
|
+
borderBottom: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
|
|
278
|
+
borderRight: `1px solid ${strokeColor}`,
|
|
273
279
|
pointerEvents: 'all',
|
|
274
280
|
whiteSpace: 'nowrap',
|
|
275
281
|
cursor: 'pointer',
|
|
@@ -292,7 +298,12 @@ function SequenceArrowParticipantEdge({
|
|
|
292
298
|
|
|
293
299
|
// Draw horizontal arrow at the midpoint between source and target Y positions
|
|
294
300
|
const arrowY = (safeSourceY + safeTargetY) / 2;
|
|
295
|
-
|
|
301
|
+
// Inset arrow endpoints so they stop just before the lifelines instead of crossing them
|
|
302
|
+
const lifelineInset = 6;
|
|
303
|
+
const direction = targetX > sourceX ? 1 : -1;
|
|
304
|
+
const startX = sourceX + direction * lifelineInset;
|
|
305
|
+
const endX = targetX - direction * lifelineInset;
|
|
306
|
+
const path = `M ${startX} ${arrowY} L ${endX} ${arrowY}`;
|
|
296
307
|
const labelX = (sourceX + targetX) / 2;
|
|
297
308
|
const labelY = arrowY;
|
|
298
309
|
|
|
@@ -314,13 +325,19 @@ function SequenceArrowParticipantEdge({
|
|
|
314
325
|
position: 'absolute',
|
|
315
326
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY - 12}px)`,
|
|
316
327
|
background: isSourceSelected ? strokeColor : theme.colors.background,
|
|
317
|
-
padding:
|
|
318
|
-
|
|
328
|
+
padding: isMoveEvent ? '3px 10px' : '2px 8px',
|
|
329
|
+
borderTopLeftRadius: 4,
|
|
330
|
+
borderTopRightRadius: 4,
|
|
331
|
+
borderBottomLeftRadius: 0,
|
|
332
|
+
borderBottomRightRadius: 0,
|
|
319
333
|
fontSize: theme.fontSizes[0],
|
|
320
334
|
fontWeight: isSourceSelected ? theme.fontWeights.bold : (isMoveEvent ? theme.fontWeights.bold : theme.fontWeights.medium),
|
|
321
335
|
fontFamily: theme.fonts.body,
|
|
322
336
|
color: isSourceSelected ? theme.colors.background : strokeColor,
|
|
323
|
-
|
|
337
|
+
borderTop: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
|
|
338
|
+
borderLeft: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
|
|
339
|
+
borderRight: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
|
|
340
|
+
borderBottom: `${isMoveEvent ? 2 : 1}px solid ${strokeColor}`,
|
|
324
341
|
pointerEvents: 'all',
|
|
325
342
|
whiteSpace: 'nowrap',
|
|
326
343
|
cursor: 'pointer',
|
|
@@ -344,6 +361,42 @@ const defaultSequenceNodeTypes: NodeTypes = {
|
|
|
344
361
|
sequenceMarker: SequenceMarkerNode,
|
|
345
362
|
};
|
|
346
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Shared transition for swimlane chrome — interpolates positions and sizes
|
|
366
|
+
* when lanes shift due to drill toggles.
|
|
367
|
+
*/
|
|
368
|
+
const swimlaneTransition =
|
|
369
|
+
'top 350ms ease-out, left 350ms ease-out, width 350ms ease-out, height 350ms ease-out';
|
|
370
|
+
|
|
371
|
+
/** Duration of the child slide-up exit animation. The close handler waits
|
|
372
|
+
* this long before applying the data change so the diagram body doesn't
|
|
373
|
+
* shift while the children are still on screen. */
|
|
374
|
+
const SWIMLANE_CLOSE_EXIT_MS = 280;
|
|
375
|
+
|
|
376
|
+
const swimlaneAnimationStyles = `
|
|
377
|
+
@keyframes swimlaneFadeIn {
|
|
378
|
+
from { opacity: 0; }
|
|
379
|
+
to { opacity: 1; }
|
|
380
|
+
}
|
|
381
|
+
@keyframes swimlaneFadeOut {
|
|
382
|
+
from { opacity: 1; }
|
|
383
|
+
to { opacity: 0; }
|
|
384
|
+
}
|
|
385
|
+
@keyframes swimlaneChildSlideDown {
|
|
386
|
+
from { transform: translateY(-100%); opacity: 0; }
|
|
387
|
+
to { transform: translateY(0); opacity: 1; }
|
|
388
|
+
}
|
|
389
|
+
@keyframes swimlaneChildSlideUp {
|
|
390
|
+
from { transform: translateY(0); opacity: 1; }
|
|
391
|
+
to { transform: translateY(-100%); opacity: 0; }
|
|
392
|
+
}
|
|
393
|
+
.swimlane-fade-in { animation: swimlaneFadeIn 250ms ease-out both; }
|
|
394
|
+
.swimlane-child-bg-in { animation: swimlaneFadeIn 300ms ease-out 250ms both; }
|
|
395
|
+
.swimlane-child-in { animation: swimlaneChildSlideDown 300ms ease-out 250ms both; }
|
|
396
|
+
.swimlane-child-out { animation: swimlaneChildSlideUp 280ms ease-in both; }
|
|
397
|
+
.swimlane-fade-out { animation: swimlaneFadeOut 250ms ease-in both; }
|
|
398
|
+
`;
|
|
399
|
+
|
|
347
400
|
/**
|
|
348
401
|
* Default edge types including sequence arrow and participant arrow
|
|
349
402
|
*/
|
|
@@ -357,10 +410,18 @@ const defaultSequenceEdgeTypes: EdgeTypes = {
|
|
|
357
410
|
*/
|
|
358
411
|
interface SwimlaneLayerProps {
|
|
359
412
|
swimlanes: Swimlane[];
|
|
413
|
+
parentHeaders: ParentHeader[];
|
|
414
|
+
headerRows: number;
|
|
360
415
|
laneWidth: number;
|
|
416
|
+
/** Per-row height (each row in the header strip is this tall) */
|
|
361
417
|
headerHeight: number;
|
|
362
418
|
totalHeight: number;
|
|
363
|
-
|
|
419
|
+
/** Called when the user clicks a chevron or parent header to toggle its drilled state */
|
|
420
|
+
onToggleNamespace?: (namespace: string) => void;
|
|
421
|
+
/** Namespaces mid-close. Their children are still in the data but render
|
|
422
|
+
* with the exit animation so the body can stay put until the animation
|
|
423
|
+
* finishes and the data change applies. */
|
|
424
|
+
closingNamespaces?: Set<string>;
|
|
364
425
|
stickyHeaders?: boolean;
|
|
365
426
|
/** When true, render lane and header backgrounds as transparent. */
|
|
366
427
|
transparent?: boolean;
|
|
@@ -371,11 +432,13 @@ interface SwimlaneLayerProps {
|
|
|
371
432
|
*/
|
|
372
433
|
function SwimlaneLayer({
|
|
373
434
|
swimlanes,
|
|
435
|
+
headerRows,
|
|
374
436
|
laneWidth,
|
|
375
437
|
headerHeight,
|
|
376
438
|
totalHeight,
|
|
377
439
|
transparent = false,
|
|
378
440
|
}: SwimlaneLayerProps) {
|
|
441
|
+
const totalHeaderHeight = headerHeight * headerRows;
|
|
379
442
|
const { x, y, zoom } = useViewport();
|
|
380
443
|
const viewportHeight = useStore((s) => s.height);
|
|
381
444
|
const { theme } = useTheme();
|
|
@@ -404,9 +467,13 @@ function SwimlaneLayer({
|
|
|
404
467
|
: isEven
|
|
405
468
|
? theme.colors.muted
|
|
406
469
|
: theme.colors.background;
|
|
470
|
+
const fadeClass = lane.isParentOpened
|
|
471
|
+
? 'swimlane-child-bg-in'
|
|
472
|
+
: 'swimlane-fade-in';
|
|
407
473
|
return (
|
|
408
474
|
<div
|
|
409
475
|
key={`bg-${lane.namespace}`}
|
|
476
|
+
className={fadeClass}
|
|
410
477
|
style={{
|
|
411
478
|
position: 'absolute',
|
|
412
479
|
left: lane.x - laneWidth / 2,
|
|
@@ -415,6 +482,7 @@ function SwimlaneLayer({
|
|
|
415
482
|
height: extendedHeight,
|
|
416
483
|
backgroundColor: laneBackground,
|
|
417
484
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
485
|
+
transition: swimlaneTransition,
|
|
418
486
|
}}
|
|
419
487
|
/>
|
|
420
488
|
);
|
|
@@ -424,14 +492,18 @@ function SwimlaneLayer({
|
|
|
424
492
|
{swimlanes.map((lane) => (
|
|
425
493
|
<div
|
|
426
494
|
key={`lifeline-${lane.namespace}`}
|
|
495
|
+
className={
|
|
496
|
+
lane.isParentOpened ? 'swimlane-child-bg-in' : 'swimlane-fade-in'
|
|
497
|
+
}
|
|
427
498
|
style={{
|
|
428
499
|
position: 'absolute',
|
|
429
500
|
left: lane.x,
|
|
430
|
-
top:
|
|
501
|
+
top: totalHeaderHeight,
|
|
431
502
|
width: 2,
|
|
432
|
-
height: extendedHeight -
|
|
433
|
-
backgroundColor:
|
|
503
|
+
height: extendedHeight - totalHeaderHeight,
|
|
504
|
+
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
|
434
505
|
transform: 'translateX(-1px)',
|
|
506
|
+
transition: swimlaneTransition,
|
|
435
507
|
}}
|
|
436
508
|
/>
|
|
437
509
|
))}
|
|
@@ -439,24 +511,141 @@ function SwimlaneLayer({
|
|
|
439
511
|
);
|
|
440
512
|
}
|
|
441
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Two offset rounded rectangles signaling that a header has nested lanes
|
|
516
|
+
* underneath it. When `opened`, the front layer is filled to indicate the
|
|
517
|
+
* stack is currently expanded.
|
|
518
|
+
*/
|
|
519
|
+
function StackIcon({
|
|
520
|
+
opened = false,
|
|
521
|
+
hovered = false,
|
|
522
|
+
accentColor,
|
|
523
|
+
}: {
|
|
524
|
+
opened?: boolean;
|
|
525
|
+
hovered?: boolean;
|
|
526
|
+
accentColor?: string;
|
|
527
|
+
}) {
|
|
528
|
+
// Closed: rects compactly stacked, both outlined in text color.
|
|
529
|
+
// Hovered: strokes tint to the accent color (preview of the action).
|
|
530
|
+
// Opened: front rect drifts down/right and fades to filled in the accent.
|
|
531
|
+
const rectTransition =
|
|
532
|
+
'transform 280ms cubic-bezier(0.2, 0, 0, 1), fill-opacity 280ms ease, stroke 200ms ease';
|
|
533
|
+
const useAccent = (opened || hovered) && !!accentColor;
|
|
534
|
+
const stroke = useAccent ? accentColor : 'currentColor';
|
|
535
|
+
const fill = accentColor || 'currentColor';
|
|
536
|
+
return (
|
|
537
|
+
<svg
|
|
538
|
+
width={22}
|
|
539
|
+
height={22}
|
|
540
|
+
viewBox="0 0 14 14"
|
|
541
|
+
fill="none"
|
|
542
|
+
strokeWidth={1.3}
|
|
543
|
+
strokeLinejoin="round"
|
|
544
|
+
aria-hidden="true"
|
|
545
|
+
style={{
|
|
546
|
+
flexShrink: 0,
|
|
547
|
+
opacity: opened ? 0.95 : hovered ? 0.9 : 0.75,
|
|
548
|
+
overflow: 'visible',
|
|
549
|
+
transition: 'opacity 200ms ease',
|
|
550
|
+
}}
|
|
551
|
+
>
|
|
552
|
+
<rect
|
|
553
|
+
x={2}
|
|
554
|
+
y={3}
|
|
555
|
+
width={7}
|
|
556
|
+
height={5}
|
|
557
|
+
rx={1}
|
|
558
|
+
stroke={stroke}
|
|
559
|
+
style={{
|
|
560
|
+
transition: rectTransition,
|
|
561
|
+
transform: opened
|
|
562
|
+
? 'translate(-1.5px, -1.5px)'
|
|
563
|
+
: 'translate(0px, 0px)',
|
|
564
|
+
}}
|
|
565
|
+
/>
|
|
566
|
+
<rect
|
|
567
|
+
x={5}
|
|
568
|
+
y={6}
|
|
569
|
+
width={7}
|
|
570
|
+
height={5}
|
|
571
|
+
rx={1}
|
|
572
|
+
stroke={stroke}
|
|
573
|
+
fill={fill}
|
|
574
|
+
style={{
|
|
575
|
+
transition: rectTransition,
|
|
576
|
+
transform: opened
|
|
577
|
+
? 'translate(1.5px, 1.5px)'
|
|
578
|
+
: 'translate(0px, 0px)',
|
|
579
|
+
fillOpacity: opened ? 0.35 : 0,
|
|
580
|
+
}}
|
|
581
|
+
/>
|
|
582
|
+
</svg>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
442
586
|
/**
|
|
443
587
|
* Swimlane headers layer that renders on top of nodes for clickability
|
|
444
588
|
*/
|
|
445
589
|
function SwimlaneHeadersLayer({
|
|
446
590
|
swimlanes,
|
|
591
|
+
parentHeaders,
|
|
447
592
|
laneWidth,
|
|
448
593
|
headerHeight,
|
|
449
|
-
|
|
594
|
+
onToggleNamespace,
|
|
595
|
+
closingNamespaces,
|
|
450
596
|
stickyHeaders = true,
|
|
451
597
|
transparent = false,
|
|
452
598
|
}: SwimlaneLayerProps) {
|
|
453
599
|
const { x, y, zoom } = useViewport();
|
|
454
600
|
const { theme } = useTheme();
|
|
601
|
+
const [hoveredNamespace, setHoveredNamespace] = useState<string | null>(null);
|
|
455
602
|
|
|
456
603
|
// When sticky headers are enabled, compensate for vertical viewport panning
|
|
457
604
|
// to keep headers at the top of the screen
|
|
458
605
|
const headerTop = stickyHeaders ? -y / zoom : 0;
|
|
459
606
|
|
|
607
|
+
// Build a unified header list: each namespace currently in view (whether a
|
|
608
|
+
// leaf or an opened ancestor) gets ONE DOM element keyed by namespace, so
|
|
609
|
+
// clicking ▶ smoothly morphs the same cell from leaf-shape to parent-shape
|
|
610
|
+
// (wider, possibly across multiple lanes) instead of unmounting+remounting.
|
|
611
|
+
type HeaderCell = {
|
|
612
|
+
namespace: string;
|
|
613
|
+
label: string;
|
|
614
|
+
x: number;
|
|
615
|
+
width: number;
|
|
616
|
+
depth: number;
|
|
617
|
+
isOpened: boolean; // currently in `openedNamespaces`
|
|
618
|
+
isParentOpened: boolean;
|
|
619
|
+
canExpand: boolean; // only meaningful when !isOpened
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const headers: HeaderCell[] = useMemo(
|
|
623
|
+
() => [
|
|
624
|
+
...parentHeaders.map((h) => ({
|
|
625
|
+
namespace: h.namespace,
|
|
626
|
+
label: h.label,
|
|
627
|
+
x: h.x,
|
|
628
|
+
width: h.width,
|
|
629
|
+
depth: h.depth,
|
|
630
|
+
isOpened: true,
|
|
631
|
+
isParentOpened: h.depth > 1,
|
|
632
|
+
canExpand: false,
|
|
633
|
+
})),
|
|
634
|
+
...swimlanes.map((lane) => ({
|
|
635
|
+
namespace: lane.namespace,
|
|
636
|
+
label: lane.label,
|
|
637
|
+
x: lane.x,
|
|
638
|
+
width: laneWidth,
|
|
639
|
+
depth: lane.namespace.split('.').length,
|
|
640
|
+
isOpened: false,
|
|
641
|
+
isParentOpened: lane.isParentOpened,
|
|
642
|
+
canExpand: lane.canExpand,
|
|
643
|
+
})),
|
|
644
|
+
],
|
|
645
|
+
[parentHeaders, swimlanes, laneWidth]
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
|
|
460
649
|
return (
|
|
461
650
|
<div
|
|
462
651
|
style={{
|
|
@@ -469,54 +658,132 @@ function SwimlaneHeadersLayer({
|
|
|
469
658
|
zIndex: 10,
|
|
470
659
|
}}
|
|
471
660
|
>
|
|
472
|
-
{
|
|
473
|
-
|
|
474
|
-
|
|
661
|
+
{headers.map((header) => {
|
|
662
|
+
const rowTop = headerTop + (header.depth - 1) * headerHeight;
|
|
663
|
+
// Child leaves (under an opened parent) get the differentiated, lighter
|
|
664
|
+
// styling. Top-level leaves AND opened parents share the original
|
|
665
|
+
// header look — so the cell you clicked doesn't change appearance, it
|
|
666
|
+
// just grows to span its children.
|
|
667
|
+
const isChild = header.isParentOpened && !header.isOpened;
|
|
668
|
+
const showOpen = header.canExpand && !header.isOpened;
|
|
669
|
+
const isClickable = header.isOpened || showOpen;
|
|
670
|
+
// If this child's parent is mid-close, play the exit animation
|
|
671
|
+
// instead of the entry. After SWIMLANE_CLOSE_EXIT_MS the data
|
|
672
|
+
// change applies and the child unmounts.
|
|
673
|
+
const parentNs =
|
|
674
|
+
header.depth > 1
|
|
675
|
+
? header.namespace.split('.').slice(0, -1).join('.')
|
|
676
|
+
: undefined;
|
|
677
|
+
const isExiting =
|
|
678
|
+
isChild && !!parentNs && !!closingNamespaces?.has(parentNs);
|
|
679
|
+
const cellClassName = isExiting
|
|
680
|
+
? 'swimlane-child-out'
|
|
681
|
+
: isChild
|
|
682
|
+
? 'swimlane-child-in'
|
|
683
|
+
: 'swimlane-fade-in';
|
|
475
684
|
return (
|
|
476
685
|
<div
|
|
477
|
-
key={
|
|
686
|
+
key={header.namespace}
|
|
687
|
+
className={cellClassName}
|
|
688
|
+
role={isClickable ? 'button' : undefined}
|
|
689
|
+
aria-label={
|
|
690
|
+
header.isOpened
|
|
691
|
+
? `Close ${header.namespace}`
|
|
692
|
+
: showOpen
|
|
693
|
+
? `Open ${header.namespace}`
|
|
694
|
+
: undefined
|
|
695
|
+
}
|
|
696
|
+
title={
|
|
697
|
+
header.isOpened
|
|
698
|
+
? `${header.namespace} (click to close)`
|
|
699
|
+
: showOpen
|
|
700
|
+
? `${header.namespace} (click to open)`
|
|
701
|
+
: header.namespace
|
|
702
|
+
}
|
|
478
703
|
style={{
|
|
479
704
|
position: 'absolute',
|
|
480
|
-
left:
|
|
481
|
-
top:
|
|
482
|
-
width:
|
|
705
|
+
left: header.x - header.width / 2,
|
|
706
|
+
top: rowTop,
|
|
707
|
+
width: header.width,
|
|
483
708
|
height: headerHeight,
|
|
484
709
|
display: 'flex',
|
|
485
710
|
alignItems: 'center',
|
|
486
711
|
justifyContent: 'center',
|
|
487
712
|
padding: '0 8px',
|
|
488
713
|
boxSizing: 'border-box',
|
|
489
|
-
backgroundColor: transparent
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
714
|
+
backgroundColor: transparent
|
|
715
|
+
? 'transparent'
|
|
716
|
+
: isChild
|
|
717
|
+
? theme.colors.background
|
|
718
|
+
: theme.colors.muted,
|
|
719
|
+
transition: swimlaneTransition,
|
|
720
|
+
borderBottom: isChild
|
|
721
|
+
? `1px solid ${theme.colors.border}`
|
|
722
|
+
: `2px solid ${theme.colors.border}`,
|
|
723
|
+
borderLeft: isChild
|
|
724
|
+
? `1px solid ${theme.colors.border}`
|
|
725
|
+
: 'none',
|
|
726
|
+
borderRight: isChild
|
|
727
|
+
? `1px solid ${theme.colors.border}`
|
|
728
|
+
: 'none',
|
|
729
|
+
fontWeight: isChild
|
|
730
|
+
? theme.fontWeights.medium
|
|
731
|
+
: theme.fontWeights.semibold,
|
|
732
|
+
fontSize: isChild ? theme.fontSizes[1] : theme.fontSizes[2],
|
|
493
733
|
fontFamily: theme.fonts.heading,
|
|
494
|
-
color: theme.colors.text,
|
|
734
|
+
color: isChild ? theme.colors.textSecondary : theme.colors.text,
|
|
495
735
|
pointerEvents: 'auto',
|
|
496
|
-
cursor: hasChildren ? 'pointer' : 'default',
|
|
497
736
|
userSelect: 'none',
|
|
737
|
+
cursor: isClickable ? 'pointer' : 'default',
|
|
738
|
+
gap: 6,
|
|
739
|
+
// Parent (opened) cells sit above leaves so children slide out
|
|
740
|
+
// from behind them rather than over the top.
|
|
741
|
+
zIndex: header.isOpened ? 2 : 1,
|
|
498
742
|
}}
|
|
499
|
-
onClick={
|
|
743
|
+
onClick={
|
|
744
|
+
isClickable
|
|
745
|
+
? (e) => {
|
|
746
|
+
e.stopPropagation();
|
|
747
|
+
onToggleNamespace?.(header.namespace);
|
|
748
|
+
}
|
|
749
|
+
: undefined
|
|
750
|
+
}
|
|
751
|
+
onMouseEnter={
|
|
752
|
+
isClickable
|
|
753
|
+
? () => setHoveredNamespace(header.namespace)
|
|
754
|
+
: undefined
|
|
755
|
+
}
|
|
756
|
+
onMouseLeave={
|
|
757
|
+
isClickable
|
|
758
|
+
? () =>
|
|
759
|
+
setHoveredNamespace((current) =>
|
|
760
|
+
current === header.namespace ? null : current
|
|
761
|
+
)
|
|
762
|
+
: undefined
|
|
763
|
+
}
|
|
500
764
|
>
|
|
501
|
-
{hasChildren && (
|
|
502
|
-
<span style={{ marginRight: 6, fontSize: 10 }}>
|
|
503
|
-
{lane.isCollapsed ? '▼' : '▶'}
|
|
504
|
-
</span>
|
|
505
|
-
)}
|
|
506
765
|
<span
|
|
507
766
|
style={{
|
|
508
767
|
overflowWrap: 'anywhere',
|
|
509
768
|
wordBreak: 'break-word',
|
|
510
769
|
lineHeight: 1.2,
|
|
511
770
|
textAlign: 'center',
|
|
771
|
+
flex: 1,
|
|
512
772
|
}}
|
|
513
|
-
title={lane.label}
|
|
514
773
|
>
|
|
515
|
-
{
|
|
774
|
+
{header.label}
|
|
516
775
|
</span>
|
|
776
|
+
{(header.isOpened || showOpen) && (
|
|
777
|
+
<StackIcon
|
|
778
|
+
opened={header.isOpened}
|
|
779
|
+
hovered={hoveredNamespace === header.namespace}
|
|
780
|
+
accentColor={theme.colors.primary}
|
|
781
|
+
/>
|
|
782
|
+
)}
|
|
517
783
|
</div>
|
|
518
784
|
);
|
|
519
785
|
})}
|
|
786
|
+
|
|
520
787
|
</div>
|
|
521
788
|
);
|
|
522
789
|
}
|
|
@@ -540,8 +807,12 @@ export interface SequenceDiagramRendererProps {
|
|
|
540
807
|
/** Optional custom edge types */
|
|
541
808
|
edgeTypes?: EdgeTypes;
|
|
542
809
|
|
|
543
|
-
/**
|
|
544
|
-
|
|
810
|
+
/**
|
|
811
|
+
* Called when the user toggles a lane's drill state via the header
|
|
812
|
+
* chevrons. Update `layoutOptions.openedNamespaces` in response to
|
|
813
|
+
* open/close the lane.
|
|
814
|
+
*/
|
|
815
|
+
onToggleNamespace?: (namespace: string) => void;
|
|
545
816
|
|
|
546
817
|
/** Callback when a node is clicked */
|
|
547
818
|
onNodeClick?: (nodeId: string, event: React.MouseEvent) => void;
|
|
@@ -588,7 +859,7 @@ function SequenceDiagramInner({
|
|
|
588
859
|
layoutOptions = {},
|
|
589
860
|
nodeTypes: customNodeTypes,
|
|
590
861
|
edgeTypes: customEdgeTypes,
|
|
591
|
-
|
|
862
|
+
onToggleNamespace,
|
|
592
863
|
onNodeClick,
|
|
593
864
|
showControls = true,
|
|
594
865
|
showBackground = false, // Default to false since swimlanes provide visual structure
|
|
@@ -602,6 +873,67 @@ function SequenceDiagramInner({
|
|
|
602
873
|
// Extract layout params
|
|
603
874
|
const { laneWidth = 250, headerHeight = 60 } = layoutOptions;
|
|
604
875
|
|
|
876
|
+
// openedNamespaces is controlled if provided in layoutOptions, otherwise
|
|
877
|
+
// we manage it internally so chevrons work out of the box.
|
|
878
|
+
const isOpenedControlled = layoutOptions.openedNamespaces !== undefined;
|
|
879
|
+
const [internalOpened, setInternalOpened] = useState<string[]>([]);
|
|
880
|
+
const effectiveOpened = isOpenedControlled
|
|
881
|
+
? layoutOptions.openedNamespaces
|
|
882
|
+
: internalOpened;
|
|
883
|
+
|
|
884
|
+
// Namespaces currently mid-close. While one is here, the children still
|
|
885
|
+
// render in the data (lifelines, events, edges unchanged) but their header
|
|
886
|
+
// cells flip to the exit animation. After the exit completes we apply the
|
|
887
|
+
// real data change, so the diagram body shifts in sync with the parent
|
|
888
|
+
// header shrink instead of ahead of it.
|
|
889
|
+
const [closingNamespaces, setClosingNamespaces] = useState<Set<string>>(
|
|
890
|
+
() => new Set()
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const handleToggleNamespace = useCallback(
|
|
894
|
+
(namespace: string) => {
|
|
895
|
+
const openedSet =
|
|
896
|
+
effectiveOpened instanceof Set
|
|
897
|
+
? effectiveOpened
|
|
898
|
+
: new Set(effectiveOpened ?? []);
|
|
899
|
+
const isCurrentlyOpened = openedSet.has(namespace);
|
|
900
|
+
|
|
901
|
+
if (isCurrentlyOpened) {
|
|
902
|
+
// Stage the close: animate children out first, then apply data change.
|
|
903
|
+
setClosingNamespaces((prev) => {
|
|
904
|
+
if (prev.has(namespace)) return prev;
|
|
905
|
+
const next = new Set(prev);
|
|
906
|
+
next.add(namespace);
|
|
907
|
+
return next;
|
|
908
|
+
});
|
|
909
|
+
setTimeout(() => {
|
|
910
|
+
if (!isOpenedControlled) {
|
|
911
|
+
setInternalOpened((prev) => prev.filter((n) => n !== namespace));
|
|
912
|
+
}
|
|
913
|
+
onToggleNamespace?.(namespace);
|
|
914
|
+
setClosingNamespaces((prev) => {
|
|
915
|
+
if (!prev.has(namespace)) return prev;
|
|
916
|
+
const next = new Set(prev);
|
|
917
|
+
next.delete(namespace);
|
|
918
|
+
return next;
|
|
919
|
+
});
|
|
920
|
+
}, SWIMLANE_CLOSE_EXIT_MS);
|
|
921
|
+
} else {
|
|
922
|
+
// Open: data change is immediate; children animate in via CSS.
|
|
923
|
+
if (!isOpenedControlled) {
|
|
924
|
+
setInternalOpened((prev) => [...prev, namespace]);
|
|
925
|
+
}
|
|
926
|
+
onToggleNamespace?.(namespace);
|
|
927
|
+
}
|
|
928
|
+
},
|
|
929
|
+
[effectiveOpened, isOpenedControlled, onToggleNamespace]
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
const effectiveLayoutOptions = useMemo(
|
|
933
|
+
() => ({ ...layoutOptions, openedNamespaces: effectiveOpened }),
|
|
934
|
+
[layoutOptions, effectiveOpened]
|
|
935
|
+
);
|
|
936
|
+
|
|
605
937
|
// Merge custom node/edge types with sequence defaults
|
|
606
938
|
const nodeTypes = useMemo(
|
|
607
939
|
() => ({ ...defaultSequenceNodeTypes, ...customNodeTypes }),
|
|
@@ -613,11 +945,15 @@ function SequenceDiagramInner({
|
|
|
613
945
|
);
|
|
614
946
|
|
|
615
947
|
// Compute layout
|
|
616
|
-
const {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
948
|
+
const {
|
|
949
|
+
nodes: layoutNodes,
|
|
950
|
+
edges,
|
|
951
|
+
swimlanes,
|
|
952
|
+
parentHeaders,
|
|
953
|
+
headerRows,
|
|
954
|
+
totalWidth,
|
|
955
|
+
totalHeight,
|
|
956
|
+
} = useSequenceLayout(events, sequenceEdges, effectiveLayoutOptions);
|
|
621
957
|
|
|
622
958
|
// Mark selected node and add showEventLabels to node data
|
|
623
959
|
const nodes = useMemo(() => {
|
|
@@ -714,6 +1050,7 @@ function SequenceDiagramInner({
|
|
|
714
1050
|
translateExtent={translateExtent}
|
|
715
1051
|
style={{ background: transparent ? 'transparent' : theme.colors.background }}
|
|
716
1052
|
>
|
|
1053
|
+
<style>{swimlaneAnimationStyles}</style>
|
|
717
1054
|
{/* SVG defs for arrow markers */}
|
|
718
1055
|
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
|
719
1056
|
<defs>
|
|
@@ -758,6 +1095,8 @@ function SequenceDiagramInner({
|
|
|
758
1095
|
{/* Swimlane layer - renders behind nodes */}
|
|
759
1096
|
<SwimlaneLayer
|
|
760
1097
|
swimlanes={swimlanes}
|
|
1098
|
+
parentHeaders={parentHeaders}
|
|
1099
|
+
headerRows={headerRows}
|
|
761
1100
|
laneWidth={laneWidth}
|
|
762
1101
|
headerHeight={headerHeight}
|
|
763
1102
|
totalHeight={totalHeight}
|
|
@@ -767,16 +1106,18 @@ function SequenceDiagramInner({
|
|
|
767
1106
|
{/* Swimlane headers layer - renders on top for clickability */}
|
|
768
1107
|
<SwimlaneHeadersLayer
|
|
769
1108
|
swimlanes={swimlanes}
|
|
1109
|
+
parentHeaders={parentHeaders}
|
|
1110
|
+
headerRows={headerRows}
|
|
770
1111
|
laneWidth={laneWidth}
|
|
771
1112
|
headerHeight={headerHeight}
|
|
772
1113
|
totalHeight={totalHeight}
|
|
773
|
-
|
|
1114
|
+
onToggleNamespace={handleToggleNamespace}
|
|
1115
|
+
closingNamespaces={closingNamespaces}
|
|
774
1116
|
stickyHeaders={stickyHeaders}
|
|
775
1117
|
transparent={transparent}
|
|
776
1118
|
/>
|
|
777
1119
|
|
|
778
|
-
{
|
|
779
|
-
{swimlanes.some((s) => s.children.length > 0) && (
|
|
1120
|
+
{(swimlanes.some((s) => s.canExpand) || parentHeaders.length > 0) && (
|
|
780
1121
|
<Panel position="top-right">
|
|
781
1122
|
<div
|
|
782
1123
|
style={{
|
|
@@ -787,9 +1128,15 @@ function SequenceDiagramInner({
|
|
|
787
1128
|
fontSize: theme.fontSizes[0],
|
|
788
1129
|
fontFamily: theme.fonts.body,
|
|
789
1130
|
color: theme.colors.textSecondary,
|
|
1131
|
+
display: 'flex',
|
|
1132
|
+
alignItems: 'center',
|
|
1133
|
+
gap: 6,
|
|
790
1134
|
}}
|
|
791
1135
|
>
|
|
792
|
-
|
|
1136
|
+
<StackIcon accentColor={theme.colors.primary} />
|
|
1137
|
+
<span>to drill in · click parent header</span>
|
|
1138
|
+
<StackIcon opened accentColor={theme.colors.primary} />
|
|
1139
|
+
<span>to close</span>
|
|
793
1140
|
</div>
|
|
794
1141
|
</Panel>
|
|
795
1142
|
)}
|
|
@@ -146,8 +146,8 @@ function convertWorkflowToSequence(
|
|
|
146
146
|
const isMoveEvent = eventToMoveEventMap.get(eventName) ?? true;
|
|
147
147
|
|
|
148
148
|
// When the canvas provides a scope, prefix it so the event lands in that
|
|
149
|
-
// participant's lane
|
|
150
|
-
//
|
|
149
|
+
// participant's lane (the layout uses the first dotted segment as the
|
|
150
|
+
// default lane). Otherwise, use the event name as-is.
|
|
151
151
|
const name = canvasScope ? `${canvasScope}.${eventName}` : eventName;
|
|
152
152
|
const participant = canvasScope ?? eventName.split('.')[0] ?? eventName;
|
|
153
153
|
|
|
@@ -186,10 +186,7 @@ function convertWorkflowToSequence(
|
|
|
186
186
|
* scenario={workflowScenario}
|
|
187
187
|
* canvas={otelCanvas}
|
|
188
188
|
* height={600}
|
|
189
|
-
* layoutOptions={{
|
|
190
|
-
* namespaceStrategy: 'first',
|
|
191
|
-
* eventSpacing: 80,
|
|
192
|
-
* }}
|
|
189
|
+
* layoutOptions={{ eventSpacing: 80 }}
|
|
193
190
|
* />
|
|
194
191
|
* ```
|
|
195
192
|
*/
|