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.
@@ -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(nodes: ArchFlowNode[], edges: Edge[]) {
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
- () => nodes.map((node) => ({ ...node, hidden: node.data.depth > depthLevel })),
23
- [nodes, depthLevel],
24
- );
25
-
26
- const visibleEdges = useMemo(() => {
27
- const hiddenIds = new Set(
28
- visibleNodes.filter((n) => n.hidden).map((n) => n.id),
29
- );
30
- return edges.map((edge) => ({
31
- ...edge,
32
- hidden: hiddenIds.has(edge.source) || hiddenIds.has(edge.target),
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
- }, [visibleNodes, edges]);
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: activeIds.has(node.id) ? 1 : 0.15,
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: activeIds.has(edge.source) && activeIds.has(edge.target) ? 1 : 0.08,
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: activeIds.has(edge.source) && activeIds.has(edge.target) ? 1 : 0,
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
- const CATEGORY_DEPTH: Record<string, DepthLevel> = {
6
- controller: 0, external: 0,
7
- service: 1, port: 1, job: 1,
8
- adapter: 2, model: 2, dto: 2,
9
- };
10
-
11
- export function getDefaultDepth(category: string): DepthLevel {
12
- return CATEGORY_DEPTH[category] ?? 1;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archrip",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Generate interactive architecture diagrams from your codebase using AI agents",
5
5
  "type": "module",
6
6
  "bin": {