next-arch-map 0.1.18 → 0.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-arch-map",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Static analyzer that builds a multi-layer architecture graph for Next.js-style apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -111,6 +111,7 @@ export function App() {
111
111
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
112
112
  const [loadError, setLoadError] = useState<string | null>(null);
113
113
  const [activePreset, setActivePreset] = useState<LayerPreset | null>(null);
114
+ const [showAdvanced, setShowAdvanced] = useState(false);
114
115
 
115
116
  useEffect(() => {
116
117
  setGraph(null);
@@ -468,6 +469,7 @@ export function App() {
468
469
  visibleEdgeKinds={visibleEdgeKinds}
469
470
  onToggleNodeType={toggleNodeType}
470
471
  onToggleEdgeKind={toggleEdgeKind}
472
+ showAdvanced={showAdvanced}
471
473
  />
472
474
 
473
475
  {/* Query */}
@@ -8,6 +8,7 @@ type FiltersProps = {
8
8
  visibleEdgeKinds: Set<EdgeKind>;
9
9
  onToggleNodeType: (type: NodeType) => void;
10
10
  onToggleEdgeKind: (kind: EdgeKind) => void;
11
+ showAdvanced?: boolean;
11
12
  };
12
13
 
13
14
  const NODE_TYPE_COLORS: Record<NodeType, string> = {
@@ -26,6 +27,7 @@ export function Filters(props: FiltersProps) {
26
27
  visibleEdgeKinds,
27
28
  onToggleNodeType,
28
29
  onToggleEdgeKind,
30
+ showAdvanced,
29
31
  } = props;
30
32
 
31
33
  return (
@@ -68,40 +70,42 @@ export function Filters(props: FiltersProps) {
68
70
  </div>
69
71
  </div>
70
72
 
71
- <div>
72
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 mb-2">
73
- Edge kinds
74
- </h3>
75
- <div className="space-y-1.5">
76
- {allEdgeKinds.map((kind) => (
77
- <label
78
- key={kind}
79
- className="flex items-center gap-2 cursor-pointer group"
80
- >
81
- <Checkbox.Root
82
- checked={visibleEdgeKinds.has(kind)}
83
- onCheckedChange={() => onToggleEdgeKind(kind)}
84
- className="h-4 w-4 rounded border border-slate-300 bg-white flex items-center justify-center transition-colors data-[state=checked]:bg-indigo-600 data-[state=checked]:border-indigo-600"
73
+ {showAdvanced && (
74
+ <div>
75
+ <h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 mb-2">
76
+ Edge kinds
77
+ </h3>
78
+ <div className="space-y-1.5">
79
+ {allEdgeKinds.map((kind) => (
80
+ <label
81
+ key={kind}
82
+ className="flex items-center gap-2 cursor-pointer group"
85
83
  >
86
- <Checkbox.Indicator>
87
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
88
- <path
89
- d="M2 5L4 7L8 3"
90
- stroke="white"
91
- strokeWidth="1.5"
92
- strokeLinecap="round"
93
- strokeLinejoin="round"
94
- />
95
- </svg>
96
- </Checkbox.Indicator>
97
- </Checkbox.Root>
98
- <span className="text-xs text-slate-600 group-hover:text-slate-900 transition-colors">
99
- {kind}
100
- </span>
101
- </label>
102
- ))}
84
+ <Checkbox.Root
85
+ checked={visibleEdgeKinds.has(kind)}
86
+ onCheckedChange={() => onToggleEdgeKind(kind)}
87
+ className="h-4 w-4 rounded border border-slate-300 bg-white flex items-center justify-center transition-colors data-[state=checked]:bg-indigo-600 data-[state=checked]:border-indigo-600"
88
+ >
89
+ <Checkbox.Indicator>
90
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
91
+ <path
92
+ d="M2 5L4 7L8 3"
93
+ stroke="white"
94
+ strokeWidth="1.5"
95
+ strokeLinecap="round"
96
+ strokeLinejoin="round"
97
+ />
98
+ </svg>
99
+ </Checkbox.Indicator>
100
+ </Checkbox.Root>
101
+ <span className="text-xs text-slate-600 group-hover:text-slate-900 transition-colors">
102
+ {kind}
103
+ </span>
104
+ </label>
105
+ ))}
106
+ </div>
103
107
  </div>
104
- </div>
108
+ )}
105
109
  </div>
106
110
  );
107
111
  }
@@ -1,14 +1,17 @@
1
- import { useCallback, useMemo, useState } from "react";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  Background,
4
4
  Controls,
5
+ Handle,
5
6
  MarkerType,
6
7
  MiniMap,
7
8
  Position,
8
9
  ReactFlow,
10
+ useReactFlow,
9
11
  type Edge as FlowEdge,
10
12
  type Node as FlowNode,
11
13
  type NodeMouseHandler,
14
+ type NodeProps,
12
15
  } from "@xyflow/react";
13
16
  import "@xyflow/react/dist/style.css";
14
17
  import type { DiffStatus, EdgeKind, Graph, NodeType } from "./types";
@@ -77,7 +80,6 @@ function optimizeNodeOrder(
77
80
  visibleNodeIds: Set<string>,
78
81
  visibleEdgeKinds: Set<EdgeKind>,
79
82
  ): Map<NodeType, Graph["nodes"]> {
80
- // Build adjacency map: nodeId -> list of connected nodeIds
81
83
  const adjacency = new Map<string, string[]>();
82
84
  for (const nodeId of visibleNodeIds) {
83
85
  adjacency.set(nodeId, []);
@@ -93,21 +95,17 @@ function optimizeNodeOrder(
93
95
  adjacency.get(edge.to)?.push(edge.from);
94
96
  }
95
97
 
96
- // Track each node's row index within its column
97
98
  const nodeRowIndex = new Map<string, number>();
98
99
  for (const type of activeTypeOrder) {
99
100
  const nodes = nodesByType.get(type) ?? [];
100
101
  nodes.forEach((node, i) => nodeRowIndex.set(node.id, i));
101
102
  }
102
103
 
103
- // Iterative barycenter: forward pass then reverse pass, repeated
104
104
  const ITERATIONS = 3;
105
105
  for (let iter = 0; iter < ITERATIONS; iter++) {
106
- // Forward pass (left to right)
107
106
  for (let col = 1; col < activeTypeOrder.length; col++) {
108
107
  reorderColumn(activeTypeOrder[col], nodesByType, adjacency, nodeRowIndex);
109
108
  }
110
- // Reverse pass (right to left)
111
109
  for (let col = activeTypeOrder.length - 2; col >= 0; col--) {
112
110
  reorderColumn(activeTypeOrder[col], nodesByType, adjacency, nodeRowIndex);
113
111
  }
@@ -125,7 +123,6 @@ function reorderColumn(
125
123
  const nodes = nodesByType.get(type);
126
124
  if (!nodes || nodes.length <= 1) return;
127
125
 
128
- // Compute barycenter for each node (average row of connected nodes)
129
126
  const barycenters = new Map<string, number>();
130
127
  for (const node of nodes) {
131
128
  const neighbors = adjacency.get(node.id) ?? [];
@@ -136,17 +133,41 @@ function reorderColumn(
136
133
  const avg = neighborRows.reduce((s, r) => s + r, 0) / neighborRows.length;
137
134
  barycenters.set(node.id, avg);
138
135
  } else {
139
- // No connections: keep current position as a tiebreaker
140
136
  barycenters.set(node.id, nodeRowIndex.get(node.id) ?? 0);
141
137
  }
142
138
  }
143
139
 
144
140
  nodes.sort((a, b) => (barycenters.get(a.id) ?? 0) - (barycenters.get(b.id) ?? 0));
145
-
146
- // Update row indices after reorder
147
141
  nodes.forEach((node, i) => nodeRowIndex.set(node.id, i));
148
142
  }
149
143
 
144
+ function PageNode({ data }: NodeProps) {
145
+ const screenshot = (data as Record<string, unknown>).screenshot as string | undefined;
146
+ return (
147
+ <div>
148
+ <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
149
+ <div style={{ fontSize: 12, fontWeight: 600 }}>
150
+ {String((data as Record<string, unknown>).label ?? "")}
151
+ </div>
152
+ {screenshot && (
153
+ <img
154
+ src={screenshot}
155
+ alt=""
156
+ style={{
157
+ marginTop: 6,
158
+ width: "100%",
159
+ borderRadius: 4,
160
+ border: "1px solid rgba(255,255,255,0.3)",
161
+ }}
162
+ />
163
+ )}
164
+ <Handle type="source" position={Position.Right} style={{ visibility: "hidden" }} />
165
+ </div>
166
+ );
167
+ }
168
+
169
+ const nodeTypes = { pageNode: PageNode };
170
+
150
171
  export function GraphView(props: GraphViewProps) {
151
172
  const {
152
173
  graph,
@@ -159,31 +180,28 @@ export function GraphView(props: GraphViewProps) {
159
180
  } = props;
160
181
 
161
182
  const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
183
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
162
184
 
163
185
  const handleNodeClick: NodeMouseHandler = (_event, node) => onSelectNode(node.id);
186
+
164
187
  const handleNodeMouseEnter: NodeMouseHandler = useCallback((_event, node) => {
188
+ if (hoverTimeoutRef.current) {
189
+ clearTimeout(hoverTimeoutRef.current);
190
+ hoverTimeoutRef.current = null;
191
+ }
165
192
  setHoveredNodeId(node.id);
166
193
  }, []);
194
+
167
195
  const handleNodeMouseLeave: NodeMouseHandler = useCallback(() => {
168
- setHoveredNodeId(null);
196
+ // Debounce leave to prevent flicker from re-renders
197
+ hoverTimeoutRef.current = setTimeout(() => {
198
+ setHoveredNodeId(null);
199
+ hoverTimeoutRef.current = null;
200
+ }, 50);
169
201
  }, []);
170
202
 
171
- // Build set of edge-connected node IDs for the active (hovered or selected) node
172
- const activeNodeId = hoveredNodeId ?? selectedNodeId;
173
- const connectedEdgeIds = useMemo(() => {
174
- if (!activeNodeId) return null;
175
- const ids = new Set<string>();
176
- ids.add(activeNodeId);
177
- for (const edge of graph.edges) {
178
- if (edge.from === activeNodeId || edge.to === activeNodeId) {
179
- ids.add(edge.from);
180
- ids.add(edge.to);
181
- }
182
- }
183
- return ids;
184
- }, [activeNodeId, graph.edges]);
185
-
186
- const { flowNodes, flowEdges } = useMemo(() => {
203
+ // Compute layout without hover state this is the expensive part
204
+ const { flowNodes: baseFlowNodes, flowEdges: baseFlowEdges } = useMemo(() => {
187
205
  const typeOrder: NodeType[] = ["page", "action", "endpoint", "handler", "db"];
188
206
  const visibleNodes = graph.nodes.filter((node) => visibleNodeTypes.has(node.type));
189
207
  const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
@@ -195,31 +213,36 @@ export function GraphView(props: GraphViewProps) {
195
213
  nodesByType.get(node.type)?.push(node);
196
214
  }
197
215
 
198
- // Initial alphabetical sort as seed for the barycenter algorithm
199
216
  for (const nodes of nodesByType.values()) {
200
217
  nodes.sort((left, right) => left.label.localeCompare(right.label));
201
218
  }
202
219
 
203
220
  const activeTypeOrder = typeOrder.filter((type) => (nodesByType.get(type)?.length ?? 0) > 0);
204
-
205
- // Optimize node ordering to minimize edge crossings
206
221
  optimizeNodeOrder(nodesByType, activeTypeOrder, graph.edges, visibleNodeIds, visibleEdgeKinds);
207
222
 
208
223
  const flowNodes: FlowNode[] = [];
209
224
  const columnWidth = 300;
210
- const rowHeight = 80;
225
+ const defaultRowHeight = 80;
226
+ const hasScreenshots = graph.nodes.some(
227
+ (n) => n.type === "page" && n.meta?.screenshot,
228
+ );
229
+ const pageRowHeight = hasScreenshots ? 140 : defaultRowHeight;
211
230
 
212
231
  activeTypeOrder.forEach((type, columnIndex) => {
213
232
  const nodes = nodesByType.get(type) ?? [];
233
+ const rowHeight = type === "page" ? pageRowHeight : defaultRowHeight;
214
234
  nodes.forEach((node, rowIndex) => {
215
235
  const isSelected = node.id === selectedNodeId;
216
236
  const status = nodeStatusById?.get(node.id) ?? "unchanged";
217
237
  const borderColor = isSelected ? "#1e293b" : DIFF_BORDER_COLOR[status];
218
238
  const borderStyle = status === "removed" ? "dashed" : "solid";
239
+ const isPage = node.type === "page";
240
+ const screenshot = isPage ? (node.meta?.screenshot as string | undefined) : undefined;
219
241
 
220
242
  flowNodes.push({
221
243
  id: node.id,
222
- data: { label: node.label },
244
+ ...(isPage ? { type: "pageNode" } : {}),
245
+ data: { label: node.label, ...(screenshot ? { screenshot } : {}) },
223
246
  position: {
224
247
  x: 80 + columnIndex * columnWidth,
225
248
  y: 80 + rowIndex * rowHeight,
@@ -246,7 +269,7 @@ export function GraphView(props: GraphViewProps) {
246
269
  : status === "removed"
247
270
  ? `0 0 0 3px rgba(239, 68, 68, 0.15)`
248
271
  : `0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)`,
249
- transition: "box-shadow 0.15s ease, border-color 0.15s ease",
272
+ transition: "opacity 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease",
250
273
  },
251
274
  });
252
275
  });
@@ -263,12 +286,6 @@ export function GraphView(props: GraphViewProps) {
263
286
  const status = edgeStatusByKey?.get(buildEdgeKey(edge.from, edge.to, edge.kind)) ?? "unchanged";
264
287
  const strokeColor = status === "unchanged" ? EDGE_COLOR[edge.kind] : DIFF_EDGE_COLOR[status];
265
288
 
266
- // Determine if this edge is highlighted (connected to active node)
267
- const isHighlighted =
268
- activeNodeId !== null &&
269
- (edge.from === activeNodeId || edge.to === activeNodeId);
270
- const isDimmed = activeNodeId !== null && !isHighlighted;
271
-
272
289
  return {
273
290
  id: `${edge.from}=>${edge.to}::${edge.kind}::${index}`,
274
291
  source: edge.from,
@@ -276,12 +293,11 @@ export function GraphView(props: GraphViewProps) {
276
293
  animated: false,
277
294
  style: {
278
295
  stroke: strokeColor,
279
- strokeWidth: isHighlighted ? 2.5 : 1.5,
296
+ strokeWidth: 1.5,
280
297
  strokeDasharray: status === "removed" ? "6 4" : undefined,
281
- opacity: isDimmed ? 0.08 : status === "removed" ? 0.6 : 0.85,
298
+ opacity: status === "removed" ? 0.6 : 0.85,
282
299
  transition: "opacity 0.15s ease, stroke-width 0.15s ease",
283
300
  },
284
- zIndex: isHighlighted ? 10 : 0,
285
301
  markerEnd: {
286
302
  type: MarkerType.ArrowClosed,
287
303
  color: strokeColor,
@@ -291,7 +307,6 @@ export function GraphView(props: GraphViewProps) {
291
307
 
292
308
  return { flowNodes, flowEdges };
293
309
  }, [
294
- activeNodeId,
295
310
  edgeStatusByKey,
296
311
  graph,
297
312
  nodeStatusById,
@@ -300,11 +315,60 @@ export function GraphView(props: GraphViewProps) {
300
315
  visibleNodeTypes,
301
316
  ]);
302
317
 
318
+ // Apply hover highlighting as a cheap pass over precomputed nodes/edges
319
+ const activeNodeId = hoveredNodeId ?? selectedNodeId;
320
+
321
+ const connectedNodeIds = useMemo(() => {
322
+ if (!activeNodeId) return null;
323
+ const ids = new Set<string>();
324
+ ids.add(activeNodeId);
325
+ for (const edge of graph.edges) {
326
+ if (edge.from === activeNodeId || edge.to === activeNodeId) {
327
+ ids.add(edge.from);
328
+ ids.add(edge.to);
329
+ }
330
+ }
331
+ return ids;
332
+ }, [activeNodeId, graph.edges]);
333
+
334
+ const flowNodes = useMemo(() => {
335
+ if (!connectedNodeIds) return baseFlowNodes;
336
+ return baseFlowNodes.map((node) => {
337
+ const isDimmed = !connectedNodeIds.has(node.id);
338
+ if (!isDimmed) return node;
339
+ return {
340
+ ...node,
341
+ style: {
342
+ ...node.style,
343
+ opacity: 0.25,
344
+ },
345
+ };
346
+ });
347
+ }, [baseFlowNodes, connectedNodeIds]);
348
+
349
+ const flowEdges = useMemo(() => {
350
+ if (!activeNodeId) return baseFlowEdges;
351
+ return baseFlowEdges.map((edge) => {
352
+ const isHighlighted = edge.source === activeNodeId || edge.target === activeNodeId;
353
+ const isDimmed = !isHighlighted;
354
+ return {
355
+ ...edge,
356
+ style: {
357
+ ...edge.style,
358
+ strokeWidth: isHighlighted ? 2.5 : 1.5,
359
+ opacity: isDimmed ? 0.08 : (edge.style?.opacity ?? 0.85),
360
+ },
361
+ zIndex: isHighlighted ? 10 : 0,
362
+ };
363
+ });
364
+ }, [baseFlowEdges, activeNodeId]);
365
+
303
366
  return (
304
367
  <div className="w-full h-full">
305
368
  <ReactFlow
306
369
  nodes={flowNodes}
307
370
  edges={flowEdges}
371
+ nodeTypes={nodeTypes}
308
372
  fitView
309
373
  fitViewOptions={{ padding: 0.18 }}
310
374
  onNodeClick={handleNodeClick}