@principal-ai/subsystems-react 0.20.4 → 0.20.6
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/construct.d.ts +23 -0
- package/dist/graphify/construct.d.ts.map +1 -0
- package/dist/graphify/construct.js +77 -0
- package/dist/graphify/construct.js.map +1 -0
- 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/subsystem/ComponentDeclaration.js +1 -1
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +87 -21
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +24 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +25 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +18 -10
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/{kind.test.ts → construct.test.ts} +20 -20
- package/src/graphify/{kind.ts → construct.ts} +17 -17
- package/src/graphify/index.ts +5 -2
- package/src/index.ts +7 -4
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +2 -6
- package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +254 -0
- package/src/subsystem/ComponentDeclaration.tsx +1 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +90 -24
- package/src/subsystem/model.test.ts +22 -0
- package/src/subsystem/model.ts +38 -9
- package/src/subsystem/nodes.tsx +21 -14
|
@@ -236,8 +236,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
236
236
|
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
237
237
|
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
238
238
|
// Hovered throughline in the flows panel: dims every canvas node/edge not
|
|
239
|
-
// involved
|
|
240
|
-
//
|
|
239
|
+
// involved in the hover preview (or selected ∪ hovered when a step is
|
|
240
|
+
// focused). `stepIndex: null` = whole flow (collapsed title hover);
|
|
241
|
+
// a number = that step. Transient — no drawer. Camera only reframes when
|
|
242
|
+
// a step is already selected.
|
|
241
243
|
const [hoveredThroughlineStep, setHoveredThroughlineStep] = useState<{
|
|
242
244
|
throughlineId: string;
|
|
243
245
|
stepIndex: number | null;
|
|
@@ -605,7 +607,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
605
607
|
}
|
|
606
608
|
return ids.size > 0 ? ids : null;
|
|
607
609
|
};
|
|
608
|
-
// Edge/nodes involved in the hovered step or whole flow
|
|
610
|
+
// Edge/nodes involved in the hovered step or whole flow.
|
|
609
611
|
const hoverEdgeIds = useMemo(() => {
|
|
610
612
|
if (!hoveredThroughlineStep || !throughlines) return null;
|
|
611
613
|
const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
|
|
@@ -616,10 +618,20 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
616
618
|
const step = tl.steps[hoveredThroughlineStep.stepIndex];
|
|
617
619
|
return step ? new Set([step.edgeId]) : null;
|
|
618
620
|
}, [throughlines, hoveredThroughlineStep]);
|
|
619
|
-
|
|
620
|
-
|
|
621
|
+
// While hovering with a *step* already selected, brighten the union of
|
|
622
|
+
// selected + hovered participants. Whole-flow focus (or no focus) keeps
|
|
623
|
+
// the old hover-replace preview so a step hover still dims the rest.
|
|
624
|
+
const previewEdgeIds = useMemo(() => {
|
|
625
|
+
if (!hoverEdgeIds) return null;
|
|
626
|
+
if (focusedStepIndex == null || !focusEdgeIds) return hoverEdgeIds;
|
|
627
|
+
const ids = new Set(hoverEdgeIds);
|
|
628
|
+
for (const id of focusEdgeIds) ids.add(id);
|
|
629
|
+
return ids;
|
|
630
|
+
}, [hoverEdgeIds, focusEdgeIds, focusedStepIndex]);
|
|
631
|
+
const previewNodeIds = useMemo(
|
|
632
|
+
() => endpointsOf(previewEdgeIds),
|
|
621
633
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
622
|
-
[baseEdges,
|
|
634
|
+
[baseEdges, previewEdgeIds],
|
|
623
635
|
);
|
|
624
636
|
const openedNodeIds = useMemo(
|
|
625
637
|
() => endpointsOf(openedEdgeIds),
|
|
@@ -654,9 +666,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
654
666
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
655
667
|
// React Flow's own selection state from updating.
|
|
656
668
|
const dispNodes = useMemo(() => {
|
|
657
|
-
// While a step/flow is hovered,
|
|
658
|
-
// everything else among the currently-visible (opened-throughline)
|
|
659
|
-
// is dimmed. Nodes outside the opened-throughline set stay hidden.
|
|
669
|
+
// While a step/flow is hovered, selected + hovered participants stay
|
|
670
|
+
// bright; everything else among the currently-visible (opened-throughline)
|
|
671
|
+
// nodes is dimmed. Nodes outside the opened-throughline set stay hidden.
|
|
660
672
|
return xyflowNodesBase.map((n) => {
|
|
661
673
|
// Boundary frames follow their members: hidden when no member is
|
|
662
674
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
@@ -668,8 +680,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
668
680
|
anyOpened: openedNodeIds != null,
|
|
669
681
|
anySelected: brightNodeIds != null,
|
|
670
682
|
});
|
|
671
|
-
const dimmed =
|
|
672
|
-
? vis.hidden || !memberIds.some((id) =>
|
|
683
|
+
const dimmed = previewNodeIds
|
|
684
|
+
? vis.hidden || !memberIds.some((id) => previewNodeIds.has(id))
|
|
673
685
|
: vis.dimmed;
|
|
674
686
|
const hidden = vis.hidden;
|
|
675
687
|
return {
|
|
@@ -691,8 +703,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
691
703
|
anyOpened: openedNodeIds != null,
|
|
692
704
|
anySelected: brightNodeIds != null,
|
|
693
705
|
});
|
|
694
|
-
|
|
695
|
-
|
|
706
|
+
const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
|
|
707
|
+
const hidden = vis.hidden;
|
|
696
708
|
if (fileMatch === undefined && !isSelected && !dimmed) {
|
|
697
709
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
698
710
|
return { ...n, hidden, data: rest };
|
|
@@ -708,7 +720,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
708
720
|
},
|
|
709
721
|
};
|
|
710
722
|
});
|
|
711
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds,
|
|
723
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds]);
|
|
712
724
|
|
|
713
725
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
714
726
|
const baseEdgesKey = useMemo(
|
|
@@ -729,7 +741,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
729
741
|
markerEnd: nextMarker,
|
|
730
742
|
};
|
|
731
743
|
};
|
|
732
|
-
if (openedEdgeIds || focusEdgeIds ||
|
|
744
|
+
if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
|
|
733
745
|
return baseEdges.map((e) => {
|
|
734
746
|
const vis = flowElementVisibility({
|
|
735
747
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
@@ -737,14 +749,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
737
749
|
anyOpened: openedEdgeIds != null,
|
|
738
750
|
anySelected: focusEdgeIds != null,
|
|
739
751
|
});
|
|
740
|
-
const dimmed =
|
|
752
|
+
const dimmed = previewEdgeIds ? vis.hidden || !previewEdgeIds.has(e.id) : vis.dimmed;
|
|
741
753
|
const hidden = vis.hidden;
|
|
742
754
|
return { ...paint(e, dimmed), hidden };
|
|
743
755
|
});
|
|
744
756
|
}
|
|
745
757
|
if (!selectedEdgeId) return baseEdges;
|
|
746
758
|
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
747
|
-
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds,
|
|
759
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds]);
|
|
748
760
|
|
|
749
761
|
const onNodesChange = useCallback(
|
|
750
762
|
(changes: NodeChange[]) => {
|
|
@@ -934,7 +946,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
934
946
|
}, []);
|
|
935
947
|
|
|
936
948
|
// Focus a single step's edge on the canvas and open/scroll the throughline
|
|
937
|
-
// drawer to that step's snippet.
|
|
949
|
+
// drawer to that step's snippet. Clicking the already-focused step clears
|
|
950
|
+
// step focus and returns to whole-flow (high-level) framing.
|
|
938
951
|
// In `dim` mode: only dim non-participants (same as hovering a step) —
|
|
939
952
|
// no camera move, no drawer, no hide.
|
|
940
953
|
const focusThroughlineStep = useCallback(
|
|
@@ -950,6 +963,20 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
950
963
|
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
951
964
|
return;
|
|
952
965
|
}
|
|
966
|
+
// Toggle: clicking the selected step unselects it (back to whole flow).
|
|
967
|
+
if (focusedThroughlineId === tl.id && focusedStepIndex === stepIndex) {
|
|
968
|
+
setFocusedStepIndex(null);
|
|
969
|
+
// Pointer is still over the row — keep hover preview so dimming doesn't
|
|
970
|
+
// flash off until mouseleave.
|
|
971
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
|
|
972
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
973
|
+
if (pendingFocusFitRef.current != null) {
|
|
974
|
+
window.clearTimeout(pendingFocusFitRef.current);
|
|
975
|
+
pendingFocusFitRef.current = null;
|
|
976
|
+
}
|
|
977
|
+
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
953
980
|
setFocusedStepIndex(stepIndex);
|
|
954
981
|
setFocusedThroughlineId(tl.id);
|
|
955
982
|
setHoveredThroughlineStep(null);
|
|
@@ -983,7 +1010,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
983
1010
|
}, FILE_DRAWER_HEIGHT_MS + 20);
|
|
984
1011
|
}
|
|
985
1012
|
},
|
|
986
|
-
[
|
|
1013
|
+
[
|
|
1014
|
+
fitFocusBounds,
|
|
1015
|
+
focusedStepIndex,
|
|
1016
|
+
focusedThroughlineId,
|
|
1017
|
+
renderThroughlineViewer,
|
|
1018
|
+
throughlineStepMode,
|
|
1019
|
+
],
|
|
987
1020
|
);
|
|
988
1021
|
|
|
989
1022
|
useEffect(() => {
|
|
@@ -994,6 +1027,37 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
994
1027
|
};
|
|
995
1028
|
}, []);
|
|
996
1029
|
|
|
1030
|
+
// While a *step* is selected, hovering another step/flow reframes the
|
|
1031
|
+
// camera to the selected ∪ hovered participants. Leaving hover snaps back
|
|
1032
|
+
// to the selection alone. No step selected → hover only dims; camera stays
|
|
1033
|
+
// at the high-level view. Click-to-focus still owns its own fit (including
|
|
1034
|
+
// the drawer-open delay) — this only reacts to previewEdgeIds transitions.
|
|
1035
|
+
const prevPreviewEdgeIdsRef = useRef<ReadonlySet<string> | null>(null);
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
const prev = prevPreviewEdgeIdsRef.current;
|
|
1038
|
+
prevPreviewEdgeIdsRef.current = previewEdgeIds;
|
|
1039
|
+
|
|
1040
|
+
if (!zoomOnThroughlineFocus || throughlineStepMode === 'dim') return;
|
|
1041
|
+
// Camera follows hover only when a specific step is already focused.
|
|
1042
|
+
if (focusedThroughlineId == null || focusedStepIndex == null) return;
|
|
1043
|
+
|
|
1044
|
+
if (previewEdgeIds) {
|
|
1045
|
+
fitFocusBounds(previewEdgeIds);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (prev != null && focusEdgeIds) {
|
|
1049
|
+
fitFocusBounds(focusEdgeIds);
|
|
1050
|
+
}
|
|
1051
|
+
}, [
|
|
1052
|
+
previewEdgeIds,
|
|
1053
|
+
focusEdgeIds,
|
|
1054
|
+
focusedThroughlineId,
|
|
1055
|
+
focusedStepIndex,
|
|
1056
|
+
fitFocusBounds,
|
|
1057
|
+
zoomOnThroughlineFocus,
|
|
1058
|
+
throughlineStepMode,
|
|
1059
|
+
]);
|
|
1060
|
+
|
|
997
1061
|
// Graph-only embeds: cycle throughline steps with hover-style dimming.
|
|
998
1062
|
useEffect(() => {
|
|
999
1063
|
if (!autoPlayThroughlines || !throughlines?.length || !layoutReady) return;
|
|
@@ -2037,7 +2101,13 @@ function ThroughlineFlow({
|
|
|
2037
2101
|
)}
|
|
2038
2102
|
</div>
|
|
2039
2103
|
{!collapsed && (
|
|
2040
|
-
<div
|
|
2104
|
+
<div
|
|
2105
|
+
style={{ display: 'flex', flexDirection: 'column' }}
|
|
2106
|
+
onMouseLeave={() => {
|
|
2107
|
+
setHoveredStep(null);
|
|
2108
|
+
onLeaveStep();
|
|
2109
|
+
}}
|
|
2110
|
+
>
|
|
2041
2111
|
{throughline.steps.map((step, i) => {
|
|
2042
2112
|
const edge = edgeById.get(step.edgeId);
|
|
2043
2113
|
const mech = edge?.mechanism;
|
|
@@ -2054,10 +2124,6 @@ function ThroughlineFlow({
|
|
|
2054
2124
|
setHoveredStep(i);
|
|
2055
2125
|
onHoverStep(throughline, i);
|
|
2056
2126
|
}}
|
|
2057
|
-
onMouseLeave={() => {
|
|
2058
|
-
setHoveredStep(null);
|
|
2059
|
-
onLeaveStep();
|
|
2060
|
-
}}
|
|
2061
2127
|
onClick={() => onFocusStep(throughline, i)}
|
|
2062
2128
|
style={{
|
|
2063
2129
|
display: 'flex',
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildSubsystemGraph,
|
|
9
9
|
deriveNameFromSymbol,
|
|
10
10
|
constructBadgeLabel,
|
|
11
|
+
rightBadgeLabel,
|
|
11
12
|
nodeMinWidthForBadges,
|
|
12
13
|
formatPurl,
|
|
13
14
|
packageColor,
|
|
@@ -156,6 +157,27 @@ describe('subsystem graph model', () => {
|
|
|
156
157
|
role: 'entry',
|
|
157
158
|
});
|
|
158
159
|
expect(withRole).toBeGreaterThan(stereotype);
|
|
160
|
+
|
|
161
|
+
const withProposed = nodeMinWidthForBadges({
|
|
162
|
+
construct: 'function',
|
|
163
|
+
proposed: true,
|
|
164
|
+
});
|
|
165
|
+
expect(withProposed).toBeGreaterThan(plain);
|
|
166
|
+
|
|
167
|
+
const withBoth = nodeMinWidthForBadges({
|
|
168
|
+
construct: 'function',
|
|
169
|
+
role: 'entry',
|
|
170
|
+
proposed: true,
|
|
171
|
+
});
|
|
172
|
+
// proposed-only label is shorter than entry · proposed; still wider than plain
|
|
173
|
+
expect(withBoth).toBe(withProposed);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('rightBadgeLabel prefers proposed over role', () => {
|
|
177
|
+
expect(rightBadgeLabel({})).toBeNull();
|
|
178
|
+
expect(rightBadgeLabel({ proposed: true })).toBe('proposed');
|
|
179
|
+
expect(rightBadgeLabel({ role: 'entry' })).toBe('entry');
|
|
180
|
+
expect(rightBadgeLabel({ role: 'service', proposed: true })).toBe('proposed');
|
|
159
181
|
});
|
|
160
182
|
|
|
161
183
|
test('deriveNameFromSymbol falls back to file basename for modules', () => {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -126,6 +126,13 @@ export interface SubsystemComponent {
|
|
|
126
126
|
purl: string;
|
|
127
127
|
/** One-line purpose shown on the node. */
|
|
128
128
|
purpose?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Design / migration placeholder — participates in edges and flows but is
|
|
131
|
+
* not a live source declaration yet. Verification skips source checks until
|
|
132
|
+
* promoted (`proposed` cleared, `file` + `symbol` filled). Orthogonal to
|
|
133
|
+
* `construct` (intended shape) and `role` (topology).
|
|
134
|
+
*/
|
|
135
|
+
proposed?: boolean;
|
|
129
136
|
/**
|
|
130
137
|
* Semantic role — where the node sits in the topology (boundary element,
|
|
131
138
|
* external system), orthogonal to `construct` (what it is). Drives the
|
|
@@ -179,13 +186,6 @@ export interface SubsystemComponent {
|
|
|
179
186
|
* letting ELK guess the order.
|
|
180
187
|
*/
|
|
181
188
|
layer?: number;
|
|
182
|
-
/**
|
|
183
|
-
* How this session *occupied* the component — `edited` (modified it) vs
|
|
184
|
-
* `analyzed` (read deeply / built an understanding) vs `referenced`
|
|
185
|
-
* (touched in passing). Drives clustering: edits strengthen a subsystem,
|
|
186
|
-
* but a read-only investigation can still form one around an analyzed seam.
|
|
187
|
-
*/
|
|
188
|
-
capture?: 'edited' | 'analyzed' | 'referenced';
|
|
189
189
|
/**
|
|
190
190
|
* Graphify drill-down detail for this component, when the facet is anchored
|
|
191
191
|
* to a graphify node. Discriminated by kind (class/function/type/module/store/
|
|
@@ -525,6 +525,33 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
|
|
|
525
525
|
service: 'service',
|
|
526
526
|
};
|
|
527
527
|
|
|
528
|
+
/** Right-badge accent for `proposed: true` (no role, or combined with role). */
|
|
529
|
+
export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Top-right badge label: proposed wins over role when both are set
|
|
533
|
+
* (proposed is the temporary exception to scan for; role stays on the model).
|
|
534
|
+
* Returns null when neither applies.
|
|
535
|
+
*/
|
|
536
|
+
export function rightBadgeLabel(component: {
|
|
537
|
+
role?: SubsystemComponentRole;
|
|
538
|
+
proposed?: boolean;
|
|
539
|
+
}): string | null {
|
|
540
|
+
if (component.proposed) return 'proposed';
|
|
541
|
+
if (component.role != null) return ROLE_LABEL[component.role];
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Color for the top-right badge (role and/or proposed). */
|
|
546
|
+
export function rightBadgeColor(component: {
|
|
547
|
+
role?: SubsystemComponentRole;
|
|
548
|
+
proposed?: boolean;
|
|
549
|
+
}): string | null {
|
|
550
|
+
if (component.proposed) return PROPOSED_COLOR;
|
|
551
|
+
if (component.role != null) return ROLE_COLOR[component.role];
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
528
555
|
/**
|
|
529
556
|
* Primary badge text for a node: prefer framework stereotype over the
|
|
530
557
|
* language construct so a React UI unit reads as "component" / "hook"
|
|
@@ -576,13 +603,15 @@ export function nodeMinWidthForBadges(component: {
|
|
|
576
603
|
framework?: string;
|
|
577
604
|
stereotype?: string;
|
|
578
605
|
role?: SubsystemComponentRole;
|
|
606
|
+
proposed?: boolean;
|
|
579
607
|
entityKind?: string;
|
|
580
608
|
}): number {
|
|
581
609
|
const left = estimateBadgeLabelWidth(constructBadgeLabel(component));
|
|
582
|
-
|
|
610
|
+
const rightLabel = rightBadgeLabel(component);
|
|
611
|
+
if (rightLabel == null) {
|
|
583
612
|
return Math.max(NODE_CSS_MIN_WIDTH, BADGE_EDGE_INSET + left + BADGE_EDGE_INSET);
|
|
584
613
|
}
|
|
585
|
-
const right = estimateBadgeLabelWidth(
|
|
614
|
+
const right = estimateBadgeLabelWidth(rightLabel);
|
|
586
615
|
return Math.max(
|
|
587
616
|
NODE_CSS_MIN_WIDTH,
|
|
588
617
|
BADGE_EDGE_INSET + left + BADGE_PAIR_GAP + right + BADGE_EDGE_INSET,
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -19,9 +19,10 @@ import { useTheme } from '@principal-ade/industry-theme';
|
|
|
19
19
|
import {
|
|
20
20
|
MECHANISM_COLOR,
|
|
21
21
|
MECHANISM_STYLE,
|
|
22
|
-
|
|
23
|
-
ROLE_LABEL,
|
|
22
|
+
PROPOSED_COLOR,
|
|
24
23
|
constructBadgeLabel,
|
|
24
|
+
rightBadgeLabel,
|
|
25
|
+
rightBadgeColor,
|
|
25
26
|
deriveNameFromSymbol,
|
|
26
27
|
BADGE_EDGE_INSET,
|
|
27
28
|
nodeMinWidthForBadges,
|
|
@@ -92,11 +93,20 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
92
93
|
// `symbol` is the source of truth; `name` is derived from it consistently.
|
|
93
94
|
const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype);
|
|
94
95
|
// Top badges are absolutely positioned — widen the node so they nowrap
|
|
95
|
-
// instead of wrapping, including when construct + role
|
|
96
|
+
// instead of wrapping, including when construct + role/proposed share the top.
|
|
96
97
|
const badgeMinWidth = nodeMinWidthForBadges(c);
|
|
98
|
+
const topRightLabel = rightBadgeLabel(c);
|
|
99
|
+
const topRightColor = rightBadgeColor(c);
|
|
97
100
|
// Set while a file is open in the drawer: true → spotlight, false → dim,
|
|
98
101
|
// absent (no file open) → neutral.
|
|
99
102
|
const fileMatch = data.fileMatch as boolean | undefined;
|
|
103
|
+
// Border: proposed uses the goldenrod accent (dashed); construct color stays
|
|
104
|
+
// on the left badge. File-open spotlight still wins with primary.
|
|
105
|
+
const borderColor = fileMatch
|
|
106
|
+
? theme.colors.primary
|
|
107
|
+
: c.proposed
|
|
108
|
+
? PROPOSED_COLOR
|
|
109
|
+
: color;
|
|
100
110
|
// Selection is stamped into data by the graph component — React Flow's own
|
|
101
111
|
// `selected` never updates because the node stops click propagation.
|
|
102
112
|
const isSelected = selected || (data.isSelected as boolean | undefined) === true;
|
|
@@ -129,10 +139,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
129
139
|
padding: '6px 10px',
|
|
130
140
|
borderRadius: 8,
|
|
131
141
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
132
|
-
// Selected / file-matched nodes get a thicker border
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
border: `${isSelected || fileMatch ? 4 : 2}px solid ${fileMatch ? theme.colors.primary : color}`,
|
|
142
|
+
// Selected / file-matched nodes get a thicker border. Proposed nodes
|
|
143
|
+
// use a dashed goldenrod border; left construct badge keeps construct color.
|
|
144
|
+
border: `${isSelected || fileMatch ? 4 : 2}px ${c.proposed ? 'dashed' : 'solid'} ${borderColor}`,
|
|
136
145
|
boxShadow: fileMatch
|
|
137
146
|
? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
|
|
138
147
|
: '0 1px 4px rgba(0,0,0,0.25)',
|
|
@@ -167,10 +176,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
167
176
|
{constructBadgeLabel(c)}
|
|
168
177
|
</div>
|
|
169
178
|
|
|
170
|
-
{/*
|
|
171
|
-
|
|
172
|
-
border stays construct-colored. */}
|
|
173
|
-
{c.role != null && (
|
|
179
|
+
{/* Right badge — proposed wins over role when both are set. */}
|
|
180
|
+
{topRightLabel != null && topRightColor != null && (
|
|
174
181
|
<div
|
|
175
182
|
style={{
|
|
176
183
|
position: 'absolute',
|
|
@@ -183,14 +190,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
|
|
|
183
190
|
textTransform: 'uppercase',
|
|
184
191
|
lineHeight: '17px',
|
|
185
192
|
whiteSpace: 'nowrap',
|
|
186
|
-
color:
|
|
193
|
+
color: topRightColor,
|
|
187
194
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
188
|
-
border: `1px solid ${
|
|
195
|
+
border: `1px solid ${topRightColor}`,
|
|
189
196
|
borderRadius: 4,
|
|
190
197
|
padding: '0 5px',
|
|
191
198
|
}}
|
|
192
199
|
>
|
|
193
|
-
{
|
|
200
|
+
{topRightLabel}
|
|
194
201
|
</div>
|
|
195
202
|
)}
|
|
196
203
|
|