@principal-ai/subsystems-react 0.37.12 → 0.38.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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +0 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +14 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +32 -12
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +123 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +91 -12
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +9 -5
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +9 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +21 -2
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/__fixtures__/ego-samples.json +545 -0
- package/src/index.ts +12 -1
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +2 -2
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +9 -9
- package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +6 -6
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +0 -1
- package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +354 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +52 -15
- package/src/subsystem/model.test.ts +75 -2
- package/src/subsystem/model.ts +201 -17
- package/src/subsystem/nodes.tsx +10 -6
- package/src/subsystem/toC4.test.ts +3 -3
- package/src/utils/elkLayout.ts +32 -1
|
@@ -2,6 +2,13 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
convertSubsystemToNodes,
|
|
4
4
|
convertSubsystemToEdges,
|
|
5
|
+
deriveGraphEdges,
|
|
6
|
+
edgeColor,
|
|
7
|
+
edgeStrokeStyle,
|
|
8
|
+
MECHANISM_COLOR,
|
|
9
|
+
GRAPHIFY_RELATION_COLOR,
|
|
10
|
+
GRAPHIFY_RELATION_FALLBACK_COLOR,
|
|
11
|
+
GRAPHIFY_RELATION_STYLE,
|
|
5
12
|
convertSubsystemToGroups,
|
|
6
13
|
getSubsystemRegions,
|
|
7
14
|
getSubsystemModuleRegions,
|
|
@@ -42,9 +49,9 @@ const comps: SubsystemComponent[] = [
|
|
|
42
49
|
];
|
|
43
50
|
|
|
44
51
|
const relations = [
|
|
45
|
-
{ id: 'e1', from: 'transcript', to: 'reader', relationType: '
|
|
52
|
+
{ id: 'e1', from: 'transcript', to: 'reader', relationType: 'method' as const },
|
|
46
53
|
// 'host' is NOT a component — this is the cross-package external case.
|
|
47
|
-
{ id: 'e2', from: 'reader', to: 'host', relationType: '
|
|
54
|
+
{ id: 'e2', from: 'reader', to: 'host', relationType: 'method' as const, refs: ['bun/index.ts'] },
|
|
48
55
|
];
|
|
49
56
|
|
|
50
57
|
const doc = { components: comps, relations };
|
|
@@ -796,6 +803,15 @@ describe('isConstructsOnlyModel', () => {
|
|
|
796
803
|
expect(isConstructsOnlyModel({ components: comps })).toBe(true);
|
|
797
804
|
});
|
|
798
805
|
|
|
806
|
+
test('false when only graphify-native edges exist', () => {
|
|
807
|
+
expect(
|
|
808
|
+
isConstructsOnlyModel({
|
|
809
|
+
components: comps,
|
|
810
|
+
graphifyRelations: [{ id: 'g', from: 'reader', to: 'transcript', relation: 'imports' }],
|
|
811
|
+
}),
|
|
812
|
+
).toBe(false);
|
|
813
|
+
});
|
|
814
|
+
|
|
799
815
|
test('false when empty, or when relations or walkthrough hops exist', () => {
|
|
800
816
|
expect(isConstructsOnlyModel({ components: [], relations: [] })).toBe(false);
|
|
801
817
|
expect(isConstructsOnlyModel({ components: comps, relations })).toBe(false);
|
|
@@ -973,3 +989,60 @@ describe('reorderTargetIndex', () => {
|
|
|
973
989
|
expect(reorderWalkthroughs(wts, 0, target).map((w) => w.id)).toEqual(['b', 'a', 'c']);
|
|
974
990
|
});
|
|
975
991
|
});
|
|
992
|
+
|
|
993
|
+
describe('graphify-native edges', () => {
|
|
994
|
+
const doc = {
|
|
995
|
+
components: comps,
|
|
996
|
+
relations: [],
|
|
997
|
+
};
|
|
998
|
+
const graphify = [
|
|
999
|
+
{ id: 'g1', from: 'reader', to: 'dst', relation: 'imports' },
|
|
1000
|
+
{ id: 'g2', from: 'reader', to: 'dst', relation: 'contains' },
|
|
1001
|
+
];
|
|
1002
|
+
|
|
1003
|
+
test('deriveGraphEdges tags graphify relations with provenance + raw verb', () => {
|
|
1004
|
+
const [e] = deriveGraphEdges({ ...doc, graphifyRelations: graphify });
|
|
1005
|
+
expect(e.provenance).toBe('graphify');
|
|
1006
|
+
expect(e.mechanism).toBe('imports');
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test('subsystem relations keep no graphify provenance', () => {
|
|
1010
|
+
const [e] = deriveGraphEdges({
|
|
1011
|
+
relations: [{ id: 'r', from: 'reader', to: 'dst', relationType: 'method' }],
|
|
1012
|
+
});
|
|
1013
|
+
expect(e.provenance).toBeUndefined();
|
|
1014
|
+
expect(e.mechanism).toBe('method');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test('graphify edges color from the separate palette', () => {
|
|
1018
|
+
expect(edgeColor({ mechanism: 'imports', provenance: 'graphify' })).toBe(
|
|
1019
|
+
GRAPHIFY_RELATION_COLOR.imports,
|
|
1020
|
+
);
|
|
1021
|
+
expect(edgeColor({ mechanism: 'imports', provenance: 'graphify' })).not.toBe(
|
|
1022
|
+
MECHANISM_COLOR.method,
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test('unknown graphify verb falls back, never undefined', () => {
|
|
1027
|
+
expect(edgeColor({ mechanism: 'brand_new_verb', provenance: 'graphify' })).toBe(
|
|
1028
|
+
GRAPHIFY_RELATION_FALLBACK_COLOR,
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('subsystem mechanism color is unaffected by the graphify palette', () => {
|
|
1033
|
+
expect(edgeColor({ mechanism: 'method' })).toBe(MECHANISM_COLOR.method);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test('graphify edges all share the graphify stroke style', () => {
|
|
1037
|
+
expect(edgeStrokeStyle({ mechanism: 'imports', provenance: 'graphify' })).toBe(
|
|
1038
|
+
GRAPHIFY_RELATION_STYLE,
|
|
1039
|
+
);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test('convertSubsystemToEdges carries provenance into edge data', () => {
|
|
1043
|
+
const edges = convertSubsystemToEdges(doc, graphify);
|
|
1044
|
+
const g = edges.find((e) => e.id === 'g1');
|
|
1045
|
+
expect((g?.data as { provenance?: string })?.provenance).toBe('graphify');
|
|
1046
|
+
expect((g?.data as { mechanism?: string })?.mechanism).toBe('imports');
|
|
1047
|
+
});
|
|
1048
|
+
});
|
package/src/subsystem/model.ts
CHANGED
|
@@ -117,8 +117,7 @@ export type SubsystemRelationType =
|
|
|
117
117
|
| 'inherits'
|
|
118
118
|
| 'implements'
|
|
119
119
|
| 'mixes_in'
|
|
120
|
-
| 'method'
|
|
121
|
-
| 'references';
|
|
120
|
+
| 'method';
|
|
122
121
|
|
|
123
122
|
/**
|
|
124
123
|
* Walkthrough hop mechanism — runtime seams with a `file:line` site.
|
|
@@ -146,6 +145,50 @@ export type SubsystemEdgeMechanism =
|
|
|
146
145
|
*/
|
|
147
146
|
export type SubsystemEdgeView = 'relations' | 'walkthroughs';
|
|
148
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Where a display edge came from.
|
|
150
|
+
*
|
|
151
|
+
* - `subsystem` (default): a verb from the authored vocabularies
|
|
152
|
+
* (`SubsystemRelationType` / `SubsystemWalkthroughMechanism`), colored from
|
|
153
|
+
* `MECHANISM_COLOR`.
|
|
154
|
+
* - `graphify`: a raw relation read off graphify's static symbol graph
|
|
155
|
+
* (`imports`, `contains`, `re_exports`, …). These are DERIVED, never authored,
|
|
156
|
+
* and are colored from `GRAPHIFY_RELATION_COLOR` so a reader can tell a
|
|
157
|
+
* graphify fact apart from a subsystem claim at a glance.
|
|
158
|
+
*/
|
|
159
|
+
export type SubsystemEdgeProvenance = 'subsystem' | 'graphify';
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A graphify-native topology edge — a raw graphify relation that has no
|
|
163
|
+
* subsystem mechanism equivalent.
|
|
164
|
+
*
|
|
165
|
+
* Kept structurally separate from `SubsystemRelation`: those are authored into
|
|
166
|
+
* a portable model and validated against a closed vocabulary, whereas these are
|
|
167
|
+
* derived from a graphify run and carry graphify's own (open) verb set. They are
|
|
168
|
+
* a display input only — never written back into a `SubsystemModelDocument`.
|
|
169
|
+
*/
|
|
170
|
+
export interface SubsystemGraphifyRelation {
|
|
171
|
+
id: string;
|
|
172
|
+
/** Source component alias. */
|
|
173
|
+
from: string;
|
|
174
|
+
/** Target component alias. */
|
|
175
|
+
to: string;
|
|
176
|
+
/** Raw graphify relation verb (e.g. `imports`, `contains`, `re_exports`). */
|
|
177
|
+
relation: string;
|
|
178
|
+
/** Concrete file/symbol refs backing the relation. */
|
|
179
|
+
refs?: string[];
|
|
180
|
+
/**
|
|
181
|
+
* 1-based source line of the relation site. Optional — available on
|
|
182
|
+
* `calls`/`references`-style edges; used to order an ego graph's callees by
|
|
183
|
+
* call site when a host opts into line-ordered layering.
|
|
184
|
+
*/
|
|
185
|
+
line?: number;
|
|
186
|
+
/** graphify's provenance tag (`EXTRACTED` / `INFERRED` / `AMBIGUOUS`). */
|
|
187
|
+
confidence?: string;
|
|
188
|
+
/** Reference context on `references` edges (e.g. `return_type`, `field`). */
|
|
189
|
+
context?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
149
192
|
/** A component node — the named unit, construct-tagged; `file` is its location. */
|
|
150
193
|
export interface SubsystemComponent {
|
|
151
194
|
/**
|
|
@@ -305,9 +348,28 @@ export interface SubsystemComponentEdge {
|
|
|
305
348
|
id: string;
|
|
306
349
|
from: string; // component alias
|
|
307
350
|
to: string; // component alias or external target label
|
|
308
|
-
|
|
351
|
+
/**
|
|
352
|
+
* The edge verb. For `provenance: 'subsystem'` (the default) this is a
|
|
353
|
+
* `SubsystemEdgeMechanism`; for `provenance: 'graphify'` it is the raw
|
|
354
|
+
* graphify relation. Typed as `string` because the display edge is a derived
|
|
355
|
+
* structure and graphify's verb set is open — the authored vocabularies
|
|
356
|
+
* (`SubsystemRelationType` / `SubsystemWalkthroughMechanism`) stay closed.
|
|
357
|
+
*/
|
|
358
|
+
mechanism: string;
|
|
359
|
+
/** Origin of the edge; absent means `'subsystem'`. */
|
|
360
|
+
provenance?: SubsystemEdgeProvenance;
|
|
309
361
|
/** Concrete file/symbol refs backing the edge (the seam). */
|
|
310
362
|
refs?: string[];
|
|
363
|
+
/**
|
|
364
|
+
* 1-based source line of the RELATION SITE — where the edge's verb was
|
|
365
|
+
* observed (e.g. a `calls` edge's call-site line inside the caller), NOT
|
|
366
|
+
* where the target is declared.
|
|
367
|
+
*/
|
|
368
|
+
line?: number;
|
|
369
|
+
/** graphify's provenance tag (`EXTRACTED` / `INFERRED` / `AMBIGUOUS`). */
|
|
370
|
+
confidence?: string;
|
|
371
|
+
/** Reference context on `references` edges (e.g. `return_type`, `field`). */
|
|
372
|
+
context?: string;
|
|
311
373
|
}
|
|
312
374
|
|
|
313
375
|
/**
|
|
@@ -362,7 +424,7 @@ export interface SubsystemModelDocument {
|
|
|
362
424
|
export function derivedGraphEdgeId(
|
|
363
425
|
from: string,
|
|
364
426
|
to: string,
|
|
365
|
-
mechanism:
|
|
427
|
+
mechanism: string,
|
|
366
428
|
): string {
|
|
367
429
|
return `${from}--${mechanism}-->${to}`;
|
|
368
430
|
}
|
|
@@ -412,6 +474,7 @@ export function reorderTargetIndex(boundary: number, from: number): number {
|
|
|
412
474
|
export function deriveGraphEdges(doc: {
|
|
413
475
|
relations?: readonly SubsystemRelation[];
|
|
414
476
|
walkthroughs?: readonly SubsystemWalkthrough[];
|
|
477
|
+
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
415
478
|
}): SubsystemComponentEdge[] {
|
|
416
479
|
const byId = new Map<string, SubsystemComponentEdge>();
|
|
417
480
|
for (const r of doc.relations ?? []) {
|
|
@@ -439,6 +502,22 @@ export function deriveGraphEdges(doc: {
|
|
|
439
502
|
}
|
|
440
503
|
}
|
|
441
504
|
}
|
|
505
|
+
for (const g of doc.graphifyRelations ?? []) {
|
|
506
|
+
const id = g.id || derivedGraphEdgeId(g.from, g.to, g.relation);
|
|
507
|
+
if (!byId.has(id)) {
|
|
508
|
+
byId.set(id, {
|
|
509
|
+
id,
|
|
510
|
+
from: g.from,
|
|
511
|
+
to: g.to,
|
|
512
|
+
mechanism: g.relation,
|
|
513
|
+
provenance: 'graphify',
|
|
514
|
+
refs: g.refs,
|
|
515
|
+
line: g.line,
|
|
516
|
+
confidence: g.confidence,
|
|
517
|
+
context: g.context,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
442
521
|
return [...byId.values()];
|
|
443
522
|
}
|
|
444
523
|
|
|
@@ -450,11 +529,13 @@ export function isConstructsOnlyModel(doc: {
|
|
|
450
529
|
components: readonly { alias: string }[];
|
|
451
530
|
relations?: readonly SubsystemRelation[];
|
|
452
531
|
walkthroughs?: readonly SubsystemWalkthrough[];
|
|
532
|
+
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
453
533
|
}): boolean {
|
|
454
534
|
if (doc.components.length === 0) return false;
|
|
455
535
|
return deriveGraphEdges({
|
|
456
536
|
relations: doc.relations,
|
|
457
537
|
walkthroughs: doc.walkthroughs,
|
|
538
|
+
graphifyRelations: doc.graphifyRelations,
|
|
458
539
|
}).length === 0;
|
|
459
540
|
}
|
|
460
541
|
|
|
@@ -972,8 +1053,17 @@ export type SubsystemGraphNode =
|
|
|
972
1053
|
| Node<SubsystemGroupNodeData, 'subsystem-group'>;
|
|
973
1054
|
|
|
974
1055
|
export interface SubsystemGraphEdgeData extends Record<string, unknown> {
|
|
975
|
-
mechanism
|
|
1056
|
+
/** The edge verb (subsystem mechanism, or raw graphify relation). */
|
|
1057
|
+
mechanism: string;
|
|
1058
|
+
/** Origin of the edge; absent means `'subsystem'`. */
|
|
1059
|
+
provenance?: SubsystemEdgeProvenance;
|
|
976
1060
|
refs?: string[];
|
|
1061
|
+
/** 1-based source line of the relation site (graphify edges when known). */
|
|
1062
|
+
line?: number;
|
|
1063
|
+
/** graphify's provenance tag (`EXTRACTED` / `INFERRED` / `AMBIGUOUS`). */
|
|
1064
|
+
confidence?: string;
|
|
1065
|
+
/** Reference context on `references` edges (e.g. `return_type`, `field`). */
|
|
1066
|
+
context?: string;
|
|
977
1067
|
/** True while another edge is selected — render this edge (and its label)
|
|
978
1068
|
* dimmed to focus the selected relationship. */
|
|
979
1069
|
dimmed?: boolean;
|
|
@@ -1006,7 +1096,6 @@ export const SUBSYSTEM_RELATION_TYPES = [
|
|
|
1006
1096
|
'implements',
|
|
1007
1097
|
'mixes_in',
|
|
1008
1098
|
'method',
|
|
1009
|
-
'references',
|
|
1010
1099
|
] as const satisfies readonly SubsystemRelationType[];
|
|
1011
1100
|
|
|
1012
1101
|
/** Runtime vocabulary of walkthrough hop mechanisms — mirrors `SubsystemWalkthroughMechanism`. */
|
|
@@ -1048,7 +1137,6 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
|
|
|
1048
1137
|
mixes_in: '#d474a8', // pink-magenta
|
|
1049
1138
|
uses: '#e3b341', // gold
|
|
1050
1139
|
method: '#c586c0', // magenta
|
|
1051
|
-
references: '#3b82f6', // blue
|
|
1052
1140
|
feeds: '#4ec9b0', // teal — data-flow into a processor
|
|
1053
1141
|
produces: '#a78bfa', // violet — emits an output type
|
|
1054
1142
|
writes: '#e8853a', // orange — mutates retained state
|
|
@@ -1065,7 +1153,6 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
|
|
|
1065
1153
|
mixes_in: 'dashed',
|
|
1066
1154
|
uses: 'solid',
|
|
1067
1155
|
method: 'solid',
|
|
1068
|
-
references: 'dotted',
|
|
1069
1156
|
feeds: 'solid',
|
|
1070
1157
|
produces: 'solid',
|
|
1071
1158
|
writes: 'solid',
|
|
@@ -1074,6 +1161,77 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
|
|
|
1074
1161
|
'registers-into': 'dashed',
|
|
1075
1162
|
};
|
|
1076
1163
|
|
|
1164
|
+
/** Fallback hue for a mechanism outside the closed palette (defensive — the
|
|
1165
|
+
* vocabulary is closed, so this only guards an unexpected verb from rendering
|
|
1166
|
+
* an undefined stroke). */
|
|
1167
|
+
export const MECHANISM_FALLBACK_COLOR = '#888888';
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* graphify-native relation hues — a palette deliberately SEPARATE from
|
|
1171
|
+
* `MECHANISM_COLOR`.
|
|
1172
|
+
*
|
|
1173
|
+
* graphify edges are derived from a static symbol graph, not authored subsystem
|
|
1174
|
+
* semantics, so they get their own (cooler, desaturated) family and their own
|
|
1175
|
+
* stroke treatment. Keeping the hue variables separate means a graphify fact
|
|
1176
|
+
* can never be mistaken for a subsystem claim, and a new graphify verb can
|
|
1177
|
+
* never accidentally inherit a mechanism hue.
|
|
1178
|
+
*
|
|
1179
|
+
* Keyed by graphify's relation verbs. Open-ended: graphify adds verbs, so an
|
|
1180
|
+
* unlisted verb falls back to `GRAPHIFY_RELATION_FALLBACK_COLOR`.
|
|
1181
|
+
*/
|
|
1182
|
+
export const GRAPHIFY_RELATION_COLOR: Record<string, string> = {
|
|
1183
|
+
contains: '#5c6b7a', // slate — structural containment
|
|
1184
|
+
defines: '#6e7d8c', // steel — defines a member
|
|
1185
|
+
imports: '#4c7fb5', // steel blue — module import
|
|
1186
|
+
imports_from: '#6699cc', // lighter steel blue
|
|
1187
|
+
re_exports: '#7b7fd4', // indigo — barrel re-export
|
|
1188
|
+
dynamic_import: '#4f9aa8', // steel cyan — lazy import
|
|
1189
|
+
indirect_call: '#8494a4', // grey — non-direct call
|
|
1190
|
+
calls: '#3f9e78', // muted green — call-graph edge
|
|
1191
|
+
uses: '#b0924e', // muted gold
|
|
1192
|
+
references: '#5f7fa6', // dusty blue
|
|
1193
|
+
extends: '#8c74b0', // muted violet
|
|
1194
|
+
inherits: '#7a68a6', // muted violet (darker)
|
|
1195
|
+
implements: '#a4739e', // muted mauve
|
|
1196
|
+
method: '#a4739e', // muted mauve
|
|
1197
|
+
decorator: '#b57fa0', // muted rose
|
|
1198
|
+
rationale_for: '#bda15e', // muted ochre
|
|
1199
|
+
semantically_similar_to: '#9a80bf', // muted lavender
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/** Fallback hue for a graphify verb absent from `GRAPHIFY_RELATION_COLOR`. */
|
|
1203
|
+
export const GRAPHIFY_RELATION_FALLBACK_COLOR = '#7d8794';
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* graphify-native edges share one stroke treatment so provenance still reads
|
|
1207
|
+
* even where a hue happens to sit near a mechanism hue. Mirrors the
|
|
1208
|
+
* `MECHANISM_STYLE` value space.
|
|
1209
|
+
*/
|
|
1210
|
+
export const GRAPHIFY_RELATION_STYLE: 'solid' | 'dashed' | 'dotted' = 'dashed';
|
|
1211
|
+
|
|
1212
|
+
/** Resolve an edge's stroke color from its provenance + verb. */
|
|
1213
|
+
export function edgeColor(
|
|
1214
|
+
edge: Pick<SubsystemComponentEdge, 'mechanism' | 'provenance'>,
|
|
1215
|
+
): string {
|
|
1216
|
+
if (edge.provenance === 'graphify') {
|
|
1217
|
+
return (
|
|
1218
|
+
GRAPHIFY_RELATION_COLOR[edge.mechanism] ?? GRAPHIFY_RELATION_FALLBACK_COLOR
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
return (
|
|
1222
|
+
MECHANISM_COLOR[edge.mechanism as SubsystemEdgeMechanism] ??
|
|
1223
|
+
MECHANISM_FALLBACK_COLOR
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Resolve an edge's dash treatment from its provenance + verb. */
|
|
1228
|
+
export function edgeStrokeStyle(
|
|
1229
|
+
edge: Pick<SubsystemComponentEdge, 'mechanism' | 'provenance'>,
|
|
1230
|
+
): 'solid' | 'dashed' | 'dotted' {
|
|
1231
|
+
if (edge.provenance === 'graphify') return GRAPHIFY_RELATION_STYLE;
|
|
1232
|
+
return MECHANISM_STYLE[edge.mechanism as SubsystemEdgeMechanism] ?? 'solid';
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1077
1235
|
/**
|
|
1078
1236
|
* Region colors for process boundaries in the aggregate graph. Kept separate
|
|
1079
1237
|
* from MECHANISM_COLOR on purpose: that palette encodes edge semantics on thin
|
|
@@ -1165,7 +1323,6 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
|
|
|
1165
1323
|
['mixes_in', 'applies mixin', true],
|
|
1166
1324
|
['uses', 'general dependency (import, call, or reference)', false],
|
|
1167
1325
|
['method', 'structural: has method / member', true],
|
|
1168
|
-
['references', 'type / symbol reference (not a call)', true],
|
|
1169
1326
|
['feeds', 'data flow: output feeds into input', false],
|
|
1170
1327
|
['produces', 'data flow: produces / outputs', false],
|
|
1171
1328
|
['writes', 'state access: mutates retained state', true],
|
|
@@ -1489,13 +1646,16 @@ export function convertSubsystemToGroups(
|
|
|
1489
1646
|
* is an external label (not a component alias) point at a synthetic stub so the
|
|
1490
1647
|
* relationship is visible without a member node.
|
|
1491
1648
|
*/
|
|
1492
|
-
export function convertSubsystemToEdges(
|
|
1649
|
+
export function convertSubsystemToEdges(
|
|
1650
|
+
doc: SubsystemModelDocument,
|
|
1651
|
+
graphifyRelations: readonly SubsystemGraphifyRelation[] = [],
|
|
1652
|
+
): SubsystemGraphEdge[] {
|
|
1493
1653
|
const compAliases = new Set(doc.components.map((c) => c.alias));
|
|
1494
1654
|
const edges: SubsystemGraphEdge[] = [];
|
|
1495
1655
|
|
|
1496
|
-
for (const e of deriveGraphEdges(doc)) {
|
|
1497
|
-
const color =
|
|
1498
|
-
const style =
|
|
1656
|
+
for (const e of deriveGraphEdges({ ...doc, graphifyRelations })) {
|
|
1657
|
+
const color = edgeColor(e);
|
|
1658
|
+
const style = edgeStrokeStyle(e);
|
|
1499
1659
|
// If `to` is a real component, connect directly; otherwise point at a stub node.
|
|
1500
1660
|
const isExternal = !compAliases.has(e.to);
|
|
1501
1661
|
const targetId = isExternal ? `external:${e.to}` : e.to;
|
|
@@ -1504,7 +1664,14 @@ export function convertSubsystemToEdges(doc: SubsystemModelDocument): SubsystemG
|
|
|
1504
1664
|
id: e.id,
|
|
1505
1665
|
source: e.from,
|
|
1506
1666
|
target: targetId,
|
|
1507
|
-
data: {
|
|
1667
|
+
data: {
|
|
1668
|
+
mechanism: e.mechanism,
|
|
1669
|
+
provenance: e.provenance,
|
|
1670
|
+
refs: e.refs,
|
|
1671
|
+
line: e.line,
|
|
1672
|
+
confidence: e.confidence,
|
|
1673
|
+
context: e.context,
|
|
1674
|
+
},
|
|
1508
1675
|
type: 'subsystem-edge',
|
|
1509
1676
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 32, height: 32 },
|
|
1510
1677
|
style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
|
|
@@ -1520,7 +1687,9 @@ export function convertSubsystemToEdges(doc: SubsystemModelDocument): SubsystemG
|
|
|
1520
1687
|
|
|
1521
1688
|
/** Stable key for layout-affecting graph fields (ignores declarationRef, etc.). */
|
|
1522
1689
|
export function subsystemGraphLayoutKey(
|
|
1523
|
-
doc: Pick<SubsystemModelDocument, 'components' | 'relations' | 'walkthroughs'
|
|
1690
|
+
doc: Pick<SubsystemModelDocument, 'components' | 'relations' | 'walkthroughs'> & {
|
|
1691
|
+
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
1692
|
+
},
|
|
1524
1693
|
): string {
|
|
1525
1694
|
const components = doc.components
|
|
1526
1695
|
.map(({ alias, purl, name, symbol, construct, file, purpose, process, module }) =>
|
|
@@ -1546,6 +1715,18 @@ export async function buildSubsystemGraph(
|
|
|
1546
1715
|
showEdgeLabels?: boolean;
|
|
1547
1716
|
measuredWidths?: Map<string, number>;
|
|
1548
1717
|
measuredHeights?: Map<string, number>;
|
|
1718
|
+
/** graphify-native relations to merge into the display graph, drawn with
|
|
1719
|
+
* the separate graphify palette. Display-only; never authored. */
|
|
1720
|
+
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
1721
|
+
/**
|
|
1722
|
+
* Order same-layer nodes by `component.line` (ascending) instead of ELK's
|
|
1723
|
+
* crossing-minimizer. Only meaningful when components carry a `line` — and
|
|
1724
|
+
* callers are expected to stamp `line` with the RELEVANT site for their
|
|
1725
|
+
* graph (e.g. an ego graph stamps each callee with the center's call-site
|
|
1726
|
+
* line, not the callee's declaration line).
|
|
1727
|
+
* @default false
|
|
1728
|
+
*/
|
|
1729
|
+
orderByLine?: boolean;
|
|
1549
1730
|
} & BoundaryFrameOptions = {},
|
|
1550
1731
|
): Promise<{
|
|
1551
1732
|
nodes: SubsystemGraphNode[];
|
|
@@ -1565,10 +1746,12 @@ export async function buildSubsystemGraph(
|
|
|
1565
1746
|
measuredHeights,
|
|
1566
1747
|
showSingletonFrames,
|
|
1567
1748
|
packageFrames,
|
|
1749
|
+
graphifyRelations,
|
|
1750
|
+
orderByLine = false,
|
|
1568
1751
|
} = opts;
|
|
1569
1752
|
const frameOpts: BoundaryFrameOptions = { showSingletonFrames, packageFrames };
|
|
1570
1753
|
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
1571
|
-
const edges = convertSubsystemToEdges(doc);
|
|
1754
|
+
const edges = convertSubsystemToEdges(doc, graphifyRelations);
|
|
1572
1755
|
// Nested boundary tree: package → process → module → leaves.
|
|
1573
1756
|
const layoutGroups = buildBoundaryLayoutGroups(doc, frameOpts);
|
|
1574
1757
|
const regions = layoutGroups.map((g) => g.region);
|
|
@@ -1602,7 +1785,7 @@ export async function buildSubsystemGraph(
|
|
|
1602
1785
|
// cross-package edges have something to land on.
|
|
1603
1786
|
const realAliases = new Set(doc.components.map((c) => c.alias));
|
|
1604
1787
|
const externalIds: string[] = [];
|
|
1605
|
-
for (const e of deriveGraphEdges(doc)) {
|
|
1788
|
+
for (const e of deriveGraphEdges({ ...doc, graphifyRelations })) {
|
|
1606
1789
|
if (!realAliases.has(e.to)) {
|
|
1607
1790
|
const extId = `external:${e.to}`;
|
|
1608
1791
|
if (!externalIds.includes(extId)) externalIds.push(extId);
|
|
@@ -1669,6 +1852,7 @@ export async function buildSubsystemGraph(
|
|
|
1669
1852
|
interLayerSpacing: showEdgeLabels === false ? 120 : EDGE_LABEL_SIDE_PADDING,
|
|
1670
1853
|
endpointInset: EDGE_ARROW_INSET,
|
|
1671
1854
|
preserveNodePositions: false,
|
|
1855
|
+
orderByLine,
|
|
1672
1856
|
edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
|
|
1673
1857
|
groups: layoutGroups.map((g) => ({
|
|
1674
1858
|
id: g.id,
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
} from '@xyflow/react';
|
|
18
18
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
edgeColor,
|
|
21
|
+
edgeStrokeStyle,
|
|
22
22
|
PROPOSED_COLOR,
|
|
23
23
|
constructBadgeColor,
|
|
24
24
|
constructBadgeLabel,
|
|
@@ -560,10 +560,14 @@ export function SubsystemEdge({
|
|
|
560
560
|
}: EdgeProps<SubsystemGraphEdge>) {
|
|
561
561
|
const path = data?.elkPath ?? '';
|
|
562
562
|
const mechanism = data?.mechanism ?? 'uses';
|
|
563
|
-
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
const
|
|
563
|
+
// Color + dash resolve from provenance: subsystem mechanisms use the
|
|
564
|
+
// MECHANISM_* tables, graphify-native relations use the separate
|
|
565
|
+
// GRAPHIFY_RELATION_* palette (see edgeColor / edgeStrokeStyle).
|
|
566
|
+
const edgeRef = { mechanism, provenance: data?.provenance };
|
|
567
|
+
const color = edgeColor(edgeRef);
|
|
568
|
+
// Dash style encodes provenance (graphify) and relationship kind (dashed =
|
|
569
|
+
// inverted-control or observational: hierarchy, registration, watches).
|
|
570
|
+
const isDashed = edgeStrokeStyle(edgeRef) === 'dashed';
|
|
567
571
|
const dimmed = data?.dimmed === true;
|
|
568
572
|
// Dim the stroke by color, never via path `opacity`. SVG markers are shared
|
|
569
573
|
// by id; opacity on the referencing path paints every arrowhead that uses
|
|
@@ -20,9 +20,9 @@ const doc: SubsystemModelDocument = {
|
|
|
20
20
|
],
|
|
21
21
|
relations: [
|
|
22
22
|
{ id: 'r1', from: 'a', to: 'b', relationType: 'method' }, // intra p1
|
|
23
|
-
{ id: 'r2', from: 'a', to: 'c', relationType: '
|
|
24
|
-
{ id: 'r3', from: 'b', to: 'c', relationType: '
|
|
25
|
-
{ id: 'r4', from: 'a', to: 'x', relationType: '
|
|
23
|
+
{ id: 'r2', from: 'a', to: 'c', relationType: 'method' },
|
|
24
|
+
{ id: 'r3', from: 'b', to: 'c', relationType: 'method' },
|
|
25
|
+
{ id: 'r4', from: 'a', to: 'x', relationType: 'method' },
|
|
26
26
|
],
|
|
27
27
|
walkthroughs: [
|
|
28
28
|
{
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -84,6 +84,16 @@ export interface ElkLayoutOptions {
|
|
|
84
84
|
*/
|
|
85
85
|
direction?: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP';
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Order same-layer nodes by their source line (ascending) instead of leaving
|
|
89
|
+
* it to the crossing-minimizer. Nodes must carry a `data.component.line`
|
|
90
|
+
* (used when set; nodes without one keep ELK's neutral ordering). Off by
|
|
91
|
+
* default — the generic layered layout is tuned for crossings, and line order
|
|
92
|
+
* only makes sense for graphs that model a source region top-to-bottom.
|
|
93
|
+
* @default false
|
|
94
|
+
*/
|
|
95
|
+
orderByLine?: boolean;
|
|
96
|
+
|
|
87
97
|
/**
|
|
88
98
|
* Compound groups — each becomes an ELK parent whose `memberIds` are laid
|
|
89
99
|
* out inside it. `memberIds` may be leaf node ids or other group ids
|
|
@@ -323,11 +333,15 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
|
323
333
|
interLayerSpacing = 0,
|
|
324
334
|
edgeLabels,
|
|
325
335
|
direction = 'RIGHT',
|
|
336
|
+
orderByLine = false,
|
|
326
337
|
} = options;
|
|
327
338
|
|
|
328
339
|
const baseOptions: LayoutOptions = {
|
|
329
340
|
'elk.algorithm': 'layered',
|
|
330
341
|
'elk.direction': direction,
|
|
342
|
+
// Keep same-layer ordering stable (not reversed / randomized) so a
|
|
343
|
+
// line-ordered rewrite or ELK's own ordering is predictable.
|
|
344
|
+
'elk.layered.crossingMinimization.semiInteractive': 'true',
|
|
331
345
|
// Spacing
|
|
332
346
|
'elk.spacing.nodeNode': String(nodeSpacing),
|
|
333
347
|
'elk.spacing.edgeEdge': String(edgeSpacing),
|
|
@@ -346,6 +360,14 @@ function getElkOptions(options: ElkLayoutOptions): LayoutOptions {
|
|
|
346
360
|
'elk.layered.thoroughness': '50',
|
|
347
361
|
};
|
|
348
362
|
|
|
363
|
+
// Model order (position hints) so a per-node `data.line` can drive same-layer
|
|
364
|
+
// ordering. ELK honours the `y` of each node as a position hint against the
|
|
365
|
+
// model order.
|
|
366
|
+
if (orderByLine) {
|
|
367
|
+
baseOptions['elk.layered.fixedAlignment'] = 'NONE';
|
|
368
|
+
baseOptions['elk.layered.considerModelOrder.strategy'] = 'NODES_AND_EDGES';
|
|
369
|
+
}
|
|
370
|
+
|
|
349
371
|
// Reserve space for inline edge labels so they don't overlap nodes/edges.
|
|
350
372
|
if (edgeLabels?.enabled !== false) {
|
|
351
373
|
const placement = edgeLabels?.placement ?? 'CENTER';
|
|
@@ -507,6 +529,7 @@ export async function computeElkLayout(
|
|
|
507
529
|
const edgeLabels = options.edgeLabels;
|
|
508
530
|
const endpointInset = options.endpointInset ?? 0;
|
|
509
531
|
const direction = options.direction ?? 'RIGHT';
|
|
532
|
+
const orderByLine = options.orderByLine ?? false;
|
|
510
533
|
|
|
511
534
|
// Build a map of original node positions BEFORE passing to ELK
|
|
512
535
|
// (ELK mutates the input nodes in place, so we must save positions first)
|
|
@@ -527,12 +550,20 @@ export async function computeElkLayout(
|
|
|
527
550
|
const nd = node.data as { component?: { layer?: number }; layer?: number } | undefined;
|
|
528
551
|
layer = nd?.layer ?? nd?.component?.layer;
|
|
529
552
|
|
|
553
|
+
// Optional source-line ordering: seed each node's `y` hint from its line so
|
|
554
|
+
// ELK's model-order placement puts lower lines further down within a layer.
|
|
555
|
+
let y = node.position.y;
|
|
556
|
+
if (orderByLine) {
|
|
557
|
+
const line = (node.data as { component?: { line?: number } } | undefined)?.component?.line;
|
|
558
|
+
if (typeof line === 'number') y = line;
|
|
559
|
+
}
|
|
560
|
+
|
|
530
561
|
return {
|
|
531
562
|
id: node.id,
|
|
532
563
|
width,
|
|
533
564
|
height,
|
|
534
565
|
x: node.position.x,
|
|
535
|
-
y
|
|
566
|
+
y,
|
|
536
567
|
// Add ports on each side for edge connections
|
|
537
568
|
ports: [
|
|
538
569
|
{ id: `${node.id}_top`, properties: { 'port.side': 'NORTH' } },
|