@principal-ai/subsystems-react 0.37.9 → 0.37.11
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 +159 -40
- 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 +178 -41
- 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);
|
|
@@ -727,7 +745,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
727
745
|
showEdgeLabels,
|
|
728
746
|
showSingletonFrames,
|
|
729
747
|
})
|
|
730
|
-
.then(({ nodes, edges: e }) => {
|
|
748
|
+
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
731
749
|
if (!alive) return;
|
|
732
750
|
// Prune dims for removed leaves; keep measurements for stable ids so a
|
|
733
751
|
// live update can finish Pass 2 without waiting on new `dimensions` events.
|
|
@@ -737,14 +755,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
737
755
|
for (const id of measuredDimsRef.current.keys()) {
|
|
738
756
|
if (!leafIds.has(id)) measuredDimsRef.current.delete(id);
|
|
739
757
|
}
|
|
740
|
-
setBuilt({ nodes, edges: e as Edge[] });
|
|
758
|
+
setBuilt({ nodes, edges: e as Edge[], absoluteRects });
|
|
741
759
|
setLayoutReady(false);
|
|
742
760
|
})
|
|
743
761
|
.catch((err) => {
|
|
744
762
|
console.warn('[subsystem-graph] initial layout failed:', err);
|
|
745
763
|
if (!alive) return;
|
|
746
764
|
// Reveal the cover even on failure so the UI is not stuck forever.
|
|
747
|
-
setBuilt({ nodes: [], edges: [] });
|
|
765
|
+
setBuilt({ nodes: [], edges: [], absoluteRects: new Map() });
|
|
748
766
|
setLayoutReady(true);
|
|
749
767
|
});
|
|
750
768
|
return () => { alive = false; };
|
|
@@ -777,9 +795,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
777
795
|
{ components, relations, walkthroughs },
|
|
778
796
|
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
|
|
779
797
|
)
|
|
780
|
-
.then(({ nodes, edges: e }) => {
|
|
798
|
+
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
781
799
|
if (gen !== pass2GenRef.current) return;
|
|
782
|
-
setBuilt({ nodes, edges: e as Edge[] });
|
|
800
|
+
setBuilt({ nodes, edges: e as Edge[], absoluteRects });
|
|
783
801
|
setLayoutReady(true);
|
|
784
802
|
})
|
|
785
803
|
.catch((err) => {
|
|
@@ -876,9 +894,21 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
876
894
|
// without a stale closure value.
|
|
877
895
|
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
878
896
|
selectedEdgeIdRef.current = selectedEdgeId;
|
|
897
|
+
// Autoplay focus: a sidebar-less embed has no expanded flow to "open", so
|
|
898
|
+
// branding every step as a selection would hide all other nodes. Instead the
|
|
899
|
+
// autoplay sets this (flow id + step) to frame the hop and dim the rest —
|
|
900
|
+
// same treatment as hover — without hiding anything.
|
|
901
|
+
const [autoPlayFocus, setAutoPlayFocus] = useState<
|
|
902
|
+
{ walkthroughId: string; stepIndex: number } | null
|
|
903
|
+
>(null);
|
|
879
904
|
// Edge ids in walkthrough focus (an active flow's edge set, or a single
|
|
880
905
|
// step's edge). Used to frame the camera. `null` = no walkthrough focus.
|
|
881
906
|
const focusEdgeIds = useMemo(() => {
|
|
907
|
+
if (autoPlayFocus) {
|
|
908
|
+
const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
|
|
909
|
+
const step = tl?.steps[autoPlayFocus.stepIndex];
|
|
910
|
+
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
911
|
+
}
|
|
882
912
|
if (focusedWalkthroughId == null || !walkthroughs) return null;
|
|
883
913
|
const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
|
|
884
914
|
if (!tl) return null;
|
|
@@ -887,7 +917,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
887
917
|
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
888
918
|
}
|
|
889
919
|
return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
|
|
890
|
-
}, [walkthroughs, focusedWalkthroughId, focusedStepIndex]);
|
|
920
|
+
}, [walkthroughs, focusedWalkthroughId, focusedStepIndex, autoPlayFocus]);
|
|
891
921
|
|
|
892
922
|
// 1-based step numbers per edge of the active flow (focused or
|
|
893
923
|
// hover/autoplay-highlighted). An edge can appear in more than one step.
|
|
@@ -941,12 +971,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
941
971
|
// selected + hovered participants. Whole-flow focus (or no focus) keeps
|
|
942
972
|
// the old hover-replace preview so a step hover still dims the rest.
|
|
943
973
|
const previewEdgeIds = useMemo(() => {
|
|
974
|
+
// Autoplay drives focus without a hover, so its hop is the preview set.
|
|
975
|
+
if (autoPlayFocus) return focusEdgeIds;
|
|
944
976
|
if (!hoverEdgeIds) return null;
|
|
977
|
+
// While hovering with a *step* already selected, brighten the union of
|
|
978
|
+
// selected + hovered participants; whole-flow focus keeps the old
|
|
979
|
+
// hover-replace preview so a step hover still dims the rest.
|
|
945
980
|
if (focusedStepIndex == null || !focusEdgeIds) return hoverEdgeIds;
|
|
946
981
|
const ids = new Set(hoverEdgeIds);
|
|
947
982
|
for (const id of focusEdgeIds) ids.add(id);
|
|
948
983
|
return ids;
|
|
949
|
-
}, [hoverEdgeIds, focusEdgeIds, focusedStepIndex]);
|
|
984
|
+
}, [hoverEdgeIds, focusEdgeIds, focusedStepIndex, autoPlayFocus]);
|
|
950
985
|
const previewNodeIds = useMemo(
|
|
951
986
|
() => endpointsOf(previewEdgeIds),
|
|
952
987
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -988,22 +1023,26 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
988
1023
|
// While a step/flow is hovered, selected + hovered participants stay
|
|
989
1024
|
// bright; everything else among the currently-visible (opened-walkthrough)
|
|
990
1025
|
// nodes is dimmed. Nodes outside the opened-walkthrough set stay hidden.
|
|
1026
|
+
// Exception: with no expanded flow at all (a sidebar-less embed), there is
|
|
1027
|
+
// nothing to hide toward — everything shows, and focus/hover only dims.
|
|
1028
|
+
const noOpenedFlow = openedEdgeIds == null;
|
|
991
1029
|
return xyflowNodesBase.map((n) => {
|
|
992
1030
|
// Boundary frames follow their members: hidden when no member is
|
|
993
1031
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
994
1032
|
if (n.type === 'subsystem-group') {
|
|
995
1033
|
const region = (n.data as { region?: { key?: string; memberAliases?: string[] } } | undefined)?.region;
|
|
996
1034
|
const memberAliases = region?.memberAliases ?? [];
|
|
1035
|
+
// Hover/autoplay dim-only: never hide a frame (see the node branch).
|
|
997
1036
|
const vis = flowElementVisibility({
|
|
998
1037
|
inOpened: memberAliases.some((alias) => openedNodeIds?.has(alias) === true),
|
|
999
1038
|
inSelected: memberAliases.some((alias) => brightNodeIds?.has(alias) === true),
|
|
1000
1039
|
anyOpened: openedNodeIds != null,
|
|
1001
|
-
anySelected: brightNodeIds != null,
|
|
1040
|
+
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1002
1041
|
});
|
|
1003
1042
|
const dimmed = previewNodeIds
|
|
1004
1043
|
? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
|
|
1005
1044
|
: vis.dimmed;
|
|
1006
|
-
const hidden = vis.hidden;
|
|
1045
|
+
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1007
1046
|
// Host override wins over the library's derived frame color.
|
|
1008
1047
|
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
1009
1048
|
return {
|
|
@@ -1019,15 +1058,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1019
1058
|
}
|
|
1020
1059
|
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
1021
1060
|
const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
|
|
1061
|
+
// Hover/autoplay is a dim-only signal: never hide non-participants (there
|
|
1062
|
+
// may be no "opened" flow to reveal them, e.g. a sidebar-less autoplay
|
|
1063
|
+
// embed), only dim them. Bases selection on the focused step, not the
|
|
1064
|
+
// hover preview, so hovering doesn't strip the frame's `isSelected`.
|
|
1022
1065
|
const isSelected = selected?.alias !== undefined && comp?.alias === selected.alias;
|
|
1023
1066
|
const vis = flowElementVisibility({
|
|
1024
1067
|
inOpened: openedNodeIds?.has(n.id) === true,
|
|
1025
1068
|
inSelected: brightNodeIds?.has(n.id) === true,
|
|
1026
1069
|
anyOpened: openedNodeIds != null,
|
|
1027
|
-
anySelected: brightNodeIds != null,
|
|
1070
|
+
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1028
1071
|
});
|
|
1029
1072
|
const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
|
|
1030
|
-
const hidden = vis.hidden;
|
|
1073
|
+
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1031
1074
|
if (fileMatch === undefined && !isSelected && !dimmed) {
|
|
1032
1075
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
1033
1076
|
return { ...n, hidden, data: rest };
|
|
@@ -1242,12 +1285,9 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1242
1285
|
// Boundary frame → frame its members on the canvas; double-click again to
|
|
1243
1286
|
// zoom back out. User-initiated, so it ignores the walkthrough zoom gate.
|
|
1244
1287
|
if (node.type === 'subsystem-group') {
|
|
1245
|
-
const
|
|
1246
|
-
(node.data as { region?: { key?: string
|
|
1247
|
-
|
|
1248
|
-
const key = region?.key;
|
|
1249
|
-
const members = region?.memberAliases ?? [];
|
|
1250
|
-
if (!key || members.length === 0) return;
|
|
1288
|
+
const key =
|
|
1289
|
+
(node.data as { region?: { key?: string } } | undefined)?.region?.key;
|
|
1290
|
+
if (!key) return;
|
|
1251
1291
|
if (focusedBoundaryKey === key) {
|
|
1252
1292
|
setFocusedBoundaryKey(null);
|
|
1253
1293
|
fitView({
|
|
@@ -1260,8 +1300,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1260
1300
|
return;
|
|
1261
1301
|
}
|
|
1262
1302
|
setFocusedBoundaryKey(key);
|
|
1303
|
+
// Frame the boundary node itself (not its members): the frame extends
|
|
1304
|
+
// above its topmost member for the label, and fitting members alone
|
|
1305
|
+
// clipped that top. The group node carries the full laid-out bounds.
|
|
1263
1306
|
fitView({
|
|
1264
|
-
nodes:
|
|
1307
|
+
nodes: [{ id: node.id }],
|
|
1265
1308
|
padding: 0.25,
|
|
1266
1309
|
duration: 300,
|
|
1267
1310
|
});
|
|
@@ -1317,27 +1360,70 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1317
1360
|
return () => window.removeEventListener('keydown', onKey);
|
|
1318
1361
|
}, [fileOverlayOpen, closeFileOverlay]);
|
|
1319
1362
|
|
|
1320
|
-
// Camera helper shared by the walkthrough interactions
|
|
1321
|
-
// edges' endpoint
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1363
|
+
// Camera helper shared by the walkthrough interactions. Fits the union of
|
|
1364
|
+
// the focused edges' endpoint rects AND their routed waypoints — the edge
|
|
1365
|
+
// carries its full polyline (`elkPathPoints`, absolute flow coords), so the
|
|
1366
|
+
// frame is the line the hop actually traces. That keeps a hop whose route
|
|
1367
|
+
// bulges out around intervening nodes from being clipped at the viewport
|
|
1368
|
+
// edge, and it is the edge's own geometry doing the guiding, not fudge
|
|
1369
|
+
// padding.
|
|
1324
1370
|
const fitFocusBounds = useCallback(
|
|
1325
1371
|
(ids: ReadonlySet<string>) => {
|
|
1326
1372
|
if (!zoomOnWalkthroughFocus) return;
|
|
1327
1373
|
const nodeIds = new Set<string>();
|
|
1374
|
+
const points: { x: number; y: number }[] = [];
|
|
1328
1375
|
for (const e of baseEdges) {
|
|
1329
1376
|
if (!ids.has(e.id)) continue;
|
|
1330
1377
|
nodeIds.add(e.source);
|
|
1331
1378
|
nodeIds.add(e.target);
|
|
1379
|
+
const pts = (e.data as { elkPathPoints?: { x: number; y: number }[] } | undefined)
|
|
1380
|
+
?.elkPathPoints;
|
|
1381
|
+
if (pts?.length) points.push(...pts);
|
|
1332
1382
|
}
|
|
1333
1383
|
if (nodeIds.size === 0) return;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1384
|
+
|
|
1385
|
+
// Box the focused edge(s): endpoint node rects UNION the routed line's
|
|
1386
|
+
// waypoints. Both come from the layout in absolute flow coords, so no
|
|
1387
|
+
// coordinate-space mixing (React Flow's grouped child `position`s are
|
|
1388
|
+
// parent-relative and would skew the box).
|
|
1389
|
+
const rects = built.absoluteRects;
|
|
1390
|
+
let minX = Infinity;
|
|
1391
|
+
let minY = Infinity;
|
|
1392
|
+
let maxX = -Infinity;
|
|
1393
|
+
let maxY = -Infinity;
|
|
1394
|
+
for (const id of nodeIds) {
|
|
1395
|
+
const r = rects.get(id);
|
|
1396
|
+
if (!r) continue;
|
|
1397
|
+
minX = Math.min(minX, r.x);
|
|
1398
|
+
minY = Math.min(minY, r.y);
|
|
1399
|
+
maxX = Math.max(maxX, r.x + r.width);
|
|
1400
|
+
maxY = Math.max(maxY, r.y + r.height);
|
|
1401
|
+
}
|
|
1402
|
+
for (const p of points) {
|
|
1403
|
+
minX = Math.min(minX, p.x);
|
|
1404
|
+
minY = Math.min(minY, p.y);
|
|
1405
|
+
maxX = Math.max(maxX, p.x);
|
|
1406
|
+
maxY = Math.max(maxY, p.y);
|
|
1407
|
+
}
|
|
1408
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
|
|
1409
|
+
const bounds = {
|
|
1410
|
+
x: minX,
|
|
1411
|
+
y: minY,
|
|
1412
|
+
width: Math.max(1, maxX - minX),
|
|
1413
|
+
height: Math.max(1, maxY - minY),
|
|
1414
|
+
};
|
|
1415
|
+
fitBounds(bounds, {
|
|
1416
|
+
padding: 0.15,
|
|
1417
|
+
duration: walkthroughFocusDurationMs,
|
|
1338
1418
|
});
|
|
1339
1419
|
},
|
|
1340
|
-
[
|
|
1420
|
+
[
|
|
1421
|
+
baseEdges,
|
|
1422
|
+
built.absoluteRects,
|
|
1423
|
+
fitBounds,
|
|
1424
|
+
zoomOnWalkthroughFocus,
|
|
1425
|
+
walkthroughFocusDurationMs,
|
|
1426
|
+
],
|
|
1341
1427
|
);
|
|
1342
1428
|
|
|
1343
1429
|
// Zoom back out to the full diagram after the last expanded walkthrough
|
|
@@ -1511,8 +1597,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1511
1597
|
prevPreviewEdgeIdsRef.current = previewEdgeIds;
|
|
1512
1598
|
|
|
1513
1599
|
if (!zoomOnWalkthroughFocus || walkthroughStepMode === 'dim') return;
|
|
1514
|
-
// Camera follows hover only when a specific step is already focused
|
|
1515
|
-
|
|
1600
|
+
// Camera follows hover only when a specific step is already focused (or
|
|
1601
|
+
// autoplay is driving an auto-focus).
|
|
1602
|
+
if (!autoPlayFocus && (focusedWalkthroughId == null || focusedStepIndex == null)) {
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1516
1605
|
|
|
1517
1606
|
if (previewEdgeIds) {
|
|
1518
1607
|
fitFocusBounds(previewEdgeIds);
|
|
@@ -1526,46 +1615,93 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1526
1615
|
focusEdgeIds,
|
|
1527
1616
|
focusedWalkthroughId,
|
|
1528
1617
|
focusedStepIndex,
|
|
1618
|
+
autoPlayFocus,
|
|
1529
1619
|
fitFocusBounds,
|
|
1530
1620
|
zoomOnWalkthroughFocus,
|
|
1531
1621
|
walkthroughStepMode,
|
|
1532
1622
|
]);
|
|
1533
1623
|
|
|
1534
|
-
// Graph-only embeds: cycle walkthrough steps
|
|
1624
|
+
// Graph-only embeds: cycle walkthrough steps. In `focus` mode each step is
|
|
1625
|
+
// selected (camera frames it via fitFocusBounds) and, when a walkthrough
|
|
1626
|
+
// viewer is supplied, its snippet drawer opens; in `dim` mode it only
|
|
1627
|
+
// dim-highlights the step (hover-style) with no camera move.
|
|
1535
1628
|
useEffect(() => {
|
|
1536
|
-
if (!autoPlayWalkthroughs ||
|
|
1629
|
+
if (!autoPlayWalkthroughs || walkthroughs == null || walkthroughs.length === 0) return;
|
|
1630
|
+
if (!layoutReady) return;
|
|
1537
1631
|
const playable = walkthroughs.filter((tl) => tl.steps.length > 0);
|
|
1538
1632
|
if (playable.length === 0) return;
|
|
1633
|
+
// Only run the cycles that can actually be shown in the current edge view.
|
|
1634
|
+
const inView = playable.filter((tl) =>
|
|
1635
|
+
tl.steps.some((s) =>
|
|
1636
|
+
resolvedEdgeView === 'relations'
|
|
1637
|
+
? isRelationMechanism(s.mechanism)
|
|
1638
|
+
: isWalkthroughMechanism(s.mechanism),
|
|
1639
|
+
),
|
|
1640
|
+
);
|
|
1641
|
+
if (inView.length === 0) return;
|
|
1642
|
+
const cyc = inView;
|
|
1539
1643
|
|
|
1540
1644
|
let cancelled = false;
|
|
1541
1645
|
let tlIdx = 0;
|
|
1542
1646
|
let stepIdx = 0;
|
|
1543
1647
|
let timer: number | null = null;
|
|
1544
1648
|
const interval = Math.max(400, walkthroughAutoPlayIntervalMs);
|
|
1649
|
+
const focusMode = walkthroughStepMode !== 'dim';
|
|
1650
|
+
let cleanup = () => {};
|
|
1545
1651
|
|
|
1546
1652
|
const tick = () => {
|
|
1547
1653
|
if (cancelled) return;
|
|
1548
|
-
const tl =
|
|
1654
|
+
const tl = cyc[tlIdx]!;
|
|
1549
1655
|
setSelected(null);
|
|
1550
1656
|
setSelectedEdgeId(null);
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1657
|
+
if (focusMode) {
|
|
1658
|
+
setHoveredWalkthroughStep(null);
|
|
1659
|
+
setFocusedWalkthroughId(null);
|
|
1660
|
+
setFocusedStepIndex(null);
|
|
1661
|
+
setAutoPlayFocus({ walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1662
|
+
if (!hideDrawer) {
|
|
1663
|
+
const step = tl.steps[stepIdx];
|
|
1664
|
+
if (step) {
|
|
1665
|
+
if (renderWalkthroughViewer) {
|
|
1666
|
+
setDrawerTarget({ kind: 'walkthrough', walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1667
|
+
} else {
|
|
1668
|
+
setDrawerTarget({ kind: 'file', file: step.file, startLine: step.line });
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
} else {
|
|
1673
|
+
setAutoPlayFocus(null);
|
|
1674
|
+
setFocusedWalkthroughId(null);
|
|
1675
|
+
setFocusedStepIndex(null);
|
|
1676
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1677
|
+
}
|
|
1554
1678
|
stepIdx += 1;
|
|
1555
1679
|
if (stepIdx >= tl.steps.length) {
|
|
1556
1680
|
stepIdx = 0;
|
|
1557
|
-
tlIdx = (tlIdx + 1) %
|
|
1681
|
+
tlIdx = (tlIdx + 1) % cyc.length;
|
|
1558
1682
|
}
|
|
1559
1683
|
timer = window.setTimeout(tick, interval);
|
|
1560
1684
|
};
|
|
1561
1685
|
|
|
1562
1686
|
tick();
|
|
1563
|
-
|
|
1687
|
+
cleanup = () => {
|
|
1564
1688
|
cancelled = true;
|
|
1565
1689
|
if (timer != null) window.clearTimeout(timer);
|
|
1566
1690
|
setHoveredWalkthroughStep(null);
|
|
1691
|
+
setFocusedWalkthroughId(null);
|
|
1692
|
+
setFocusedStepIndex(null);
|
|
1693
|
+
setAutoPlayFocus(null);
|
|
1567
1694
|
};
|
|
1568
|
-
|
|
1695
|
+
return cleanup;
|
|
1696
|
+
}, [
|
|
1697
|
+
autoPlayWalkthroughs,
|
|
1698
|
+
walkthroughs,
|
|
1699
|
+
walkthroughAutoPlayIntervalMs,
|
|
1700
|
+
layoutReady,
|
|
1701
|
+
walkthroughStepMode,
|
|
1702
|
+
renderWalkthroughViewer,
|
|
1703
|
+
hideDrawer,
|
|
1704
|
+
]);
|
|
1569
1705
|
|
|
1570
1706
|
// Arrow keys step through the focused walkthrough once a step is active
|
|
1571
1707
|
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
@@ -2390,6 +2526,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2390
2526
|
onClose={closeDrawer}
|
|
2391
2527
|
fillHeight={drawerFillHeight}
|
|
2392
2528
|
suppressEscape={fileOverlayOpen}
|
|
2529
|
+
hidden={hideDrawer}
|
|
2393
2530
|
>
|
|
2394
2531
|
{drawerTarget?.kind === 'walkthrough' &&
|
|
2395
2532
|
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
|
|