archrip 0.1.7 → 0.1.9
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/schema/architecture.schema.json +1 -1
- package/dist/templates/slash-commands/claude/archrip-scan.md +26 -11
- package/dist/templates/slash-commands/codex/archrip-scan.md +26 -11
- package/dist/templates/slash-commands/gemini/archrip-scan.md +26 -11
- package/dist/viewer-template/package.json +1 -0
- package/dist/viewer-template/src/App.tsx +6 -5
- package/dist/viewer-template/src/components/DetailPanel.tsx +247 -169
- package/dist/viewer-template/src/components/nodes/GroupNode.tsx +62 -0
- package/dist/viewer-template/src/data/loader.ts +8 -2
- package/dist/viewer-template/src/hooks/useArchitecture.ts +3 -1
- package/dist/viewer-template/src/hooks/useDepthFilter.ts +169 -15
- package/dist/viewer-template/src/hooks/useUseCaseFilter.ts +21 -4
- package/dist/viewer-template/src/types.ts +48 -8
- package/dist/viewer-template/src/utils/layout.ts +202 -0
- package/package.json +1 -1
|
@@ -2,7 +2,9 @@ import { useMemo } from 'react';
|
|
|
2
2
|
import { useQueryState, parseAsInteger } from 'nuqs';
|
|
3
3
|
import type { Edge } from '@xyflow/react';
|
|
4
4
|
|
|
5
|
-
import type { ArchFlowNode, DepthLevel } from '../types.ts';
|
|
5
|
+
import type { ArchFlowNode, ArchNodeData, DepthLevel, MemberNodeSummary } from '../types.ts';
|
|
6
|
+
import { getCategoryLabel } from '../types.ts';
|
|
7
|
+
import { computeLayout, NODE_WIDTH, NODE_HEIGHT, GROUP_NODE_WIDTH, GROUP_NODE_HEIGHT } from '../utils/layout.ts';
|
|
6
8
|
|
|
7
9
|
function clampDepth(value: number): DepthLevel {
|
|
8
10
|
if (value <= 0) return 0;
|
|
@@ -10,7 +12,11 @@ function clampDepth(value: number): DepthLevel {
|
|
|
10
12
|
return value as DepthLevel;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function useDepthFilter(
|
|
15
|
+
export function useDepthFilter(
|
|
16
|
+
nodes: ArchFlowNode[],
|
|
17
|
+
edges: Edge[],
|
|
18
|
+
layoutType: 'dagre' | 'concentric',
|
|
19
|
+
) {
|
|
14
20
|
const [rawDepth, setRawDepth] = useQueryState('depth', parseAsInteger.withDefault(2).withOptions({ history: 'replace' }));
|
|
15
21
|
const depthLevel = clampDepth(rawDepth);
|
|
16
22
|
|
|
@@ -18,20 +24,168 @@ export function useDepthFilter(nodes: ArchFlowNode[], edges: Edge[]) {
|
|
|
18
24
|
void setRawDepth(level);
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
const visibleNodes = useMemo(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
const { visibleNodes, visibleEdges } = useMemo(() => {
|
|
28
|
+
// depth=2 (Detail): pass-through, no merging
|
|
29
|
+
if (depthLevel === 2) {
|
|
30
|
+
return { visibleNodes: nodes, visibleEdges: edges };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Split nodes into kept (visible at this depth) and mergeable (to be grouped)
|
|
34
|
+
const kept: ArchFlowNode[] = [];
|
|
35
|
+
const mergeable: ArchFlowNode[] = [];
|
|
36
|
+
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
if (node.data.depth <= depthLevel) {
|
|
39
|
+
kept.push(node);
|
|
40
|
+
} else {
|
|
41
|
+
mergeable.push(node);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group mergeable nodes by category
|
|
46
|
+
const categoryGroups = new Map<string, ArchFlowNode[]>();
|
|
47
|
+
for (const node of mergeable) {
|
|
48
|
+
const cat = node.data.category;
|
|
49
|
+
const group = categoryGroups.get(cat);
|
|
50
|
+
if (group) {
|
|
51
|
+
group.push(node);
|
|
52
|
+
} else {
|
|
53
|
+
categoryGroups.set(cat, [node]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build nodeToGroup mapping & create group nodes
|
|
58
|
+
const nodeToGroup = new Map<string, string>();
|
|
59
|
+
const groupNodes: ArchFlowNode[] = [];
|
|
60
|
+
|
|
61
|
+
for (const [category, groupMembers] of categoryGroups) {
|
|
62
|
+
if (groupMembers.length === 1) {
|
|
63
|
+
// Singleton: keep as-is, no grouping
|
|
64
|
+
kept.push(groupMembers[0]!);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const groupId = `__group_${category}`;
|
|
69
|
+
|
|
70
|
+
// Map all member IDs to the group ID
|
|
71
|
+
for (const member of groupMembers) {
|
|
72
|
+
nodeToGroup.set(member.id, groupId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Collect member summaries
|
|
76
|
+
const memberNodes: MemberNodeSummary[] = groupMembers.map((m) => ({
|
|
77
|
+
id: m.id,
|
|
78
|
+
label: m.data.label,
|
|
79
|
+
description: m.data.description,
|
|
80
|
+
filePath: m.data.filePath,
|
|
81
|
+
sourceUrl: m.data.sourceUrl,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
// Merge useCases from all members
|
|
85
|
+
const useCasesSet = new Set<string>();
|
|
86
|
+
for (const m of groupMembers) {
|
|
87
|
+
for (const uc of m.data.useCases) {
|
|
88
|
+
useCasesSet.add(uc);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Use the most common layer from members for layout
|
|
93
|
+
const layerCounts = new Map<number, number>();
|
|
94
|
+
for (const m of groupMembers) {
|
|
95
|
+
const l = m.data.layer ?? 0;
|
|
96
|
+
layerCounts.set(l, (layerCounts.get(l) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
let bestLayer = 0;
|
|
99
|
+
let bestCount = 0;
|
|
100
|
+
for (const [l, c] of layerCounts) {
|
|
101
|
+
if (c > bestCount) {
|
|
102
|
+
bestLayer = l;
|
|
103
|
+
bestCount = c;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const label = `${getCategoryLabel(category)} (${groupMembers.length})`;
|
|
108
|
+
|
|
109
|
+
const data: ArchNodeData = {
|
|
110
|
+
label,
|
|
111
|
+
category,
|
|
112
|
+
depth: 0,
|
|
113
|
+
description: `${groupMembers.length} ${getCategoryLabel(category).toLowerCase()} nodes`,
|
|
114
|
+
filePath: '',
|
|
115
|
+
sourceUrl: '',
|
|
116
|
+
layer: bestLayer,
|
|
117
|
+
useCases: [...useCasesSet],
|
|
118
|
+
isGroup: true,
|
|
119
|
+
memberCount: groupMembers.length,
|
|
120
|
+
memberNodes,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
groupNodes.push({
|
|
124
|
+
id: groupId,
|
|
125
|
+
type: 'groupNode',
|
|
126
|
+
position: { x: 0, y: 0 }, // will be computed by layout
|
|
127
|
+
data,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Combine kept + group nodes
|
|
132
|
+
const allNodes = [...kept, ...groupNodes];
|
|
133
|
+
|
|
134
|
+
// Remap edges
|
|
135
|
+
const edgeSet = new Set<string>();
|
|
136
|
+
const remappedEdges: Edge[] = [];
|
|
137
|
+
|
|
138
|
+
for (const edge of edges) {
|
|
139
|
+
const source = nodeToGroup.get(edge.source) ?? edge.source;
|
|
140
|
+
const target = nodeToGroup.get(edge.target) ?? edge.target;
|
|
141
|
+
|
|
142
|
+
// Skip self-loops (same group)
|
|
143
|
+
if (source === target) continue;
|
|
144
|
+
|
|
145
|
+
// Skip if either endpoint doesn't exist in allNodes
|
|
146
|
+
const sourceExists = allNodes.some((n) => n.id === source);
|
|
147
|
+
const targetExists = allNodes.some((n) => n.id === target);
|
|
148
|
+
if (!sourceExists || !targetExists) continue;
|
|
149
|
+
|
|
150
|
+
// Deduplicate by (source, target)
|
|
151
|
+
const key = `${source}->${target}`;
|
|
152
|
+
if (edgeSet.has(key)) continue;
|
|
153
|
+
edgeSet.add(key);
|
|
154
|
+
|
|
155
|
+
remappedEdges.push({
|
|
156
|
+
...edge,
|
|
157
|
+
id: key,
|
|
158
|
+
source,
|
|
159
|
+
target,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Compute layout for merged graph
|
|
164
|
+
const layoutNodes = allNodes.map((n) => ({
|
|
165
|
+
id: n.id,
|
|
166
|
+
width: n.data.isGroup ? GROUP_NODE_WIDTH : NODE_WIDTH,
|
|
167
|
+
height: n.data.isGroup ? GROUP_NODE_HEIGHT : NODE_HEIGHT,
|
|
168
|
+
layer: n.data.layer,
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
const layoutEdges = remappedEdges.map((e) => ({
|
|
172
|
+
source: e.source,
|
|
173
|
+
target: e.target,
|
|
33
174
|
}));
|
|
34
|
-
|
|
175
|
+
|
|
176
|
+
const positions = computeLayout(layoutNodes, layoutEdges, layoutType);
|
|
177
|
+
|
|
178
|
+
// Apply computed positions
|
|
179
|
+
const positionedNodes: ArchFlowNode[] = allNodes.map((node) => {
|
|
180
|
+
const pos = positions.get(node.id);
|
|
181
|
+
if (pos) {
|
|
182
|
+
return { ...node, position: { x: pos.x, y: pos.y } };
|
|
183
|
+
}
|
|
184
|
+
return node;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { visibleNodes: positionedNodes, visibleEdges: remappedEdges };
|
|
188
|
+
}, [nodes, edges, depthLevel, layoutType]);
|
|
35
189
|
|
|
36
190
|
return { depthLevel, setDepthLevel, visibleNodes, visibleEdges };
|
|
37
191
|
}
|
|
@@ -4,6 +4,13 @@ import type { Edge } from '@xyflow/react';
|
|
|
4
4
|
|
|
5
5
|
import type { ArchFlowNode, UseCase } from '../types.ts';
|
|
6
6
|
|
|
7
|
+
function isNodeActive(node: ArchFlowNode, activeIds: Set<string>): boolean {
|
|
8
|
+
if (node.data.isGroup && node.data.memberNodes) {
|
|
9
|
+
return node.data.memberNodes.some((m) => activeIds.has(m.id));
|
|
10
|
+
}
|
|
11
|
+
return activeIds.has(node.id);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases: UseCase[]) {
|
|
8
15
|
const [selectedUseCase, setSelectedUseCase] = useQueryState('uc', parseAsString.withOptions({ history: 'replace' }));
|
|
9
16
|
|
|
@@ -22,11 +29,12 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
|
|
|
22
29
|
const activeIds = new Set(uc.nodeIds);
|
|
23
30
|
return nodes.map((node) => {
|
|
24
31
|
if (node.hidden) return node;
|
|
32
|
+
const active = isNodeActive(node, activeIds);
|
|
25
33
|
return {
|
|
26
34
|
...node,
|
|
27
35
|
style: {
|
|
28
36
|
...node.style,
|
|
29
|
-
opacity:
|
|
37
|
+
opacity: active ? 1 : 0.15,
|
|
30
38
|
transition: 'opacity 0.3s',
|
|
31
39
|
},
|
|
32
40
|
};
|
|
@@ -38,19 +46,28 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
|
|
|
38
46
|
const uc = useCases.find((u) => u.id === selectedUseCase);
|
|
39
47
|
if (!uc) return edges;
|
|
40
48
|
const activeIds = new Set(uc.nodeIds);
|
|
49
|
+
|
|
50
|
+
// Build a set of active node IDs (including group nodes whose members are active)
|
|
51
|
+
const activeNodeIds = new Set<string>();
|
|
52
|
+
for (const node of nodes) {
|
|
53
|
+
if (isNodeActive(node, activeIds)) {
|
|
54
|
+
activeNodeIds.add(node.id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
return edges.map((edge) => ({
|
|
42
59
|
...edge,
|
|
43
60
|
style: {
|
|
44
61
|
...edge.style,
|
|
45
|
-
opacity:
|
|
62
|
+
opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0.08,
|
|
46
63
|
transition: 'opacity 0.3s',
|
|
47
64
|
},
|
|
48
65
|
labelStyle: {
|
|
49
66
|
...((edge.labelStyle as Record<string, unknown>) ?? {}),
|
|
50
|
-
opacity:
|
|
67
|
+
opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0,
|
|
51
68
|
},
|
|
52
69
|
}));
|
|
53
|
-
}, [edges, selectedUseCase, useCases]);
|
|
70
|
+
}, [edges, nodes, selectedUseCase, useCases]);
|
|
54
71
|
|
|
55
72
|
return { selectedUseCase, setSelectedUseCase, categories, filteredNodes, filteredEdges };
|
|
56
73
|
}
|
|
@@ -2,14 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
export type DepthLevel = 0 | 1 | 2;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
export function computeDepths(nodes: { layer: number }[]): Map<number, DepthLevel> {
|
|
6
|
+
if (nodes.length === 0) return new Map();
|
|
7
|
+
const layers = [...new Set(nodes.map(n => n.layer))].sort((a, b) => a - b);
|
|
8
|
+
|
|
9
|
+
const map = new Map<number, DepthLevel>();
|
|
10
|
+
|
|
11
|
+
// Flat / workflow: filtering not useful — always show all
|
|
12
|
+
if (layers.length <= 2) {
|
|
13
|
+
for (const l of layers) map.set(l, 0);
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Exactly 3 layers: 1:1:1 mapping
|
|
18
|
+
if (layers.length === 3) {
|
|
19
|
+
map.set(layers[0], 0);
|
|
20
|
+
map.set(layers[1], 1);
|
|
21
|
+
map.set(layers[2], 2);
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 4+ layers: equal thirds
|
|
26
|
+
const min = layers[0];
|
|
27
|
+
const max = layers[layers.length - 1];
|
|
28
|
+
const range = max - min;
|
|
29
|
+
const t1 = min + range / 3;
|
|
30
|
+
const t2 = min + (2 * range) / 3;
|
|
31
|
+
for (const l of layers) {
|
|
32
|
+
if (l <= t1) map.set(l, 0);
|
|
33
|
+
else if (l <= t2) map.set(l, 1);
|
|
34
|
+
else map.set(l, 2);
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
13
37
|
}
|
|
14
38
|
|
|
15
39
|
export const DEPTH_LEVELS = [
|
|
@@ -95,6 +119,14 @@ export interface TableSchema {
|
|
|
95
119
|
enumValues?: Record<string, Record<string, string>>;
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export interface MemberNodeSummary {
|
|
123
|
+
id: string;
|
|
124
|
+
label: string;
|
|
125
|
+
description: string;
|
|
126
|
+
filePath: string;
|
|
127
|
+
sourceUrl: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
export interface ArchNodeData {
|
|
99
131
|
[key: string]: unknown;
|
|
100
132
|
label: string;
|
|
@@ -103,6 +135,7 @@ export interface ArchNodeData {
|
|
|
103
135
|
description: string;
|
|
104
136
|
filePath: string;
|
|
105
137
|
sourceUrl: string;
|
|
138
|
+
layer?: number;
|
|
106
139
|
methods?: string[];
|
|
107
140
|
useCases: string[];
|
|
108
141
|
schema?: TableSchema;
|
|
@@ -110,6 +143,13 @@ export interface ArchNodeData {
|
|
|
110
143
|
routes?: string[];
|
|
111
144
|
implements?: string;
|
|
112
145
|
externalService?: string;
|
|
146
|
+
isGroup?: boolean;
|
|
147
|
+
memberCount?: number;
|
|
148
|
+
memberNodes?: MemberNodeSummary[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function isGroupNode(data: ArchNodeData): boolean {
|
|
152
|
+
return data.isGroup === true;
|
|
113
153
|
}
|
|
114
154
|
|
|
115
155
|
export interface UseCase {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import dagre from '@dagrejs/dagre';
|
|
2
|
+
|
|
3
|
+
interface LayoutNode {
|
|
4
|
+
id: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
layer?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface LayoutEdge {
|
|
11
|
+
source: string;
|
|
12
|
+
target: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NODE_WIDTH = 180;
|
|
16
|
+
const NODE_HEIGHT = 80;
|
|
17
|
+
const GROUP_NODE_WIDTH = 200;
|
|
18
|
+
const GROUP_NODE_HEIGHT = 90;
|
|
19
|
+
const RANK_SEP = 160;
|
|
20
|
+
const NODE_SEP = 40;
|
|
21
|
+
|
|
22
|
+
// Concentric layout constants
|
|
23
|
+
const RING_SPACING = 250;
|
|
24
|
+
const MIN_ARC_SPACING = 200;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute layout positions for a set of nodes and edges.
|
|
28
|
+
* Ported from packages/cli/src/utils/layout.ts for use in the viewer
|
|
29
|
+
* when depth-filtered graphs need re-layout after merging.
|
|
30
|
+
*/
|
|
31
|
+
export function computeLayout(
|
|
32
|
+
nodes: LayoutNode[],
|
|
33
|
+
edges: LayoutEdge[],
|
|
34
|
+
layoutType: 'dagre' | 'concentric',
|
|
35
|
+
): Map<string, { x: number; y: number }> {
|
|
36
|
+
if (nodes.length === 0) return new Map();
|
|
37
|
+
|
|
38
|
+
if (layoutType === 'concentric') {
|
|
39
|
+
return computeConcentricLayout(nodes, edges);
|
|
40
|
+
}
|
|
41
|
+
return computeDagreLayout(nodes, edges);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function computeDagreLayout(
|
|
45
|
+
nodes: LayoutNode[],
|
|
46
|
+
edges: LayoutEdge[],
|
|
47
|
+
): Map<string, { x: number; y: number }> {
|
|
48
|
+
const g = new dagre.graphlib.Graph();
|
|
49
|
+
g.setGraph({
|
|
50
|
+
rankdir: 'TB',
|
|
51
|
+
ranksep: RANK_SEP,
|
|
52
|
+
nodesep: NODE_SEP,
|
|
53
|
+
marginx: 40,
|
|
54
|
+
marginy: 40,
|
|
55
|
+
});
|
|
56
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
57
|
+
|
|
58
|
+
for (const node of nodes) {
|
|
59
|
+
g.setNode(node.id, {
|
|
60
|
+
width: node.width,
|
|
61
|
+
height: node.height,
|
|
62
|
+
rank: node.layer,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const edge of edges) {
|
|
67
|
+
g.setEdge(edge.source, edge.target);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dagre.layout(g);
|
|
71
|
+
|
|
72
|
+
const result = new Map<string, { x: number; y: number }>();
|
|
73
|
+
for (const nodeId of g.nodes()) {
|
|
74
|
+
const n = g.node(nodeId);
|
|
75
|
+
if (n) {
|
|
76
|
+
const layoutNode = nodes.find((ln) => ln.id === nodeId);
|
|
77
|
+
const w = layoutNode?.width ?? NODE_WIDTH;
|
|
78
|
+
const h = layoutNode?.height ?? NODE_HEIGHT;
|
|
79
|
+
result.set(nodeId, { x: n.x - w / 2, y: n.y - h / 2 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildAdjacencyMap(edges: LayoutEdge[]): Map<string, Set<string>> {
|
|
87
|
+
const adj = new Map<string, Set<string>>();
|
|
88
|
+
for (const edge of edges) {
|
|
89
|
+
let srcSet = adj.get(edge.source);
|
|
90
|
+
if (!srcSet) {
|
|
91
|
+
srcSet = new Set();
|
|
92
|
+
adj.set(edge.source, srcSet);
|
|
93
|
+
}
|
|
94
|
+
srcSet.add(edge.target);
|
|
95
|
+
|
|
96
|
+
let tgtSet = adj.get(edge.target);
|
|
97
|
+
if (!tgtSet) {
|
|
98
|
+
tgtSet = new Set();
|
|
99
|
+
adj.set(edge.target, tgtSet);
|
|
100
|
+
}
|
|
101
|
+
tgtSet.add(edge.source);
|
|
102
|
+
}
|
|
103
|
+
return adj;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function circularMean(angles: number[]): number {
|
|
107
|
+
let sinSum = 0;
|
|
108
|
+
let cosSum = 0;
|
|
109
|
+
for (const a of angles) {
|
|
110
|
+
sinSum += Math.sin(a);
|
|
111
|
+
cosSum += Math.cos(a);
|
|
112
|
+
}
|
|
113
|
+
return Math.atan2(sinSum / angles.length, cosSum / angles.length);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function computeConcentricLayout(
|
|
117
|
+
nodes: LayoutNode[],
|
|
118
|
+
edges: LayoutEdge[],
|
|
119
|
+
): Map<string, { x: number; y: number }> {
|
|
120
|
+
const result = new Map<string, { x: number; y: number }>();
|
|
121
|
+
|
|
122
|
+
// Group nodes by layer
|
|
123
|
+
const layerGroups = new Map<number, LayoutNode[]>();
|
|
124
|
+
for (const node of nodes) {
|
|
125
|
+
const layer = node.layer ?? 0;
|
|
126
|
+
const group = layerGroups.get(layer);
|
|
127
|
+
if (group) {
|
|
128
|
+
group.push(node);
|
|
129
|
+
} else {
|
|
130
|
+
layerGroups.set(layer, [node]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sort layers descending: highest layer = ring 0 (center)
|
|
135
|
+
const sortedLayers = [...layerGroups.keys()].sort((a, b) => b - a);
|
|
136
|
+
|
|
137
|
+
const adjacency = buildAdjacencyMap(edges);
|
|
138
|
+
const placedAngles = new Map<string, number>();
|
|
139
|
+
|
|
140
|
+
for (let ringIndex = 0; ringIndex < sortedLayers.length; ringIndex++) {
|
|
141
|
+
const layer = sortedLayers[ringIndex]!;
|
|
142
|
+
const ringNodes = layerGroups.get(layer)!;
|
|
143
|
+
const count = ringNodes.length;
|
|
144
|
+
|
|
145
|
+
if (ringIndex === 0 && count === 1) {
|
|
146
|
+
const node = ringNodes[0]!;
|
|
147
|
+
placedAngles.set(node.id, 0);
|
|
148
|
+
result.set(node.id, { x: -node.width / 2, y: -node.height / 2 });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Compute target angle for each node based on placed neighbors
|
|
153
|
+
const withAngle: { node: LayoutNode; targetAngle: number }[] = [];
|
|
154
|
+
const withoutAngle: LayoutNode[] = [];
|
|
155
|
+
|
|
156
|
+
for (const node of ringNodes) {
|
|
157
|
+
const neighbors = adjacency.get(node.id);
|
|
158
|
+
if (!neighbors) {
|
|
159
|
+
withoutAngle.push(node);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const placedNeighborAngles: number[] = [];
|
|
164
|
+
for (const n of neighbors) {
|
|
165
|
+
const angle = placedAngles.get(n);
|
|
166
|
+
if (angle !== undefined) {
|
|
167
|
+
placedNeighborAngles.push(angle);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (placedNeighborAngles.length === 0) {
|
|
172
|
+
withoutAngle.push(node);
|
|
173
|
+
} else {
|
|
174
|
+
withAngle.push({ node, targetAngle: circularMean(placedNeighborAngles) });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
withAngle.sort((a, b) => a.targetAngle - b.targetAngle);
|
|
179
|
+
const sorted = [...withAngle.map((n) => n.node), ...withoutAngle];
|
|
180
|
+
|
|
181
|
+
const baseRadius = Math.max(ringIndex * RING_SPACING, RING_SPACING / 2);
|
|
182
|
+
const circumference = count * MIN_ARC_SPACING;
|
|
183
|
+
const minRadius = circumference / (2 * Math.PI);
|
|
184
|
+
const ringRadius = Math.max(minRadius, baseRadius);
|
|
185
|
+
|
|
186
|
+
const angleStep = (2 * Math.PI) / count;
|
|
187
|
+
for (let i = 0; i < count; i++) {
|
|
188
|
+
const angle = i * angleStep - Math.PI / 2;
|
|
189
|
+
const node = sorted[i]!;
|
|
190
|
+
placedAngles.set(node.id, angle);
|
|
191
|
+
|
|
192
|
+
const x = ringRadius * Math.cos(angle);
|
|
193
|
+
const y = ringRadius * Math.sin(angle);
|
|
194
|
+
|
|
195
|
+
result.set(node.id, { x: x - node.width / 2, y: y - node.height / 2 });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { NODE_WIDTH, NODE_HEIGHT, GROUP_NODE_WIDTH, GROUP_NODE_HEIGHT };
|