@principal-ai/subsystems-react 0.36.2 → 0.37.1
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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreWalkthroughCodeView.d.ts +19 -1
- package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreWalkthroughCodeView.js +53 -2
- package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +13 -1
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +58 -7
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/ConstructsCatalog.d.ts +6 -3
- package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
- package/dist/subsystem/ConstructsCatalog.js +206 -217
- package/dist/subsystem/ConstructsCatalog.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +108 -31
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.js +1 -1
- package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +7 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/utils/edgeLabel.d.ts +51 -0
- package/dist/utils/edgeLabel.d.ts.map +1 -0
- package/dist/utils/edgeLabel.js +81 -0
- package/dist/utils/edgeLabel.js.map +1 -0
- package/dist/utils/elkLayout.d.ts +6 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +27 -6
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +7 -0
- package/src/pierre/PierreWalkthroughCodeView.tsx +75 -1
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +222 -0
- package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +443 -0
- package/src/subsystem/ComponentDeclaration.tsx +100 -7
- package/src/subsystem/ConstructsCatalog.tsx +291 -323
- package/src/subsystem/SubsystemComponentGraph.tsx +173 -30
- package/src/subsystem/SymbolInspectionCard.tsx +10 -9
- package/src/subsystem/model.ts +7 -1
- package/src/utils/edgeLabel.test.ts +23 -0
- package/src/utils/edgeLabel.ts +95 -0
- package/src/utils/elkLayout.ts +41 -6
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from './model';
|
|
54
54
|
import { ConstructsCatalog } from './ConstructsCatalog';
|
|
55
55
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
56
|
+
import type { WalkthroughSymbolQuery } from '../pierre/PierreWalkthroughCodeView';
|
|
56
57
|
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
57
58
|
import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
|
|
58
59
|
import {
|
|
@@ -69,12 +70,13 @@ import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
|
|
|
69
70
|
import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
|
|
70
71
|
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
71
72
|
import { WalkthroughsPanel, WALKTHROUGH_PLAY_PAUSE_MS } from './WalkthroughsPanel';
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
import {
|
|
74
|
+
EDGE_LABEL_WIDTH,
|
|
75
|
+
EDGE_LABEL_HEIGHT,
|
|
76
|
+
EDGE_LABEL_FONT_SIZE,
|
|
77
|
+
EDGE_LABEL_CLOUD_PATH,
|
|
78
|
+
EDGE_LABEL_CLOUD_EXTRA_TOP,
|
|
79
|
+
} from '../utils/edgeLabel';
|
|
78
80
|
|
|
79
81
|
/** Context passed to `renderWalkthroughViewer` when a flow/step is focused. */
|
|
80
82
|
export interface WalkthroughViewerContext {
|
|
@@ -88,6 +90,28 @@ export interface WalkthroughViewerContext {
|
|
|
88
90
|
* but whose endpoint is proposed can be labelled as planned, not missing.
|
|
89
91
|
*/
|
|
90
92
|
proposedAliases: ReadonlySet<string>;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a token in a step's snippet to a construct the step touches (its
|
|
95
|
+
* `from`/`to` component), returning that component's alias. `null` when the
|
|
96
|
+
* token names no touched construct. Forward to the code view so constructs
|
|
97
|
+
* read as clickable.
|
|
98
|
+
*/
|
|
99
|
+
resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
|
|
100
|
+
/** A clicked construct token — open that construct's declaration line. */
|
|
101
|
+
onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Identifiers a token could match to name this component as a construct. */
|
|
105
|
+
function constructIdentifiers(comp: SubsystemComponent): string[] {
|
|
106
|
+
const ids = new Set<string>();
|
|
107
|
+
if (comp.name) ids.add(comp.name);
|
|
108
|
+
if (comp.symbol) {
|
|
109
|
+
ids.add(comp.symbol);
|
|
110
|
+
for (const part of comp.symbol.split('.')) {
|
|
111
|
+
if (part) ids.add(part);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...ids];
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
type DrawerTarget =
|
|
@@ -390,14 +414,29 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
|
|
|
390
414
|
stepIndex,
|
|
391
415
|
onOpenFile,
|
|
392
416
|
proposedAliases,
|
|
417
|
+
resolveSymbol,
|
|
418
|
+
onSymbolClick,
|
|
393
419
|
}: {
|
|
394
420
|
render: (ctx: WalkthroughViewerContext) => ReactNode;
|
|
395
421
|
walkthrough: SubsystemWalkthrough;
|
|
396
422
|
stepIndex: number | null;
|
|
397
423
|
onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
|
|
398
424
|
proposedAliases: ReadonlySet<string>;
|
|
425
|
+
resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
|
|
426
|
+
onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
|
|
399
427
|
}) {
|
|
400
|
-
return
|
|
428
|
+
return (
|
|
429
|
+
<>
|
|
430
|
+
{render({
|
|
431
|
+
walkthrough,
|
|
432
|
+
stepIndex,
|
|
433
|
+
onOpenFile,
|
|
434
|
+
proposedAliases,
|
|
435
|
+
resolveSymbol,
|
|
436
|
+
onSymbolClick,
|
|
437
|
+
})}
|
|
438
|
+
</>
|
|
439
|
+
);
|
|
401
440
|
});
|
|
402
441
|
|
|
403
442
|
interface InnerProps extends SubsystemComponentGraphProps {
|
|
@@ -570,6 +609,10 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
570
609
|
if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
|
|
571
610
|
return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
|
|
572
611
|
}, [drawerTarget, walkthroughs]);
|
|
612
|
+
// Ref mirror of the drawer's walkthrough id so the symbol resolver stays
|
|
613
|
+
// stable across graph re-renders (the drawer is memoized on callback identity).
|
|
614
|
+
const drawerWalkthroughIdRef = useRef<string | null>(null);
|
|
615
|
+
drawerWalkthroughIdRef.current = focusedWalkthrough?.id ?? null;
|
|
573
616
|
|
|
574
617
|
// Walkthrough shown on the canvas title chip (focus or hover/autoplay highlight).
|
|
575
618
|
const overlayWalkthroughTitle = useMemo(() => {
|
|
@@ -1576,6 +1619,60 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1576
1619
|
[components, onSelect],
|
|
1577
1620
|
);
|
|
1578
1621
|
|
|
1622
|
+
// Construct tokens inside a walkthrough snippet. A step's line is an edge
|
|
1623
|
+
// between its `from`/`to` components, so a token naming either of those
|
|
1624
|
+
// constructs should navigate to that construct's declaration. Index the
|
|
1625
|
+
// matchable identifiers per step (`walkthroughId:index`).
|
|
1626
|
+
const walkthroughSymbolIndex = useMemo(() => {
|
|
1627
|
+
const byAlias = new Map(components.map((c) => [c.alias, c]));
|
|
1628
|
+
const index = new Map<string, Map<string, string>>();
|
|
1629
|
+
for (const wt of walkthroughs ?? []) {
|
|
1630
|
+
wt.steps.forEach((step, i) => {
|
|
1631
|
+
const tokens = new Map<string, string>();
|
|
1632
|
+
for (const alias of [step.from, step.to]) {
|
|
1633
|
+
const comp = byAlias.get(alias);
|
|
1634
|
+
if (!comp) continue;
|
|
1635
|
+
for (const ident of constructIdentifiers(comp)) tokens.set(ident, alias);
|
|
1636
|
+
}
|
|
1637
|
+
index.set(`${wt.id}:${i}`, tokens);
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
return index;
|
|
1641
|
+
}, [components, walkthroughs]);
|
|
1642
|
+
const walkthroughSymbolIndexRef = useRef(walkthroughSymbolIndex);
|
|
1643
|
+
walkthroughSymbolIndexRef.current = walkthroughSymbolIndex;
|
|
1644
|
+
|
|
1645
|
+
// Stable resolver: reads the live index + focused walkthrough from refs so
|
|
1646
|
+
// the memoized walkthrough drawer isn't rebuilt on every graph render.
|
|
1647
|
+
const resolveWalkthroughSymbol = useCallback(
|
|
1648
|
+
(query: WalkthroughSymbolQuery): string | null => {
|
|
1649
|
+
const walkthroughId = drawerWalkthroughIdRef.current;
|
|
1650
|
+
if (walkthroughId == null) return null;
|
|
1651
|
+
return (
|
|
1652
|
+
walkthroughSymbolIndexRef.current
|
|
1653
|
+
.get(`${walkthroughId}:${query.stepIndex}`)
|
|
1654
|
+
?.get(query.tokenText) ?? null
|
|
1655
|
+
);
|
|
1656
|
+
},
|
|
1657
|
+
[],
|
|
1658
|
+
);
|
|
1659
|
+
|
|
1660
|
+
// A construct token click navigates to that construct's declaration: open
|
|
1661
|
+
// its file at the anchored declaration line (same path as the declaration
|
|
1662
|
+
// panel's file link), falling back to the file top when unanchored.
|
|
1663
|
+
const openConstructDeclaration = useCallback(
|
|
1664
|
+
(alias: string) => {
|
|
1665
|
+
const comp = components.find((c) => c.alias === alias);
|
|
1666
|
+
if (!comp?.file) return;
|
|
1667
|
+
const startLine = comp.declarationRef?.startLine;
|
|
1668
|
+
onOpenDeclarationFile(
|
|
1669
|
+
comp.file,
|
|
1670
|
+
startLine != null ? { startLine } : undefined,
|
|
1671
|
+
);
|
|
1672
|
+
},
|
|
1673
|
+
[components, onOpenDeclarationFile],
|
|
1674
|
+
);
|
|
1675
|
+
|
|
1579
1676
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
1580
1677
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
1581
1678
|
// the actual edge path (not node-center approximations).
|
|
@@ -1940,14 +2037,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1940
2037
|
const text = lbl.stepNos?.length
|
|
1941
2038
|
? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
|
|
1942
2039
|
: lbl.mechanism;
|
|
1943
|
-
// Labels stay readable at full size until they'd exceed a share of
|
|
1944
|
-
// the edge's screen length, then shrink with zoom.
|
|
1945
|
-
const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
|
|
1946
|
-
const screenEdgeLen = lbl.pathLength * viewport.zoom;
|
|
1947
|
-
const scale =
|
|
1948
|
-
lbl.pathLength > 0 && estWidth > 0
|
|
1949
|
-
? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
|
|
1950
|
-
: 1;
|
|
1951
2040
|
return (
|
|
1952
2041
|
<div
|
|
1953
2042
|
key={lbl.id}
|
|
@@ -1961,29 +2050,81 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
1961
2050
|
position: 'absolute',
|
|
1962
2051
|
left: screenX,
|
|
1963
2052
|
top: screenY,
|
|
1964
|
-
// Center on the flow-space midpoint
|
|
1965
|
-
// space
|
|
1966
|
-
//
|
|
1967
|
-
transform: `translate(-50%, -50%) scale(${
|
|
2053
|
+
// Center on the flow-space midpoint, and scale with the graph
|
|
2054
|
+
// (flow-space size): labels shrink as you zoom out instead of
|
|
2055
|
+
// staying fixed-screen-size and dominating the smaller graph.
|
|
2056
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
1968
2057
|
transformOrigin: 'center center',
|
|
1969
2058
|
display: 'flex',
|
|
1970
2059
|
alignItems: 'center',
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
background: 'rgba(21,21,21,0.9)',
|
|
1977
|
-
border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
|
|
1978
|
-
borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
|
|
1979
|
-
padding: '3px 8px',
|
|
2060
|
+
justifyContent: 'center',
|
|
2061
|
+
width: EDGE_LABEL_WIDTH,
|
|
2062
|
+
height: EDGE_LABEL_HEIGHT,
|
|
2063
|
+
boxSizing: 'border-box',
|
|
2064
|
+
padding: '7px 8px',
|
|
1980
2065
|
cursor: 'pointer',
|
|
1981
2066
|
pointerEvents: 'auto',
|
|
1982
2067
|
opacity: lbl.dimmed ? 0.15 : 1,
|
|
1983
|
-
whiteSpace: 'nowrap',
|
|
1984
2068
|
}}
|
|
1985
2069
|
>
|
|
1986
|
-
{
|
|
2070
|
+
{/* Background layer. Opaque theme surface (matches the node fill)
|
|
2071
|
+
so the edge line behind is hidden. Verifiable mechanisms get a
|
|
2072
|
+
crisp rounded box; soft / ambiguous ones (uses, feeds,
|
|
2073
|
+
watches …) get a real lobed cloud silhouette — ambiguous, not
|
|
2074
|
+
a dashed proposal (or a pill, which border-radius can only
|
|
2075
|
+
ever make). */}
|
|
2076
|
+
{verifiable ? (
|
|
2077
|
+
<span
|
|
2078
|
+
aria-hidden
|
|
2079
|
+
style={{
|
|
2080
|
+
position: 'absolute',
|
|
2081
|
+
inset: 0,
|
|
2082
|
+
borderRadius: 4,
|
|
2083
|
+
border: `0.5px solid ${color}`,
|
|
2084
|
+
background:
|
|
2085
|
+
theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
2086
|
+
pointerEvents: 'none',
|
|
2087
|
+
}}
|
|
2088
|
+
/>
|
|
2089
|
+
) : (
|
|
2090
|
+
<svg
|
|
2091
|
+
aria-hidden
|
|
2092
|
+
width={EDGE_LABEL_WIDTH}
|
|
2093
|
+
height={EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}
|
|
2094
|
+
viewBox={`0 0 ${EDGE_LABEL_WIDTH} ${EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}`}
|
|
2095
|
+
style={{
|
|
2096
|
+
position: 'absolute',
|
|
2097
|
+
left: 0,
|
|
2098
|
+
top: -EDGE_LABEL_CLOUD_EXTRA_TOP,
|
|
2099
|
+
pointerEvents: 'none',
|
|
2100
|
+
}}
|
|
2101
|
+
>
|
|
2102
|
+
<path
|
|
2103
|
+
d={EDGE_LABEL_CLOUD_PATH}
|
|
2104
|
+
fill={theme.colors.backgroundSecondary ?? theme.colors.background}
|
|
2105
|
+
stroke={hexWithAlpha(color, 0.75)}
|
|
2106
|
+
strokeWidth={1.2}
|
|
2107
|
+
strokeLinejoin="round"
|
|
2108
|
+
/>
|
|
2109
|
+
</svg>
|
|
2110
|
+
)}
|
|
2111
|
+
<span
|
|
2112
|
+
style={{
|
|
2113
|
+
position: 'relative',
|
|
2114
|
+
display: 'block',
|
|
2115
|
+
maxWidth: '100%',
|
|
2116
|
+
overflow: 'hidden',
|
|
2117
|
+
textOverflow: 'ellipsis',
|
|
2118
|
+
whiteSpace: 'nowrap',
|
|
2119
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
2120
|
+
lineHeight: 1,
|
|
2121
|
+
fontFamily: theme.fonts.monospace,
|
|
2122
|
+
fontWeight: 500,
|
|
2123
|
+
color,
|
|
2124
|
+
}}
|
|
2125
|
+
>
|
|
2126
|
+
{text}
|
|
2127
|
+
</span>
|
|
1987
2128
|
</div>
|
|
1988
2129
|
);
|
|
1989
2130
|
})}
|
|
@@ -2210,6 +2351,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2210
2351
|
stepIndex={drawerTarget.stepIndex}
|
|
2211
2352
|
onOpenFile={onOpenFileFromWalkthrough}
|
|
2212
2353
|
proposedAliases={proposedAliases}
|
|
2354
|
+
resolveSymbol={resolveWalkthroughSymbol}
|
|
2355
|
+
onSymbolClick={openConstructDeclaration}
|
|
2213
2356
|
/>
|
|
2214
2357
|
) : drawerTarget?.kind === 'file' ? (
|
|
2215
2358
|
<FileDrawerContent
|
|
@@ -337,7 +337,7 @@ export function SymbolInspectionCard({
|
|
|
337
337
|
×
|
|
338
338
|
</button>
|
|
339
339
|
|
|
340
|
-
{/* Top: the declaration itself, full-bleed (or
|
|
340
|
+
{/* Top: the declaration itself, full-bleed (or just the name on a miss). */}
|
|
341
341
|
<div
|
|
342
342
|
style={{
|
|
343
343
|
padding: '12px 32px 12px 12px',
|
|
@@ -363,20 +363,21 @@ export function SymbolInspectionCard({
|
|
|
363
363
|
)}
|
|
364
364
|
|
|
365
365
|
{!error && !hasBody && (
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
</span>
|
|
370
|
-
<span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
|
|
371
|
-
{statusText}
|
|
372
|
-
</span>
|
|
373
|
-
</div>
|
|
366
|
+
<span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
|
|
367
|
+
{symbolRef.name}
|
|
368
|
+
</span>
|
|
374
369
|
)}
|
|
375
370
|
</div>
|
|
376
371
|
|
|
377
372
|
{/* Meta: where it lives, ambiguity, and the action. */}
|
|
378
373
|
{!error && (
|
|
379
374
|
<div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
375
|
+
{!hasBody && (
|
|
376
|
+
<span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
|
|
377
|
+
{statusText}
|
|
378
|
+
</span>
|
|
379
|
+
)}
|
|
380
|
+
|
|
380
381
|
{node?.sourceFile && (
|
|
381
382
|
<StackedRow label="Source">
|
|
382
383
|
{onOpenFile ? (
|
package/src/subsystem/model.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type Node,
|
|
19
19
|
} from '@xyflow/react';
|
|
20
20
|
import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
|
|
21
|
+
import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
|
|
21
22
|
import type { GraphifyComponentDetail } from '../graphify';
|
|
22
23
|
import type { SubsystemDeclarationRef } from './declarationRef';
|
|
23
24
|
import { purlOwnerName, purlRepoKey } from './paths';
|
|
@@ -1642,7 +1643,12 @@ export async function buildSubsystemGraph(
|
|
|
1642
1643
|
nodeSpacing: 60,
|
|
1643
1644
|
edgeSpacing: 30,
|
|
1644
1645
|
edgeNodeSpacing: 60,
|
|
1645
|
-
|
|
1646
|
+
// With labels on, ELK's CENTER_LAYER strategy applies this spacing on
|
|
1647
|
+
// both sides of the label layer, so it IS the per-side clearance — the
|
|
1648
|
+
// label box itself is reserved separately in elkLayout. A large value
|
|
1649
|
+
// here would be counted twice. Labels off → ordinary layer gap.
|
|
1650
|
+
interLayerSpacing: showEdgeLabels === false ? 120 : EDGE_LABEL_SIDE_PADDING,
|
|
1651
|
+
endpointInset: EDGE_ARROW_INSET,
|
|
1646
1652
|
preserveNodePositions: false,
|
|
1647
1653
|
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
1648
1654
|
groups: layoutGroups.map((g) => ({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared edge-label geometry.
|
|
3
|
+
*
|
|
4
|
+
* The overlay and the ELK reservation both import these values, so the
|
|
5
|
+
* invariants here (fixed box, min edge run covers the box) are what keep
|
|
6
|
+
* rendered labels consistent and non-overlapping.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test';
|
|
10
|
+
import {
|
|
11
|
+
EDGE_LABEL_WIDTH,
|
|
12
|
+
EDGE_LABEL_SIDE_PADDING,
|
|
13
|
+
EDGE_LABEL_EDGE_GAP,
|
|
14
|
+
} from './edgeLabel';
|
|
15
|
+
|
|
16
|
+
describe('edge-label geometry', () => {
|
|
17
|
+
test('the labelled-edge gap is the box plus clearance on both sides', () => {
|
|
18
|
+
expect(EDGE_LABEL_EDGE_GAP).toBe(
|
|
19
|
+
EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2,
|
|
20
|
+
);
|
|
21
|
+
expect(EDGE_LABEL_EDGE_GAP).toBeGreaterThan(EDGE_LABEL_WIDTH);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared geometry for the HTML edge-label overlay and the ELK layout that
|
|
3
|
+
* reserves room for it.
|
|
4
|
+
*
|
|
5
|
+
* The overlay (SubsystemComponentGraph) and the layout (elkLayout) must agree
|
|
6
|
+
* on one fixed box size, or the space ELK leaves for a label drifts from what
|
|
7
|
+
* actually renders. Keeping the geometry here lets both import the same values
|
|
8
|
+
* without a component → util → component import cycle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Fixed width of every edge label, in flow/screen px. Wide enough for the
|
|
12
|
+
* longest mechanism (`registers-into`) plus horizontal padding at the label
|
|
13
|
+
* font size. */
|
|
14
|
+
export const EDGE_LABEL_WIDTH = 140;
|
|
15
|
+
|
|
16
|
+
/** Fixed height of every edge label. Sized to the label font plus padding. */
|
|
17
|
+
export const EDGE_LABEL_HEIGHT = 40;
|
|
18
|
+
|
|
19
|
+
/** Label font size. Fixed so every label renders at the same text size. */
|
|
20
|
+
export const EDGE_LABEL_FONT_SIZE = 14;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clear space we want on each side of a label box.
|
|
24
|
+
*
|
|
25
|
+
* Passed as ELK's `interLayerSpacing` (`nodeNodeBetweenLayers`). ELK's
|
|
26
|
+
* `CENTER_LAYER` label strategy inserts a dedicated label layer and applies
|
|
27
|
+
* that spacing on *both* sides of it, so the resulting gap between the two
|
|
28
|
+
* nodes an edge connects is:
|
|
29
|
+
*
|
|
30
|
+
* EDGE_LABEL_WIDTH + 2 * EDGE_LABEL_SIDE_PADDING
|
|
31
|
+
*
|
|
32
|
+
* i.e. the reserved label box plus this clearance at each end. Setting a big
|
|
33
|
+
* "min edge length" here double-counts (it is applied twice), which is how the
|
|
34
|
+
* gap silently ballooned before.
|
|
35
|
+
*/
|
|
36
|
+
export const EDGE_LABEL_SIDE_PADDING = 32;
|
|
37
|
+
|
|
38
|
+
/** Actual end-to-end gap ELK leaves for a labelled edge (box + both clearances). */
|
|
39
|
+
export const EDGE_LABEL_EDGE_GAP =
|
|
40
|
+
EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How far (in flow px) the target end of an edge stops short of the node
|
|
44
|
+
* border. ELK ends the path exactly on the border, so the arrowhead tip lands
|
|
45
|
+
* on the border line; pulling it back this much lets the arrow visually touch
|
|
46
|
+
* the node without overlapping its border. 0 = flush (old behaviour).
|
|
47
|
+
*/
|
|
48
|
+
export const EDGE_ARROW_INSET = 2;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a "cloud" outline that fills a `w × h` box: a flat-ish bottom with
|
|
52
|
+
* rounded corners and three top lobes (large left, medium middle, small right).
|
|
53
|
+
* Used as the background silhouette for ambiguous (non-verifiable) edge labels —
|
|
54
|
+
* a real cloud shape, which `border-radius` cannot express. Control points are
|
|
55
|
+
* normalized (`0…1`) so the shape scales with the label box.
|
|
56
|
+
*/
|
|
57
|
+
function cloudPath(w: number, h: number): string {
|
|
58
|
+
const p = (nx: number, ny: number) =>
|
|
59
|
+
`${(nx * w).toFixed(2)} ${(ny * h).toFixed(2)}`;
|
|
60
|
+
return [
|
|
61
|
+
`M ${p(0.043, 0.65)}`,
|
|
62
|
+
// Bottom-left rounded corner + flat bottom.
|
|
63
|
+
`C ${p(0.014, 0.8)} ${p(0.05, 0.925)} ${p(0.114, 0.925)}`,
|
|
64
|
+
`L ${p(0.829, 0.925)}`,
|
|
65
|
+
// Bottom-right rounded corner up into the right (small) lobe.
|
|
66
|
+
`C ${p(0.914, 0.925)} ${p(0.986, 0.825)} ${p(0.986, 0.675)}`,
|
|
67
|
+
`C ${p(0.986, 0.475)} ${p(0.943, 0.325)} ${p(0.879, 0.325)}`,
|
|
68
|
+
`C ${p(0.829, 0.325)} ${p(0.793, 0.425)} ${p(0.771, 0.525)}`,
|
|
69
|
+
// Middle lobe.
|
|
70
|
+
`C ${p(0.736, 0.275)} ${p(0.664, 0.15)} ${p(0.6, 0.2)}`,
|
|
71
|
+
`C ${p(0.564, 0.225)} ${p(0.536, 0.3)} ${p(0.521, 0.375)}`,
|
|
72
|
+
// Big left lobe.
|
|
73
|
+
`C ${p(0.486, 0.1)} ${p(0.4, 0.025)} ${p(0.336, 0.075)}`,
|
|
74
|
+
`C ${p(0.279, 0.1)} ${p(0.243, 0.225)} ${p(0.221, 0.375)}`,
|
|
75
|
+
// Down the left side, back to the corner.
|
|
76
|
+
`C ${p(0.2, 0.5)} ${p(0.143, 0.475)} ${p(0.1, 0.525)}`,
|
|
77
|
+
`C ${p(0.064, 0.55)} ${p(0.057, 0.6)} ${p(0.043, 0.65)}`,
|
|
78
|
+
'Z',
|
|
79
|
+
].join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extra height (flow px) the cloud rises above the label box. The cloud and
|
|
84
|
+
* its text share the label box, but ambiguous labels draw a taller silhouette
|
|
85
|
+
* that overhangs upward — so the cloud gets more presence without moving the
|
|
86
|
+
* text or changing the verifiable labels' box.
|
|
87
|
+
*/
|
|
88
|
+
export const EDGE_LABEL_CLOUD_EXTRA_TOP = 14;
|
|
89
|
+
|
|
90
|
+
/** Cloud silhouette for ambiguous edge labels, matched to the label box. */
|
|
91
|
+
export const EDGE_LABEL_CLOUD_PATH = cloudPath(
|
|
92
|
+
EDGE_LABEL_WIDTH,
|
|
93
|
+
EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP,
|
|
94
|
+
);
|
|
95
|
+
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import ELK, { type ElkNode, type ElkExtendedEdge, type LayoutOptions } from 'elkjs/lib/elk.bundled.js';
|
|
9
9
|
import type { Node, Edge } from '@xyflow/react';
|
|
10
|
+
import {
|
|
11
|
+
EDGE_LABEL_WIDTH,
|
|
12
|
+
EDGE_LABEL_HEIGHT,
|
|
13
|
+
EDGE_LABEL_SIDE_PADDING,
|
|
14
|
+
} from './edgeLabel';
|
|
10
15
|
|
|
11
16
|
/** ELK layout options for different routing styles */
|
|
12
17
|
export type ElkRoutingStyle = 'orthogonal' | 'splines' | 'polyline';
|
|
@@ -54,6 +59,13 @@ export interface ElkLayoutOptions {
|
|
|
54
59
|
*/
|
|
55
60
|
interLayerSpacing?: number;
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Pull the target end of each edge back from the node border by this many
|
|
64
|
+
* flow px, so the arrowhead tip touches the node without sitting on its
|
|
65
|
+
* border. @default 0
|
|
66
|
+
*/
|
|
67
|
+
endpointInset?: number;
|
|
68
|
+
|
|
57
69
|
/**
|
|
58
70
|
* Reserve space along edges for inline labels so they don't overlap nodes
|
|
59
71
|
* or other edges. When enabled ELK places labels inline on the edge with the
|
|
@@ -486,6 +498,7 @@ export async function computeElkLayout(
|
|
|
486
498
|
): Promise<ElkLayoutResult> {
|
|
487
499
|
const { preserveNodePositions = true, keepSingletonGroups = false } = options;
|
|
488
500
|
const edgeLabels = options.edgeLabels;
|
|
501
|
+
const endpointInset = options.endpointInset ?? 0;
|
|
489
502
|
const direction = options.direction ?? 'RIGHT';
|
|
490
503
|
|
|
491
504
|
// Build a map of original node positions BEFORE passing to ELK
|
|
@@ -620,13 +633,12 @@ export async function computeElkLayout(
|
|
|
620
633
|
sources: [sourcePort],
|
|
621
634
|
targets: [targetPort],
|
|
622
635
|
};
|
|
623
|
-
//
|
|
624
|
-
//
|
|
636
|
+
// Reserve the fixed label box so ELK leaves the same room for every label,
|
|
637
|
+
// regardless of text length. The overlay renders into this exact box.
|
|
625
638
|
if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
elkEdge.labels = [{ text, width: labelWidth, height: labelHeight }];
|
|
639
|
+
elkEdge.labels = [
|
|
640
|
+
{ text: edge.label, width: EDGE_LABEL_WIDTH, height: EDGE_LABEL_HEIGHT },
|
|
641
|
+
];
|
|
630
642
|
}
|
|
631
643
|
return elkEdge;
|
|
632
644
|
});
|
|
@@ -647,6 +659,12 @@ export async function computeElkLayout(
|
|
|
647
659
|
// edge by ~13px) plus a gap before the first child.
|
|
648
660
|
'elk.padding': '[top=64,left=24,bottom=24,right=24]',
|
|
649
661
|
'elk.spacing.nodeNode': '40',
|
|
662
|
+
// Edges inside a frame host labels too: ELK applies this on both sides of
|
|
663
|
+
// the reserved label layer, so it is the per-side clearance (same as root).
|
|
664
|
+
// Labels off falls back to a plain between-layer gap.
|
|
665
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(
|
|
666
|
+
edgeLabels?.enabled === false ? 40 : EDGE_LABEL_SIDE_PADDING,
|
|
667
|
+
),
|
|
650
668
|
};
|
|
651
669
|
|
|
652
670
|
const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
|
|
@@ -900,6 +918,23 @@ export async function computeElkLayout(
|
|
|
900
918
|
allPoints.push(end);
|
|
901
919
|
}
|
|
902
920
|
|
|
921
|
+
// Pull the target end back from the node border so the arrowhead tip
|
|
922
|
+
// touches the node without overlapping its border.
|
|
923
|
+
if (endpointInset > 0 && allPoints.length >= 2) {
|
|
924
|
+
const end = allPoints[allPoints.length - 1];
|
|
925
|
+
const prev = allPoints[allPoints.length - 2];
|
|
926
|
+
const dx = end.x - prev.x;
|
|
927
|
+
const dy = end.y - prev.y;
|
|
928
|
+
const segLen = Math.hypot(dx, dy);
|
|
929
|
+
if (segLen > endpointInset) {
|
|
930
|
+
const t = (segLen - endpointInset) / segLen;
|
|
931
|
+
allPoints[allPoints.length - 1] = {
|
|
932
|
+
x: prev.x + dx * t,
|
|
933
|
+
y: prev.y + dy * t,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
903
938
|
// Convert to path
|
|
904
939
|
const path =
|
|
905
940
|
options.routingStyle === 'orthogonal'
|