@principal-ai/subsystems-react 0.37.12 → 0.39.0
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/graphify/consolidated.d.ts +8 -0
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/construct.d.ts +18 -0
- package/dist/graphify/construct.d.ts.map +1 -1
- package/dist/graphify/construct.js +23 -0
- package/dist/graphify/construct.js.map +1 -1
- package/dist/graphify/index.d.ts +2 -2
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +0 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +49 -3
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +180 -79
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +23 -2
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +492 -23
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.js +19 -5
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/model.d.ts +161 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +91 -12
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +133 -72
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/symbolRefs.d.ts.map +1 -1
- package/dist/subsystem/symbolRefs.js +2 -0
- package/dist/subsystem/symbolRefs.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +9 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +21 -2
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/graphify/__fixtures__/ego-samples.json +545 -0
- package/src/graphify/consolidated.ts +8 -0
- package/src/graphify/construct.test.ts +61 -1
- package/src/graphify/construct.ts +33 -0
- package/src/graphify/index.ts +6 -1
- package/src/index.ts +13 -1
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +9 -9
- package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +0 -1
- package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +354 -0
- package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
- package/src/subsystem/IssueList.focus.test.tsx +113 -0
- package/src/subsystem/IssueList.icon.test.tsx +95 -0
- package/src/subsystem/IssueList.tsx +267 -111
- package/src/subsystem/SubsystemComponentGraph.tsx +582 -27
- package/src/subsystem/formatDeclaration.test.ts +64 -0
- package/src/subsystem/formatDeclaration.ts +20 -5
- package/src/subsystem/model.test.ts +75 -2
- package/src/subsystem/model.ts +247 -17
- package/src/subsystem/nodes.group.test.tsx +52 -0
- package/src/subsystem/nodes.tsx +112 -8
- package/src/subsystem/symbolRefs.ts +2 -0
- package/src/subsystem/toC4.test.ts +3 -3
- package/src/utils/elkLayout.ts +32 -1
|
@@ -38,16 +38,20 @@ import {
|
|
|
38
38
|
buildSubsystemGraph,
|
|
39
39
|
deriveGraphEdges,
|
|
40
40
|
isConstructsOnlyModel,
|
|
41
|
+
moduleGroupNodeId,
|
|
41
42
|
isRelationMechanism,
|
|
42
43
|
isWalkthroughMechanism,
|
|
43
|
-
|
|
44
|
+
edgeColor,
|
|
44
45
|
MECHANISM_DESCRIPTIONS,
|
|
45
46
|
subsystemGraphLayoutKey,
|
|
46
47
|
walkthroughStepGraphEdgeId,
|
|
47
48
|
type SubsystemComponentEdge,
|
|
48
49
|
type SubsystemComponent,
|
|
50
|
+
type SubsystemEdgeProvenance,
|
|
49
51
|
type SubsystemEdgeView,
|
|
50
|
-
type
|
|
52
|
+
type SubsystemGraphifyRelation,
|
|
53
|
+
type SubsystemNodeIssue,
|
|
54
|
+
type SubsystemRegionIssue,
|
|
51
55
|
type SubsystemRelation,
|
|
52
56
|
type SubsystemWalkthrough,
|
|
53
57
|
} from './model';
|
|
@@ -58,6 +62,11 @@ import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CA
|
|
|
58
62
|
import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
|
|
59
63
|
import {
|
|
60
64
|
SubsystemIssueList,
|
|
65
|
+
issueCategory,
|
|
66
|
+
issueKindOrder,
|
|
67
|
+
issueRung,
|
|
68
|
+
ISSUE_KIND_ICON,
|
|
69
|
+
ISSUE_RUNG_ORDER,
|
|
61
70
|
type SubsystemIssue,
|
|
62
71
|
type SubsystemIssueCategory,
|
|
63
72
|
} from './IssueList';
|
|
@@ -174,6 +183,19 @@ export interface SubsystemComponentGraphProps {
|
|
|
174
183
|
* (unselected ones dimmed); everything else is hidden.
|
|
175
184
|
*/
|
|
176
185
|
walkthroughs?: SubsystemWalkthrough[];
|
|
186
|
+
/**
|
|
187
|
+
* graphify-native relations (raw static-graph edges: `imports`, `contains`,
|
|
188
|
+
* `re_exports`, …). Display-only, drawn with the separate
|
|
189
|
+
* `GRAPHIFY_RELATION_COLOR` palette so they read as derived facts, distinct
|
|
190
|
+
* from authored subsystem mechanisms. Visible in the `relations` edge view.
|
|
191
|
+
*/
|
|
192
|
+
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
193
|
+
/**
|
|
194
|
+
* Order same-layer nodes by `component.line` (ascending) instead of ELK's
|
|
195
|
+
* crossing-minimizer — reads a source region top-to-bottom. Components need a
|
|
196
|
+
* `line` for this to have any effect. @default false
|
|
197
|
+
*/
|
|
198
|
+
orderByLine?: boolean;
|
|
177
199
|
/**
|
|
178
200
|
* Deep-link target: when set, the matching walkthrough is selected on mount
|
|
179
201
|
* — its steps expanded and its flow focused on the canvas. Hosts use this
|
|
@@ -301,7 +323,15 @@ export interface SubsystemComponentGraphProps {
|
|
|
301
323
|
* plain issues view expands everything.
|
|
302
324
|
*/
|
|
303
325
|
focusIssueCategory?: SubsystemIssueCategory;
|
|
304
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Click an issue. The graph first focuses the target on the canvas itself —
|
|
328
|
+
* a component target is selected and framed (any edge / walkthrough focus
|
|
329
|
+
* that would hide it is cleared), a relation target frames its edge, and a
|
|
330
|
+
* module target frames that boundary frame — then this fires so the host can
|
|
331
|
+
* open its own detail. Collapsing the card again reverses that (deselect +
|
|
332
|
+
* zoom out). Other target kinds (flow / repo) have no node to frame and just
|
|
333
|
+
* forward here.
|
|
334
|
+
*/
|
|
305
335
|
onSelectIssue?: (issue: SubsystemIssue) => void;
|
|
306
336
|
/** Apply an issue's deterministic fix. */
|
|
307
337
|
onApplyIssueFix?: (issue: SubsystemIssue) => void;
|
|
@@ -470,7 +500,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
470
500
|
measured: { w: number; h: number } | null;
|
|
471
501
|
}
|
|
472
502
|
|
|
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) {
|
|
503
|
+
function Inner({ components, relations, walkthroughs, graphifyRelations, orderByLine, 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) {
|
|
474
504
|
const { theme } = useTheme();
|
|
475
505
|
const { fitView, fitBounds } = useReactFlow();
|
|
476
506
|
const viewport = useViewport();
|
|
@@ -530,6 +560,13 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
530
560
|
const [sidebarView, setSidebarView] = useState<'files' | 'walkthroughs'>(() =>
|
|
531
561
|
walkthroughs?.length ? 'walkthroughs' : 'files',
|
|
532
562
|
);
|
|
563
|
+
// Temporary edge-vocabulary override. Focusing a relation finding whose edge
|
|
564
|
+
// belongs to the *other* vocabulary flips the canvas so the edge is actually
|
|
565
|
+
// visible; it reverts when the focus is undone (see `unfocusIssueTarget`) or
|
|
566
|
+
// when the diagnostics list closes. Null = use the host-pinned / tab view.
|
|
567
|
+
const [edgeViewOverride, setEdgeViewOverride] = useState<SubsystemEdgeView | null>(
|
|
568
|
+
null,
|
|
569
|
+
);
|
|
533
570
|
// One edge vocabulary at a time. When the caller doesn't pick, the sidebar's
|
|
534
571
|
// Files / Walkthroughs tab picks: Files draws topology relation edges,
|
|
535
572
|
// Walkthroughs draws runtime hop edges. Without visible tabs (no walkthroughs,
|
|
@@ -537,6 +574,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
537
574
|
// to walkthrough edges rather than empty, everything else to relations.
|
|
538
575
|
const sidebarTabsVisible = !hideSidebar && (walkthroughs?.length ?? 0) > 0;
|
|
539
576
|
const resolvedEdgeView: SubsystemEdgeView =
|
|
577
|
+
edgeViewOverride ??
|
|
540
578
|
edgeView ??
|
|
541
579
|
(sidebarTabsVisible
|
|
542
580
|
? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'relations')
|
|
@@ -626,6 +664,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
626
664
|
// closure over the effect deps) can toggle without a stale value.
|
|
627
665
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
628
666
|
selectedRef.current = selected;
|
|
667
|
+
// The module frame an issue card framed. A module target selects no node and
|
|
668
|
+
// no edge, so nothing else records that focus — without this the canvas has
|
|
669
|
+
// no way to tell a module focus it still owns from one a later click took
|
|
670
|
+
// over, and the camera would stay parked on the region forever.
|
|
671
|
+
const issueModuleFocusRef = useRef<string | null>(null);
|
|
629
672
|
// Ref mirror of the open file drawer target for tree-click toggle.
|
|
630
673
|
const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
|
|
631
674
|
const openFile =
|
|
@@ -730,15 +773,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
730
773
|
// The pane stays hidden until Pass 2 completes. Key off layout-affecting
|
|
731
774
|
// fields only — declarationRef updates after verify must not re-run ELK.
|
|
732
775
|
const layoutKey = useMemo(
|
|
733
|
-
() => subsystemGraphLayoutKey({ components, relations, walkthroughs }),
|
|
734
|
-
[components, relations, walkthroughs],
|
|
776
|
+
() => subsystemGraphLayoutKey({ components, relations, walkthroughs, graphifyRelations }),
|
|
777
|
+
[components, relations, walkthroughs, graphifyRelations],
|
|
735
778
|
);
|
|
736
779
|
const componentsRef = useRef(components);
|
|
737
780
|
const relationsRef = useRef(relations);
|
|
738
781
|
const walkthroughsRef = useRef(walkthroughs);
|
|
782
|
+
const graphifyRelationsRef = useRef(graphifyRelations);
|
|
739
783
|
componentsRef.current = components;
|
|
740
784
|
relationsRef.current = relations;
|
|
741
785
|
walkthroughsRef.current = walkthroughs;
|
|
786
|
+
graphifyRelationsRef.current = graphifyRelations;
|
|
742
787
|
|
|
743
788
|
// Track measured dimensions from React Flow's dimension changes.
|
|
744
789
|
// These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
|
|
@@ -764,6 +809,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
764
809
|
maxNodeWidth,
|
|
765
810
|
showEdgeLabels,
|
|
766
811
|
showSingletonFrames,
|
|
812
|
+
graphifyRelations: graphifyRelationsRef.current,
|
|
813
|
+
orderByLine,
|
|
767
814
|
})
|
|
768
815
|
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
769
816
|
if (!alive) return;
|
|
@@ -813,7 +860,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
813
860
|
const gen = ++pass2GenRef.current;
|
|
814
861
|
void buildSubsystemGraph(
|
|
815
862
|
{ components, relations, walkthroughs },
|
|
816
|
-
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames },
|
|
863
|
+
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, graphifyRelations, orderByLine },
|
|
817
864
|
)
|
|
818
865
|
.then(({ nodes, edges: e, absoluteRects }) => {
|
|
819
866
|
if (gen !== pass2GenRef.current) return;
|
|
@@ -826,7 +873,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
826
873
|
// Reveal Pass 1 layout rather than leaving the cover up forever.
|
|
827
874
|
setLayoutReady(true);
|
|
828
875
|
});
|
|
829
|
-
}, [built.nodes, components, relations, walkthroughs, maxNodeWidth, showEdgeLabels]);
|
|
876
|
+
}, [built.nodes, components, relations, walkthroughs, graphifyRelations, orderByLine, maxNodeWidth, showEdgeLabels]);
|
|
830
877
|
|
|
831
878
|
// After Pass 1 commits, try Pass 2 immediately with retained measurements.
|
|
832
879
|
// Same-id live updates often get no new React Flow `dimensions` events, so
|
|
@@ -908,16 +955,273 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
908
955
|
const xyflowNodesBase = nodes as Node[];
|
|
909
956
|
const baseEdges = convertedEdges as Edge[];
|
|
910
957
|
|
|
958
|
+
// Resolve an issue's component target to the matching node. `id` is the
|
|
959
|
+
// stable alias; `label` may be an alias, name, or symbol. Non-component
|
|
960
|
+
// targets (relation / module / flow / repo) have no single node → null.
|
|
961
|
+
const issueComponent = useCallback(
|
|
962
|
+
(issue: SubsystemIssue): SubsystemComponent | null => {
|
|
963
|
+
const target = issue.target;
|
|
964
|
+
if (target?.kind !== 'component') return null;
|
|
965
|
+
return (
|
|
966
|
+
components.find(
|
|
967
|
+
(c) =>
|
|
968
|
+
(target.id != null && c.alias === target.id) ||
|
|
969
|
+
c.alias === target.label ||
|
|
970
|
+
c.name === target.label ||
|
|
971
|
+
c.symbol === target.label,
|
|
972
|
+
) ?? null
|
|
973
|
+
);
|
|
974
|
+
},
|
|
975
|
+
[components],
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
// Resolve a `relation` issue target to the display edge it flags. The target
|
|
979
|
+
// names the two endpoints (`from → to`); match an edge joining both, in
|
|
980
|
+
// either direction, preferring the target's `detail` mechanism when it names
|
|
981
|
+
// one. Null when no display edge matches.
|
|
982
|
+
const issueEdge = useCallback(
|
|
983
|
+
(issue: SubsystemIssue): Edge | null => {
|
|
984
|
+
const target = issue.target;
|
|
985
|
+
if (target?.kind !== 'relation') return null;
|
|
986
|
+
const parts = `${target.id ?? ''} → ${target.label}`
|
|
987
|
+
.split('→')
|
|
988
|
+
.map((p) => p.replace(/\(\)$/, '').trim())
|
|
989
|
+
.filter(Boolean);
|
|
990
|
+
const joins = (e: Edge): boolean =>
|
|
991
|
+
parts.includes(e.source) && parts.includes(e.target);
|
|
992
|
+
const matches = baseEdges.filter(joins);
|
|
993
|
+
const byMechanism = target.detail
|
|
994
|
+
? matches.filter(
|
|
995
|
+
(e) =>
|
|
996
|
+
(e.data as { mechanism?: string } | undefined)?.mechanism ===
|
|
997
|
+
target.detail,
|
|
998
|
+
)
|
|
999
|
+
: matches;
|
|
1000
|
+
return byMechanism[0] ?? matches[0] ?? null;
|
|
1001
|
+
},
|
|
1002
|
+
[baseEdges],
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
// A `module` issue target names a boundary frame, so focusing the card frames
|
|
1006
|
+
// that region on the canvas. Null when the target names no frame (a singleton
|
|
1007
|
+
// dropped from the layout, or a region key nothing declares).
|
|
1008
|
+
const issueModuleNodeId = useCallback(
|
|
1009
|
+
(issue: SubsystemIssue): string | null => {
|
|
1010
|
+
const target = issue.target;
|
|
1011
|
+
if (target?.kind !== 'module') return null;
|
|
1012
|
+
const key = target.id ?? target.label;
|
|
1013
|
+
if (!components.some((c) => c.module?.trim() === key)) return null;
|
|
1014
|
+
return moduleGroupNodeId(key);
|
|
1015
|
+
},
|
|
1016
|
+
[components],
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
// A `step` issue target names ONE step of a walkthrough — the walkthrough id in
|
|
1020
|
+
// `id`, the 0-based step position in `stepIndex`. Validated against the loaded
|
|
1021
|
+
// walkthroughs so a finding left over from an edited flow resolves to null
|
|
1022
|
+
// rather than framing whatever now happens to sit at that index.
|
|
1023
|
+
const issueStep = useCallback(
|
|
1024
|
+
(
|
|
1025
|
+
issue: SubsystemIssue,
|
|
1026
|
+
): { walkthrough: SubsystemWalkthrough; stepIndex: number } | null => {
|
|
1027
|
+
const target = issue.target;
|
|
1028
|
+
if (target?.kind !== 'step') return null;
|
|
1029
|
+
if (target.id == null || target.stepIndex == null) return null;
|
|
1030
|
+
const walkthrough = walkthroughs?.find((t) => t.id === target.id);
|
|
1031
|
+
if (!walkthrough?.steps[target.stepIndex]) return null;
|
|
1032
|
+
return { walkthrough, stepIndex: target.stepIndex };
|
|
1033
|
+
},
|
|
1034
|
+
[walkthroughs],
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
// Per-node diagnostics badge: fold each component-targeted finding that maps
|
|
1038
|
+
// to a verification rung into one badge per node — worst severity wins, the
|
|
1039
|
+
// chip shows the earliest failing rung, and the count tallies the findings.
|
|
1040
|
+
const issueBadgeByAlias = useMemo(() => {
|
|
1041
|
+
const map = new Map<string, SubsystemNodeIssue>();
|
|
1042
|
+
if (!issues?.length) return map;
|
|
1043
|
+
for (const issue of issues) {
|
|
1044
|
+
const rung = issueRung(issue.kind);
|
|
1045
|
+
if (!rung) continue;
|
|
1046
|
+
const comp = issueComponent(issue);
|
|
1047
|
+
if (!comp) continue;
|
|
1048
|
+
const prev = map.get(comp.alias);
|
|
1049
|
+
map.set(comp.alias, {
|
|
1050
|
+
severity:
|
|
1051
|
+
prev?.severity === 'error' || issue.severity === 'error'
|
|
1052
|
+
? 'error'
|
|
1053
|
+
: 'info',
|
|
1054
|
+
rung:
|
|
1055
|
+
prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
|
|
1056
|
+
? prev.rung
|
|
1057
|
+
: rung,
|
|
1058
|
+
count: (prev?.count ?? 0) + 1,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
return map;
|
|
1062
|
+
}, [issues, issueComponent]);
|
|
1063
|
+
|
|
1064
|
+
// Per-frame diagnostics badge: the same fold, but for findings about a
|
|
1065
|
+
// BOUNDARY rather than a construct. Keyed by the region's React Flow node id.
|
|
1066
|
+
// Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
|
|
1067
|
+
// finding with no icon of its own has nothing to badge the frame with, and
|
|
1068
|
+
// borrowing its layer's icon would imply the frame is at fault for it.
|
|
1069
|
+
const regionIssueByNodeId = useMemo(() => {
|
|
1070
|
+
const map = new Map<string, SubsystemRegionIssue>();
|
|
1071
|
+
if (!issues?.length) return map;
|
|
1072
|
+
for (const issue of issues) {
|
|
1073
|
+
const target = issue.target;
|
|
1074
|
+
if (target?.kind !== 'module' || !ISSUE_KIND_ICON[issue.kind]) continue;
|
|
1075
|
+
const id = moduleGroupNodeId(target.id ?? target.label);
|
|
1076
|
+
const prev = map.get(id);
|
|
1077
|
+
map.set(id, {
|
|
1078
|
+
severity:
|
|
1079
|
+
prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
|
|
1080
|
+
kind:
|
|
1081
|
+
prev != null && issueKindOrder(prev.kind) <= issueKindOrder(issue.kind)
|
|
1082
|
+
? prev.kind
|
|
1083
|
+
: issue.kind,
|
|
1084
|
+
count: (prev?.count ?? 0) + 1,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return map;
|
|
1088
|
+
}, [issues]);
|
|
1089
|
+
|
|
1090
|
+
// Expanded verification layers in the diagnostics list. When any layer with
|
|
1091
|
+
// findings is expanded, the canvas dims everything that layer does not
|
|
1092
|
+
// implicate (see `issueFocus`). The list publishes this through
|
|
1093
|
+
// `onExpandedCategoriesChange`; empty while every layer is collapsed.
|
|
1094
|
+
const [expandedIssueCategories, setExpandedIssueCategories] = useState<
|
|
1095
|
+
SubsystemIssueCategory[]
|
|
1096
|
+
>([]);
|
|
1097
|
+
// Stable setter — the list re-emits on mount and on every toggle, so compare
|
|
1098
|
+
// before storing to avoid churn (and to keep the list effect's dep stable).
|
|
1099
|
+
const handleExpandedCategoriesChange = useCallback(
|
|
1100
|
+
(cats: SubsystemIssueCategory[]) => {
|
|
1101
|
+
setExpandedIssueCategories((prev) =>
|
|
1102
|
+
prev.length === cats.length && prev.every((c, i) => c === cats[i])
|
|
1103
|
+
? prev
|
|
1104
|
+
: cats,
|
|
1105
|
+
);
|
|
1106
|
+
},
|
|
1107
|
+
[],
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
// Resolve a component name / symbol / alias reference to a component alias.
|
|
1111
|
+
const resolveAlias = useCallback(
|
|
1112
|
+
(ref: string): string | null => {
|
|
1113
|
+
const clean = ref.replace(/\(\)$/, '').trim();
|
|
1114
|
+
const c = components.find(
|
|
1115
|
+
(x) =>
|
|
1116
|
+
x.alias === clean ||
|
|
1117
|
+
x.name === clean ||
|
|
1118
|
+
(x.symbol != null && x.symbol.replace(/\(\)$/, '') === clean),
|
|
1119
|
+
);
|
|
1120
|
+
return c?.alias ?? null;
|
|
1121
|
+
},
|
|
1122
|
+
[components],
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1125
|
+
// Graph elements implicated by the expanded layers' findings — the bright set
|
|
1126
|
+
// the canvas keeps while everything else dims. Derived from each finding's
|
|
1127
|
+
// target:
|
|
1128
|
+
// component → that node
|
|
1129
|
+
// relation → the edge it flags (+ its two endpoint nodes)
|
|
1130
|
+
// module → every component in that module
|
|
1131
|
+
// walkthrough → the flow's nodes + its hop edges
|
|
1132
|
+
// repo → every component of that repo
|
|
1133
|
+
// graph → whole-graph finding; implicates nothing specific
|
|
1134
|
+
// Returns null when nothing is expanded, no expanded layer has findings, or
|
|
1135
|
+
// the findings implicate nothing — so no dimming is applied.
|
|
1136
|
+
const issueFocus = useMemo(() => {
|
|
1137
|
+
if (!issues?.length || expandedIssueCategories.length === 0) return null;
|
|
1138
|
+
const active = new Set(expandedIssueCategories);
|
|
1139
|
+
const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
|
|
1140
|
+
if (activeIssues.length === 0) return null;
|
|
1141
|
+
const nodeIds = new Set<string>();
|
|
1142
|
+
const edgeIds = new Set<string>();
|
|
1143
|
+
for (const issue of activeIssues) {
|
|
1144
|
+
const t = issue.target;
|
|
1145
|
+
if (!t) continue; // whole-graph finding — no specific element
|
|
1146
|
+
if (t.kind === 'component') {
|
|
1147
|
+
const a = resolveAlias(t.id ?? t.label);
|
|
1148
|
+
if (a) nodeIds.add(a);
|
|
1149
|
+
} else if (t.kind === 'relation') {
|
|
1150
|
+
// The target names the two endpoints (`from → to`); light the edge and
|
|
1151
|
+
// its endpoints. Falls back to endpoints alone if no edge matches.
|
|
1152
|
+
const edge = issueEdge(issue);
|
|
1153
|
+
if (edge) {
|
|
1154
|
+
edgeIds.add(edge.id);
|
|
1155
|
+
nodeIds.add(edge.source);
|
|
1156
|
+
nodeIds.add(edge.target);
|
|
1157
|
+
} else {
|
|
1158
|
+
for (const part of `${t.id ?? ''} → ${t.label}`.split('→')) {
|
|
1159
|
+
const a = resolveAlias(part);
|
|
1160
|
+
if (a) nodeIds.add(a);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
} else if (t.kind === 'module') {
|
|
1164
|
+
const key = t.id ?? t.label;
|
|
1165
|
+
const prefix = `${key.replace(/\/$/, '')}/`;
|
|
1166
|
+
for (const c of components) {
|
|
1167
|
+
if (
|
|
1168
|
+
c.module === key ||
|
|
1169
|
+
c.file === key ||
|
|
1170
|
+
(c.file !== '' && c.file.endsWith(key)) ||
|
|
1171
|
+
c.file.startsWith(prefix)
|
|
1172
|
+
) {
|
|
1173
|
+
nodeIds.add(c.alias);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
} else if (t.kind === 'walkthrough') {
|
|
1177
|
+
const wt = (walkthroughs ?? []).find(
|
|
1178
|
+
(w) => w.id === t.id || w.title === t.label,
|
|
1179
|
+
);
|
|
1180
|
+
if (wt) {
|
|
1181
|
+
for (const s of wt.steps) {
|
|
1182
|
+
nodeIds.add(s.from);
|
|
1183
|
+
nodeIds.add(s.to);
|
|
1184
|
+
edgeIds.add(walkthroughStepGraphEdgeId(s));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} else if (t.kind === 'repo') {
|
|
1188
|
+
const key = t.id ?? t.label;
|
|
1189
|
+
for (const c of components) {
|
|
1190
|
+
if (c.purl === key) nodeIds.add(c.alias);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (nodeIds.size === 0 && edgeIds.size === 0) return null;
|
|
1195
|
+
// An edge whose endpoints both sit in the bright set stays lit too, so a
|
|
1196
|
+
// focused cluster doesn't read as isolated nodes.
|
|
1197
|
+
for (const e of baseEdges) {
|
|
1198
|
+
if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
|
|
1199
|
+
}
|
|
1200
|
+
return { nodeIds, edgeIds };
|
|
1201
|
+
}, [issues, expandedIssueCategories, components, walkthroughs, baseEdges, resolveAlias, issueEdge]);
|
|
1202
|
+
const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
|
|
1203
|
+
const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
|
|
1204
|
+
|
|
911
1205
|
// When an edge is selected, dim every other edge + its label to focus it.
|
|
912
1206
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
913
1207
|
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
914
1208
|
// without a stale closure value.
|
|
915
1209
|
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
916
1210
|
selectedEdgeIdRef.current = selectedEdgeId;
|
|
1211
|
+
// Ref mirrors of the focused step, so `unfocusIssueTarget` (a useCallback
|
|
1212
|
+
// over early deps) can tell a step focus it still owns from one the user has
|
|
1213
|
+
// since moved elsewhere in the walkthrough panel.
|
|
1214
|
+
const focusedStepRef = useRef<{ walkthroughId: string; stepIndex: number } | null>(
|
|
1215
|
+
null,
|
|
1216
|
+
);
|
|
1217
|
+
focusedStepRef.current =
|
|
1218
|
+
focusedWalkthroughId != null && focusedStepIndex != null
|
|
1219
|
+
? { walkthroughId: focusedWalkthroughId, stepIndex: focusedStepIndex }
|
|
1220
|
+
: null;
|
|
1221
|
+
|
|
917
1222
|
// Edge ids in walkthrough focus (an active flow's edge set, or a single
|
|
918
1223
|
// step's edge). Used to frame the camera. `null` = no walkthrough focus.
|
|
919
|
-
const focusEdgeIds = useMemo(() => {
|
|
920
|
-
if (autoPlayFocus) {
|
|
1224
|
+
const focusEdgeIds = useMemo(() => { if (autoPlayFocus) {
|
|
921
1225
|
const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
|
|
922
1226
|
const step = tl?.steps[autoPlayFocus.stepIndex];
|
|
923
1227
|
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
@@ -1052,12 +1356,22 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1052
1356
|
anyOpened: openedNodeIds != null,
|
|
1053
1357
|
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1054
1358
|
});
|
|
1055
|
-
|
|
1056
|
-
|
|
1359
|
+
// `brightNodeIds` is the focused step's endpoints. It has to be a dim
|
|
1360
|
+
// source, not just a bright set: without an expanded flow,
|
|
1361
|
+
// `flowElementVisibility` reports non-participants as *hidden*, and the
|
|
1362
|
+
// noOpenedFlow escape below discards that — leaving them neither hidden
|
|
1363
|
+
// nor dimmed. Naming the participants as the dim source dims everything
|
|
1364
|
+
// outside the focused step however the flow got focused.
|
|
1365
|
+
const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
|
|
1366
|
+
const dimmed = dimSource
|
|
1367
|
+
? vis.hidden || !memberAliases.some((alias) => dimSource.has(alias))
|
|
1057
1368
|
: vis.dimmed;
|
|
1058
1369
|
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1059
1370
|
// Host override wins over the library's derived frame color.
|
|
1060
1371
|
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
1372
|
+
// Boundary findings badge the FRAME, not a member — a region's shape is
|
|
1373
|
+
// the fault, so no leaf construct carries it.
|
|
1374
|
+
const regionIssue = regionIssueByNodeId.get(n.id);
|
|
1061
1375
|
return {
|
|
1062
1376
|
...n,
|
|
1063
1377
|
hidden,
|
|
@@ -1066,6 +1380,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1066
1380
|
...(n.data as object),
|
|
1067
1381
|
...(color != null && { color }),
|
|
1068
1382
|
...(dimmed && { dimmed: true }),
|
|
1383
|
+
...(regionIssue && { issue: regionIssue }),
|
|
1069
1384
|
},
|
|
1070
1385
|
};
|
|
1071
1386
|
}
|
|
@@ -1082,9 +1397,17 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1082
1397
|
anyOpened: openedNodeIds != null,
|
|
1083
1398
|
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
1084
1399
|
});
|
|
1085
|
-
|
|
1400
|
+
// `brightNodeIds` (the focused step's endpoints) is a dim source as well
|
|
1401
|
+
// as a bright set — see the group branch above.
|
|
1402
|
+
const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
|
|
1403
|
+
const dimmed = dimSource ? (vis.hidden || !dimSource.has(n.id)) : vis.dimmed;
|
|
1086
1404
|
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
1087
|
-
|
|
1405
|
+
// Component findings key by alias, boundary findings by region node id.
|
|
1406
|
+
// The two id spaces are disjoint (`module:` / `process:` are prefixed), so
|
|
1407
|
+
// one lookup covers both node kinds.
|
|
1408
|
+
const issueBadge =
|
|
1409
|
+
issueBadgeByAlias.get(n.id) ?? regionIssueByNodeId.get(n.id);
|
|
1410
|
+
if (fileMatch === undefined && !isSelected && !dimmed && !issueBadge) {
|
|
1088
1411
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
1089
1412
|
return { ...n, hidden, data: rest };
|
|
1090
1413
|
}
|
|
@@ -1096,10 +1419,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1096
1419
|
...(fileMatch !== undefined && { fileMatch }),
|
|
1097
1420
|
...(isSelected && { isSelected }),
|
|
1098
1421
|
...(dimmed && { dimmed: true }),
|
|
1422
|
+
...(issueBadge && { issue: issueBadge }),
|
|
1099
1423
|
},
|
|
1100
1424
|
};
|
|
1101
1425
|
});
|
|
1102
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
|
|
1426
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, issueFocusNodeIds, issueBadgeByAlias, regionIssueByNodeId, boundaryColors]);
|
|
1103
1427
|
|
|
1104
1428
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
1105
1429
|
const baseEdgesKey = useMemo(
|
|
@@ -1121,13 +1445,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1121
1445
|
};
|
|
1122
1446
|
};
|
|
1123
1447
|
// Edges outside the selected view are hidden entirely (labels included).
|
|
1448
|
+
// graphify-native edges belong to the relations view (they are static
|
|
1449
|
+
// topology, not runtime hops) regardless of their verb.
|
|
1124
1450
|
const edgeInView = (e: Edge): boolean => {
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1451
|
+
const d = e.data as
|
|
1452
|
+
| { mechanism?: string; provenance?: SubsystemEdgeProvenance }
|
|
1453
|
+
| undefined;
|
|
1454
|
+
const mechanism = d?.mechanism ?? 'uses';
|
|
1455
|
+
if (resolvedEdgeView === 'relations') {
|
|
1456
|
+
return d?.provenance === 'graphify' || isRelationMechanism(mechanism);
|
|
1457
|
+
}
|
|
1458
|
+
return isWalkthroughMechanism(mechanism);
|
|
1129
1459
|
};
|
|
1130
|
-
if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
|
|
1460
|
+
if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
|
|
1131
1461
|
return baseEdges.map((e) => {
|
|
1132
1462
|
const vis = flowElementVisibility({
|
|
1133
1463
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
@@ -1135,7 +1465,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1135
1465
|
anyOpened: openedEdgeIds != null,
|
|
1136
1466
|
anySelected: focusEdgeIds != null,
|
|
1137
1467
|
});
|
|
1138
|
-
const
|
|
1468
|
+
const dimSource = previewEdgeIds ?? issueFocusEdgeIds;
|
|
1469
|
+
const dimmed = dimSource ? vis.hidden || !dimSource.has(e.id) : vis.dimmed;
|
|
1139
1470
|
const hidden = vis.hidden || !edgeInView(e);
|
|
1140
1471
|
return { ...paint(e, dimmed), hidden };
|
|
1141
1472
|
});
|
|
@@ -1147,7 +1478,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1147
1478
|
}));
|
|
1148
1479
|
}
|
|
1149
1480
|
return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
|
|
1150
|
-
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, resolvedEdgeView]);
|
|
1481
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
|
|
1151
1482
|
|
|
1152
1483
|
const onNodesChange = useCallback(
|
|
1153
1484
|
(changes: NodeChange[]) => {
|
|
@@ -1201,6 +1532,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1201
1532
|
return;
|
|
1202
1533
|
}
|
|
1203
1534
|
setSelected(comp);
|
|
1535
|
+
issueModuleFocusRef.current = null;
|
|
1204
1536
|
if (comp.alias) onSelect?.(comp.alias);
|
|
1205
1537
|
}
|
|
1206
1538
|
},
|
|
@@ -1220,6 +1552,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1220
1552
|
setSelectedEdgeId(null);
|
|
1221
1553
|
setFocusedWalkthroughId(null);
|
|
1222
1554
|
setFocusedStepIndex(null);
|
|
1555
|
+
setEdgeViewOverride(null);
|
|
1556
|
+
issueModuleFocusRef.current = null;
|
|
1223
1557
|
}, []);
|
|
1224
1558
|
|
|
1225
1559
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
@@ -1239,6 +1573,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1239
1573
|
);
|
|
1240
1574
|
const controlledIssues = showIssues !== undefined;
|
|
1241
1575
|
const issuesActive = controlledIssues ? showIssues : diagnosticsOpen;
|
|
1576
|
+
// Layer focus only applies while the diagnostics list is on screen — closing
|
|
1577
|
+
// it clears any dimming the expanded layers were driving.
|
|
1578
|
+
useEffect(() => {
|
|
1579
|
+
if (!issuesActive) {
|
|
1580
|
+
setExpandedIssueCategories([]);
|
|
1581
|
+
setEdgeViewOverride(null);
|
|
1582
|
+
}
|
|
1583
|
+
}, [issuesActive]);
|
|
1242
1584
|
const toggleIssues = () => {
|
|
1243
1585
|
if (!controlledIssues) setDiagnosticsOpen((v) => !v);
|
|
1244
1586
|
diagnostic?.onToggle?.();
|
|
@@ -1439,6 +1781,66 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1439
1781
|
],
|
|
1440
1782
|
);
|
|
1441
1783
|
|
|
1784
|
+
// A node's laid-out rect in absolute flow coords. `absoluteRects` is the
|
|
1785
|
+
// authority; the fallback covers an unparented group node, whose `position` is
|
|
1786
|
+
// already absolute. Nested groups are parent-relative, so they are skipped
|
|
1787
|
+
// rather than mis-boxed.
|
|
1788
|
+
const rectForNode = useCallback(
|
|
1789
|
+
(id: string): { x: number; y: number; width: number; height: number } | undefined => {
|
|
1790
|
+
const r = built.absoluteRects.get(id);
|
|
1791
|
+
if (r) return r;
|
|
1792
|
+
const n = built.nodes.find((x) => x.id === id) as
|
|
1793
|
+
| {
|
|
1794
|
+
position?: { x: number; y: number };
|
|
1795
|
+
width?: number;
|
|
1796
|
+
height?: number;
|
|
1797
|
+
parentId?: string;
|
|
1798
|
+
}
|
|
1799
|
+
| undefined;
|
|
1800
|
+
if (!n?.position || n.parentId) return undefined;
|
|
1801
|
+
return {
|
|
1802
|
+
x: n.position.x,
|
|
1803
|
+
y: n.position.y,
|
|
1804
|
+
width: n.width ?? 0,
|
|
1805
|
+
height: n.height ?? 0,
|
|
1806
|
+
};
|
|
1807
|
+
},
|
|
1808
|
+
[built.absoluteRects, built.nodes],
|
|
1809
|
+
);
|
|
1810
|
+
|
|
1811
|
+
// Frame a set of nodes by their laid-out rects. Unlike `fitFocusBounds` this
|
|
1812
|
+
// takes node ids, so it works for boundary frames too — React Flow's own
|
|
1813
|
+
// `fitView({ nodes })` can't be relied on for group nodes here, and the rects
|
|
1814
|
+
// are already in absolute flow coords (grouped child `position`s are
|
|
1815
|
+
// parent-relative and would skew the box).
|
|
1816
|
+
const fitNodeRects = useCallback(
|
|
1817
|
+
(ids: ReadonlySet<string>, padding: number) => {
|
|
1818
|
+
let minX = Infinity;
|
|
1819
|
+
let minY = Infinity;
|
|
1820
|
+
let maxX = -Infinity;
|
|
1821
|
+
let maxY = -Infinity;
|
|
1822
|
+
for (const id of ids) {
|
|
1823
|
+
const r = rectForNode(id);
|
|
1824
|
+
if (!r) continue;
|
|
1825
|
+
minX = Math.min(minX, r.x);
|
|
1826
|
+
minY = Math.min(minY, r.y);
|
|
1827
|
+
maxX = Math.max(maxX, r.x + r.width);
|
|
1828
|
+
maxY = Math.max(maxY, r.y + r.height);
|
|
1829
|
+
}
|
|
1830
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
|
|
1831
|
+
fitBounds(
|
|
1832
|
+
{
|
|
1833
|
+
x: minX,
|
|
1834
|
+
y: minY,
|
|
1835
|
+
width: Math.max(1, maxX - minX),
|
|
1836
|
+
height: Math.max(1, maxY - minY),
|
|
1837
|
+
},
|
|
1838
|
+
{ padding, duration: walkthroughFocusDurationMs },
|
|
1839
|
+
);
|
|
1840
|
+
},
|
|
1841
|
+
[rectForNode, fitBounds, walkthroughFocusDurationMs],
|
|
1842
|
+
);
|
|
1843
|
+
|
|
1442
1844
|
// Zoom back out to the full diagram after the last expanded walkthrough
|
|
1443
1845
|
// closes (visibility restores non-flow nodes that were hidden).
|
|
1444
1846
|
const fitOverview = useCallback(() => {
|
|
@@ -1817,6 +2219,144 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1817
2219
|
[components, onSelect],
|
|
1818
2220
|
);
|
|
1819
2221
|
|
|
2222
|
+
// Issue click → select + frame the target on the canvas. Component targets
|
|
2223
|
+
// frame their node; relation targets frame their edge (endpoints + the routed
|
|
2224
|
+
// line); other kinds (module / flow / repo) have no single element and just
|
|
2225
|
+
// forward. Selections / focus that would hide the target are cleared first,
|
|
2226
|
+
// then the camera flies to it on the next frame. The host's `onSelectIssue`
|
|
2227
|
+
// still fires afterwards for its own detail.
|
|
2228
|
+
const focusIssueTarget = useCallback(
|
|
2229
|
+
(issue: SubsystemIssue) => {
|
|
2230
|
+
const comp = issueComponent(issue);
|
|
2231
|
+
const edge = comp ? null : issueEdge(issue);
|
|
2232
|
+
const step = comp || edge ? null : issueStep(issue);
|
|
2233
|
+
if (comp) {
|
|
2234
|
+
setSelected(comp);
|
|
2235
|
+
setSelectedEdgeId(null);
|
|
2236
|
+
setFocusedWalkthroughId(null);
|
|
2237
|
+
setFocusedStepIndex(null);
|
|
2238
|
+
setHoveredWalkthroughStep(null);
|
|
2239
|
+
issueModuleFocusRef.current = null;
|
|
2240
|
+
onSelect?.(comp.alias);
|
|
2241
|
+
requestAnimationFrame(() => {
|
|
2242
|
+
fitView({
|
|
2243
|
+
nodes: [{ id: comp.alias }],
|
|
2244
|
+
padding: 0.4,
|
|
2245
|
+
duration: 300,
|
|
2246
|
+
minZoom: 0.05,
|
|
2247
|
+
maxZoom: 1.5,
|
|
2248
|
+
});
|
|
2249
|
+
});
|
|
2250
|
+
} else if (edge) {
|
|
2251
|
+
setSelected(null);
|
|
2252
|
+
setFocusedWalkthroughId(null);
|
|
2253
|
+
setFocusedStepIndex(null);
|
|
2254
|
+
setHoveredWalkthroughStep(null);
|
|
2255
|
+
setSelectedEdgeId(edge.id);
|
|
2256
|
+
issueModuleFocusRef.current = null;
|
|
2257
|
+
// Flip to the vocabulary this edge belongs to, so a hop flagged in the
|
|
2258
|
+
// relations view (or vice versa) is actually drawn while focused.
|
|
2259
|
+
const d = edge.data as
|
|
2260
|
+
| { mechanism?: string; provenance?: SubsystemEdgeProvenance }
|
|
2261
|
+
| undefined;
|
|
2262
|
+
const mechanism = d?.mechanism ?? 'uses';
|
|
2263
|
+
setEdgeViewOverride(
|
|
2264
|
+
d?.provenance === 'graphify' || isRelationMechanism(mechanism)
|
|
2265
|
+
? 'relations'
|
|
2266
|
+
: 'walkthroughs',
|
|
2267
|
+
);
|
|
2268
|
+
requestAnimationFrame(() => {
|
|
2269
|
+
fitFocusBounds(new Set([edge.id]));
|
|
2270
|
+
});
|
|
2271
|
+
} else if (step) {
|
|
2272
|
+
// A step finding focuses the step itself, through the same entry point
|
|
2273
|
+
// the walkthrough panel uses — so the drawer, the step numbering, the
|
|
2274
|
+
// dim-mode hover preview, and the camera all behave identically whether
|
|
2275
|
+
// the step was reached from the panel or from a diagnostics card.
|
|
2276
|
+
issueModuleFocusRef.current = null;
|
|
2277
|
+
focusWalkthroughStep(step.walkthrough, step.stepIndex);
|
|
2278
|
+
} else {
|
|
2279
|
+
// Nothing selectable — but a module target names a boundary frame, so
|
|
2280
|
+
// frame that region.
|
|
2281
|
+
const moduleNodeId = issueModuleNodeId(issue);
|
|
2282
|
+
if (moduleNodeId) {
|
|
2283
|
+
setSelected(null);
|
|
2284
|
+
setSelectedEdgeId(null);
|
|
2285
|
+
setFocusedWalkthroughId(null);
|
|
2286
|
+
setFocusedStepIndex(null);
|
|
2287
|
+
setHoveredWalkthroughStep(null);
|
|
2288
|
+
issueModuleFocusRef.current = moduleNodeId;
|
|
2289
|
+
requestAnimationFrame(() => {
|
|
2290
|
+
fitNodeRects(new Set([moduleNodeId]), 0.4);
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
onSelectIssue?.(issue);
|
|
2295
|
+
},
|
|
2296
|
+
[
|
|
2297
|
+
issueComponent,
|
|
2298
|
+
issueEdge,
|
|
2299
|
+
issueStep,
|
|
2300
|
+
issueModuleNodeId,
|
|
2301
|
+
focusWalkthroughStep,
|
|
2302
|
+
onSelect,
|
|
2303
|
+
onSelectIssue,
|
|
2304
|
+
fitView,
|
|
2305
|
+
fitFocusBounds,
|
|
2306
|
+
fitNodeRects,
|
|
2307
|
+
],
|
|
2308
|
+
);
|
|
2309
|
+
|
|
2310
|
+
// Collapsing the issue card again undoes the focus: drop the node / edge
|
|
2311
|
+
// selection (only when it is still this issue's target — another card may
|
|
2312
|
+
// have taken it over) and zoom back out to the whole graph. A module target
|
|
2313
|
+
// frames a region rather than selecting anything, so its ownership lives in
|
|
2314
|
+
// `issueModuleFocusRef`; without this branch a module focus never unwinds and
|
|
2315
|
+
// the camera never zooms back out.
|
|
2316
|
+
const unfocusIssueTarget = useCallback(
|
|
2317
|
+
(issue: SubsystemIssue) => {
|
|
2318
|
+
const comp = issueComponent(issue);
|
|
2319
|
+
if (comp) {
|
|
2320
|
+
if (selectedRef.current?.alias !== comp.alias) return;
|
|
2321
|
+
setSelected(null);
|
|
2322
|
+
setSelectedEdgeId(null);
|
|
2323
|
+
} else {
|
|
2324
|
+
const edge = issueEdge(issue);
|
|
2325
|
+
const step = edge ? null : issueStep(issue);
|
|
2326
|
+
const moduleNodeId = edge || step ? null : issueModuleNodeId(issue);
|
|
2327
|
+
if (edge) {
|
|
2328
|
+
if (selectedEdgeIdRef.current !== edge.id) return;
|
|
2329
|
+
setSelectedEdgeId(null);
|
|
2330
|
+
setEdgeViewOverride(null);
|
|
2331
|
+
} else if (step) {
|
|
2332
|
+
// Only unwind a step focus this card still owns. A dim-mode graph
|
|
2333
|
+
// keeps its step focus in hover state instead (owned by the pointer,
|
|
2334
|
+
// not the card), and correctly leaves that alone here.
|
|
2335
|
+
const owned = focusedStepRef.current;
|
|
2336
|
+
if (
|
|
2337
|
+
owned?.walkthroughId !== step.walkthrough.id ||
|
|
2338
|
+
owned?.stepIndex !== step.stepIndex
|
|
2339
|
+
) {
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
setFocusedWalkthroughId(null);
|
|
2343
|
+
setFocusedStepIndex(null);
|
|
2344
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
2345
|
+
} else if (moduleNodeId) {
|
|
2346
|
+
// Only unwind framing this card still owns.
|
|
2347
|
+
if (issueModuleFocusRef.current !== moduleNodeId) return;
|
|
2348
|
+
issueModuleFocusRef.current = null;
|
|
2349
|
+
} else {
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
requestAnimationFrame(() => {
|
|
2354
|
+
fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
|
|
2355
|
+
});
|
|
2356
|
+
},
|
|
2357
|
+
[issueComponent, issueEdge, issueStep, issueModuleNodeId, fitView],
|
|
2358
|
+
);
|
|
2359
|
+
|
|
1820
2360
|
// Construct tokens inside a walkthrough snippet. A step's line is an edge
|
|
1821
2361
|
// between its `from`/`to` components, so a token naming either of those
|
|
1822
2362
|
// constructs should navigate to that construct's declaration. Index the
|
|
@@ -1884,6 +2424,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1884
2424
|
.map((e) => {
|
|
1885
2425
|
const d = e.data as {
|
|
1886
2426
|
mechanism?: string;
|
|
2427
|
+
provenance?: SubsystemEdgeProvenance;
|
|
1887
2428
|
dimmed?: boolean;
|
|
1888
2429
|
labelX?: number;
|
|
1889
2430
|
labelY?: number;
|
|
@@ -1892,6 +2433,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1892
2433
|
return {
|
|
1893
2434
|
id: e.id,
|
|
1894
2435
|
mechanism: d?.mechanism ?? 'uses',
|
|
2436
|
+
provenance: d?.provenance,
|
|
1895
2437
|
dimmed: d?.dimmed === true,
|
|
1896
2438
|
midX: d?.labelX ?? 0,
|
|
1897
2439
|
midY: d?.labelY ?? 0,
|
|
@@ -2070,9 +2612,11 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2070
2612
|
<SubsystemIssueList
|
|
2071
2613
|
issues={issues ?? []}
|
|
2072
2614
|
focusCategory={focusIssueCategory}
|
|
2073
|
-
onSelectIssue={
|
|
2615
|
+
onSelectIssue={focusIssueTarget}
|
|
2616
|
+
onDeselectIssue={unfocusIssueTarget}
|
|
2074
2617
|
onApplyFix={onApplyIssueFix}
|
|
2075
2618
|
onHoverIssue={onHoverIssue}
|
|
2619
|
+
onExpandedCategoriesChange={handleExpandedCategoriesChange}
|
|
2076
2620
|
/>
|
|
2077
2621
|
) : (
|
|
2078
2622
|
<>
|
|
@@ -2227,9 +2771,19 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2227
2771
|
}}
|
|
2228
2772
|
>
|
|
2229
2773
|
{edgeLabels.map((lbl) => {
|
|
2230
|
-
const mechanism = lbl.mechanism
|
|
2231
|
-
const
|
|
2232
|
-
const
|
|
2774
|
+
const mechanism = lbl.mechanism;
|
|
2775
|
+
const isGraphify = lbl.provenance === 'graphify';
|
|
2776
|
+
const color = edgeColor({ mechanism, provenance: lbl.provenance });
|
|
2777
|
+
// Label chrome stays the crisp rounded box for every edge — graphify
|
|
2778
|
+
// provenance is carried by hue (GRAPHIFY_RELATION_COLOR), the dashed
|
|
2779
|
+
// stroke, and the tooltip, NOT by the "ambiguous" cloud silhouette
|
|
2780
|
+
// (that idiom is reserved for soft authored mechanisms).
|
|
2781
|
+
const verifiable = true;
|
|
2782
|
+
const labelTitle = isGraphify
|
|
2783
|
+
? 'Derived from graphify (static symbol graph)'
|
|
2784
|
+
: MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] === false
|
|
2785
|
+
? 'Not directly verifiable with graphify'
|
|
2786
|
+
: undefined;
|
|
2233
2787
|
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
2234
2788
|
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
2235
2789
|
const text = lbl.stepNos?.length
|
|
@@ -2239,7 +2793,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2239
2793
|
<div
|
|
2240
2794
|
key={lbl.id}
|
|
2241
2795
|
data-edge-label={lbl.id}
|
|
2242
|
-
title={
|
|
2796
|
+
title={labelTitle}
|
|
2243
2797
|
onClick={(e) => {
|
|
2244
2798
|
e.stopPropagation();
|
|
2245
2799
|
selectEdge(lbl.id);
|
|
@@ -2720,6 +3274,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
|
2720
3274
|
components: props.components,
|
|
2721
3275
|
relations: props.relations,
|
|
2722
3276
|
walkthroughs: props.walkthroughs,
|
|
3277
|
+
graphifyRelations: props.graphifyRelations,
|
|
2723
3278
|
});
|
|
2724
3279
|
|
|
2725
3280
|
return (
|