@principal-ai/subsystems-react 0.43.2 → 0.44.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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/SubsystemModelTransition.d.ts +40 -0
- package/dist/subsystem/SubsystemModelTransition.d.ts.map +1 -0
- package/dist/subsystem/SubsystemModelTransition.js +232 -0
- package/dist/subsystem/SubsystemModelTransition.js.map +1 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +3 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +8 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +100 -0
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +120 -0
- package/src/subsystem/SubsystemModelTransition.tsx +389 -0
- package/src/subsystem/model.ts +3 -0
- package/src/utils/elkLayout.ts +106 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
4
|
+
import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
|
|
5
|
+
import {
|
|
6
|
+
SubsystemModelTransition,
|
|
7
|
+
type SubsystemTransitionStep,
|
|
8
|
+
} from '../../../subsystem/SubsystemModelTransition';
|
|
9
|
+
import type { SubsystemComponent, SubsystemWalkthrough } from '../../../subsystem/model';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Harness for `SubsystemModelTransition` — the same progression cases
|
|
13
|
+
* (constructs → static → dynamic → walkthrough) animated between, with a nav.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Subsystem/ComponentGraph/Animated progression',
|
|
18
|
+
component: SubsystemComponentGraph,
|
|
19
|
+
parameters: { layout: 'fullscreen' },
|
|
20
|
+
} satisfies Meta<typeof SubsystemComponentGraph>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
const PURL = 'pkg:github/you/scheduling-app';
|
|
26
|
+
|
|
27
|
+
const components: SubsystemComponent[] = [
|
|
28
|
+
{ alias: 'booking-page', name: 'BookingPage', construct: 'function', symbol: 'BookingPage', role: 'entry', framework: 'react', stereotype: 'component', purl: PURL, file: 'app/book/page.tsx', module: 'app/book/page.tsx', process: 'booking-web/client' },
|
|
29
|
+
{ alias: 'list-open-slots', name: 'listOpenSlots', construct: 'function', symbol: 'listOpenSlots', role: 'entry', framework: 'next', stereotype: 'server-action', purl: PURL, file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'booking-web/server' },
|
|
30
|
+
{ alias: 'book-slot', name: 'bookSlot', construct: 'function', symbol: 'bookSlot', role: 'entry', framework: 'next', stereotype: 'server-action', purl: PURL, file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'booking-web/server' },
|
|
31
|
+
{ alias: 'list-slots', name: 'listSlots', construct: 'function', symbol: 'listSlots', purl: PURL, file: 'lib/listSlots.ts', module: 'lib/listSlots.ts', process: 'booking-web/server' },
|
|
32
|
+
{ alias: 'create-booking', name: 'createBooking', construct: 'function', symbol: 'createBooking', purl: PURL, file: 'lib/createBooking.ts', module: 'lib/createBooking.ts', process: 'booking-web/server' },
|
|
33
|
+
{ alias: 'capture-event', name: 'captureEvent', construct: 'function', symbol: 'captureEvent', purl: PURL, file: 'lib/captureEvent.ts', module: 'lib/captureEvent.ts', process: 'booking-web/client' },
|
|
34
|
+
{ alias: 'Database', name: 'Database', construct: 'external', role: 'service', purl: 'external', file: '' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const walkthroughs: SubsystemWalkthrough[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'wt-book',
|
|
40
|
+
title: 'Guest books',
|
|
41
|
+
steps: [
|
|
42
|
+
{ from: 'booking-page', to: 'book-slot', mechanism: 'calls', file: 'app/book/page.tsx', line: 24, purl: PURL, symbol: 'bookSlot' },
|
|
43
|
+
{ from: 'book-slot', to: 'create-booking', mechanism: 'calls', file: 'app/book/actions.ts', line: 22, purl: PURL, symbol: 'createBooking' },
|
|
44
|
+
{ from: 'create-booking', to: 'Database', mechanism: 'writes', file: 'lib/createBooking.ts', line: 13, purl: PURL, symbol: 'insert' },
|
|
45
|
+
{ from: 'booking-page', to: 'capture-event', mechanism: 'calls', file: 'app/book/page.tsx', line: 26, purl: PURL, symbol: 'captureEvent' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'wt-pick',
|
|
50
|
+
title: 'Guest picks a slot',
|
|
51
|
+
steps: [
|
|
52
|
+
{ from: 'booking-page', to: 'list-open-slots', mechanism: 'calls', file: 'app/book/page.tsx', line: 17, purl: PURL, symbol: 'listOpenSlots' },
|
|
53
|
+
{ from: 'list-open-slots', to: 'list-slots', mechanism: 'calls', file: 'app/book/actions.ts', line: 14, purl: PURL, symbol: 'listSlots' },
|
|
54
|
+
{ from: 'list-slots', to: 'Database', mechanism: 'reads', file: 'lib/listSlots.ts', line: 9, purl: PURL, symbol: 'findOpen' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const LABELS = ['Constructs', 'Static topology', 'Dynamic topology', 'Walkthrough'];
|
|
60
|
+
|
|
61
|
+
const STEPS: SubsystemTransitionStep[] = [
|
|
62
|
+
{
|
|
63
|
+
model: { title: 'Booking', components: components.map(({ module: _m, process: _p, ...rest }) => rest) },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
model: { title: 'Booking', components: components.map(({ process: _p, ...rest }) => rest) },
|
|
67
|
+
moduleNesting: 'path',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
model: { title: 'Booking', components, walkthroughs },
|
|
71
|
+
moduleNesting: 'path',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
model: { title: 'Booking', components, walkthroughs },
|
|
75
|
+
moduleNesting: 'path',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function Harness() {
|
|
80
|
+
const [active, setActive] = useState(0);
|
|
81
|
+
const steps = useMemo(() => STEPS, []);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const t = window.setInterval(() => setActive((i) => (i + 1) % STEPS.length), 3600);
|
|
85
|
+
return () => window.clearInterval(t);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div style={{ position: 'relative', width: '100%', height: '100vh', background: defaultEditorTheme.colors.background }}>
|
|
90
|
+
<SubsystemModelTransition steps={steps} activeIndex={active} />
|
|
91
|
+
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', gap: 8, zIndex: 10 }}>
|
|
92
|
+
{LABELS.map((label, i) => (
|
|
93
|
+
<button
|
|
94
|
+
key={label}
|
|
95
|
+
onClick={() => setActive(i)}
|
|
96
|
+
style={{
|
|
97
|
+
padding: '6px 12px',
|
|
98
|
+
borderRadius: 6,
|
|
99
|
+
border: `1px solid ${i === active ? defaultEditorTheme.colors.primary : '#555'}`,
|
|
100
|
+
background: i === active ? defaultEditorTheme.colors.primary : 'transparent',
|
|
101
|
+
color: i === active ? '#fff' : defaultEditorTheme.colors.text,
|
|
102
|
+
cursor: 'pointer',
|
|
103
|
+
font: '600 12px system-ui',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const Cases: Story = {
|
|
115
|
+
render: () => (
|
|
116
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
117
|
+
<Harness />
|
|
118
|
+
</ThemeProvider>
|
|
119
|
+
),
|
|
120
|
+
};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemModelTransition — a sibling of `SubsystemComponentGraph` that
|
|
3
|
+
* presents ONE subsystem as a sequence of steps (e.g. constructs → static
|
|
4
|
+
* topology → dynamic topology → walkthrough) and animates between them.
|
|
5
|
+
*
|
|
6
|
+
* Same look as the component graph: it reuses the same node / frame / edge
|
|
7
|
+
* components and the edge-label chrome, so only the motion is new. Each step is
|
|
8
|
+
* laid out with `buildSubsystemGraph`; nodes and frames are flattened to
|
|
9
|
+
* absolute rects and position + size + opacity are interpolated on a rAF tween.
|
|
10
|
+
* The viewport refits to the incoming layout at the start of a transition, and
|
|
11
|
+
* the graph's real (ELK-routed, mechanism-colored) edges appear once it settles.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
Background,
|
|
17
|
+
EdgeLabelRenderer,
|
|
18
|
+
ReactFlow,
|
|
19
|
+
ReactFlowProvider,
|
|
20
|
+
useReactFlow,
|
|
21
|
+
type Edge,
|
|
22
|
+
type EdgeProps,
|
|
23
|
+
type EdgeTypes,
|
|
24
|
+
type Node,
|
|
25
|
+
type NodeTypes,
|
|
26
|
+
} from '@xyflow/react';
|
|
27
|
+
import '@xyflow/react/dist/style.css';
|
|
28
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
|
+
import {
|
|
30
|
+
buildSubsystemGraph,
|
|
31
|
+
edgeColor,
|
|
32
|
+
MECHANISM_DESCRIPTIONS,
|
|
33
|
+
type SubsystemEdgeProvenance,
|
|
34
|
+
type SubsystemGraphNode,
|
|
35
|
+
type SubsystemModelDocument,
|
|
36
|
+
} from './model';
|
|
37
|
+
import {
|
|
38
|
+
hexWithAlpha,
|
|
39
|
+
SubsystemCallbacksProvider,
|
|
40
|
+
SubsystemComponentNode,
|
|
41
|
+
SubsystemEdge,
|
|
42
|
+
SubsystemGroupNode,
|
|
43
|
+
} from './nodes';
|
|
44
|
+
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
|
|
45
|
+
import {
|
|
46
|
+
EDGE_LABEL_CLOUD_EXTRA_TOP,
|
|
47
|
+
EDGE_LABEL_CLOUD_PATH,
|
|
48
|
+
EDGE_LABEL_FONT_SIZE,
|
|
49
|
+
EDGE_LABEL_HEIGHT,
|
|
50
|
+
EDGE_LABEL_WIDTH,
|
|
51
|
+
} from '../utils/edgeLabel';
|
|
52
|
+
|
|
53
|
+
/** One step of the transition — a full model plus its view options. */
|
|
54
|
+
export interface SubsystemTransitionStep {
|
|
55
|
+
model: SubsystemModelDocument;
|
|
56
|
+
/** `path` derives directory frames and nests modules under them. */
|
|
57
|
+
moduleNesting?: 'exact' | 'path';
|
|
58
|
+
/** Show mechanism labels on edges. @default true */
|
|
59
|
+
showEdgeLabels?: boolean;
|
|
60
|
+
/** Explicit boundary frame colors (region key → hex); host override. */
|
|
61
|
+
boundaryColors?: Record<string, string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SubsystemModelTransitionProps {
|
|
65
|
+
steps: SubsystemTransitionStep[];
|
|
66
|
+
/** Which step is shown. Controlled by the host (its nav / timer). */
|
|
67
|
+
activeIndex: number;
|
|
68
|
+
/** Tween duration, ms. @default 750 */
|
|
69
|
+
durationMs?: number;
|
|
70
|
+
/** fitView padding for the viewport refit. @default 0.18 */
|
|
71
|
+
fitPadding?: number;
|
|
72
|
+
/** Called when a node is clicked. */
|
|
73
|
+
onSelect?: (componentAlias: string) => void;
|
|
74
|
+
className?: string;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Graph edge look (ELK orth path) + the mechanism label the graph overlays. */
|
|
79
|
+
function TransitionEdge(props: EdgeProps) {
|
|
80
|
+
const { theme } = useTheme();
|
|
81
|
+
const data = props.data as
|
|
82
|
+
| { mechanism?: string; provenance?: SubsystemEdgeProvenance; labelX?: number; labelY?: number }
|
|
83
|
+
| undefined;
|
|
84
|
+
const mechanism = data?.mechanism ?? 'uses';
|
|
85
|
+
const color = edgeColor({ mechanism, provenance: data?.provenance });
|
|
86
|
+
const verifiable =
|
|
87
|
+
data?.provenance === 'graphify' ||
|
|
88
|
+
MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] !== false;
|
|
89
|
+
const labelBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
90
|
+
const showLabel = data?.labelX != null && data?.labelY != null;
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<EdgeRenderer {...props} />
|
|
94
|
+
{showLabel && (
|
|
95
|
+
<EdgeLabelRenderer>
|
|
96
|
+
<div
|
|
97
|
+
style={{
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
transform: `translate(-50%, -50%) translate(${data!.labelX}px, ${data!.labelY}px)`,
|
|
100
|
+
display: 'flex',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
justifyContent: 'center',
|
|
103
|
+
width: EDGE_LABEL_WIDTH,
|
|
104
|
+
height: EDGE_LABEL_HEIGHT,
|
|
105
|
+
boxSizing: 'border-box',
|
|
106
|
+
padding: '7px 8px',
|
|
107
|
+
pointerEvents: 'none',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{verifiable ? (
|
|
111
|
+
<span
|
|
112
|
+
aria-hidden
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
inset: 0,
|
|
116
|
+
borderRadius: 4,
|
|
117
|
+
border: `0.5px solid ${color}`,
|
|
118
|
+
background: labelBg,
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<svg
|
|
123
|
+
aria-hidden
|
|
124
|
+
width={EDGE_LABEL_WIDTH}
|
|
125
|
+
height={EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}
|
|
126
|
+
viewBox={`0 0 ${EDGE_LABEL_WIDTH} ${EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}`}
|
|
127
|
+
style={{ position: 'absolute', left: 0, top: -EDGE_LABEL_CLOUD_EXTRA_TOP }}
|
|
128
|
+
>
|
|
129
|
+
<path
|
|
130
|
+
d={EDGE_LABEL_CLOUD_PATH}
|
|
131
|
+
fill={labelBg}
|
|
132
|
+
stroke={hexWithAlpha(color, 0.75)}
|
|
133
|
+
strokeWidth={1.2}
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
/>
|
|
136
|
+
</svg>
|
|
137
|
+
)}
|
|
138
|
+
<span
|
|
139
|
+
style={{
|
|
140
|
+
position: 'relative',
|
|
141
|
+
display: 'block',
|
|
142
|
+
maxWidth: '100%',
|
|
143
|
+
overflow: 'hidden',
|
|
144
|
+
textOverflow: 'ellipsis',
|
|
145
|
+
whiteSpace: 'nowrap',
|
|
146
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
147
|
+
lineHeight: 1,
|
|
148
|
+
fontFamily: theme.fonts.monospace,
|
|
149
|
+
fontWeight: 500,
|
|
150
|
+
color,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{mechanism}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</EdgeLabelRenderer>
|
|
157
|
+
)}
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const nodeTypes: NodeTypes = {
|
|
163
|
+
'subsystem-component': SubsystemComponentNode,
|
|
164
|
+
'subsystem-group': SubsystemGroupNode,
|
|
165
|
+
};
|
|
166
|
+
const edgeTypes: EdgeTypes = { 'subsystem-edge': TransitionEdge };
|
|
167
|
+
|
|
168
|
+
/** `SubsystemEdge` narrows its props; render it through a permissive alias. */
|
|
169
|
+
const EdgeRenderer = SubsystemEdge as unknown as ComponentType<EdgeProps>;
|
|
170
|
+
|
|
171
|
+
type Rect = { x: number; y: number; w: number; h: number };
|
|
172
|
+
type Live = Rect & { o: number };
|
|
173
|
+
type StepMeta = { type: 'subsystem-component' | 'subsystem-group'; data: Record<string, unknown> };
|
|
174
|
+
type StepLayout = {
|
|
175
|
+
rects: Map<string, Rect>;
|
|
176
|
+
meta: Map<string, StepMeta>;
|
|
177
|
+
order: string[];
|
|
178
|
+
edges: Edge[];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const ease = (t: number) => t * t * (3 - 2 * t);
|
|
182
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
183
|
+
|
|
184
|
+
function flatten(
|
|
185
|
+
result: Awaited<ReturnType<typeof buildSubsystemGraph>>,
|
|
186
|
+
boundaryColors?: Record<string, string>,
|
|
187
|
+
): StepLayout {
|
|
188
|
+
const rects = new Map<string, Rect>();
|
|
189
|
+
const meta = new Map<string, StepMeta>();
|
|
190
|
+
const order: string[] = [];
|
|
191
|
+
const nodes: SubsystemGraphNode[] = [
|
|
192
|
+
...result.nodes.filter((n) => n.type === 'subsystem-group'),
|
|
193
|
+
...result.nodes.filter((n) => n.type !== 'subsystem-group'),
|
|
194
|
+
];
|
|
195
|
+
for (const n of nodes) {
|
|
196
|
+
const r = result.absoluteRects.get(n.id);
|
|
197
|
+
if (!r) continue;
|
|
198
|
+
rects.set(n.id, { x: r.x, y: r.y, w: r.width, h: r.height });
|
|
199
|
+
const data = { ...((n.data ?? {}) as Record<string, unknown>) };
|
|
200
|
+
if (n.type === 'subsystem-group' && boundaryColors) {
|
|
201
|
+
const key = (data as { region?: { key?: string } }).region?.key;
|
|
202
|
+
if (key && boundaryColors[key] != null) data.color = boundaryColors[key];
|
|
203
|
+
}
|
|
204
|
+
meta.set(n.id, { type: n.type as StepMeta['type'], data });
|
|
205
|
+
order.push(n.id);
|
|
206
|
+
}
|
|
207
|
+
return { rects, meta, order, edges: result.edges as unknown as Edge[] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function TransitionInner({
|
|
211
|
+
steps,
|
|
212
|
+
activeIndex,
|
|
213
|
+
durationMs = 750,
|
|
214
|
+
fitPadding = 0.18,
|
|
215
|
+
onSelect,
|
|
216
|
+
className,
|
|
217
|
+
style,
|
|
218
|
+
}: SubsystemModelTransitionProps) {
|
|
219
|
+
const { theme } = useTheme();
|
|
220
|
+
const { fitBounds } = useReactFlow();
|
|
221
|
+
const [layouts, setLayouts] = useState<StepLayout[] | null>(null);
|
|
222
|
+
const [display, setDisplay] = useState<Map<string, Live>>(new Map());
|
|
223
|
+
const [settled, setSettled] = useState(false);
|
|
224
|
+
const displayRef = useRef(display);
|
|
225
|
+
displayRef.current = display;
|
|
226
|
+
const rafRef = useRef<number | null>(null);
|
|
227
|
+
|
|
228
|
+
// Lay out every step whenever the steps prop changes (identity-keyed).
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
let alive = true;
|
|
231
|
+
void (async () => {
|
|
232
|
+
const next: StepLayout[] = [];
|
|
233
|
+
for (const s of steps) {
|
|
234
|
+
const result = await buildSubsystemGraph(s.model, {
|
|
235
|
+
showSingletonFrames: true,
|
|
236
|
+
moduleNesting: s.moduleNesting,
|
|
237
|
+
showEdgeLabels: s.showEdgeLabels ?? true,
|
|
238
|
+
});
|
|
239
|
+
next.push(flatten(result, s.boundaryColors));
|
|
240
|
+
}
|
|
241
|
+
if (alive) setLayouts(next);
|
|
242
|
+
})();
|
|
243
|
+
return () => {
|
|
244
|
+
alive = false;
|
|
245
|
+
};
|
|
246
|
+
}, [steps]);
|
|
247
|
+
|
|
248
|
+
// Tween to the active step; refit; hide edges until settled.
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!layouts) return;
|
|
251
|
+
const step = layouts[activeIndex];
|
|
252
|
+
if (!step) return;
|
|
253
|
+
setSettled(false);
|
|
254
|
+
|
|
255
|
+
const rects = [...step.rects.values()];
|
|
256
|
+
if (rects.length > 0) {
|
|
257
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
258
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
259
|
+
const maxX = Math.max(...rects.map((r) => r.x + r.w));
|
|
260
|
+
const maxY = Math.max(...rects.map((r) => r.y + r.h));
|
|
261
|
+
fitBounds(
|
|
262
|
+
{ x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
263
|
+
{ padding: fitPadding, duration: durationMs },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const start = new Map(displayRef.current);
|
|
268
|
+
const ids = new Set<string>([...start.keys(), ...step.rects.keys()]);
|
|
269
|
+
const t0 = performance.now();
|
|
270
|
+
const tick = (now: number) => {
|
|
271
|
+
const t = Math.min(1, (now - t0) / durationMs);
|
|
272
|
+
const e = ease(t);
|
|
273
|
+
const next = new Map<string, Live>();
|
|
274
|
+
for (const id of ids) {
|
|
275
|
+
const cur = start.get(id);
|
|
276
|
+
const tgt = step.rects.get(id);
|
|
277
|
+
if (tgt) {
|
|
278
|
+
const from = cur ?? { ...tgt, o: 0 };
|
|
279
|
+
next.set(id, {
|
|
280
|
+
x: lerp(from.x, tgt.x, e),
|
|
281
|
+
y: lerp(from.y, tgt.y, e),
|
|
282
|
+
w: lerp(from.w, tgt.w, e),
|
|
283
|
+
h: lerp(from.h, tgt.h, e),
|
|
284
|
+
o: lerp(from.o ?? 0, 1, e),
|
|
285
|
+
});
|
|
286
|
+
} else if (cur) {
|
|
287
|
+
next.set(id, { ...cur, o: lerp(cur.o, 0, e) });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
setDisplay(next);
|
|
291
|
+
if (t < 1) {
|
|
292
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
293
|
+
} else {
|
|
294
|
+
setSettled(true);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
298
|
+
return () => {
|
|
299
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
300
|
+
};
|
|
301
|
+
}, [layouts, activeIndex, durationMs, fitPadding, fitBounds]);
|
|
302
|
+
|
|
303
|
+
const unionMeta = useMemo(() => {
|
|
304
|
+
const m = new Map<string, StepMeta>();
|
|
305
|
+
for (const s of layouts ?? []) for (const [id, meta] of s.meta) if (!m.has(id)) m.set(id, meta);
|
|
306
|
+
return m;
|
|
307
|
+
}, [layouts]);
|
|
308
|
+
|
|
309
|
+
const activeLayout = layouts?.[activeIndex];
|
|
310
|
+
|
|
311
|
+
const nodes: Node[] = useMemo(() => {
|
|
312
|
+
const activeOrder = new Set(activeLayout?.order ?? []);
|
|
313
|
+
const ids = [...display.entries()]
|
|
314
|
+
.filter(([id, r]) => r.o > 0.01 || activeOrder.has(id))
|
|
315
|
+
.map(([id]) => id);
|
|
316
|
+
ids.sort((a, b) => {
|
|
317
|
+
const ga = unionMeta.get(a)?.type === 'subsystem-group' ? 0 : 1;
|
|
318
|
+
const gb = unionMeta.get(b)?.type === 'subsystem-group' ? 0 : 1;
|
|
319
|
+
return ga - gb;
|
|
320
|
+
});
|
|
321
|
+
const out: Node[] = [];
|
|
322
|
+
for (const id of ids) {
|
|
323
|
+
const meta = unionMeta.get(id);
|
|
324
|
+
const rect = display.get(id);
|
|
325
|
+
if (!meta || !rect) continue;
|
|
326
|
+
out.push({
|
|
327
|
+
id,
|
|
328
|
+
type: meta.type,
|
|
329
|
+
position: { x: rect.x, y: rect.y },
|
|
330
|
+
width: rect.w,
|
|
331
|
+
height: rect.h,
|
|
332
|
+
data: meta.data,
|
|
333
|
+
draggable: false,
|
|
334
|
+
selectable: false,
|
|
335
|
+
style: { opacity: rect.o },
|
|
336
|
+
} as Node);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}, [display, activeLayout, unionMeta]);
|
|
340
|
+
|
|
341
|
+
const edges: Edge[] = useMemo(
|
|
342
|
+
() => (settled ? activeLayout?.edges ?? [] : []),
|
|
343
|
+
[settled, activeLayout],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const callbacks = useMemo(() => (onSelect ? { onSelect } : {}), [onSelect]);
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div
|
|
350
|
+
className={className}
|
|
351
|
+
style={{
|
|
352
|
+
position: 'relative',
|
|
353
|
+
width: '100%',
|
|
354
|
+
height: '100%',
|
|
355
|
+
minHeight: 0,
|
|
356
|
+
background: theme.colors.background,
|
|
357
|
+
...style,
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
{!layouts ? null : (
|
|
361
|
+
<SubsystemCallbacksProvider value={callbacks}>
|
|
362
|
+
<ReactFlow
|
|
363
|
+
nodes={nodes}
|
|
364
|
+
edges={edges}
|
|
365
|
+
nodeTypes={nodeTypes}
|
|
366
|
+
edgeTypes={edgeTypes}
|
|
367
|
+
className={GRAPH_CANVAS_CLASS}
|
|
368
|
+
{...GRAPH_NAV_PROPS}
|
|
369
|
+
proOptions={{ hideAttribution: true }}
|
|
370
|
+
nodesDraggable={false}
|
|
371
|
+
nodesConnectable={false}
|
|
372
|
+
style={{ width: '100%', height: '100%', background: theme.colors.background }}
|
|
373
|
+
>
|
|
374
|
+
<Background />
|
|
375
|
+
<GraphChrome />
|
|
376
|
+
</ReactFlow>
|
|
377
|
+
</SubsystemCallbacksProvider>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function SubsystemModelTransition(props: SubsystemModelTransitionProps) {
|
|
384
|
+
return (
|
|
385
|
+
<ReactFlowProvider>
|
|
386
|
+
<TransitionInner {...props} />
|
|
387
|
+
</ReactFlowProvider>
|
|
388
|
+
);
|
|
389
|
+
}
|
package/src/subsystem/model.ts
CHANGED
|
@@ -2159,6 +2159,9 @@ export async function buildSubsystemGraph(
|
|
|
2159
2159
|
// layered pass place those sibling frames along the horizontal axis
|
|
2160
2160
|
// while each frame's members stack vertically.
|
|
2161
2161
|
direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
|
|
2162
|
+
// Edge-less containment: fold the root frames into a column-major grid
|
|
2163
|
+
// so the shape reads closer to square than one long strip.
|
|
2164
|
+
reflowGrid: edges.length === 0,
|
|
2162
2165
|
nodeSpacing: 60,
|
|
2163
2166
|
edgeSpacing: 30,
|
|
2164
2167
|
edgeNodeSpacing: 60,
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -94,6 +94,15 @@ export interface ElkLayoutOptions {
|
|
|
94
94
|
*/
|
|
95
95
|
orderByLine?: boolean;
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Reflow root-level compound frames into a column-major grid after ELK runs:
|
|
99
|
+
* fill each column top-to-bottom, then start the next column to the right.
|
|
100
|
+
* Gives an approximately square shape for edge-less containment graphs, which
|
|
101
|
+
* ELK otherwise packs into one long strip. No effect when edges exist (their
|
|
102
|
+
* routes would need re-routing).
|
|
103
|
+
*/
|
|
104
|
+
reflowGrid?: boolean;
|
|
105
|
+
|
|
97
106
|
/**
|
|
98
107
|
* Compound groups — each becomes an ELK parent whose `memberIds` are laid
|
|
99
108
|
* out inside it. `memberIds` may be leaf node ids or other group ids
|
|
@@ -529,6 +538,7 @@ export async function computeElkLayout(
|
|
|
529
538
|
const edgeLabels = options.edgeLabels;
|
|
530
539
|
const endpointInset = options.endpointInset ?? 0;
|
|
531
540
|
const direction = options.direction ?? 'RIGHT';
|
|
541
|
+
const reflowGrid = options.reflowGrid === true;
|
|
532
542
|
const orderByLine = options.orderByLine ?? false;
|
|
533
543
|
|
|
534
544
|
// Build a map of original node positions BEFORE passing to ELK
|
|
@@ -1023,6 +1033,102 @@ export async function computeElkLayout(
|
|
|
1023
1033
|
return node;
|
|
1024
1034
|
});
|
|
1025
1035
|
|
|
1036
|
+
// Optional column-major grid reflow for edge-less containment graphs. ELK
|
|
1037
|
+
// packs disconnected root frames into one long strip; fold them into a grid
|
|
1038
|
+
// (fill a column top-to-bottom, then start the next column) so the shape
|
|
1039
|
+
// approaches a square. Root frames are moved; their descendants (relative
|
|
1040
|
+
// group bounds / leaf positions) ride along, and absoluteRects is rebased so
|
|
1041
|
+
// hit-testing stays correct.
|
|
1042
|
+
if (reflowGrid && edges.length === 0) {
|
|
1043
|
+
const rootGroupIds: string[] = [];
|
|
1044
|
+
for (const g of groupDefs) {
|
|
1045
|
+
if (!builtGroups.has(g.id)) continue;
|
|
1046
|
+
if (g.parentId && builtGroups.has(g.parentId)) continue;
|
|
1047
|
+
rootGroupIds.push(g.id);
|
|
1048
|
+
}
|
|
1049
|
+
type GridItem = {
|
|
1050
|
+
id: string;
|
|
1051
|
+
kind: 'group' | 'leaf';
|
|
1052
|
+
x: number;
|
|
1053
|
+
y: number;
|
|
1054
|
+
w: number;
|
|
1055
|
+
h: number;
|
|
1056
|
+
};
|
|
1057
|
+
const items: GridItem[] = [];
|
|
1058
|
+
for (const id of rootGroupIds) {
|
|
1059
|
+
const b = groupBounds.get(id);
|
|
1060
|
+
if (b) items.push({ id, kind: 'group', x: b.x, y: b.y, w: b.width, h: b.height });
|
|
1061
|
+
}
|
|
1062
|
+
for (const id of ungroupedElkNodes.map((nd) => nd.id)) {
|
|
1063
|
+
const rel = elkRelativePositions.get(id) ?? { x: 0, y: 0 };
|
|
1064
|
+
const r = absoluteRects.get(id);
|
|
1065
|
+
items.push({ id, kind: 'leaf', x: rel.x, y: rel.y, w: r?.width ?? 160, h: r?.height ?? 60 });
|
|
1066
|
+
}
|
|
1067
|
+
if (items.length > 1) {
|
|
1068
|
+
const count = items.length;
|
|
1069
|
+
const rows = Math.max(1, Math.ceil(Math.sqrt(count)));
|
|
1070
|
+
const cols = Math.ceil(count / rows);
|
|
1071
|
+
const GAP = 80;
|
|
1072
|
+
const PAD = 40;
|
|
1073
|
+
const colW: number[] = new Array(cols).fill(0);
|
|
1074
|
+
const rowH: number[] = new Array(rows).fill(0);
|
|
1075
|
+
items.forEach((it, i) => {
|
|
1076
|
+
const c = Math.floor(i / rows);
|
|
1077
|
+
const r = i % rows;
|
|
1078
|
+
colW[c] = Math.max(colW[c] ?? 0, it.w);
|
|
1079
|
+
rowH[r] = Math.max(rowH[r] ?? 0, it.h);
|
|
1080
|
+
});
|
|
1081
|
+
const colX: number[] = [];
|
|
1082
|
+
let acc = PAD;
|
|
1083
|
+
for (let c = 0; c < cols; c++) {
|
|
1084
|
+
colX[c] = acc;
|
|
1085
|
+
acc += (colW[c] ?? 0) + GAP;
|
|
1086
|
+
}
|
|
1087
|
+
const rowY: number[] = [];
|
|
1088
|
+
acc = PAD;
|
|
1089
|
+
for (let r = 0; r < rows; r++) {
|
|
1090
|
+
rowY[r] = acc;
|
|
1091
|
+
acc += (rowH[r] ?? 0) + GAP;
|
|
1092
|
+
}
|
|
1093
|
+
const shiftAbsSubtree = (id: string, dx: number, dy: number) => {
|
|
1094
|
+
const ar = absoluteRects.get(id);
|
|
1095
|
+
if (ar) absoluteRects.set(id, { ...ar, x: ar.x + dx, y: ar.y + dy });
|
|
1096
|
+
const g = groupById.get(id);
|
|
1097
|
+
if (!g) return;
|
|
1098
|
+
for (const mid of g.memberIds) {
|
|
1099
|
+
if (builtGroups.has(mid)) shiftAbsSubtree(mid, dx, dy);
|
|
1100
|
+
else {
|
|
1101
|
+
const mar = absoluteRects.get(mid);
|
|
1102
|
+
if (mar) absoluteRects.set(mid, { ...mar, x: mar.x + dx, y: mar.y + dy });
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
const movedLeaves = new Map<string, { x: number; y: number }>();
|
|
1107
|
+
items.forEach((it, i) => {
|
|
1108
|
+
const c = Math.floor(i / rows);
|
|
1109
|
+
const r = i % rows;
|
|
1110
|
+
const dx = (colX[c] ?? 0) - it.x;
|
|
1111
|
+
const dy = (rowY[r] ?? 0) - it.y;
|
|
1112
|
+
if (dx === 0 && dy === 0) return;
|
|
1113
|
+
if (it.kind === 'group') {
|
|
1114
|
+
const b = groupBounds.get(it.id);
|
|
1115
|
+
if (b) groupBounds.set(it.id, { ...b, x: b.x + dx, y: b.y + dy });
|
|
1116
|
+
shiftAbsSubtree(it.id, dx, dy);
|
|
1117
|
+
} else {
|
|
1118
|
+
shiftAbsSubtree(it.id, dx, dy);
|
|
1119
|
+
const rel = elkRelativePositions.get(it.id);
|
|
1120
|
+
if (rel) movedLeaves.set(it.id, { x: rel.x + dx, y: rel.y + dy });
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
if (movedLeaves.size > 0) {
|
|
1124
|
+
for (const node of resultNodes) {
|
|
1125
|
+
const m = movedLeaves.get(node.id);
|
|
1126
|
+
if (m) (node as { position: { x: number; y: number } }).position = m;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1026
1132
|
return {
|
|
1027
1133
|
nodes: resultNodes,
|
|
1028
1134
|
edgePaths,
|