@principal-ai/principal-view-react 0.16.57 → 0.16.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/subsystem/SubsystemComponentGraph.d.ts +14 -5
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +436 -50
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +73 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +91 -11
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +38 -3
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +95 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +26 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +152 -13
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +181 -0
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +68 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +600 -50
- package/src/subsystem/model.test.ts +56 -0
- package/src/subsystem/model.ts +156 -12
- package/src/subsystem/nodes.test.ts +56 -0
- package/src/subsystem/nodes.tsx +122 -6
- package/src/utils/elkLayout.ts +164 -13
|
@@ -2,6 +2,9 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
convertSubsystemToNodes,
|
|
4
4
|
convertSubsystemToEdges,
|
|
5
|
+
convertSubsystemToGroups,
|
|
6
|
+
getSubsystemRegions,
|
|
7
|
+
processGroupNodeId,
|
|
5
8
|
buildSubsystemGraph,
|
|
6
9
|
deriveNameFromSymbol,
|
|
7
10
|
formatPurl,
|
|
@@ -121,6 +124,59 @@ describe('subsystem graph model', () => {
|
|
|
121
124
|
expect(formatPurl('not-a-purl')).toBe('not-a-purl');
|
|
122
125
|
});
|
|
123
126
|
|
|
127
|
+
test('getSubsystemRegions groups by process, skipping process-less nodes', () => {
|
|
128
|
+
const regions = getSubsystemRegions({
|
|
129
|
+
components: [
|
|
130
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
131
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
132
|
+
{ id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/acme/app', process: 'app/renderer' },
|
|
133
|
+
{ id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
expect(regions.map((r) => r.key)).toEqual(['app/host', 'app/renderer']);
|
|
137
|
+
expect(regions[0]!.memberIds).toEqual(['a', 'b']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('convertSubsystemToNodes stamps parentId for process members only', () => {
|
|
141
|
+
const nodes = convertSubsystemToNodes({
|
|
142
|
+
components: [
|
|
143
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
144
|
+
{ id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
|
|
145
|
+
],
|
|
146
|
+
edges: [],
|
|
147
|
+
});
|
|
148
|
+
expect((nodes.find((n) => n.id === 'a') as { parentId?: string }).parentId).toBe(
|
|
149
|
+
processGroupNodeId('app/host'),
|
|
150
|
+
);
|
|
151
|
+
expect((nodes.find((n) => n.id === 'd') as { parentId?: string }).parentId).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('convertSubsystemToGroups emits one parent per process', () => {
|
|
155
|
+
const groups = convertSubsystemToGroups({
|
|
156
|
+
components: [
|
|
157
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
158
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
expect(groups).toHaveLength(1);
|
|
162
|
+
expect(groups[0]!.id).toBe(processGroupNodeId('app/host'));
|
|
163
|
+
expect(groups[0]!.type).toBe('subsystem-group');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('buildSubsystemGraph drops singleton process frames (no parentId, no group)', async () => {
|
|
167
|
+
const { nodes, regions } = await buildSubsystemGraph({
|
|
168
|
+
components: [
|
|
169
|
+
{ id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
170
|
+
{ id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
|
|
171
|
+
{ id: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
|
|
172
|
+
],
|
|
173
|
+
edges: [{ id: 'e1', from: 'a', to: 'b', mechanism: 'calls' }],
|
|
174
|
+
});
|
|
175
|
+
expect(regions.map((r) => r.key)).toEqual(['app/host']);
|
|
176
|
+
expect((nodes.find((n) => n.id === 'solo') as { parentId?: string }).parentId).toBeUndefined();
|
|
177
|
+
expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
124
180
|
test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
|
|
125
181
|
const base = { components: comps, edges };
|
|
126
182
|
const withRef = {
|
package/src/subsystem/model.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type Edge,
|
|
18
18
|
type Node,
|
|
19
19
|
} from '@xyflow/react';
|
|
20
|
-
import { computeElkLayout } from '../utils/elkLayout';
|
|
20
|
+
import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
|
|
21
21
|
import type { GraphifyComponentDetail } from '../graphify';
|
|
22
22
|
import type { SubsystemDeclarationRef } from './declarationRef';
|
|
23
23
|
|
|
@@ -177,6 +177,38 @@ export interface SubsystemComponentEdge {
|
|
|
177
177
|
refs?: string[];
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A single site on an existing edge — the exact `file:line` where that edge's
|
|
182
|
+
* seam manifests for a given flow. The edge stays the abstract contract
|
|
183
|
+
* (`from`, `to`, `mechanism`); a throughline step picks the concrete
|
|
184
|
+
* manifestation. One edge can appear in many steps.
|
|
185
|
+
*/
|
|
186
|
+
export interface SubsystemThroughlineStep {
|
|
187
|
+
/** Id of the existing edge this hop traverses. */
|
|
188
|
+
edgeId: string;
|
|
189
|
+
/** Repo-root-relative path of the file where the edge fires. */
|
|
190
|
+
file: string;
|
|
191
|
+
/** 1-based line of the site within `file`. */
|
|
192
|
+
line: number;
|
|
193
|
+
/**
|
|
194
|
+
* Frame name for this hop — the function/method/symbol on the stack at
|
|
195
|
+
* this site. Optional so existing throughlines keep working; when set the
|
|
196
|
+
* flows list shows it instead of mechanism + filename.
|
|
197
|
+
*/
|
|
198
|
+
symbol?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* An ordered execution story over a graph's edges — each step references an
|
|
203
|
+
* existing edge and the exact site where that relationship fires for a flow;
|
|
204
|
+
* ordering is the array. One throughline per flow (save flow, load flow, …).
|
|
205
|
+
*/
|
|
206
|
+
export interface SubsystemThroughline {
|
|
207
|
+
id: string;
|
|
208
|
+
title: string;
|
|
209
|
+
steps: SubsystemThroughlineStep[];
|
|
210
|
+
}
|
|
211
|
+
|
|
180
212
|
/**
|
|
181
213
|
* Derive a consistent display `name` from a code `symbol` + construct.
|
|
182
214
|
*
|
|
@@ -250,6 +282,8 @@ export function formatPurl(purl: string): string {
|
|
|
250
282
|
export interface SubsystemGraphDocument {
|
|
251
283
|
components: SubsystemComponent[];
|
|
252
284
|
edges: SubsystemComponentEdge[];
|
|
285
|
+
/** Ordered execution stories over the graph's edges (one per flow). */
|
|
286
|
+
throughlines?: SubsystemThroughline[];
|
|
253
287
|
}
|
|
254
288
|
|
|
255
289
|
// ---------------------------------------------------------------------------
|
|
@@ -258,15 +292,65 @@ export interface SubsystemGraphDocument {
|
|
|
258
292
|
|
|
259
293
|
export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
260
294
|
|
|
295
|
+
/**
|
|
296
|
+
* One process boundary region — all components sharing a `process` value.
|
|
297
|
+
* Nodes without a `process` sit outside every boundary (no region).
|
|
298
|
+
*/
|
|
299
|
+
export interface SubsystemProcessRegion {
|
|
300
|
+
/** The `process` value (e.g. `trail-viewer/host`). */
|
|
301
|
+
key: string;
|
|
302
|
+
/** Display label for the boundary frame. */
|
|
303
|
+
label: string;
|
|
304
|
+
/** Component ids that are members of this region. */
|
|
305
|
+
memberIds: string[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** React Flow id for a process boundary group node. */
|
|
309
|
+
export function processGroupNodeId(processKey: string): string {
|
|
310
|
+
return `process:${processKey}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Derive boundary regions from a document — one per distinct non-empty
|
|
315
|
+
* `process` value, in first-appearance order.
|
|
316
|
+
*/
|
|
317
|
+
export function getSubsystemRegions(
|
|
318
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
319
|
+
): SubsystemProcessRegion[] {
|
|
320
|
+
const byProcess = new Map<string, string[]>();
|
|
321
|
+
for (const c of doc.components) {
|
|
322
|
+
const p = c.process?.trim();
|
|
323
|
+
if (!p) continue;
|
|
324
|
+
const list = byProcess.get(p) ?? [];
|
|
325
|
+
list.push(c.id);
|
|
326
|
+
byProcess.set(p, list);
|
|
327
|
+
}
|
|
328
|
+
return [...byProcess.entries()].map(([key, memberIds]) => ({
|
|
329
|
+
key,
|
|
330
|
+
label: key,
|
|
331
|
+
memberIds,
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
261
335
|
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
262
336
|
component: SubsystemComponent;
|
|
263
337
|
/** Set while a file is open in the drawer: true if this node's component
|
|
264
338
|
* lives in that file (spotlighted), false otherwise (dimmed). Absent when
|
|
265
339
|
* no file is open — render neutrally. */
|
|
266
340
|
fileMatch?: boolean;
|
|
341
|
+
/** True while this node is on an opened-but-unselected flow. */
|
|
342
|
+
dimmed?: boolean;
|
|
267
343
|
}
|
|
268
344
|
|
|
269
|
-
export
|
|
345
|
+
export interface SubsystemGroupNodeData extends Record<string, unknown> {
|
|
346
|
+
region: SubsystemProcessRegion;
|
|
347
|
+
/** True while the region's members are dimmed by flow focus. */
|
|
348
|
+
dimmed?: boolean;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export type SubsystemGraphNode =
|
|
352
|
+
| Node<SubsystemGraphNodeData, 'subsystem-component'>
|
|
353
|
+
| Node<SubsystemGroupNodeData, 'subsystem-group'>;
|
|
270
354
|
|
|
271
355
|
export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
272
356
|
mechanism: SubsystemEdgeMechanism;
|
|
@@ -277,6 +361,8 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
|
277
361
|
/** ELK-computed label midpoint (from the actual edge path, not node centers). */
|
|
278
362
|
labelX?: number;
|
|
279
363
|
labelY?: number;
|
|
364
|
+
/** Polyline length in flow-space units (for capping screen-space label size). */
|
|
365
|
+
pathLength?: number;
|
|
280
366
|
/** ELK-computed SVG edge path (overrides React Flow's default path). */
|
|
281
367
|
elkPath?: string;
|
|
282
368
|
}
|
|
@@ -358,12 +444,11 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
|
|
|
358
444
|
};
|
|
359
445
|
|
|
360
446
|
/**
|
|
361
|
-
* Convert a subsystem graph document into React Flow nodes.
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* Flow; the package boundary is a visual region, not a sub-flow node.
|
|
447
|
+
* Convert a subsystem graph document into React Flow nodes. Components that
|
|
448
|
+
* carry a `process` get a `parentId` pointing at their boundary group node
|
|
449
|
+
* (`process:<process>`); nodes without one stay top-level (outside every
|
|
450
|
+
* boundary). The initial grid groups by `process ?? purl` so the pre-ELK
|
|
451
|
+
* positions are already clustered; ELK then refines with compound layout.
|
|
367
452
|
*/
|
|
368
453
|
export function convertSubsystemToNodes(
|
|
369
454
|
doc: SubsystemGraphDocument,
|
|
@@ -411,9 +496,11 @@ export function convertSubsystemToNodes(
|
|
|
411
496
|
const cssBorder = 4; // 2px border each side
|
|
412
497
|
const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
|
|
413
498
|
const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
|
|
499
|
+
const processKey = c.process?.trim();
|
|
414
500
|
nodes.push({
|
|
415
501
|
id: c.id,
|
|
416
502
|
type: 'subsystem-component',
|
|
503
|
+
...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
|
|
417
504
|
position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
|
|
418
505
|
width: nodeWidth,
|
|
419
506
|
height: 84,
|
|
@@ -425,6 +512,24 @@ export function convertSubsystemToNodes(
|
|
|
425
512
|
return nodes;
|
|
426
513
|
}
|
|
427
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Convert boundary regions into React Flow parent (group) nodes. One per
|
|
517
|
+
* distinct `process` value; member components point at these via `parentId`.
|
|
518
|
+
* Positions/sizes are placeholders — ELK compound layout overwrites them.
|
|
519
|
+
*/
|
|
520
|
+
export function convertSubsystemToGroups(
|
|
521
|
+
doc: Pick<SubsystemGraphDocument, 'components'>,
|
|
522
|
+
): SubsystemGraphNode[] {
|
|
523
|
+
return getSubsystemRegions(doc).map((region) => ({
|
|
524
|
+
id: processGroupNodeId(region.key),
|
|
525
|
+
type: 'subsystem-group',
|
|
526
|
+
position: { x: 0, y: 0 },
|
|
527
|
+
width: 400,
|
|
528
|
+
height: 300,
|
|
529
|
+
data: { region },
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
|
|
428
533
|
/**
|
|
429
534
|
* Convert a subsystem graph document into React Flow edges. Edges whose target
|
|
430
535
|
* is an external label (not a component id) point at a synthetic stub so the
|
|
@@ -447,7 +552,7 @@ export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemG
|
|
|
447
552
|
target: targetId,
|
|
448
553
|
data: { mechanism: e.mechanism, refs: e.refs },
|
|
449
554
|
type: 'subsystem-edge',
|
|
450
|
-
markerEnd: { type: MarkerType.ArrowClosed, color, width:
|
|
555
|
+
markerEnd: { type: MarkerType.ArrowClosed, color, width: 32, height: 32 },
|
|
451
556
|
style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
|
|
452
557
|
// `label` feeds ELK's label-space reservation only; the visible label is
|
|
453
558
|
// rendered by the custom SubsystemEdge as an HTML overlay.
|
|
@@ -486,10 +591,23 @@ export async function buildSubsystemGraph(
|
|
|
486
591
|
): Promise<{
|
|
487
592
|
nodes: SubsystemGraphNode[];
|
|
488
593
|
edges: SubsystemGraphEdge[];
|
|
594
|
+
regions: SubsystemProcessRegion[];
|
|
489
595
|
}> {
|
|
490
596
|
const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
|
|
491
597
|
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
492
598
|
const edges = convertSubsystemToEdges(doc);
|
|
599
|
+
// Boundary regions: one per multi-member process. Singletons get no frame —
|
|
600
|
+
// strip the parentId convertSubsystemToNodes stamped so React Flow never
|
|
601
|
+
// points at a non-existent parent.
|
|
602
|
+
const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
|
|
603
|
+
const regionKeys = new Set(regions.map((r) => r.key));
|
|
604
|
+
for (const n of nodes) {
|
|
605
|
+
if (n.type !== 'subsystem-component') continue;
|
|
606
|
+
const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
|
|
607
|
+
if (proc && !regionKeys.has(proc)) {
|
|
608
|
+
delete (n as { parentId?: string }).parentId;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
493
611
|
|
|
494
612
|
// External edge targets that aren't real components → create stub nodes so
|
|
495
613
|
// cross-package edges have something to land on.
|
|
@@ -538,10 +656,12 @@ export async function buildSubsystemGraph(
|
|
|
538
656
|
}
|
|
539
657
|
}
|
|
540
658
|
|
|
541
|
-
// ELK auto-layout: position nodes (layered, minimized crossings)
|
|
659
|
+
// ELK auto-layout: position nodes (layered, minimized crossings) with
|
|
660
|
+
// process partitions as compound parents so boundaries shape the layout.
|
|
542
661
|
let placedNodes = nodes;
|
|
543
662
|
let labelPositions = new Map<string, { x: number; y: number }>();
|
|
544
663
|
let elkPathStrings = new Map<string, string>();
|
|
664
|
+
let elkPathPoints = new Map<string, { x: number; y: number }[]>();
|
|
545
665
|
if (nodes.length > 0) {
|
|
546
666
|
try {
|
|
547
667
|
const result = await computeElkLayout(nodes, edges, {
|
|
@@ -553,10 +673,29 @@ export async function buildSubsystemGraph(
|
|
|
553
673
|
interLayerSpacing: 120,
|
|
554
674
|
preserveNodePositions: false,
|
|
555
675
|
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
676
|
+
groups: regions.map((r) => ({
|
|
677
|
+
id: processGroupNodeId(r.key),
|
|
678
|
+
memberIds: r.memberIds,
|
|
679
|
+
})),
|
|
680
|
+
});
|
|
681
|
+
const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
|
|
682
|
+
const bounds = result.groupBounds.get(processGroupNodeId(region.key));
|
|
683
|
+
if (!bounds) return [];
|
|
684
|
+
const group: SubsystemGraphNode = {
|
|
685
|
+
id: processGroupNodeId(region.key),
|
|
686
|
+
type: 'subsystem-group',
|
|
687
|
+
position: { x: bounds.x, y: bounds.y },
|
|
688
|
+
width: Math.max(200, bounds.width),
|
|
689
|
+
height: Math.max(160, bounds.height),
|
|
690
|
+
data: { region },
|
|
691
|
+
};
|
|
692
|
+
return [group];
|
|
556
693
|
});
|
|
557
|
-
|
|
694
|
+
// Parents first — React Flow resolves children via parentId.
|
|
695
|
+
placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
|
|
558
696
|
labelPositions = result.edgeLabelPositions;
|
|
559
697
|
elkPathStrings = result.edgePaths;
|
|
698
|
+
elkPathPoints = result.edgePathPoints;
|
|
560
699
|
} catch (err) {
|
|
561
700
|
// Fall back to the (unpositioned) grid if ELK is unavailable.
|
|
562
701
|
console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
|
|
@@ -577,7 +716,12 @@ export async function buildSubsystemGraph(
|
|
|
577
716
|
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
578
717
|
d.elkPath = elkP;
|
|
579
718
|
}
|
|
719
|
+
const pts = elkPathPoints.get(e.id);
|
|
720
|
+
if (pts && pts.length > 1) {
|
|
721
|
+
const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
|
|
722
|
+
d.pathLength = calculatePathLength(pts);
|
|
723
|
+
}
|
|
580
724
|
}
|
|
581
725
|
|
|
582
|
-
return { nodes: placedNodes, edges };
|
|
726
|
+
return { nodes: placedNodes, edges, regions };
|
|
583
727
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha } from './nodes';
|
|
3
|
+
|
|
4
|
+
describe('hexWithAlpha', () => {
|
|
5
|
+
test('appends a two-digit alpha to #rrggbb', () => {
|
|
6
|
+
expect(hexWithAlpha('#4ec9b0', 1)).toBe('#4ec9b0ff');
|
|
7
|
+
expect(hexWithAlpha('#4ec9b0', EDGE_DIM_ALPHA)).toBe('#4ec9b026');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('expands #rgb', () => {
|
|
11
|
+
expect(hexWithAlpha('#abc', 1)).toBe('#aabbccff');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('leaves non-hex values alone', () => {
|
|
15
|
+
expect(hexWithAlpha('teal', 0.15)).toBe('teal');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('fileMatchForNode', () => {
|
|
20
|
+
test('no open file → neutral', () => {
|
|
21
|
+
expect(fileMatchForNode('src/a.ts', null, false)).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('open file spotlights matches and dims others', () => {
|
|
25
|
+
expect(fileMatchForNode('src/a.ts', 'src/a.ts', false)).toBe(true);
|
|
26
|
+
expect(fileMatchForNode('src/b.ts', 'src/a.ts', false)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('focused-edge endpoint is not dimmed when it lives in another file', () => {
|
|
30
|
+
expect(fileMatchForNode('src/target.ts', 'src/source.ts', true)).toBeUndefined();
|
|
31
|
+
expect(fileMatchForNode('src/source.ts', 'src/source.ts', true)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('flowElementVisibility', () => {
|
|
36
|
+
test('nothing open or selected → everything full', () => {
|
|
37
|
+
expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: false, anySelected: false }))
|
|
38
|
+
.toEqual({ hidden: false, dimmed: false });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('opened, nothing selected → opened members full, rest hidden', () => {
|
|
42
|
+
expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: false }))
|
|
43
|
+
.toEqual({ hidden: false, dimmed: false });
|
|
44
|
+
expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: false }))
|
|
45
|
+
.toEqual({ hidden: true, dimmed: false });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('selected flow or step full; other opened members dimmed; rest hidden', () => {
|
|
49
|
+
expect(flowElementVisibility({ inOpened: true, inSelected: true, anyOpened: true, anySelected: true }))
|
|
50
|
+
.toEqual({ hidden: false, dimmed: false });
|
|
51
|
+
expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: true }))
|
|
52
|
+
.toEqual({ hidden: false, dimmed: true });
|
|
53
|
+
expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: true }))
|
|
54
|
+
.toEqual({ hidden: true, dimmed: false });
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { useState } from 'react';
|
|
|
11
11
|
import {
|
|
12
12
|
Handle,
|
|
13
13
|
Position,
|
|
14
|
+
type Node,
|
|
14
15
|
type NodeProps,
|
|
15
16
|
type EdgeProps,
|
|
16
17
|
} from '@xyflow/react';
|
|
@@ -21,7 +22,9 @@ import {
|
|
|
21
22
|
ROLE_COLOR,
|
|
22
23
|
ROLE_LABEL,
|
|
23
24
|
deriveNameFromSymbol,
|
|
24
|
-
|
|
25
|
+
packageColor,
|
|
26
|
+
type SubsystemGraphNodeData,
|
|
27
|
+
type SubsystemGroupNodeData,
|
|
25
28
|
type SubsystemGraphEdge,
|
|
26
29
|
} from './model';
|
|
27
30
|
import { constructColorsFromPierreTheme } from '../pierre/constructColors';
|
|
@@ -70,7 +73,7 @@ export interface SubsystemGraphCallbacks {
|
|
|
70
73
|
/** Root callbacks carried through node data (injected by the graph component). */
|
|
71
74
|
export const SUBSYSTEM_CALLBACKS: SubsystemGraphCallbacks = {};
|
|
72
75
|
|
|
73
|
-
export function SubsystemComponentNode(props: NodeProps<
|
|
76
|
+
export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
|
|
74
77
|
const { theme, mode } = useTheme();
|
|
75
78
|
const { data, selected, width: nodeWidth, height: nodeHeight } = props;
|
|
76
79
|
const c = data.component;
|
|
@@ -126,7 +129,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
126
129
|
boxShadow: fileMatch
|
|
127
130
|
? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
|
|
128
131
|
: '0 1px 4px rgba(0,0,0,0.25)',
|
|
129
|
-
opacity: fileMatch === false ? 0.18 : 1,
|
|
132
|
+
opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
|
|
130
133
|
transition: 'opacity 150ms ease',
|
|
131
134
|
cursor: 'pointer',
|
|
132
135
|
fontFamily: theme.fonts.body,
|
|
@@ -267,6 +270,117 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
267
270
|
);
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Process boundary frame — a React Flow parent node. Members render inside
|
|
275
|
+
* via `parentId`; this draws the labeled container only (no handles, no
|
|
276
|
+
* selection). The border color derives deterministically from the process key
|
|
277
|
+
* so each deployment unit reads as its own region.
|
|
278
|
+
*/
|
|
279
|
+
export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData, 'subsystem-group'>>) {
|
|
280
|
+
const { theme } = useTheme();
|
|
281
|
+
const { data, width, height, selected } = props as unknown as {
|
|
282
|
+
data: SubsystemGroupNodeData;
|
|
283
|
+
width?: number;
|
|
284
|
+
height?: number;
|
|
285
|
+
selected?: boolean;
|
|
286
|
+
};
|
|
287
|
+
const region = data.region;
|
|
288
|
+
const color = packageColor(region?.key ?? 'process');
|
|
289
|
+
const dimmed = data.dimmed === true;
|
|
290
|
+
const hidden = (data as { hidden?: boolean }).hidden === true;
|
|
291
|
+
|
|
292
|
+
if (!region) return null;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
style={{
|
|
297
|
+
position: 'relative',
|
|
298
|
+
width: width ?? 400,
|
|
299
|
+
height: height ?? 300,
|
|
300
|
+
boxSizing: 'border-box',
|
|
301
|
+
borderRadius: 12,
|
|
302
|
+
border: `2px ${selected ? 'solid' : 'dashed'} ${color}`,
|
|
303
|
+
background: `${color}14`,
|
|
304
|
+
opacity: hidden ? 0 : dimmed ? 0.35 : 1,
|
|
305
|
+
transition: 'opacity 150ms ease',
|
|
306
|
+
pointerEvents: 'none',
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<div
|
|
310
|
+
style={{
|
|
311
|
+
position: 'absolute',
|
|
312
|
+
top: -13,
|
|
313
|
+
left: 12,
|
|
314
|
+
fontFamily: theme.fonts.monospace,
|
|
315
|
+
fontSize: theme.fontSizes[0],
|
|
316
|
+
fontWeight: 700,
|
|
317
|
+
letterSpacing: 0.6,
|
|
318
|
+
color,
|
|
319
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
320
|
+
border: `1px solid ${color}`,
|
|
321
|
+
borderRadius: 4,
|
|
322
|
+
padding: '1px 7px',
|
|
323
|
+
whiteSpace: 'nowrap',
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{`process: ${region.label}`}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `#rrggbb` + alpha → `#rrggbbaa`. Used to dim a stroke/marker by color so
|
|
333
|
+
* each opacity gets its own SVG marker id — path `opacity` leaks across every
|
|
334
|
+
* edge that shares a `url(#marker)` (the focused edge's arrowhead dims). */
|
|
335
|
+
export function hexWithAlpha(hex: string, alpha: number): string {
|
|
336
|
+
const raw = hex.replace('#', '');
|
|
337
|
+
const full = raw.length === 3 ? [...raw].map((c) => c + c).join('') : raw;
|
|
338
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return hex;
|
|
339
|
+
const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
|
|
340
|
+
.toString(16)
|
|
341
|
+
.padStart(2, '0');
|
|
342
|
+
return `#${full}${a}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* File-open spotlight flag for a node. `true` = lives in the open file,
|
|
347
|
+
* `false` = dim, `undefined` = render neutrally.
|
|
348
|
+
*
|
|
349
|
+
* Focused-edge endpoints stay neutral when they don't live in the open file
|
|
350
|
+
* (the target of a focused edge must not dim with the rest).
|
|
351
|
+
*/
|
|
352
|
+
export function fileMatchForNode(
|
|
353
|
+
nodeFile: string | undefined,
|
|
354
|
+
openFile: string | null,
|
|
355
|
+
isFocusEndpoint: boolean,
|
|
356
|
+
): boolean | undefined {
|
|
357
|
+
if (!openFile) return undefined;
|
|
358
|
+
if (nodeFile === openFile) return true;
|
|
359
|
+
if (isFocusEndpoint) return undefined;
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Hide / dim a node or edge while flows are open.
|
|
365
|
+
* - not in any opened flow → hidden
|
|
366
|
+
* - in an opened flow, but not the selected flow/step → dimmed
|
|
367
|
+
* - in the selected flow or step (or opened with nothing selected) → full
|
|
368
|
+
*/
|
|
369
|
+
export function flowElementVisibility(opts: {
|
|
370
|
+
inOpened: boolean;
|
|
371
|
+
inSelected: boolean;
|
|
372
|
+
anyOpened: boolean;
|
|
373
|
+
anySelected: boolean;
|
|
374
|
+
}): { hidden: boolean; dimmed: boolean } {
|
|
375
|
+
const { inOpened, inSelected, anyOpened, anySelected } = opts;
|
|
376
|
+
if (!anyOpened && !anySelected) return { hidden: false, dimmed: false };
|
|
377
|
+
if (!inOpened && !inSelected) return { hidden: true, dimmed: false };
|
|
378
|
+
if (anySelected && !inSelected) return { hidden: false, dimmed: true };
|
|
379
|
+
return { hidden: false, dimmed: false };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const EDGE_DIM_ALPHA = 0.15;
|
|
383
|
+
|
|
270
384
|
/** Subsystem edge — SVG path only. The mechanism label is rendered as an
|
|
271
385
|
* absolutely-positioned HTML overlay OUTSIDE the ReactFlow tree (by the
|
|
272
386
|
* parent Inner component) so it sits above the pane and receives pointer
|
|
@@ -282,7 +396,10 @@ export function SubsystemEdge({
|
|
|
282
396
|
// observational relationships: hierarchy, registration, watches).
|
|
283
397
|
const isDashed = MECHANISM_STYLE[mechanism] === 'dashed';
|
|
284
398
|
const dimmed = data?.dimmed === true;
|
|
285
|
-
|
|
399
|
+
// Dim the stroke by color, never via path `opacity`. SVG markers are shared
|
|
400
|
+
// by id; opacity on the referencing path paints every arrowhead that uses
|
|
401
|
+
// the same marker — including the focused edge's target.
|
|
402
|
+
const stroke = dimmed ? hexWithAlpha(color, EDGE_DIM_ALPHA) : color;
|
|
286
403
|
|
|
287
404
|
return (
|
|
288
405
|
<>
|
|
@@ -301,10 +418,9 @@ export function SubsystemEdge({
|
|
|
301
418
|
<path
|
|
302
419
|
d={path}
|
|
303
420
|
fill="none"
|
|
304
|
-
stroke={
|
|
421
|
+
stroke={stroke}
|
|
305
422
|
strokeWidth={1.5}
|
|
306
423
|
strokeDasharray={isDashed ? '6 4' : undefined}
|
|
307
|
-
opacity={opacity}
|
|
308
424
|
markerEnd={markerEnd}
|
|
309
425
|
style={{ pointerEvents: 'none' }}
|
|
310
426
|
/>
|