@principal-ai/subsystems-react 0.37.10 → 0.37.12
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/subsystem/FileDrawer.d.ts +3 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +2 -2
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +13 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +168 -37
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +21 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +4 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +12 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +10 -0
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/WalkthroughAutoplay.stories.tsx +218 -0
- package/src/subsystem/FileDrawer.tsx +4 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +187 -37
- package/src/subsystem/model.ts +20 -1
- package/src/utils/elkLayout.ts +20 -0
|
@@ -245,6 +245,12 @@ export interface SubsystemComponentGraphProps {
|
|
|
245
245
|
* @default true
|
|
246
246
|
*/
|
|
247
247
|
zoomOnWalkthroughFocus?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Duration (ms) of the camera pan/zoom when a walkthrough step or flow is
|
|
250
|
+
* focused. Higher = a slower, more legible flight between steps.
|
|
251
|
+
* @default 300
|
|
252
|
+
*/
|
|
253
|
+
walkthroughFocusDurationMs?: number;
|
|
248
254
|
/**
|
|
249
255
|
* Subsystem title rendered as a non-interactive overlay chip on the graph
|
|
250
256
|
* canvas (top-center). Does not trigger the sidebar — for graph-only
|
|
@@ -313,6 +319,13 @@ export interface SubsystemComponentGraphProps {
|
|
|
313
319
|
* updates it as the focused step changes.
|
|
314
320
|
*/
|
|
315
321
|
renderWalkthroughViewer?: (ctx: WalkthroughViewerContext) => ReactNode;
|
|
322
|
+
/**
|
|
323
|
+
* Suppress the bottom file/walkthrough drawer entirely. Focusing a step then
|
|
324
|
+
* only frames it on the canvas (and dims the rest) without dropping a snippet
|
|
325
|
+
* panel below — for embeds that want the camera to tell the story. Defaults
|
|
326
|
+
* to false.
|
|
327
|
+
*/
|
|
328
|
+
hideDrawer?: boolean;
|
|
316
329
|
/**
|
|
317
330
|
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
318
331
|
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
@@ -457,9 +470,9 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
457
470
|
measured: { w: number; h: number } | null;
|
|
458
471
|
}
|
|
459
472
|
|
|
460
|
-
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, persistKey }: InnerProps) {
|
|
473
|
+
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey }: InnerProps) {
|
|
461
474
|
const { theme } = useTheme();
|
|
462
|
-
const { fitView } = useReactFlow();
|
|
475
|
+
const { fitView, fitBounds } = useReactFlow();
|
|
463
476
|
const viewport = useViewport();
|
|
464
477
|
// Restored once per mount from `localStorage` (see `readViewState`). Each
|
|
465
478
|
// graph is its own tab/mount, so `persistKey` is stable for a mount.
|
|
@@ -468,9 +481,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
468
481
|
() => deriveGraphEdges({ relations, walkthroughs }),
|
|
469
482
|
[relations, walkthroughs],
|
|
470
483
|
);
|
|
471
|
-
const [built, setBuilt] = useState<{
|
|
484
|
+
const [built, setBuilt] = useState<{
|
|
485
|
+
nodes: Node[];
|
|
486
|
+
edges: Edge[];
|
|
487
|
+
absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
488
|
+
}>({
|
|
472
489
|
nodes: [],
|
|
473
490
|
edges: [],
|
|
491
|
+
absoluteRects: new Map(),
|
|
474
492
|
});
|
|
475
493
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
476
494
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
@@ -623,31 +641,50 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
623
641
|
if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
|
|
624
642
|
return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
|
|
625
643
|
}, [drawerTarget, walkthroughs]);
|
|
644
|
+
// Autoplay focus: a sidebar-less embed has no expanded flow to "open", so
|
|
645
|
+
// branding every step as a selection would hide all other nodes. Instead the
|
|
646
|
+
// autoplay sets this (flow id + step) to frame the hop and dim the rest —
|
|
647
|
+
// same treatment as hover — without hiding anything. Declared before the
|
|
648
|
+
// overlay memos, which read it to title the canvas chip + step bar.
|
|
649
|
+
const [autoPlayFocus, setAutoPlayFocus] = useState<
|
|
650
|
+
{ walkthroughId: string; stepIndex: number } | null
|
|
651
|
+
>(null);
|
|
626
652
|
// Ref mirror of the drawer's walkthrough id so the symbol resolver stays
|
|
627
653
|
// stable across graph re-renders (the drawer is memoized on callback identity).
|
|
628
654
|
const drawerWalkthroughIdRef = useRef<string | null>(null);
|
|
629
655
|
drawerWalkthroughIdRef.current = focusedWalkthrough?.id ?? null;
|
|
630
656
|
|
|
631
|
-
// Walkthrough shown on the canvas title chip (focus
|
|
657
|
+
// Walkthrough shown on the canvas title chip (focus, hover highlight, or
|
|
658
|
+
// autoplay focus).
|
|
632
659
|
const overlayWalkthroughTitle = useMemo(() => {
|
|
633
660
|
if (!showWalkthroughTitle || !walkthroughs?.length) return null;
|
|
634
|
-
const id =
|
|
661
|
+
const id =
|
|
662
|
+
focusedWalkthroughId ??
|
|
663
|
+
autoPlayFocus?.walkthroughId ??
|
|
664
|
+
hoveredWalkthroughStep?.walkthroughId;
|
|
635
665
|
if (!id) return null;
|
|
636
666
|
return walkthroughs.find((t) => t.id === id)?.title ?? null;
|
|
637
667
|
}, [
|
|
638
668
|
showWalkthroughTitle,
|
|
639
669
|
walkthroughs,
|
|
640
670
|
focusedWalkthroughId,
|
|
671
|
+
autoPlayFocus,
|
|
641
672
|
hoveredWalkthroughStep,
|
|
642
673
|
]);
|
|
643
674
|
|
|
644
675
|
// Active step for the bottom-of-title progress + annotation chip.
|
|
645
676
|
const overlayWalkthroughStep = useMemo(() => {
|
|
646
677
|
if (!showWalkthroughTitle || !walkthroughs?.length) return null;
|
|
647
|
-
const tlId =
|
|
678
|
+
const tlId =
|
|
679
|
+
focusedWalkthroughId ??
|
|
680
|
+
autoPlayFocus?.walkthroughId ??
|
|
681
|
+
hoveredWalkthroughStep?.walkthroughId ??
|
|
682
|
+
null;
|
|
648
683
|
let stepIndex: number | null = null;
|
|
649
684
|
if (focusedWalkthroughId != null) {
|
|
650
685
|
stepIndex = focusedStepIndex;
|
|
686
|
+
} else if (autoPlayFocus != null) {
|
|
687
|
+
stepIndex = autoPlayFocus.stepIndex;
|
|
651
688
|
} else if (hoveredWalkthroughStep != null) {
|
|
652
689
|
stepIndex = hoveredWalkthroughStep.stepIndex;
|
|
653
690
|
}
|
|
@@ -665,6 +702,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
665
702
|
walkthroughs,
|
|
666
703
|
focusedWalkthroughId,
|
|
667
704
|
focusedStepIndex,
|
|
705
|
+
autoPlayFocus,
|
|
668
706
|
hoveredWalkthroughStep,
|
|
669
707
|
]);
|
|
670
708
|
|
|
@@ -727,7 +765,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
727
765
|
showEdgeLabels,
|
|
728
766
|
showSingletonFrames,
|
|
729
767
|
})
|
|
730
|
-
.then(({ nodes, edges: e }) => {
|
|
768
|
+
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
731
769
|
if (!alive) return;
|
|
732
770
|
// Prune dims for removed leaves; keep measurements for stable ids so a
|
|
733
771
|
// live update can finish Pass 2 without waiting on new `dimensions` events.
|
|
@@ -737,14 +775,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
737
775
|
for (const id of measuredDimsRef.current.keys()) {
|
|
738
776
|
if (!leafIds.has(id)) measuredDimsRef.current.delete(id);
|
|
739
777
|
}
|
|
740
|
-
setBuilt({ nodes, edges: e as Edge[] });
|
|
778
|
+
setBuilt({ nodes, edges: e as Edge[], absoluteRects });
|
|
741
779
|
setLayoutReady(false);
|
|
742
780
|
})
|
|
743
781
|
.catch((err) => {
|
|
744
782
|
console.warn('[subsystem-graph] initial layout failed:', err);
|
|
745
783
|
if (!alive) return;
|
|
746
784
|
// Reveal the cover even on failure so the UI is not stuck forever.
|
|
747
|
-
setBuilt({ nodes: [], edges: [] });
|
|
785
|
+
setBuilt({ nodes: [], edges: [], absoluteRects: new Map() });
|
|
748
786
|
setLayoutReady(true);
|
|
749
787
|
});
|
|
750
788
|
return () => { alive = false; };
|
|
@@ -777,9 +815,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
777
815
|
{ components, relations, walkthroughs },
|
|
778
816
|
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
|
|
779
817
|
)
|
|
780
|
-
.then(({ nodes, edges: e }) => {
|
|
818
|
+
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
781
819
|
if (gen !== pass2GenRef.current) return;
|
|
782
|
-
setBuilt({ nodes, edges: e as Edge[] });
|
|
820
|
+
setBuilt({ nodes, edges: e as Edge[], absoluteRects });
|
|
783
821
|
setLayoutReady(true);
|
|
784
822
|
})
|
|
785
823
|
.catch((err) => {
|
|
@@ -879,6 +917,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
879
917
|
// Edge ids in walkthrough focus (an active flow's edge set, or a single
|
|
880
918
|
// step's edge). Used to frame the camera. `null` = no walkthrough focus.
|
|
881
919
|
const focusEdgeIds = useMemo(() => {
|
|
920
|
+
if (autoPlayFocus) {
|
|
921
|
+
const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
|
|
922
|
+
const step = tl?.steps[autoPlayFocus.stepIndex];
|
|
923
|
+
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
924
|
+
}
|
|
882
925
|
if (focusedWalkthroughId == null || !walkthroughs) return null;
|
|
883
926
|
const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
|
|
884
927
|
if (!tl) return null;
|
|
@@ -887,7 +930,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
887
930
|
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
888
931
|
}
|
|
889
932
|
return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
|
|
890
|
-
}, [walkthroughs, focusedWalkthroughId, focusedStepIndex]);
|
|
933
|
+
}, [walkthroughs, focusedWalkthroughId, focusedStepIndex, autoPlayFocus]);
|
|
891
934
|
|
|
892
935
|
// 1-based step numbers per edge of the active flow (focused or
|
|
893
936
|
// hover/autoplay-highlighted). An edge can appear in more than one step.
|
|
@@ -941,12 +984,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
941
984
|
// selected + hovered participants. Whole-flow focus (or no focus) keeps
|
|
942
985
|
// the old hover-replace preview so a step hover still dims the rest.
|
|
943
986
|
const previewEdgeIds = useMemo(() => {
|
|
987
|
+
// Autoplay drives focus without a hover, so its hop is the preview set.
|
|
988
|
+
if (autoPlayFocus) return focusEdgeIds;
|
|
944
989
|
if (!hoverEdgeIds) return null;
|
|
990
|
+
// While hovering with a *step* already selected, brighten the union of
|
|
991
|
+
// selected + hovered participants; whole-flow focus keeps the old
|
|
992
|
+
// hover-replace preview so a step hover still dims the rest.
|
|
945
993
|
if (focusedStepIndex == null || !focusEdgeIds) return hoverEdgeIds;
|
|
946
994
|
const ids = new Set(hoverEdgeIds);
|
|
947
995
|
for (const id of focusEdgeIds) ids.add(id);
|
|
948
996
|
return ids;
|
|
949
|
-
}, [hoverEdgeIds, focusEdgeIds, focusedStepIndex]);
|
|
997
|
+
}, [hoverEdgeIds, focusEdgeIds, focusedStepIndex, autoPlayFocus]);
|
|
950
998
|
const previewNodeIds = useMemo(
|
|
951
999
|
() => endpointsOf(previewEdgeIds),
|
|
952
1000
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -988,22 +1036,26 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
988
1036
|
// While a step/flow is hovered, selected + hovered participants stay
|
|
989
1037
|
// bright; everything else among the currently-visible (opened-walkthrough)
|
|
990
1038
|
// nodes is dimmed. Nodes outside the opened-walkthrough set stay hidden.
|
|
1039
|
+
// Exception: with no expanded flow at all (a sidebar-less embed), there is
|
|
1040
|
+
// nothing to hide toward — everything shows, and focus/hover only dims.
|
|
1041
|
+
const noOpenedFlow = openedEdgeIds == null;
|
|
991
1042
|
return xyflowNodesBase.map((n) => {
|
|
992
1043
|
// Boundary frames follow their members: hidden when no member is
|
|
993
1044
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
994
1045
|
if (n.type === 'subsystem-group') {
|
|
995
1046
|
const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
|
|
996
1047
|
const memberAliases = region?.memberAliases ?? [];
|
|
1048
|
+
// Hover/autoplay dim-only: never hide a frame (see the node branch).
|
|
997
1049
|
const vis = flowElementVisibility({
|
|
998
1050
|
inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
|
|
999
1051
|
inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
|
|
1000
1052
|
anyOpened: openedNodeIds != null,
|
|
1001
|
-
anySelected: brightNodeIds != null,
|
|
1053
|
+
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1002
1054
|
});
|
|
1003
1055
|
const dimmed = previewNodeIds
|
|
1004
1056
|
? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
|
|
1005
1057
|
: vis.dimmed;
|
|
1006
|
-
const hidden = vis.hidden;
|
|
1058
|
+
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1007
1059
|
// Host override wins over the library's derived frame color.
|
|
1008
1060
|
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
1009
1061
|
return {
|
|
@@ -1019,15 +1071,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1019
1071
|
}
|
|
1020
1072
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
1021
1073
|
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
1074
|
+
// Hover/autoplay is a dim-only signal: never hide non-participants (there
|
|
1075
|
+
// may be no "opened" flow to reveal them, e.g. a sidebar-less autoplay
|
|
1076
|
+
// embed), only dim them. Bases selection on the focused step, not the
|
|
1077
|
+
// hover preview, so hovering doesn't strip the frame's `isSelected`.
|
|
1022
1078
|
const isSelected = selected?.alias !== undefined && comp?.alias === selected.alias;
|
|
1023
1079
|
const vis = flowElementVisibility({
|
|
1024
1080
|
inOpened: openedNodeIds?.has(n.id) === true,
|
|
1025
1081
|
inSelected: brightNodeIds?.has(n.id) === true,
|
|
1026
1082
|
anyOpened: openedNodeIds != null,
|
|
1027
|
-
anySelected: brightNodeIds != null,
|
|
1083
|
+
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1028
1084
|
});
|
|
1029
1085
|
const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
|
|
1030
|
-
const hidden = vis.hidden;
|
|
1086
|
+
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1031
1087
|
if (fileMatch === undefined && !isSelected && !dimmed) {
|
|
1032
1088
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
1033
1089
|
return { ...n, hidden, data: rest };
|
|
@@ -1317,27 +1373,70 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1317
1373
|
return () => window.removeEventListener('keydown', onKey);
|
|
1318
1374
|
}, [fileOverlayOpen, closeFileOverlay]);
|
|
1319
1375
|
|
|
1320
|
-
// Camera helper shared by the walkthrough interactions
|
|
1321
|
-
// edges' endpoint
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1376
|
+
// Camera helper shared by the walkthrough interactions. Fits the union of
|
|
1377
|
+
// the focused edges' endpoint rects AND their routed waypoints — the edge
|
|
1378
|
+
// carries its full polyline (`elkPathPoints`, absolute flow coords), so the
|
|
1379
|
+
// frame is the line the hop actually traces. That keeps a hop whose route
|
|
1380
|
+
// bulges out around intervening nodes from being clipped at the viewport
|
|
1381
|
+
// edge, and it is the edge's own geometry doing the guiding, not fudge
|
|
1382
|
+
// padding.
|
|
1324
1383
|
const fitFocusBounds = useCallback(
|
|
1325
1384
|
(ids: ReadonlySet<string>) => {
|
|
1326
1385
|
if (!zoomOnWalkthroughFocus) return;
|
|
1327
1386
|
const nodeIds = new Set<string>();
|
|
1387
|
+
const points: { x: number; y: number }[] = [];
|
|
1328
1388
|
for (const e of baseEdges) {
|
|
1329
1389
|
if (!ids.has(e.id)) continue;
|
|
1330
1390
|
nodeIds.add(e.source);
|
|
1331
1391
|
nodeIds.add(e.target);
|
|
1392
|
+
const pts = (e.data as { elkPathPoints?: { x: number; y: number }[] } | undefined)
|
|
1393
|
+
?.elkPathPoints;
|
|
1394
|
+
if (pts?.length) points.push(...pts);
|
|
1332
1395
|
}
|
|
1333
1396
|
if (nodeIds.size === 0) return;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1397
|
+
|
|
1398
|
+
// Box the focused edge(s): endpoint node rects UNION the routed line's
|
|
1399
|
+
// waypoints. Both come from the layout in absolute flow coords, so no
|
|
1400
|
+
// coordinate-space mixing (React Flow's grouped child `position`s are
|
|
1401
|
+
// parent-relative and would skew the box).
|
|
1402
|
+
const rects = built.absoluteRects;
|
|
1403
|
+
let minX = Infinity;
|
|
1404
|
+
let minY = Infinity;
|
|
1405
|
+
let maxX = -Infinity;
|
|
1406
|
+
let maxY = -Infinity;
|
|
1407
|
+
for (const id of nodeIds) {
|
|
1408
|
+
const r = rects.get(id);
|
|
1409
|
+
if (!r) continue;
|
|
1410
|
+
minX = Math.min(minX, r.x);
|
|
1411
|
+
minY = Math.min(minY, r.y);
|
|
1412
|
+
maxX = Math.max(maxX, r.x + r.width);
|
|
1413
|
+
maxY = Math.max(maxY, r.y + r.height);
|
|
1414
|
+
}
|
|
1415
|
+
for (const p of points) {
|
|
1416
|
+
minX = Math.min(minX, p.x);
|
|
1417
|
+
minY = Math.min(minY, p.y);
|
|
1418
|
+
maxX = Math.max(maxX, p.x);
|
|
1419
|
+
maxY = Math.max(maxY, p.y);
|
|
1420
|
+
}
|
|
1421
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
|
|
1422
|
+
const bounds = {
|
|
1423
|
+
x: minX,
|
|
1424
|
+
y: minY,
|
|
1425
|
+
width: Math.max(1, maxX - minX),
|
|
1426
|
+
height: Math.max(1, maxY - minY),
|
|
1427
|
+
};
|
|
1428
|
+
fitBounds(bounds, {
|
|
1429
|
+
padding: 0.15,
|
|
1430
|
+
duration: walkthroughFocusDurationMs,
|
|
1338
1431
|
});
|
|
1339
1432
|
},
|
|
1340
|
-
[
|
|
1433
|
+
[
|
|
1434
|
+
baseEdges,
|
|
1435
|
+
built.absoluteRects,
|
|
1436
|
+
fitBounds,
|
|
1437
|
+
zoomOnWalkthroughFocus,
|
|
1438
|
+
walkthroughFocusDurationMs,
|
|
1439
|
+
],
|
|
1341
1440
|
);
|
|
1342
1441
|
|
|
1343
1442
|
// Zoom back out to the full diagram after the last expanded walkthrough
|
|
@@ -1511,8 +1610,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1511
1610
|
prevPreviewEdgeIdsRef.current = previewEdgeIds;
|
|
1512
1611
|
|
|
1513
1612
|
if (!zoomOnWalkthroughFocus || walkthroughStepMode === 'dim') return;
|
|
1514
|
-
// Camera follows hover only when a specific step is already focused
|
|
1515
|
-
|
|
1613
|
+
// Camera follows hover only when a specific step is already focused (or
|
|
1614
|
+
// autoplay is driving an auto-focus).
|
|
1615
|
+
if (!autoPlayFocus && (focusedWalkthroughId == null || focusedStepIndex == null)) {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1516
1618
|
|
|
1517
1619
|
if (previewEdgeIds) {
|
|
1518
1620
|
fitFocusBounds(previewEdgeIds);
|
|
@@ -1526,46 +1628,93 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1526
1628
|
focusEdgeIds,
|
|
1527
1629
|
focusedWalkthroughId,
|
|
1528
1630
|
focusedStepIndex,
|
|
1631
|
+
autoPlayFocus,
|
|
1529
1632
|
fitFocusBounds,
|
|
1530
1633
|
zoomOnWalkthroughFocus,
|
|
1531
1634
|
walkthroughStepMode,
|
|
1532
1635
|
]);
|
|
1533
1636
|
|
|
1534
|
-
// Graph-only embeds: cycle walkthrough steps
|
|
1637
|
+
// Graph-only embeds: cycle walkthrough steps. In `focus` mode each step is
|
|
1638
|
+
// selected (camera frames it via fitFocusBounds) and, when a walkthrough
|
|
1639
|
+
// viewer is supplied, its snippet drawer opens; in `dim` mode it only
|
|
1640
|
+
// dim-highlights the step (hover-style) with no camera move.
|
|
1535
1641
|
useEffect(() => {
|
|
1536
|
-
if (!autoPlayWalkthroughs ||
|
|
1642
|
+
if (!autoPlayWalkthroughs || walkthroughs == null || walkthroughs.length === 0) return;
|
|
1643
|
+
if (!layoutReady) return;
|
|
1537
1644
|
const playable = walkthroughs.filter((tl) => tl.steps.length > 0);
|
|
1538
1645
|
if (playable.length === 0) return;
|
|
1646
|
+
// Only run the cycles that can actually be shown in the current edge view.
|
|
1647
|
+
const inView = playable.filter((tl) =>
|
|
1648
|
+
tl.steps.some((s) =>
|
|
1649
|
+
resolvedEdgeView === 'relations'
|
|
1650
|
+
? isRelationMechanism(s.mechanism)
|
|
1651
|
+
: isWalkthroughMechanism(s.mechanism),
|
|
1652
|
+
),
|
|
1653
|
+
);
|
|
1654
|
+
if (inView.length === 0) return;
|
|
1655
|
+
const cyc = inView;
|
|
1539
1656
|
|
|
1540
1657
|
let cancelled = false;
|
|
1541
1658
|
let tlIdx = 0;
|
|
1542
1659
|
let stepIdx = 0;
|
|
1543
1660
|
let timer: number | null = null;
|
|
1544
1661
|
const interval = Math.max(400, walkthroughAutoPlayIntervalMs);
|
|
1662
|
+
const focusMode = walkthroughStepMode !== 'dim';
|
|
1663
|
+
let cleanup = () => {};
|
|
1545
1664
|
|
|
1546
1665
|
const tick = () => {
|
|
1547
1666
|
if (cancelled) return;
|
|
1548
|
-
const tl =
|
|
1667
|
+
const tl = cyc[tlIdx]!;
|
|
1549
1668
|
setSelected(null);
|
|
1550
1669
|
setSelectedEdgeId(null);
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1670
|
+
if (focusMode) {
|
|
1671
|
+
setHoveredWalkthroughStep(null);
|
|
1672
|
+
setFocusedWalkthroughId(null);
|
|
1673
|
+
setFocusedStepIndex(null);
|
|
1674
|
+
setAutoPlayFocus({ walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1675
|
+
if (!hideDrawer) {
|
|
1676
|
+
const step = tl.steps[stepIdx];
|
|
1677
|
+
if (step) {
|
|
1678
|
+
if (renderWalkthroughViewer) {
|
|
1679
|
+
setDrawerTarget({ kind: 'walkthrough', walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1680
|
+
} else {
|
|
1681
|
+
setDrawerTarget({ kind: 'file', file: step.file, startLine: step.line });
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
} else {
|
|
1686
|
+
setAutoPlayFocus(null);
|
|
1687
|
+
setFocusedWalkthroughId(null);
|
|
1688
|
+
setFocusedStepIndex(null);
|
|
1689
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1690
|
+
}
|
|
1554
1691
|
stepIdx += 1;
|
|
1555
1692
|
if (stepIdx >= tl.steps.length) {
|
|
1556
1693
|
stepIdx = 0;
|
|
1557
|
-
tlIdx = (tlIdx + 1) %
|
|
1694
|
+
tlIdx = (tlIdx + 1) % cyc.length;
|
|
1558
1695
|
}
|
|
1559
1696
|
timer = window.setTimeout(tick, interval);
|
|
1560
1697
|
};
|
|
1561
1698
|
|
|
1562
1699
|
tick();
|
|
1563
|
-
|
|
1700
|
+
cleanup = () => {
|
|
1564
1701
|
cancelled = true;
|
|
1565
1702
|
if (timer != null) window.clearTimeout(timer);
|
|
1566
1703
|
setHoveredWalkthroughStep(null);
|
|
1704
|
+
setFocusedWalkthroughId(null);
|
|
1705
|
+
setFocusedStepIndex(null);
|
|
1706
|
+
setAutoPlayFocus(null);
|
|
1567
1707
|
};
|
|
1568
|
-
|
|
1708
|
+
return cleanup;
|
|
1709
|
+
}, [
|
|
1710
|
+
autoPlayWalkthroughs,
|
|
1711
|
+
walkthroughs,
|
|
1712
|
+
walkthroughAutoPlayIntervalMs,
|
|
1713
|
+
layoutReady,
|
|
1714
|
+
walkthroughStepMode,
|
|
1715
|
+
renderWalkthroughViewer,
|
|
1716
|
+
hideDrawer,
|
|
1717
|
+
]);
|
|
1569
1718
|
|
|
1570
1719
|
// Arrow keys step through the focused walkthrough once a step is active
|
|
1571
1720
|
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
@@ -2390,6 +2539,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2390
2539
|
onClose={closeDrawer}
|
|
2391
2540
|
fillHeight={drawerFillHeight}
|
|
2392
2541
|
suppressEscape={fileOverlayOpen}
|
|
2542
|
+
hidden={hideDrawer}
|
|
2393
2543
|
>
|
|
2394
2544
|
{drawerTarget?.kind === 'walkthrough' &&
|
|
2395
2545
|
focusedWalkthrough &&
|
package/src/subsystem/model.ts
CHANGED
|
@@ -984,6 +984,13 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
|
984
984
|
pathLength?: number;
|
|
985
985
|
/** ELK-computed SVG edge path (overrides React Flow's default path). */
|
|
986
986
|
elkPath?: string;
|
|
987
|
+
/**
|
|
988
|
+
* ELK-computed route waypoints (absolute flow coords) for this edge. Kept so
|
|
989
|
+
* the camera can include the whole routed line — not just the endpoint nodes
|
|
990
|
+
* — when framing a focused hop, which otherwise clips edges that bulge out
|
|
991
|
+
* around their endpoints.
|
|
992
|
+
*/
|
|
993
|
+
elkPathPoints?: { x: number; y: number }[];
|
|
987
994
|
}
|
|
988
995
|
|
|
989
996
|
export type SubsystemGraphEdge = Edge<SubsystemGraphEdgeData>;
|
|
@@ -1544,6 +1551,12 @@ export async function buildSubsystemGraph(
|
|
|
1544
1551
|
nodes: SubsystemGraphNode[];
|
|
1545
1552
|
edges: SubsystemGraphEdge[];
|
|
1546
1553
|
regions: SubsystemProcessRegion[];
|
|
1554
|
+
/**
|
|
1555
|
+
* Absolute flow-coord rect per node id (leaves + groups), same space as each
|
|
1556
|
+
* edge's `elkPathPoints`. Lets the camera union node rects with a routed edge
|
|
1557
|
+
* without mixing React Flow's parent-relative child positions in.
|
|
1558
|
+
*/
|
|
1559
|
+
absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
1547
1560
|
}> {
|
|
1548
1561
|
const {
|
|
1549
1562
|
maxNodeWidth,
|
|
@@ -1637,6 +1650,10 @@ export async function buildSubsystemGraph(
|
|
|
1637
1650
|
let labelPositions = new Map<string, { x: number; y: number }>();
|
|
1638
1651
|
let elkPathStrings = new Map<string, string>();
|
|
1639
1652
|
let elkPathPoints = new Map<string, { x: number; y: number }[]>();
|
|
1653
|
+
let absoluteRects = new Map<
|
|
1654
|
+
string,
|
|
1655
|
+
{ x: number; y: number; width: number; height: number }
|
|
1656
|
+
>();
|
|
1640
1657
|
if (nodes.length > 0) {
|
|
1641
1658
|
try {
|
|
1642
1659
|
const result = await computeElkLayout(nodes, edges, {
|
|
@@ -1711,6 +1728,7 @@ export async function buildSubsystemGraph(
|
|
|
1711
1728
|
labelPositions = result.edgeLabelPositions;
|
|
1712
1729
|
elkPathStrings = result.edgePaths;
|
|
1713
1730
|
elkPathPoints = result.edgePathPoints;
|
|
1731
|
+
absoluteRects = result.absoluteRects;
|
|
1714
1732
|
} catch (err) {
|
|
1715
1733
|
// Fall back to the (unpositioned) grid if ELK is unavailable — still
|
|
1716
1734
|
// emit multi-member frames so parentId targets exist.
|
|
@@ -1764,8 +1782,9 @@ export async function buildSubsystemGraph(
|
|
|
1764
1782
|
if (pts && pts.length > 1) {
|
|
1765
1783
|
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
1766
1784
|
d.pathLength = calculatePathLength(pts);
|
|
1785
|
+
d.elkPathPoints = pts;
|
|
1767
1786
|
}
|
|
1768
1787
|
}
|
|
1769
1788
|
|
|
1770
|
-
return { nodes: placedNodes, edges, regions };
|
|
1789
|
+
return { nodes: placedNodes, edges, regions, absoluteRects };
|
|
1771
1790
|
}
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -116,6 +116,13 @@ export interface ElkLayoutResult {
|
|
|
116
116
|
edgePathPoints: Map<string, Point[]>;
|
|
117
117
|
/** Compound parent bounds from ELK (absolute flow coords), keyed by group id. */
|
|
118
118
|
groupBounds: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
119
|
+
/**
|
|
120
|
+
* Absolute flow-coord rect for every laid-out node (leaves and groups),
|
|
121
|
+
* including grouped children whose React Flow `position` is parent-relative.
|
|
122
|
+
* Same space as `edgePathPoints`, so callers can union node rects with a
|
|
123
|
+
* route without mixing coordinate systems.
|
|
124
|
+
*/
|
|
125
|
+
absoluteRects: Map<string, { x: number; y: number; width: number; height: number }>;
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/** Point in 2D space */
|
|
@@ -744,10 +751,22 @@ export async function computeElkLayout(
|
|
|
744
751
|
const elkPositions = new Map<string, { x: number; y: number }>();
|
|
745
752
|
const elkRelativePositions = new Map<string, { x: number; y: number }>();
|
|
746
753
|
const groupBounds = new Map<string, { x: number; y: number; width: number; height: number }>();
|
|
754
|
+
// Absolute rect per node, in the same space as `edgePathPoints`. Uses ELK's
|
|
755
|
+
// own sizes so it's valid before React Flow has measured anything.
|
|
756
|
+
const absoluteRects = new Map<
|
|
757
|
+
string,
|
|
758
|
+
{ x: number; y: number; width: number; height: number }
|
|
759
|
+
>();
|
|
747
760
|
const walkElk = (n: ElkNode, ox: number, oy: number) => {
|
|
748
761
|
const ax = ox + (n.x ?? 0);
|
|
749
762
|
const ay = oy + (n.y ?? 0);
|
|
750
763
|
elkAbsOffsets.set(n.id, { x: ax, y: ay });
|
|
764
|
+
absoluteRects.set(n.id, {
|
|
765
|
+
x: ax,
|
|
766
|
+
y: ay,
|
|
767
|
+
width: n.width ?? 0,
|
|
768
|
+
height: n.height ?? 0,
|
|
769
|
+
});
|
|
751
770
|
for (const c of n.children ?? []) walkElk(c, ax, ay);
|
|
752
771
|
};
|
|
753
772
|
walkElk(layoutedGraph, 0, 0);
|
|
@@ -969,6 +988,7 @@ export async function computeElkLayout(
|
|
|
969
988
|
edgeLabelPositions,
|
|
970
989
|
edgePathPoints,
|
|
971
990
|
groupBounds,
|
|
991
|
+
absoluteRects,
|
|
972
992
|
};
|
|
973
993
|
}
|
|
974
994
|
|