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 +1 -1
- package/viewer/src/App.tsx +2 -0
- package/viewer/src/Filters.tsx +36 -32
- package/viewer/src/GraphView.tsx +107 -43
package/package.json
CHANGED
package/viewer/src/App.tsx
CHANGED
|
@@ -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 */}
|
package/viewer/src/Filters.tsx
CHANGED
|
@@ -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
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
+
)}
|
|
105
109
|
</div>
|
|
106
110
|
);
|
|
107
111
|
}
|
package/viewer/src/GraphView.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
172
|
-
const
|
|
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
|
|
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
|
-
|
|
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:
|
|
296
|
+
strokeWidth: 1.5,
|
|
280
297
|
strokeDasharray: status === "removed" ? "6 4" : undefined,
|
|
281
|
-
opacity:
|
|
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}
|