@eventcatalog/core 4.4.0 → 4.5.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/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-57CTITPR.js → chunk-BEQZJNID.js} +3 -3
- package/dist/{chunk-LNOQKTI4.js → chunk-IAG6XOLP.js} +1 -1
- package/dist/{chunk-JSOK4BIG.js → chunk-MODVEMKD.js} +1 -1
- package/dist/{chunk-P7BFML7F.js → chunk-P3RVSNBP.js} +1 -1
- package/dist/{chunk-UWBPGWUW.js → chunk-YGWPAFVQ.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.config.d.cts +7 -0
- package/dist/eventcatalog.config.d.ts +7 -0
- package/dist/eventcatalog.js +9 -9
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +1406 -0
- package/eventcatalog/src/pages/visualiser/graph/index.astro +42 -0
- package/eventcatalog/src/stores/sidebar-store/state.ts +23 -7
- package/eventcatalog/src/utils/feature.ts +3 -0
- package/eventcatalog/src/utils/node-graphs/catalog-force-graph.ts +254 -0
- package/package.json +12 -4
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CatalogForceGraph
|
|
3
|
+
*
|
|
4
|
+
* A D3 force-directed graph of every resource in the catalog and the
|
|
5
|
+
* relationships between them. React owns the chrome (lens picker, search,
|
|
6
|
+
* legend, tooltip); D3 owns the simulation, zoom and drag behaviours.
|
|
7
|
+
*
|
|
8
|
+
* Built to stay responsive on catalogs with thousands of resources:
|
|
9
|
+
* - Renders to a single <canvas> instead of SVG (no per-node DOM cost).
|
|
10
|
+
* - Draw calls are batched per collection colour; offscreen nodes are culled.
|
|
11
|
+
* - Labels use collision-aware placement: drawn in priority order (hubs and
|
|
12
|
+
* high-degree nodes first) into a screen-space occupancy list, skipping any
|
|
13
|
+
* label that would overlap one already placed.
|
|
14
|
+
* - Node icons are pre-rasterised sprites, drawn only when readable.
|
|
15
|
+
* - Pointer events use simulation.find() hit-testing, not per-node listeners.
|
|
16
|
+
* - Props use a compact wire format (index-based links) to keep the payload
|
|
17
|
+
* Astro serialises into the page small.
|
|
18
|
+
* - Node positions persist across lens/filter changes so the layout doesn't
|
|
19
|
+
* restart from scratch.
|
|
20
|
+
*
|
|
21
|
+
* Interactions: click a node (or search) to focus its neighbourhood,
|
|
22
|
+
* double-click to open its docs, click the background to clear focus.
|
|
23
|
+
*/
|
|
24
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
25
|
+
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force';
|
|
26
|
+
import { select } from 'd3-selection';
|
|
27
|
+
import { zoom, zoomIdentity } from 'd3-zoom';
|
|
28
|
+
import type { ZoomTransform } from 'd3-zoom';
|
|
29
|
+
import { drag } from 'd3-drag';
|
|
30
|
+
import { getColorForCollection, tailwind500RgbByColor } from '@utils/collection-colors';
|
|
31
|
+
import { getIconForCollection } from '@utils/collections/icons';
|
|
32
|
+
import { buildUrl } from '@utils/url-builder';
|
|
33
|
+
|
|
34
|
+
/** Compact wire format — expanded client-side to keep the serialised page payload small. */
|
|
35
|
+
export interface CatalogGraphWireNode {
|
|
36
|
+
/** Resource id, e.g. `InventoryService` */
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
collection: string;
|
|
40
|
+
version?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** [sourceIndex, targetIndex, labelIndex] into the nodes / linkLabels arrays */
|
|
44
|
+
export type CatalogGraphWireLink = [number, number, number];
|
|
45
|
+
|
|
46
|
+
interface Props {
|
|
47
|
+
nodes: CatalogGraphWireNode[];
|
|
48
|
+
links: CatalogGraphWireLink[];
|
|
49
|
+
linkLabels: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GraphNode extends CatalogGraphWireNode {
|
|
53
|
+
/** Unique node key, e.g. `services/InventoryService` */
|
|
54
|
+
key: string;
|
|
55
|
+
url: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface GraphLink {
|
|
59
|
+
source: string;
|
|
60
|
+
target: string;
|
|
61
|
+
label: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// d3-force mutates its inputs, so we hand it disposable copies typed loosely.
|
|
65
|
+
type SimNode = GraphNode & {
|
|
66
|
+
x?: number;
|
|
67
|
+
y?: number;
|
|
68
|
+
vx?: number;
|
|
69
|
+
vy?: number;
|
|
70
|
+
fx?: number | null;
|
|
71
|
+
fy?: number | null;
|
|
72
|
+
degree: number;
|
|
73
|
+
};
|
|
74
|
+
type SimLink = { source: SimNode | string; target: SimNode | string; label: string };
|
|
75
|
+
|
|
76
|
+
const COLLECTION_LABELS: Record<string, string> = {
|
|
77
|
+
domains: 'Domains',
|
|
78
|
+
systems: 'Systems',
|
|
79
|
+
services: 'Services',
|
|
80
|
+
events: 'Events',
|
|
81
|
+
commands: 'Commands',
|
|
82
|
+
queries: 'Queries',
|
|
83
|
+
flows: 'Flows',
|
|
84
|
+
entities: 'Entities',
|
|
85
|
+
containers: 'Containers',
|
|
86
|
+
'data-products': 'Data products',
|
|
87
|
+
agents: 'Agents',
|
|
88
|
+
teams: 'Teams',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface Lens {
|
|
92
|
+
label: string;
|
|
93
|
+
description: string;
|
|
94
|
+
/** Collections rendered as large anchor nodes in this view */
|
|
95
|
+
hubCollections?: string[];
|
|
96
|
+
/** Collections removed from this view entirely */
|
|
97
|
+
excludeCollections?: string[];
|
|
98
|
+
/** When set, only these relationship types are kept — and only the resources involved in them */
|
|
99
|
+
edgeLabels?: string[];
|
|
100
|
+
/** Collection whose nodes act as cluster centres: members are pulled together and wrapped in a hull */
|
|
101
|
+
clusterBy?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const LENSES: Record<string, Lens> = {
|
|
105
|
+
all: {
|
|
106
|
+
label: 'All resources',
|
|
107
|
+
description: 'Every resource and relationship in the catalog',
|
|
108
|
+
clusterBy: 'domains',
|
|
109
|
+
},
|
|
110
|
+
domains: {
|
|
111
|
+
label: 'Domains',
|
|
112
|
+
description: 'Domains as anchors, with their architecture radiating out',
|
|
113
|
+
hubCollections: ['domains'],
|
|
114
|
+
excludeCollections: ['teams'],
|
|
115
|
+
clusterBy: 'domains',
|
|
116
|
+
},
|
|
117
|
+
systems: {
|
|
118
|
+
label: 'Systems',
|
|
119
|
+
description: 'Systems as anchors, with the services and infrastructure inside them',
|
|
120
|
+
hubCollections: ['systems'],
|
|
121
|
+
excludeCollections: ['teams'],
|
|
122
|
+
clusterBy: 'systems',
|
|
123
|
+
},
|
|
124
|
+
services: {
|
|
125
|
+
label: 'Services',
|
|
126
|
+
description: 'Services and agents as anchors, with the messages and data they touch',
|
|
127
|
+
hubCollections: ['services', 'agents'],
|
|
128
|
+
excludeCollections: ['teams', 'domains', 'systems'],
|
|
129
|
+
clusterBy: 'services',
|
|
130
|
+
},
|
|
131
|
+
teams: {
|
|
132
|
+
label: 'Teams',
|
|
133
|
+
description: 'Teams as anchors, with the resources they own',
|
|
134
|
+
hubCollections: ['teams'],
|
|
135
|
+
edgeLabels: ['owned by'],
|
|
136
|
+
clusterBy: 'teams',
|
|
137
|
+
},
|
|
138
|
+
messages: {
|
|
139
|
+
label: 'Message flow',
|
|
140
|
+
description: 'Services, agents and domains connected only by the messages they exchange',
|
|
141
|
+
edgeLabels: ['publishes', 'invokes', 'requests', 'subscribed by', 'accepts', 'sends', 'received by'],
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Ownership edges are social, not structural — never route cluster assignment through them */
|
|
146
|
+
const OWNERSHIP_EDGE_LABELS = new Set(['owned by']);
|
|
147
|
+
|
|
148
|
+
const nodeColor = (collection: string) => `rgb(${tailwind500RgbByColor[getColorForCollection(collection)]})`;
|
|
149
|
+
|
|
150
|
+
const nodeRadius = (degree: number, isHub = false) =>
|
|
151
|
+
isHub ? Math.min(16 + Math.sqrt(degree) * 3, 38) : Math.min(6 + Math.sqrt(degree) * 2.5, 22);
|
|
152
|
+
|
|
153
|
+
/** Above this node count, expensive extras (collision force) are disabled. */
|
|
154
|
+
const LARGE_GRAPH_NODE_COUNT = 1500;
|
|
155
|
+
|
|
156
|
+
/** Sprite resolution for node icons (drawn scaled-down, so keep it crisp) */
|
|
157
|
+
const ICON_SPRITE_SIZE = 64;
|
|
158
|
+
|
|
159
|
+
/** Icons only draw once a node is at least this many pixels on screen */
|
|
160
|
+
const ICON_MIN_SCREEN_RADIUS = 7;
|
|
161
|
+
|
|
162
|
+
/** Hard cap on labels per frame — beyond this the view is unreadable anyway */
|
|
163
|
+
const MAX_LABELS_PER_FRAME = 200;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Non-hub labels only draw once their node is at least this many pixels on
|
|
167
|
+
* screen — raise to make the zoomed-out view reduce to hub labels sooner.
|
|
168
|
+
*/
|
|
169
|
+
const NON_HUB_LABEL_MIN_SCREEN_RADIUS = 8;
|
|
170
|
+
|
|
171
|
+
/** How strongly cluster members are pulled toward their cluster centre */
|
|
172
|
+
const CLUSTER_FORCE_STRENGTH = 0.03;
|
|
173
|
+
|
|
174
|
+
/** World-space padding around cluster hulls */
|
|
175
|
+
const HULL_PADDING = 28;
|
|
176
|
+
|
|
177
|
+
/** Extra padding per containment-ancestor ring around a focused node's bubble */
|
|
178
|
+
const ANCESTOR_RING_WIDTH = 44;
|
|
179
|
+
|
|
180
|
+
/** Auto-fit sizes the graph to this fraction of the viewport */
|
|
181
|
+
const FIT_VIEWPORT_FRACTION = 0.8;
|
|
182
|
+
|
|
183
|
+
/** Auto-fit never zooms in past this, so a tiny focus subgraph doesn't look comical */
|
|
184
|
+
const MAX_FIT_SCALE = 2.5;
|
|
185
|
+
|
|
186
|
+
/** The lens detail slider's top position means "show everything" */
|
|
187
|
+
const MAX_LENS_DEPTH = 4;
|
|
188
|
+
|
|
189
|
+
const escapeHtml = (value: string) =>
|
|
190
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
191
|
+
|
|
192
|
+
const readThemeColor = (element: HTMLElement, variable: string, fallback: string) => {
|
|
193
|
+
const value = getComputedStyle(element).getPropertyValue(variable).trim();
|
|
194
|
+
return value ? `rgb(${value})` : fallback;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Andrew's monotone chain convex hull — small enough to not warrant a dependency */
|
|
198
|
+
const convexHull = (points: [number, number][]): [number, number][] => {
|
|
199
|
+
if (points.length <= 2) return points;
|
|
200
|
+
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
201
|
+
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
|
202
|
+
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
203
|
+
const lower: [number, number][] = [];
|
|
204
|
+
for (const p of sorted) {
|
|
205
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
|
206
|
+
lower.push(p);
|
|
207
|
+
}
|
|
208
|
+
const upper: [number, number][] = [];
|
|
209
|
+
for (const p of [...sorted].reverse()) {
|
|
210
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
|
|
211
|
+
upper.push(p);
|
|
212
|
+
}
|
|
213
|
+
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const distanceToSegment = (px: number, py: number, ax: number, ay: number, bx: number, by: number) => {
|
|
217
|
+
const dx = bx - ax;
|
|
218
|
+
const dy = by - ay;
|
|
219
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
220
|
+
const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
|
221
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const pointInPolygon = (px: number, py: number, polygon: [number, number][]) => {
|
|
225
|
+
let inside = false;
|
|
226
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
227
|
+
const [xi, yi] = polygon[i];
|
|
228
|
+
const [xj, yj] = polygon[j];
|
|
229
|
+
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
|
|
230
|
+
}
|
|
231
|
+
return inside;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Distance from a point to a hull outline — 0 when the point is inside it */
|
|
235
|
+
const distanceToHull = (px: number, py: number, hull: [number, number][]) => {
|
|
236
|
+
if (hull.length >= 3 && pointInPolygon(px, py, hull)) return 0;
|
|
237
|
+
let min = Infinity;
|
|
238
|
+
for (let i = 0; i < hull.length; i++) {
|
|
239
|
+
const [ax, ay] = hull[i];
|
|
240
|
+
const [bx, by] = hull[(i + 1) % hull.length];
|
|
241
|
+
min = Math.min(min, distanceToSegment(px, py, ax, ay, bx, by));
|
|
242
|
+
}
|
|
243
|
+
return min;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
247
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
248
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
249
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
250
|
+
const iconSourceRef = useRef<HTMLDivElement>(null);
|
|
251
|
+
// Positions survive lens/filter switches so the layout doesn't restart from scratch
|
|
252
|
+
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
|
253
|
+
// Per-collection icons pre-rasterised to offscreen canvases — drawImage from a
|
|
254
|
+
// cached bitmap is cheap enough to run per node per frame
|
|
255
|
+
const iconSpritesRef = useRef(new Map<string, HTMLCanvasElement>());
|
|
256
|
+
const drawRef = useRef<() => void>(() => {});
|
|
257
|
+
// View state initialises from the URL so people can share what they're looking
|
|
258
|
+
// at (?lens=domains&focus=services/OrderService&depth=2&detail=3&hide=teams,entities)
|
|
259
|
+
const [hiddenCollections, setHiddenCollections] = useState<Set<string>>(() => {
|
|
260
|
+
const hide = new URLSearchParams(window.location.search).get('hide');
|
|
261
|
+
return new Set(hide ? hide.split(',').filter(Boolean) : []);
|
|
262
|
+
});
|
|
263
|
+
const [lensKey, setLensKey] = useState(() => {
|
|
264
|
+
const lens = new URLSearchParams(window.location.search).get('lens');
|
|
265
|
+
return lens && lens in LENSES ? lens : 'all';
|
|
266
|
+
});
|
|
267
|
+
const [focusKey, setFocusKey] = useState<string | null>(() => new URLSearchParams(window.location.search).get('focus'));
|
|
268
|
+
const [focusDepth, setFocusDepth] = useState(() =>
|
|
269
|
+
Math.min(3, Math.max(1, Number(new URLSearchParams(window.location.search).get('depth')) || 1))
|
|
270
|
+
);
|
|
271
|
+
// How many hops of detail radiate out from a lens's anchor nodes (4 = everything)
|
|
272
|
+
const [lensDepth, setLensDepth] = useState(() =>
|
|
273
|
+
Math.min(MAX_LENS_DEPTH, Math.max(1, Number(new URLSearchParams(window.location.search).get('detail')) || 1))
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Reflect the view state back into the URL (replaceState, so no history spam);
|
|
277
|
+
// unrelated params — e.g. the stress page's ?nodes — are preserved
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const params = new URLSearchParams(window.location.search);
|
|
280
|
+
const setOrDelete = (key: string, value: string | null) => (value ? params.set(key, value) : params.delete(key));
|
|
281
|
+
setOrDelete('lens', lensKey !== 'all' ? lensKey : null);
|
|
282
|
+
setOrDelete('focus', focusKey);
|
|
283
|
+
setOrDelete('depth', focusKey && focusDepth !== 1 ? String(focusDepth) : null);
|
|
284
|
+
setOrDelete('detail', lensDepth !== 1 ? String(lensDepth) : null);
|
|
285
|
+
setOrDelete('hide', hiddenCollections.size > 0 ? [...hiddenCollections].sort().join(',') : null);
|
|
286
|
+
const query = params.toString();
|
|
287
|
+
window.history.replaceState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
|
|
288
|
+
}, [lensKey, focusKey, focusDepth, lensDepth, hiddenCollections]);
|
|
289
|
+
const [searchValue, setSearchValue] = useState('');
|
|
290
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
291
|
+
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
|
292
|
+
const searchContainerRef = useRef<HTMLDivElement>(null);
|
|
293
|
+
const lens = LENSES[lensKey] ?? LENSES.all;
|
|
294
|
+
|
|
295
|
+
const graph = useMemo(() => {
|
|
296
|
+
const expandedNodes: GraphNode[] = nodes.map((node) => ({
|
|
297
|
+
...node,
|
|
298
|
+
key: `${node.collection}/${node.id}`,
|
|
299
|
+
url:
|
|
300
|
+
node.collection === 'teams' || node.collection === 'users'
|
|
301
|
+
? buildUrl(`/docs/${node.collection}/${node.id}`)
|
|
302
|
+
: buildUrl(`/docs/${node.collection}/${node.id}/${node.version ?? 'latest'}`),
|
|
303
|
+
}));
|
|
304
|
+
const expandedLinks: GraphLink[] = links.map(([source, target, label]) => ({
|
|
305
|
+
source: expandedNodes[source].key,
|
|
306
|
+
target: expandedNodes[target].key,
|
|
307
|
+
label: linkLabels[label],
|
|
308
|
+
}));
|
|
309
|
+
return { nodes: expandedNodes, links: expandedLinks };
|
|
310
|
+
}, [nodes, links, linkLabels]);
|
|
311
|
+
|
|
312
|
+
const { lensNodes, lensLinks } = useMemo(() => {
|
|
313
|
+
let lensNodes = lens.excludeCollections
|
|
314
|
+
? graph.nodes.filter((n) => !lens.excludeCollections!.includes(n.collection))
|
|
315
|
+
: graph.nodes;
|
|
316
|
+
const nodeKeys = new Set(lensNodes.map((n) => n.key));
|
|
317
|
+
let lensLinks = graph.links.filter((l) => nodeKeys.has(l.source) && nodeKeys.has(l.target));
|
|
318
|
+
if (lens.edgeLabels) {
|
|
319
|
+
lensLinks = lensLinks.filter((l) => lens.edgeLabels!.includes(l.label));
|
|
320
|
+
const connected = new Set(lensLinks.flatMap((l) => [l.source, l.target]));
|
|
321
|
+
lensNodes = lensNodes.filter((n) => connected.has(n.key) || lens.hubCollections?.includes(n.collection));
|
|
322
|
+
}
|
|
323
|
+
return { lensNodes, lensLinks };
|
|
324
|
+
}, [graph, lens]);
|
|
325
|
+
|
|
326
|
+
// The focused node's containment chain, nearest parent first (a service may
|
|
327
|
+
// sit inside a system inside a domain). Drawn as nested rings around the
|
|
328
|
+
// focus bubble so drilling in reads as descending the architecture.
|
|
329
|
+
const focusAncestors = useMemo(() => {
|
|
330
|
+
if (!focusKey) return [];
|
|
331
|
+
const nodeByKey = new Map(lensNodes.map((n) => [n.key, n]));
|
|
332
|
+
const ancestors: { key: string; collection: string; label: string }[] = [];
|
|
333
|
+
const visited = new Set([focusKey]);
|
|
334
|
+
let frontier = new Set([focusKey]);
|
|
335
|
+
while (frontier.size > 0 && ancestors.length < 4) {
|
|
336
|
+
const parents = new Set<string>();
|
|
337
|
+
for (const link of lensLinks) {
|
|
338
|
+
if (link.label !== 'contains') continue;
|
|
339
|
+
if (frontier.has(link.target) && !visited.has(link.source)) {
|
|
340
|
+
visited.add(link.source);
|
|
341
|
+
parents.add(link.source);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const parent of parents) {
|
|
345
|
+
const node = nodeByKey.get(parent);
|
|
346
|
+
if (node) ancestors.push({ key: parent, collection: node.collection, label: node.label });
|
|
347
|
+
}
|
|
348
|
+
frontier = parents;
|
|
349
|
+
}
|
|
350
|
+
return ancestors;
|
|
351
|
+
}, [focusKey, lensNodes, lensLinks]);
|
|
352
|
+
|
|
353
|
+
// Lens overview detail: for hub lenses, only show what's within `lensDepth`
|
|
354
|
+
// hops of an anchor node — the full graph is unreadable at scale, so the
|
|
355
|
+
// default is a tight containment overview that users can widen.
|
|
356
|
+
const { overviewNodes, overviewLinks } = useMemo(() => {
|
|
357
|
+
if (!lens.hubCollections || lensDepth >= MAX_LENS_DEPTH) return { overviewNodes: lensNodes, overviewLinks: lensLinks };
|
|
358
|
+
const hubCollections = new Set(lens.hubCollections);
|
|
359
|
+
const adjacency = new Map<string, string[]>();
|
|
360
|
+
for (const link of lensLinks) {
|
|
361
|
+
if (!adjacency.has(link.source)) adjacency.set(link.source, []);
|
|
362
|
+
if (!adjacency.has(link.target)) adjacency.set(link.target, []);
|
|
363
|
+
adjacency.get(link.source)!.push(link.target);
|
|
364
|
+
adjacency.get(link.target)!.push(link.source);
|
|
365
|
+
}
|
|
366
|
+
const keep = new Set<string>();
|
|
367
|
+
let frontier: string[] = [];
|
|
368
|
+
for (const node of lensNodes) {
|
|
369
|
+
if (hubCollections.has(node.collection)) {
|
|
370
|
+
keep.add(node.key);
|
|
371
|
+
frontier.push(node.key);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (let depth = 0; depth < lensDepth; depth++) {
|
|
375
|
+
const next: string[] = [];
|
|
376
|
+
for (const key of frontier) {
|
|
377
|
+
for (const neighbour of adjacency.get(key) ?? []) {
|
|
378
|
+
if (keep.has(neighbour)) continue;
|
|
379
|
+
keep.add(neighbour);
|
|
380
|
+
next.push(neighbour);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
frontier = next;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
overviewNodes: lensNodes.filter((n) => keep.has(n.key)),
|
|
387
|
+
overviewLinks: lensLinks.filter((l) => keep.has(l.source) && keep.has(l.target)),
|
|
388
|
+
};
|
|
389
|
+
}, [lensNodes, lensLinks, lens, lensDepth]);
|
|
390
|
+
|
|
391
|
+
// Focus mode: reduce the view to a node and its neighbourhood, out to a
|
|
392
|
+
// user-chosen number of hops. Containment ancestors are represented by the
|
|
393
|
+
// rings around the bubble, so their nodes are left out — and the BFS never
|
|
394
|
+
// routes through them, or a wrapper would leak its whole contents into view.
|
|
395
|
+
// Focus deliberately works on the FULL lens graph, not the depth-limited
|
|
396
|
+
// overview — drilling into a node should always show its complete detail.
|
|
397
|
+
const { viewNodes, viewLinks } = useMemo(() => {
|
|
398
|
+
if (!focusKey || !lensNodes.some((n) => n.key === focusKey)) return { viewNodes: overviewNodes, viewLinks: overviewLinks };
|
|
399
|
+
const ancestorKeys = new Set(focusAncestors.map((ancestor) => ancestor.key));
|
|
400
|
+
const adjacency = new Map<string, string[]>();
|
|
401
|
+
for (const link of lensLinks) {
|
|
402
|
+
if (ancestorKeys.has(link.source) || ancestorKeys.has(link.target)) continue;
|
|
403
|
+
if (!adjacency.has(link.source)) adjacency.set(link.source, []);
|
|
404
|
+
if (!adjacency.has(link.target)) adjacency.set(link.target, []);
|
|
405
|
+
adjacency.get(link.source)!.push(link.target);
|
|
406
|
+
adjacency.get(link.target)!.push(link.source);
|
|
407
|
+
}
|
|
408
|
+
const keep = new Set([focusKey]);
|
|
409
|
+
let frontier = [focusKey];
|
|
410
|
+
for (let depth = 0; depth < focusDepth; depth++) {
|
|
411
|
+
const next: string[] = [];
|
|
412
|
+
for (const key of frontier) {
|
|
413
|
+
for (const neighbour of adjacency.get(key) ?? []) {
|
|
414
|
+
if (keep.has(neighbour)) continue;
|
|
415
|
+
keep.add(neighbour);
|
|
416
|
+
next.push(neighbour);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
frontier = next;
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
viewNodes: lensNodes.filter((n) => keep.has(n.key)),
|
|
423
|
+
viewLinks: lensLinks.filter((l) => keep.has(l.source) && keep.has(l.target)),
|
|
424
|
+
};
|
|
425
|
+
}, [lensNodes, lensLinks, overviewNodes, overviewLinks, focusKey, focusDepth, focusAncestors]);
|
|
426
|
+
|
|
427
|
+
// Clear a focus that no longer exists in the current lens
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (focusKey && !lensNodes.some((n) => n.key === focusKey)) setFocusKey(null);
|
|
430
|
+
}, [lensNodes, focusKey]);
|
|
431
|
+
|
|
432
|
+
// Assign every node to a cluster centre (its nearest domain/system/team) via
|
|
433
|
+
// multi-source BFS over the lens graph. Powers the cluster force and hulls.
|
|
434
|
+
const clusterAssignments = useMemo(() => {
|
|
435
|
+
const assignments = new Map<string, string>();
|
|
436
|
+
const clusterCollection = lens.clusterBy;
|
|
437
|
+
if (!clusterCollection) return assignments;
|
|
438
|
+
const adjacency = new Map<string, string[]>();
|
|
439
|
+
for (const link of lensLinks) {
|
|
440
|
+
if (clusterCollection !== 'teams' && OWNERSHIP_EDGE_LABELS.has(link.label)) continue;
|
|
441
|
+
if (!adjacency.has(link.source)) adjacency.set(link.source, []);
|
|
442
|
+
if (!adjacency.has(link.target)) adjacency.set(link.target, []);
|
|
443
|
+
adjacency.get(link.source)!.push(link.target);
|
|
444
|
+
adjacency.get(link.target)!.push(link.source);
|
|
445
|
+
}
|
|
446
|
+
const queue: string[] = [];
|
|
447
|
+
for (const node of lensNodes) {
|
|
448
|
+
if (node.collection === clusterCollection) {
|
|
449
|
+
assignments.set(node.key, node.key);
|
|
450
|
+
queue.push(node.key);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (let i = 0; i < queue.length; i++) {
|
|
454
|
+
const current = queue[i];
|
|
455
|
+
const cluster = assignments.get(current)!;
|
|
456
|
+
for (const neighbour of adjacency.get(current) ?? []) {
|
|
457
|
+
if (assignments.has(neighbour)) continue;
|
|
458
|
+
assignments.set(neighbour, cluster);
|
|
459
|
+
queue.push(neighbour);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return assignments;
|
|
463
|
+
}, [lensNodes, lensLinks, lens]);
|
|
464
|
+
|
|
465
|
+
const collectionsInGraph = useMemo(() => {
|
|
466
|
+
const counts = new Map<string, number>();
|
|
467
|
+
for (const node of overviewNodes) counts.set(node.collection, (counts.get(node.collection) ?? 0) + 1);
|
|
468
|
+
// Stable order: the well-known collections first, anything unexpected after
|
|
469
|
+
const known = Object.keys(COLLECTION_LABELS).filter((c) => counts.has(c));
|
|
470
|
+
const unknown = [...counts.keys()].filter((c) => !(c in COLLECTION_LABELS));
|
|
471
|
+
return [...known, ...unknown].map((collection) => ({ collection, count: counts.get(collection) ?? 0 }));
|
|
472
|
+
}, [overviewNodes]);
|
|
473
|
+
|
|
474
|
+
const searchSuggestions = useMemo(() => {
|
|
475
|
+
const query = searchValue.trim().toLowerCase();
|
|
476
|
+
const matches = query
|
|
477
|
+
? lensNodes.filter((n) => n.label.toLowerCase().includes(query) || n.id.toLowerCase().includes(query))
|
|
478
|
+
: lensNodes;
|
|
479
|
+
return matches.slice(0, 50);
|
|
480
|
+
}, [lensNodes, searchValue]);
|
|
481
|
+
|
|
482
|
+
const selectSuggestion = (node: GraphNode) => {
|
|
483
|
+
setFocusKey(node.key);
|
|
484
|
+
setSearchValue('');
|
|
485
|
+
setShowSuggestions(false);
|
|
486
|
+
setSelectedSuggestionIndex(-1);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
490
|
+
if (event.key === 'ArrowDown') {
|
|
491
|
+
event.preventDefault();
|
|
492
|
+
if (searchSuggestions.length === 0) return;
|
|
493
|
+
setShowSuggestions(true);
|
|
494
|
+
setSelectedSuggestionIndex((previous) => (previous < searchSuggestions.length - 1 ? previous + 1 : 0));
|
|
495
|
+
} else if (event.key === 'ArrowUp') {
|
|
496
|
+
event.preventDefault();
|
|
497
|
+
if (searchSuggestions.length === 0) return;
|
|
498
|
+
setShowSuggestions(true);
|
|
499
|
+
setSelectedSuggestionIndex((previous) => (previous > 0 ? previous - 1 : searchSuggestions.length - 1));
|
|
500
|
+
} else if (event.key === 'Enter') {
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
const pick = selectedSuggestionIndex >= 0 ? searchSuggestions[selectedSuggestionIndex] : searchSuggestions[0];
|
|
503
|
+
if (pick && searchValue.trim()) selectSuggestion(pick);
|
|
504
|
+
} else if (event.key === 'Escape') {
|
|
505
|
+
setShowSuggestions(false);
|
|
506
|
+
setSelectedSuggestionIndex(-1);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Chip/badge colours derive from the collection palette (inline styles because
|
|
511
|
+
// the class names would be dynamic, which Tailwind can't see)
|
|
512
|
+
const collectionChipStyle = (collection: string) => {
|
|
513
|
+
const rgb = tailwind500RgbByColor[getColorForCollection(collection)];
|
|
514
|
+
return {
|
|
515
|
+
border: `1px solid rgb(${rgb} / 0.25)`,
|
|
516
|
+
backgroundColor: `rgb(${rgb} / 0.1)`,
|
|
517
|
+
color: `rgb(${rgb})`,
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Close the search dropdown when clicking anywhere else
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
524
|
+
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as globalThis.Node)) {
|
|
525
|
+
setShowSuggestions(false);
|
|
526
|
+
setSelectedSuggestionIndex(-1);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
530
|
+
return () => document.removeEventListener('mousedown', handleMouseDown);
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
// Rasterise the collection icons (rendered hidden by React below) into sprite
|
|
534
|
+
// canvases once on mount. Loads are async — each arrival triggers a redraw.
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
const source = iconSourceRef.current;
|
|
537
|
+
if (!source) return;
|
|
538
|
+
const serializer = new XMLSerializer();
|
|
539
|
+
source.querySelectorAll('span[data-collection]').forEach((span) => {
|
|
540
|
+
const collection = span.getAttribute('data-collection')!;
|
|
541
|
+
const svg = span.querySelector('svg');
|
|
542
|
+
if (!svg || iconSpritesRef.current.has(collection)) return;
|
|
543
|
+
const image = new Image();
|
|
544
|
+
image.onload = () => {
|
|
545
|
+
const sprite = document.createElement('canvas');
|
|
546
|
+
sprite.width = ICON_SPRITE_SIZE;
|
|
547
|
+
sprite.height = ICON_SPRITE_SIZE;
|
|
548
|
+
sprite.getContext('2d')?.drawImage(image, 0, 0, ICON_SPRITE_SIZE, ICON_SPRITE_SIZE);
|
|
549
|
+
iconSpritesRef.current.set(collection, sprite);
|
|
550
|
+
drawRef.current();
|
|
551
|
+
};
|
|
552
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializer.serializeToString(svg))}`;
|
|
553
|
+
});
|
|
554
|
+
}, []);
|
|
555
|
+
|
|
556
|
+
useEffect(() => {
|
|
557
|
+
const container = containerRef.current;
|
|
558
|
+
const canvas = canvasRef.current;
|
|
559
|
+
const tooltip = tooltipRef.current;
|
|
560
|
+
const context = canvas?.getContext('2d');
|
|
561
|
+
if (!container || !canvas || !tooltip || !context) return;
|
|
562
|
+
|
|
563
|
+
const hubs = new Set(lens.hubCollections ?? []);
|
|
564
|
+
// In focus mode only the focused node is the anchor — everything else renders
|
|
565
|
+
// normal-sized, whatever the lens's usual hubs are
|
|
566
|
+
const isHub = (d: SimNode) => (focusKey ? d.key === focusKey : hubs.has(d.collection));
|
|
567
|
+
|
|
568
|
+
const visibleNodes: SimNode[] = viewNodes
|
|
569
|
+
.filter((n) => !hiddenCollections.has(n.collection))
|
|
570
|
+
.map((n) => ({ ...n, degree: 0, ...positionsRef.current.get(n.key) }));
|
|
571
|
+
const nodeByKey = new Map(visibleNodes.map((n) => [n.key, n]));
|
|
572
|
+
const visibleLinks: SimLink[] = viewLinks
|
|
573
|
+
.filter((l) => nodeByKey.has(l.source) && nodeByKey.has(l.target))
|
|
574
|
+
.map((l) => ({ ...l }));
|
|
575
|
+
|
|
576
|
+
for (const link of visibleLinks) {
|
|
577
|
+
nodeByKey.get(link.source as string)!.degree += 1;
|
|
578
|
+
nodeByKey.get(link.target as string)!.degree += 1;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Adjacency for hover highlighting (ego network of the hovered node)
|
|
582
|
+
const neighbours = new Map<string, Set<string>>();
|
|
583
|
+
for (const link of visibleLinks) {
|
|
584
|
+
const s = link.source as string;
|
|
585
|
+
const t = link.target as string;
|
|
586
|
+
if (!neighbours.has(s)) neighbours.set(s, new Set());
|
|
587
|
+
if (!neighbours.has(t)) neighbours.set(t, new Set());
|
|
588
|
+
neighbours.get(s)!.add(t);
|
|
589
|
+
neighbours.get(t)!.add(s);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Batch draw calls by colour so the canvas state changes once per collection
|
|
593
|
+
const nodesByCollection = new Map<string, SimNode[]>();
|
|
594
|
+
for (const node of visibleNodes) {
|
|
595
|
+
if (!nodesByCollection.has(node.collection)) nodesByCollection.set(node.collection, []);
|
|
596
|
+
nodesByCollection.get(node.collection)!.push(node);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Label priority: hubs first, then by connectedness
|
|
600
|
+
const labelOrder = [...visibleNodes].sort((a, b) => (isHub(b) ? 1e6 : 0) + b.degree - ((isHub(a) ? 1e6 : 0) + a.degree));
|
|
601
|
+
const labelWidthCache = new Map<string, number>();
|
|
602
|
+
|
|
603
|
+
// In focus mode the whole neighbourhood becomes one bubble in the focused
|
|
604
|
+
// node's colour (a domain wraps its view in yellow, a service in pink, …);
|
|
605
|
+
// otherwise assignments come from the lens's clusterBy BFS.
|
|
606
|
+
const effectiveAssignments =
|
|
607
|
+
focusKey && nodeByKey.has(focusKey) ? new Map(visibleNodes.map((node) => [node.key, focusKey])) : clusterAssignments;
|
|
608
|
+
|
|
609
|
+
// Cluster groups for the hull backdrops (members keep a reference into the sim)
|
|
610
|
+
const clusterGroups = new Map<string, SimNode[]>();
|
|
611
|
+
for (const node of visibleNodes) {
|
|
612
|
+
const cluster = effectiveAssignments.get(node.key);
|
|
613
|
+
if (!cluster || !nodeByKey.has(cluster)) continue;
|
|
614
|
+
if (!clusterGroups.has(cluster)) clusterGroups.set(cluster, []);
|
|
615
|
+
clusterGroups.get(cluster)!.push(node);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let width = container.clientWidth;
|
|
619
|
+
let height = container.clientHeight;
|
|
620
|
+
const devicePixelRatio = window.devicePixelRatio || 1;
|
|
621
|
+
const isLargeGraph = visibleNodes.length > LARGE_GRAPH_NODE_COUNT;
|
|
622
|
+
|
|
623
|
+
// Canvas colours can't use CSS variables directly — resolve them, and
|
|
624
|
+
// re-resolve when the theme flips.
|
|
625
|
+
let theme = { bg: '', text: '', muted: '' };
|
|
626
|
+
const resolveTheme = () => {
|
|
627
|
+
theme = {
|
|
628
|
+
bg: readThemeColor(container, '--ec-page-bg', 'rgb(255 255 255)'),
|
|
629
|
+
text: readThemeColor(container, '--ec-page-text', 'rgb(17 24 39)'),
|
|
630
|
+
muted: readThemeColor(container, '--ec-page-text-muted', 'rgb(107 114 128)'),
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
resolveTheme();
|
|
634
|
+
|
|
635
|
+
const sizeCanvas = () => {
|
|
636
|
+
width = container.clientWidth;
|
|
637
|
+
height = container.clientHeight;
|
|
638
|
+
canvas.width = Math.round(width * devicePixelRatio);
|
|
639
|
+
canvas.height = Math.round(height * devicePixelRatio);
|
|
640
|
+
canvas.style.width = `${width}px`;
|
|
641
|
+
canvas.style.height = `${height}px`;
|
|
642
|
+
};
|
|
643
|
+
sizeCanvas();
|
|
644
|
+
|
|
645
|
+
let transform: ZoomTransform = zoomIdentity;
|
|
646
|
+
let hovered: SimNode | null = null;
|
|
647
|
+
// The camera auto-fits the settling layout until the user zooms, pans or drags
|
|
648
|
+
let userAdjustedView = false;
|
|
649
|
+
|
|
650
|
+
const drawNodeBatch = (batch: SimNode[], minX: number, minY: number, maxX: number, maxY: number) => {
|
|
651
|
+
context.beginPath();
|
|
652
|
+
for (const node of batch) {
|
|
653
|
+
const r = nodeRadius(node.degree, isHub(node));
|
|
654
|
+
if (node.x! < minX - r || node.x! > maxX + r || node.y! < minY - r || node.y! > maxY + r) continue;
|
|
655
|
+
context.moveTo(node.x! + r, node.y!);
|
|
656
|
+
context.arc(node.x!, node.y!, r, 0, 2 * Math.PI);
|
|
657
|
+
}
|
|
658
|
+
context.fill();
|
|
659
|
+
context.stroke();
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Arrowheads sit at the target node's rim, showing edge direction
|
|
663
|
+
// (e.g. service → event = publishes, event → service = consumed)
|
|
664
|
+
const drawArrowheads = (links: SimLink[], minX: number, minY: number, maxX: number, maxY: number) => {
|
|
665
|
+
const size = 5;
|
|
666
|
+
context.beginPath();
|
|
667
|
+
for (const link of links) {
|
|
668
|
+
const source = link.source as SimNode;
|
|
669
|
+
const target = link.target as SimNode;
|
|
670
|
+
if (Math.max(source.x!, target.x!) < minX || Math.min(source.x!, target.x!) > maxX) continue;
|
|
671
|
+
if (Math.max(source.y!, target.y!) < minY || Math.min(source.y!, target.y!) > maxY) continue;
|
|
672
|
+
const angle = Math.atan2(target.y! - source.y!, target.x! - source.x!);
|
|
673
|
+
const r = nodeRadius(target.degree, isHub(target));
|
|
674
|
+
const tipX = target.x! - Math.cos(angle) * (r + 1);
|
|
675
|
+
const tipY = target.y! - Math.sin(angle) * (r + 1);
|
|
676
|
+
context.moveTo(tipX, tipY);
|
|
677
|
+
context.lineTo(tipX - size * Math.cos(angle - 0.45), tipY - size * Math.sin(angle - 0.45));
|
|
678
|
+
context.lineTo(tipX - size * Math.cos(angle + 0.45), tipY - size * Math.sin(angle + 0.45));
|
|
679
|
+
context.closePath();
|
|
680
|
+
}
|
|
681
|
+
context.fill();
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const draw = () => {
|
|
685
|
+
context.save();
|
|
686
|
+
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
|
687
|
+
context.clearRect(0, 0, width, height);
|
|
688
|
+
context.translate(transform.x, transform.y);
|
|
689
|
+
context.scale(transform.k, transform.k);
|
|
690
|
+
|
|
691
|
+
// Visible world-space bounds, for culling offscreen work
|
|
692
|
+
const [minX, minY] = transform.invert([0, 0]);
|
|
693
|
+
const [maxX, maxY] = transform.invert([width, height]);
|
|
694
|
+
|
|
695
|
+
const ego = hovered ? (neighbours.get(hovered.key) ?? new Set<string>()) : null;
|
|
696
|
+
const egoLinks = hovered
|
|
697
|
+
? visibleLinks.filter((l) => (l.source as SimNode).key === hovered!.key || (l.target as SimNode).key === hovered!.key)
|
|
698
|
+
: [];
|
|
699
|
+
|
|
700
|
+
// Cluster hulls: a soft tinted backdrop per domain/system/team neighbourhood.
|
|
701
|
+
// The thick round-joined stroke pads the hull outward from the member nodes.
|
|
702
|
+
const drawHull = (members: SimNode[], colour: string, padding: number) => {
|
|
703
|
+
if (members.length < 2) return;
|
|
704
|
+
context.globalAlpha = 0.05;
|
|
705
|
+
context.lineJoin = 'round';
|
|
706
|
+
context.lineCap = 'round';
|
|
707
|
+
context.lineWidth = padding * 2;
|
|
708
|
+
context.fillStyle = colour;
|
|
709
|
+
context.strokeStyle = colour;
|
|
710
|
+
const hull = convexHull(members.map((m) => [m.x!, m.y!] as [number, number]));
|
|
711
|
+
context.beginPath();
|
|
712
|
+
context.moveTo(hull[0][0], hull[0][1]);
|
|
713
|
+
for (let i = 1; i < hull.length; i++) context.lineTo(hull[i][0], hull[i][1]);
|
|
714
|
+
context.closePath();
|
|
715
|
+
context.stroke();
|
|
716
|
+
context.fill();
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
if (clusterGroups.size > 0) {
|
|
720
|
+
// In focus mode, wrap the bubble in one outer ring per containment
|
|
721
|
+
// ancestor (a system inside a domain gets the domain's yellow ring) —
|
|
722
|
+
// outermost ancestor first, so the rings nest like the hierarchy does
|
|
723
|
+
const focusMembers = focusKey ? clusterGroups.get(focusKey) : undefined;
|
|
724
|
+
if (focusMembers) {
|
|
725
|
+
for (let i = focusAncestors.length - 1; i >= 0; i--) {
|
|
726
|
+
drawHull(focusMembers, nodeColor(focusAncestors[i].collection), HULL_PADDING + (i + 1) * ANCESTOR_RING_WIDTH);
|
|
727
|
+
}
|
|
728
|
+
// Name each wrapping ring, sitting inside its band at the top of the
|
|
729
|
+
// bubble, with a small icon badge so the wrapper's type reads at a glance
|
|
730
|
+
if (focusMembers.length >= 2 && focusAncestors.length > 0) {
|
|
731
|
+
let topY = Infinity;
|
|
732
|
+
let sumX = 0;
|
|
733
|
+
for (const member of focusMembers) {
|
|
734
|
+
if (member.y! < topY) topY = member.y!;
|
|
735
|
+
sumX += member.x!;
|
|
736
|
+
}
|
|
737
|
+
const centreX = sumX / focusMembers.length;
|
|
738
|
+
context.textAlign = 'center';
|
|
739
|
+
context.textBaseline = 'middle';
|
|
740
|
+
context.font = `600 ${11 / transform.k}px sans-serif`;
|
|
741
|
+
context.lineWidth = 3 / transform.k;
|
|
742
|
+
context.strokeStyle = theme.bg;
|
|
743
|
+
const badgeRadius = 9 / transform.k;
|
|
744
|
+
const badgeGap = 5 / transform.k;
|
|
745
|
+
for (let i = 0; i < focusAncestors.length; i++) {
|
|
746
|
+
const ancestor = focusAncestors[i];
|
|
747
|
+
const colour = nodeColor(ancestor.collection);
|
|
748
|
+
const labelY = topY - (HULL_PADDING + i * ANCESTOR_RING_WIDTH + ANCESTOR_RING_WIDTH / 2);
|
|
749
|
+
const textWidth = context.measureText(ancestor.label).width;
|
|
750
|
+
const startX = centreX - (badgeRadius * 2 + badgeGap + textWidth) / 2;
|
|
751
|
+
const badgeX = startX + badgeRadius;
|
|
752
|
+
context.globalAlpha = 1;
|
|
753
|
+
context.fillStyle = colour;
|
|
754
|
+
context.beginPath();
|
|
755
|
+
context.arc(badgeX, labelY, badgeRadius, 0, 2 * Math.PI);
|
|
756
|
+
context.fill();
|
|
757
|
+
const sprite = iconSpritesRef.current.get(ancestor.collection);
|
|
758
|
+
if (sprite) {
|
|
759
|
+
const iconSize = badgeRadius * 1.3;
|
|
760
|
+
context.drawImage(sprite, badgeX - iconSize / 2, labelY - iconSize / 2, iconSize, iconSize);
|
|
761
|
+
}
|
|
762
|
+
const textX = startX + badgeRadius * 2 + badgeGap + textWidth / 2;
|
|
763
|
+
context.globalAlpha = 0.9;
|
|
764
|
+
context.fillStyle = colour;
|
|
765
|
+
context.strokeText(ancestor.label, textX, labelY);
|
|
766
|
+
context.fillText(ancestor.label, textX, labelY);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
for (const [clusterKey, members] of clusterGroups) {
|
|
771
|
+
drawHull(members, nodeColor(nodeByKey.get(clusterKey)!.collection), HULL_PADDING);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Links: one batched path for the base layer…
|
|
776
|
+
context.lineWidth = 1;
|
|
777
|
+
context.strokeStyle = theme.muted;
|
|
778
|
+
context.globalAlpha = hovered ? 0.06 : 0.2;
|
|
779
|
+
context.beginPath();
|
|
780
|
+
for (const link of visibleLinks) {
|
|
781
|
+
const source = link.source as SimNode;
|
|
782
|
+
const target = link.target as SimNode;
|
|
783
|
+
if (Math.max(source.x!, target.x!) < minX || Math.min(source.x!, target.x!) > maxX) continue;
|
|
784
|
+
if (Math.max(source.y!, target.y!) < minY || Math.min(source.y!, target.y!) > maxY) continue;
|
|
785
|
+
context.moveTo(source.x!, source.y!);
|
|
786
|
+
context.lineTo(target.x!, target.y!);
|
|
787
|
+
}
|
|
788
|
+
context.stroke();
|
|
789
|
+
|
|
790
|
+
// Arrowheads are sub-pixel noise when zoomed far out — skip them there
|
|
791
|
+
if (transform.k >= 0.4) {
|
|
792
|
+
context.fillStyle = theme.muted;
|
|
793
|
+
drawArrowheads(visibleLinks, minX, minY, maxX, maxY);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// …and a highlight pass for the hovered node's edges
|
|
797
|
+
if (hovered) {
|
|
798
|
+
context.strokeStyle = nodeColor(hovered.collection);
|
|
799
|
+
context.globalAlpha = 0.9;
|
|
800
|
+
context.beginPath();
|
|
801
|
+
for (const link of egoLinks) {
|
|
802
|
+
context.moveTo((link.source as SimNode).x!, (link.source as SimNode).y!);
|
|
803
|
+
context.lineTo((link.target as SimNode).x!, (link.target as SimNode).y!);
|
|
804
|
+
}
|
|
805
|
+
context.stroke();
|
|
806
|
+
context.fillStyle = nodeColor(hovered.collection);
|
|
807
|
+
drawArrowheads(egoLinks, minX, minY, maxX, maxY);
|
|
808
|
+
|
|
809
|
+
// Relationship labels at the midpoint of each highlighted edge, so the
|
|
810
|
+
// semantics (publishes / subscribed by / contains…) read on hover.
|
|
811
|
+
// Skipped on mega-hubs where the labels would just pile up.
|
|
812
|
+
if (egoLinks.length <= 40) {
|
|
813
|
+
// Divide by the zoom scale so the labels stay a constant 9px on
|
|
814
|
+
// screen — same treatment as the screen-space node labels
|
|
815
|
+
context.font = `${9 / transform.k}px sans-serif`;
|
|
816
|
+
context.textAlign = 'center';
|
|
817
|
+
context.textBaseline = 'middle';
|
|
818
|
+
context.lineWidth = 3 / transform.k;
|
|
819
|
+
context.strokeStyle = theme.bg;
|
|
820
|
+
context.fillStyle = theme.text;
|
|
821
|
+
for (const link of egoLinks) {
|
|
822
|
+
const source = link.source as SimNode;
|
|
823
|
+
const target = link.target as SimNode;
|
|
824
|
+
const midX = (source.x! + target.x!) / 2;
|
|
825
|
+
const midY = (source.y! + target.y!) / 2;
|
|
826
|
+
context.strokeText(link.label, midX, midY);
|
|
827
|
+
context.fillText(link.label, midX, midY);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Nodes, batched per collection colour
|
|
833
|
+
context.lineWidth = 1.5;
|
|
834
|
+
context.strokeStyle = theme.bg;
|
|
835
|
+
for (const [collection, batch] of nodesByCollection) {
|
|
836
|
+
context.fillStyle = nodeColor(collection);
|
|
837
|
+
if (!ego) {
|
|
838
|
+
context.globalAlpha = 0.9;
|
|
839
|
+
drawNodeBatch(batch, minX, minY, maxX, maxY);
|
|
840
|
+
} else {
|
|
841
|
+
const dimmed = batch.filter((n) => n.key !== hovered!.key && !ego.has(n.key));
|
|
842
|
+
const focused = batch.filter((n) => n.key === hovered!.key || ego.has(n.key));
|
|
843
|
+
context.globalAlpha = 0.12;
|
|
844
|
+
drawNodeBatch(dimmed, minX, minY, maxX, maxY);
|
|
845
|
+
context.globalAlpha = 1;
|
|
846
|
+
drawNodeBatch(focused, minX, minY, maxX, maxY);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Icons: cached-bitmap blits, only once a node is big enough on screen to read
|
|
851
|
+
for (const node of visibleNodes) {
|
|
852
|
+
const r = nodeRadius(node.degree, isHub(node));
|
|
853
|
+
if (r * transform.k < ICON_MIN_SCREEN_RADIUS) continue;
|
|
854
|
+
const sprite = iconSpritesRef.current.get(node.collection);
|
|
855
|
+
if (!sprite) continue;
|
|
856
|
+
if (node.x! < minX - r || node.x! > maxX + r || node.y! < minY - r || node.y! > maxY + r) continue;
|
|
857
|
+
context.globalAlpha = ego ? (node.key === hovered!.key || ego.has(node.key) ? 1 : 0.12) : 0.9;
|
|
858
|
+
const size = r * 1.1;
|
|
859
|
+
context.drawImage(sprite, node.x! - size / 2, node.y! - size / 2, size, size);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
context.restore();
|
|
863
|
+
|
|
864
|
+
// Labels: collision-aware placement in screen space at fixed font size.
|
|
865
|
+
// Placed in priority order — a label that would overlap an already-placed
|
|
866
|
+
// one is skipped, so dense areas show only their most connected resources.
|
|
867
|
+
context.save();
|
|
868
|
+
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
|
869
|
+
context.textAlign = 'center';
|
|
870
|
+
context.textBaseline = 'top';
|
|
871
|
+
const placed: [number, number, number, number][] = [];
|
|
872
|
+
const overlapsPlaced = (x: number, y: number, w: number, h: number) =>
|
|
873
|
+
placed.some(([px, py, pw, ph]) => x < px + pw && px < x + w && y < py + ph && py < y + h);
|
|
874
|
+
|
|
875
|
+
const tryLabel = (node: SimNode, force = false) => {
|
|
876
|
+
if (placed.length >= MAX_LABELS_PER_FRAME) return;
|
|
877
|
+
const hub = isHub(node);
|
|
878
|
+
const focused = hovered && (node.key === hovered.key || ego?.has(node.key));
|
|
879
|
+
if (hovered && !focused && !hub && !force) return;
|
|
880
|
+
const r = nodeRadius(node.degree, hub);
|
|
881
|
+
const screenR = r * transform.k;
|
|
882
|
+
if (!hub && !focused && screenR < NON_HUB_LABEL_MIN_SCREEN_RADIUS) return;
|
|
883
|
+
const sx = transform.applyX(node.x!);
|
|
884
|
+
const sy = transform.applyY(node.y!);
|
|
885
|
+
if (sx < -100 || sx > width + 100 || sy < -30 || sy > height + 30) return;
|
|
886
|
+
const font = hub ? '600 12px sans-serif' : '10px sans-serif';
|
|
887
|
+
const cacheKey = `${font}|${node.label}`;
|
|
888
|
+
let textWidth = labelWidthCache.get(cacheKey);
|
|
889
|
+
if (textWidth === undefined) {
|
|
890
|
+
context.font = font;
|
|
891
|
+
textWidth = context.measureText(node.label).width;
|
|
892
|
+
labelWidthCache.set(cacheKey, textWidth);
|
|
893
|
+
}
|
|
894
|
+
const boxX = sx - textWidth / 2 - 2;
|
|
895
|
+
const boxY = sy + screenR + 2;
|
|
896
|
+
const boxW = textWidth + 4;
|
|
897
|
+
const boxH = hub ? 16 : 14;
|
|
898
|
+
if (overlapsPlaced(boxX, boxY, boxW, boxH)) return;
|
|
899
|
+
placed.push([boxX, boxY, boxW, boxH]);
|
|
900
|
+
context.font = font;
|
|
901
|
+
context.lineWidth = 3;
|
|
902
|
+
context.strokeStyle = theme.bg;
|
|
903
|
+
context.fillStyle = hub ? theme.text : theme.muted;
|
|
904
|
+
context.globalAlpha = 1;
|
|
905
|
+
context.strokeText(node.label, sx, sy + screenR + 4);
|
|
906
|
+
context.fillText(node.label, sx, sy + screenR + 4);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
if (hovered) {
|
|
910
|
+
tryLabel(hovered, true);
|
|
911
|
+
for (const node of labelOrder) {
|
|
912
|
+
if (ego?.has(node.key)) tryLabel(node, true);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
for (const node of labelOrder) tryLabel(node);
|
|
916
|
+
context.restore();
|
|
917
|
+
};
|
|
918
|
+
drawRef.current = draw;
|
|
919
|
+
|
|
920
|
+
const simulation = forceSimulation(visibleNodes)
|
|
921
|
+
.force(
|
|
922
|
+
'link',
|
|
923
|
+
forceLink<SimNode, any>(visibleLinks)
|
|
924
|
+
.id((d) => d.key)
|
|
925
|
+
.distance(120)
|
|
926
|
+
.strength(0.3)
|
|
927
|
+
)
|
|
928
|
+
// distanceMax bounds the many-body cost on large graphs with little visual impact
|
|
929
|
+
.force('charge', forceManyBody().strength(-400).distanceMax(1000))
|
|
930
|
+
.force('center', forceCenter(width / 2, height / 2))
|
|
931
|
+
.force('x', forceX(width / 2).strength(visibleNodes.length < 150 ? 0.08 : 0.03))
|
|
932
|
+
.force('y', forceY(height / 2).strength(visibleNodes.length < 150 ? 0.08 : 0.03))
|
|
933
|
+
// Settle faster on large graphs — fewer total ticks, each tick is O(n log n)
|
|
934
|
+
.alphaDecay(isLargeGraph ? 0.06 : 0.0228)
|
|
935
|
+
// While settling, the camera follows the layout (auto-fit); once the user
|
|
936
|
+
// has zoomed/panned/dragged, ticks just redraw under their chosen view
|
|
937
|
+
.on('tick', () => {
|
|
938
|
+
if (userAdjustedView) draw();
|
|
939
|
+
else fitToView();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// Pull cluster members toward their cluster centre so domains/systems/teams
|
|
943
|
+
// form visible neighbourhoods instead of one tangle
|
|
944
|
+
if (clusterGroups.size > 0) {
|
|
945
|
+
simulation.force('cluster', (alpha: number) => {
|
|
946
|
+
for (const node of visibleNodes) {
|
|
947
|
+
const clusterKey = effectiveAssignments.get(node.key);
|
|
948
|
+
if (!clusterKey || clusterKey === node.key) continue;
|
|
949
|
+
const centre = nodeByKey.get(clusterKey);
|
|
950
|
+
if (!centre) continue;
|
|
951
|
+
node.vx! += (centre.x! - node.x!) * alpha * CLUSTER_FORCE_STRENGTH;
|
|
952
|
+
node.vy! += (centre.y! - node.y!) * alpha * CLUSTER_FORCE_STRENGTH;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Collision is the most expensive force and mostly cosmetic — skip it at scale
|
|
958
|
+
if (!isLargeGraph) {
|
|
959
|
+
simulation.force(
|
|
960
|
+
'collide',
|
|
961
|
+
forceCollide<SimNode>().radius((d) => nodeRadius(d.degree, isHub(d)) + 16)
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const findNode = (canvasX: number, canvasY: number): SimNode | null => {
|
|
966
|
+
if (Number.isNaN(canvasX) || Number.isNaN(canvasY)) return null;
|
|
967
|
+
const x = transform.invertX(canvasX);
|
|
968
|
+
const y = transform.invertY(canvasY);
|
|
969
|
+
const candidate = simulation.find(x, y, 50) as SimNode | undefined;
|
|
970
|
+
if (!candidate) return null;
|
|
971
|
+
const r = nodeRadius(candidate.degree, isHub(candidate));
|
|
972
|
+
const distance = Math.hypot(candidate.x! - x, candidate.y! - y);
|
|
973
|
+
return distance <= r + 4 ? candidate : null;
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// Touch events carry their coordinates on touches[0], not the event itself
|
|
977
|
+
const pointerPosition = (event: MouseEvent | TouchEvent | Touch): [number, number] => {
|
|
978
|
+
const source = 'touches' in event ? event.touches[0] : event;
|
|
979
|
+
if (!source) return [NaN, NaN];
|
|
980
|
+
const rect = canvas.getBoundingClientRect();
|
|
981
|
+
return [source.clientX - rect.left, source.clientY - rect.top];
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
const zoomBehaviour = zoom<HTMLCanvasElement, unknown>()
|
|
985
|
+
.scaleExtent([0.1, 4])
|
|
986
|
+
// Let node drags win over panning: ignore presses that start on a node
|
|
987
|
+
.filter((event) => {
|
|
988
|
+
if (event.type === 'mousedown' || event.type === 'touchstart') {
|
|
989
|
+
const [x, y] = pointerPosition(event);
|
|
990
|
+
return !findNode(x, y);
|
|
991
|
+
}
|
|
992
|
+
return !event.ctrlKey || event.type === 'wheel';
|
|
993
|
+
})
|
|
994
|
+
.on('zoom', (event) => {
|
|
995
|
+
// sourceEvent is null for programmatic transforms (auto-fit) — only a
|
|
996
|
+
// real user gesture takes the camera off auto-fit
|
|
997
|
+
if (event.sourceEvent) userAdjustedView = true;
|
|
998
|
+
transform = event.transform;
|
|
999
|
+
draw();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const dragBehaviour = drag<HTMLCanvasElement, unknown>()
|
|
1003
|
+
.subject((event) => {
|
|
1004
|
+
const [x, y] = pointerPosition(event.sourceEvent);
|
|
1005
|
+
const node = findNode(x, y);
|
|
1006
|
+
if (!node) return undefined as any;
|
|
1007
|
+
// d3-drag works in screen space; hand it screen coordinates for the node
|
|
1008
|
+
return { node, x: transform.applyX(node.x!), y: transform.applyY(node.y!) };
|
|
1009
|
+
})
|
|
1010
|
+
.clickDistance(4)
|
|
1011
|
+
.on('start', (event) => {
|
|
1012
|
+
userAdjustedView = true;
|
|
1013
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
1014
|
+
const node = event.subject.node as SimNode;
|
|
1015
|
+
node.fx = node.x;
|
|
1016
|
+
node.fy = node.y;
|
|
1017
|
+
})
|
|
1018
|
+
.on('drag', (event) => {
|
|
1019
|
+
const node = event.subject.node as SimNode;
|
|
1020
|
+
node.fx = transform.invertX(event.x);
|
|
1021
|
+
node.fy = transform.invertY(event.y);
|
|
1022
|
+
})
|
|
1023
|
+
.on('end', (event) => {
|
|
1024
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
1025
|
+
const node = event.subject.node as SimNode;
|
|
1026
|
+
node.fx = null;
|
|
1027
|
+
node.fy = null;
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const selection = select(canvas);
|
|
1031
|
+
selection.call(dragBehaviour).call(zoomBehaviour).call(zoomBehaviour.transform, zoomIdentity);
|
|
1032
|
+
// Double-click opens docs instead of zooming
|
|
1033
|
+
selection.on('dblclick.zoom', null);
|
|
1034
|
+
|
|
1035
|
+
// Fit the whole visible graph into ~80% of the viewport. Runs every tick
|
|
1036
|
+
// during settle so the camera smoothly tracks the layout — clicking into a
|
|
1037
|
+
// focus therefore fills the screen with that neighbourhood.
|
|
1038
|
+
const fitToView = () => {
|
|
1039
|
+
if (visibleNodes.length === 0) return;
|
|
1040
|
+
let minX = Infinity;
|
|
1041
|
+
let minY = Infinity;
|
|
1042
|
+
let maxX = -Infinity;
|
|
1043
|
+
let maxY = -Infinity;
|
|
1044
|
+
for (const node of visibleNodes) {
|
|
1045
|
+
const r = nodeRadius(node.degree, isHub(node)) + 30;
|
|
1046
|
+
if (node.x! - r < minX) minX = node.x! - r;
|
|
1047
|
+
if (node.x! + r > maxX) maxX = node.x! + r;
|
|
1048
|
+
if (node.y! - r < minY) minY = node.y! - r;
|
|
1049
|
+
if (node.y! + r > maxY) maxY = node.y! + r;
|
|
1050
|
+
}
|
|
1051
|
+
const scale = Math.min(
|
|
1052
|
+
MAX_FIT_SCALE,
|
|
1053
|
+
Math.max(0.1, FIT_VIEWPORT_FRACTION * Math.min(width / (maxX - minX), height / (maxY - minY)))
|
|
1054
|
+
);
|
|
1055
|
+
const fitted = zoomIdentity
|
|
1056
|
+
.translate(width / 2, height / 2)
|
|
1057
|
+
.scale(scale)
|
|
1058
|
+
.translate(-(minX + maxX) / 2, -(minY + maxY) / 2);
|
|
1059
|
+
selection.call(zoomBehaviour.transform, fitted);
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
1063
|
+
const [x, y] = pointerPosition(event);
|
|
1064
|
+
const node = findNode(x, y);
|
|
1065
|
+
if (node !== hovered) {
|
|
1066
|
+
hovered = node;
|
|
1067
|
+
canvas.style.cursor = node ? 'pointer' : 'default';
|
|
1068
|
+
if (node) {
|
|
1069
|
+
const connections = neighbours.get(node.key)?.size ?? 0;
|
|
1070
|
+
tooltip.innerHTML = `
|
|
1071
|
+
<div class="font-semibold">${escapeHtml(node.label)}</div>
|
|
1072
|
+
<div class="text-[rgb(var(--ec-page-text-muted))]">
|
|
1073
|
+
${escapeHtml(COLLECTION_LABELS[node.collection] ?? node.collection)}${node.version ? ` · v${escapeHtml(node.version)}` : ''} · ${connections} connection${connections === 1 ? '' : 's'}
|
|
1074
|
+
</div>
|
|
1075
|
+
<div class="mt-1 text-[10px] text-[rgb(var(--ec-page-text-muted))]">Click to focus · double-click to open</div>`;
|
|
1076
|
+
tooltip.style.opacity = '1';
|
|
1077
|
+
} else {
|
|
1078
|
+
tooltip.style.opacity = '0';
|
|
1079
|
+
}
|
|
1080
|
+
draw();
|
|
1081
|
+
}
|
|
1082
|
+
if (node) {
|
|
1083
|
+
const rect = container.getBoundingClientRect();
|
|
1084
|
+
tooltip.style.left = `${event.clientX - rect.left + 12}px`;
|
|
1085
|
+
tooltip.style.top = `${event.clientY - rect.top + 12}px`;
|
|
1086
|
+
} else {
|
|
1087
|
+
// Wrapper rings are clickable (drill up) — signal it with the cursor
|
|
1088
|
+
canvas.style.cursor = findAncestorRing(x, y) ? 'pointer' : 'default';
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const handleMouseLeave = () => {
|
|
1093
|
+
if (!hovered) return;
|
|
1094
|
+
hovered = null;
|
|
1095
|
+
canvas.style.cursor = 'default';
|
|
1096
|
+
tooltip.style.opacity = '0';
|
|
1097
|
+
draw();
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// Which ancestor ring (if any) a canvas point falls in — measured by the
|
|
1101
|
+
// point's distance outward from the focus bubble's hull
|
|
1102
|
+
const findAncestorRing = (canvasX: number, canvasY: number): string | null => {
|
|
1103
|
+
if (!focusKey || focusAncestors.length === 0) return null;
|
|
1104
|
+
const members = clusterGroups.get(focusKey);
|
|
1105
|
+
if (!members || members.length < 2) return null;
|
|
1106
|
+
const x = transform.invertX(canvasX);
|
|
1107
|
+
const y = transform.invertY(canvasY);
|
|
1108
|
+
const hull = convexHull(members.map((m) => [m.x!, m.y!] as [number, number]));
|
|
1109
|
+
const distance = distanceToHull(x, y, hull);
|
|
1110
|
+
if (distance <= HULL_PADDING) return null;
|
|
1111
|
+
const ring = Math.floor((distance - HULL_PADDING) / ANCESTOR_RING_WIDTH);
|
|
1112
|
+
return ring < focusAncestors.length ? focusAncestors[ring].key : null;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const handleClick = (event: MouseEvent) => {
|
|
1116
|
+
const [x, y] = pointerPosition(event);
|
|
1117
|
+
const node = findNode(x, y);
|
|
1118
|
+
// The graph is about to re-layout under the pointer — drop the tooltip
|
|
1119
|
+
// rather than leave it describing whatever was clicked
|
|
1120
|
+
hovered = null;
|
|
1121
|
+
tooltip.style.opacity = '0';
|
|
1122
|
+
canvas.style.cursor = 'default';
|
|
1123
|
+
if (node) {
|
|
1124
|
+
setFocusKey(node.key);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// Clicking a wrapper ring drills up to that ancestor; beyond the rings clears
|
|
1128
|
+
setFocusKey(findAncestorRing(x, y));
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const handleDoubleClick = (event: MouseEvent) => {
|
|
1132
|
+
const [x, y] = pointerPosition(event);
|
|
1133
|
+
const node = findNode(x, y);
|
|
1134
|
+
if (node?.url) window.location.href = node.url;
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
canvas.addEventListener('mousemove', handleMouseMove);
|
|
1138
|
+
canvas.addEventListener('mouseleave', handleMouseLeave);
|
|
1139
|
+
canvas.addEventListener('click', handleClick);
|
|
1140
|
+
canvas.addEventListener('dblclick', handleDoubleClick);
|
|
1141
|
+
|
|
1142
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1143
|
+
sizeCanvas();
|
|
1144
|
+
// Refit to the new container size unless the user has taken the camera —
|
|
1145
|
+
// the settled layout would otherwise stay clipped or under-sized
|
|
1146
|
+
if (userAdjustedView) draw();
|
|
1147
|
+
else fitToView();
|
|
1148
|
+
});
|
|
1149
|
+
resizeObserver.observe(container);
|
|
1150
|
+
|
|
1151
|
+
// Redraw with fresh colours when the user flips light/dark mode
|
|
1152
|
+
const themeObserver = new MutationObserver(() => {
|
|
1153
|
+
resolveTheme();
|
|
1154
|
+
draw();
|
|
1155
|
+
});
|
|
1156
|
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
1157
|
+
|
|
1158
|
+
return () => {
|
|
1159
|
+
for (const node of visibleNodes) {
|
|
1160
|
+
if (node.x !== undefined && node.y !== undefined) positionsRef.current.set(node.key, { x: node.x, y: node.y });
|
|
1161
|
+
}
|
|
1162
|
+
simulation.stop();
|
|
1163
|
+
// The tooltip outlives this effect — never leave it showing stale content
|
|
1164
|
+
tooltip.style.opacity = '0';
|
|
1165
|
+
canvas.style.cursor = 'default';
|
|
1166
|
+
selection.on('.zoom', null).on('.drag', null);
|
|
1167
|
+
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
1168
|
+
canvas.removeEventListener('mouseleave', handleMouseLeave);
|
|
1169
|
+
canvas.removeEventListener('click', handleClick);
|
|
1170
|
+
canvas.removeEventListener('dblclick', handleDoubleClick);
|
|
1171
|
+
resizeObserver.disconnect();
|
|
1172
|
+
themeObserver.disconnect();
|
|
1173
|
+
};
|
|
1174
|
+
}, [viewNodes, viewLinks, lens, hiddenCollections, clusterAssignments, focusKey, focusAncestors]);
|
|
1175
|
+
|
|
1176
|
+
const toggleCollection = (collection: string) => {
|
|
1177
|
+
setHiddenCollections((current) => {
|
|
1178
|
+
const next = new Set(current);
|
|
1179
|
+
if (next.has(collection)) next.delete(collection);
|
|
1180
|
+
else next.add(collection);
|
|
1181
|
+
return next;
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const focusedNode = focusKey ? graph.nodes.find((n) => n.key === focusKey) : undefined;
|
|
1186
|
+
|
|
1187
|
+
return (
|
|
1188
|
+
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
|
|
1189
|
+
{/* Hidden icon sources: React renders them once, the sprite effect rasterises
|
|
1190
|
+
them for the canvas. Both lucide (color prop) and heroicons (currentColor
|
|
1191
|
+
via style) end up with white strokes. */}
|
|
1192
|
+
<div ref={iconSourceRef} className="hidden" aria-hidden="true">
|
|
1193
|
+
{Object.keys(COLLECTION_LABELS).map((collection) => {
|
|
1194
|
+
const Icon = getIconForCollection(collection);
|
|
1195
|
+
return (
|
|
1196
|
+
<span key={collection} data-collection={collection}>
|
|
1197
|
+
<Icon width={24} height={24} color="#ffffff" style={{ color: '#ffffff' }} strokeWidth={2} />
|
|
1198
|
+
</span>
|
|
1199
|
+
);
|
|
1200
|
+
})}
|
|
1201
|
+
</div>
|
|
1202
|
+
<canvas ref={canvasRef} role="img" aria-label="Catalog resource graph" />
|
|
1203
|
+
<div
|
|
1204
|
+
ref={tooltipRef}
|
|
1205
|
+
className="pointer-events-none absolute z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] shadow-md transition-opacity duration-100"
|
|
1206
|
+
style={{ opacity: 0 }}
|
|
1207
|
+
/>
|
|
1208
|
+
<div className="absolute left-3 top-3 z-10 flex flex-col gap-2 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs shadow-xs">
|
|
1209
|
+
<div className="flex items-center gap-2">
|
|
1210
|
+
<label htmlFor="catalog-graph-lens" className="font-semibold text-[rgb(var(--ec-page-text))]">
|
|
1211
|
+
Lens
|
|
1212
|
+
</label>
|
|
1213
|
+
<select
|
|
1214
|
+
id="catalog-graph-lens"
|
|
1215
|
+
value={lensKey}
|
|
1216
|
+
onChange={(event) => {
|
|
1217
|
+
// A new lens is a fresh view — drop any focus and legend filters
|
|
1218
|
+
setLensKey(event.target.value);
|
|
1219
|
+
setFocusKey(null);
|
|
1220
|
+
setFocusDepth(1);
|
|
1221
|
+
setHiddenCollections(new Set());
|
|
1222
|
+
}}
|
|
1223
|
+
title={lens.description}
|
|
1224
|
+
className="rounded-sm border border-[rgb(var(--ec-input-border))] bg-[rgb(var(--ec-input-bg))] px-2 py-1 text-[rgb(var(--ec-input-text))]"
|
|
1225
|
+
>
|
|
1226
|
+
{Object.entries(LENSES).map(([key, { label }]) => (
|
|
1227
|
+
<option key={key} value={key}>
|
|
1228
|
+
{label}
|
|
1229
|
+
</option>
|
|
1230
|
+
))}
|
|
1231
|
+
</select>
|
|
1232
|
+
</div>
|
|
1233
|
+
{lens.hubCollections && !focusedNode && (
|
|
1234
|
+
<label
|
|
1235
|
+
className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]"
|
|
1236
|
+
title="How many relationship hops to show around the anchor nodes"
|
|
1237
|
+
>
|
|
1238
|
+
Detail
|
|
1239
|
+
<input
|
|
1240
|
+
type="range"
|
|
1241
|
+
min={1}
|
|
1242
|
+
max={MAX_LENS_DEPTH}
|
|
1243
|
+
step={1}
|
|
1244
|
+
value={lensDepth}
|
|
1245
|
+
onChange={(event) => setLensDepth(Number(event.target.value))}
|
|
1246
|
+
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1247
|
+
/>
|
|
1248
|
+
<span className="w-5 text-[rgb(var(--ec-page-text))]">{lensDepth >= MAX_LENS_DEPTH ? 'All' : lensDepth}</span>
|
|
1249
|
+
</label>
|
|
1250
|
+
)}
|
|
1251
|
+
</div>
|
|
1252
|
+
<div className="absolute right-3 top-3 z-10 flex flex-col gap-2 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs shadow-xs">
|
|
1253
|
+
<div ref={searchContainerRef} className="relative">
|
|
1254
|
+
<input
|
|
1255
|
+
type="text"
|
|
1256
|
+
placeholder="Search & focus…"
|
|
1257
|
+
value={searchValue}
|
|
1258
|
+
onChange={(event) => {
|
|
1259
|
+
setSearchValue(event.target.value);
|
|
1260
|
+
setShowSuggestions(true);
|
|
1261
|
+
setSelectedSuggestionIndex(-1);
|
|
1262
|
+
}}
|
|
1263
|
+
onFocus={() => setShowSuggestions(true)}
|
|
1264
|
+
onKeyDown={handleSearchKeyDown}
|
|
1265
|
+
className="w-56 rounded-sm border border-[rgb(var(--ec-input-border))] bg-[rgb(var(--ec-input-bg))] py-1 pl-2 pr-7 text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
|
|
1266
|
+
/>
|
|
1267
|
+
{searchValue && (
|
|
1268
|
+
<button
|
|
1269
|
+
type="button"
|
|
1270
|
+
onClick={() => setSearchValue('')}
|
|
1271
|
+
aria-label="Clear search"
|
|
1272
|
+
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]"
|
|
1273
|
+
>
|
|
1274
|
+
×
|
|
1275
|
+
</button>
|
|
1276
|
+
)}
|
|
1277
|
+
{showSuggestions && searchSuggestions.length > 0 && (
|
|
1278
|
+
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-lg">
|
|
1279
|
+
{searchSuggestions.map((node, index) => {
|
|
1280
|
+
const Icon = getIconForCollection(node.collection);
|
|
1281
|
+
const chipStyle = collectionChipStyle(node.collection);
|
|
1282
|
+
const isSelected = index === selectedSuggestionIndex;
|
|
1283
|
+
return (
|
|
1284
|
+
<button
|
|
1285
|
+
key={node.key}
|
|
1286
|
+
type="button"
|
|
1287
|
+
onClick={() => selectSuggestion(node)}
|
|
1288
|
+
onMouseEnter={() => setSelectedSuggestionIndex(index)}
|
|
1289
|
+
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left ${isSelected ? 'bg-[rgb(var(--ec-accent-subtle))]' : ''}`}
|
|
1290
|
+
>
|
|
1291
|
+
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md" style={chipStyle}>
|
|
1292
|
+
<Icon width={13} height={13} />
|
|
1293
|
+
</span>
|
|
1294
|
+
<span className="min-w-0 flex-1">
|
|
1295
|
+
<span className="block truncate font-medium text-[rgb(var(--ec-page-text))]">{node.label}</span>
|
|
1296
|
+
{node.version && (
|
|
1297
|
+
<span className="block truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">v{node.version}</span>
|
|
1298
|
+
)}
|
|
1299
|
+
</span>
|
|
1300
|
+
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" style={chipStyle}>
|
|
1301
|
+
{COLLECTION_LABELS[node.collection] ?? node.collection}
|
|
1302
|
+
</span>
|
|
1303
|
+
</button>
|
|
1304
|
+
);
|
|
1305
|
+
})}
|
|
1306
|
+
</div>
|
|
1307
|
+
)}
|
|
1308
|
+
</div>
|
|
1309
|
+
{focusedNode && (
|
|
1310
|
+
<>
|
|
1311
|
+
<button
|
|
1312
|
+
type="button"
|
|
1313
|
+
onClick={() => setFocusKey(null)}
|
|
1314
|
+
className="flex items-center gap-1 self-start rounded-full border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 text-[rgb(var(--ec-page-text))] hover:opacity-80"
|
|
1315
|
+
title="Clear focus"
|
|
1316
|
+
>
|
|
1317
|
+
<span
|
|
1318
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
1319
|
+
style={{ backgroundColor: nodeColor(focusedNode.collection) }}
|
|
1320
|
+
/>
|
|
1321
|
+
Focused: {focusedNode.label}
|
|
1322
|
+
<span aria-hidden="true">×</span>
|
|
1323
|
+
</button>
|
|
1324
|
+
<label className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]">
|
|
1325
|
+
Depth
|
|
1326
|
+
<input
|
|
1327
|
+
type="range"
|
|
1328
|
+
min={1}
|
|
1329
|
+
max={3}
|
|
1330
|
+
step={1}
|
|
1331
|
+
value={focusDepth}
|
|
1332
|
+
onChange={(event) => setFocusDepth(Number(event.target.value))}
|
|
1333
|
+
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1334
|
+
/>
|
|
1335
|
+
<span className="w-3 text-[rgb(var(--ec-page-text))]">{focusDepth}</span>
|
|
1336
|
+
</label>
|
|
1337
|
+
</>
|
|
1338
|
+
)}
|
|
1339
|
+
</div>
|
|
1340
|
+
{focusedNode && (
|
|
1341
|
+
<div className="absolute bottom-3 left-3 z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-3 text-xs shadow-xs">
|
|
1342
|
+
<div className="mb-2 font-semibold text-[rgb(var(--ec-page-text))]">You are here</div>
|
|
1343
|
+
<ul className="space-y-1">
|
|
1344
|
+
{[...focusAncestors].reverse().map((ancestor, index) => (
|
|
1345
|
+
<li key={ancestor.key} style={{ paddingLeft: index * 14 }}>
|
|
1346
|
+
<button
|
|
1347
|
+
type="button"
|
|
1348
|
+
onClick={() => setFocusKey(ancestor.key)}
|
|
1349
|
+
title={`Focus ${ancestor.label}`}
|
|
1350
|
+
className="flex w-full items-center gap-1.5 rounded-sm px-1 py-0.5 text-left hover:bg-[rgb(var(--ec-accent-subtle))]"
|
|
1351
|
+
>
|
|
1352
|
+
{index > 0 && <span className="text-[rgb(var(--ec-page-text-muted))]">↳</span>}
|
|
1353
|
+
<span
|
|
1354
|
+
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
1355
|
+
style={{ backgroundColor: nodeColor(ancestor.collection) }}
|
|
1356
|
+
/>
|
|
1357
|
+
<span className="text-[rgb(var(--ec-page-text))]">{ancestor.label}</span>
|
|
1358
|
+
<span className="text-[10px] text-[rgb(var(--ec-page-text-muted))]">
|
|
1359
|
+
{COLLECTION_LABELS[ancestor.collection] ?? ancestor.collection}
|
|
1360
|
+
</span>
|
|
1361
|
+
</button>
|
|
1362
|
+
</li>
|
|
1363
|
+
))}
|
|
1364
|
+
<li style={{ paddingLeft: focusAncestors.length * 14 }}>
|
|
1365
|
+
<span className="flex items-center gap-1.5 px-1 py-0.5 font-semibold">
|
|
1366
|
+
{focusAncestors.length > 0 && <span className="text-[rgb(var(--ec-page-text-muted))]">↳</span>}
|
|
1367
|
+
<span
|
|
1368
|
+
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
1369
|
+
style={{ backgroundColor: nodeColor(focusedNode.collection) }}
|
|
1370
|
+
/>
|
|
1371
|
+
<span className="text-[rgb(var(--ec-page-text))]">{focusedNode.label}</span>
|
|
1372
|
+
<span className="text-[10px] font-normal text-[rgb(var(--ec-page-text-muted))]">
|
|
1373
|
+
{COLLECTION_LABELS[focusedNode.collection] ?? focusedNode.collection}
|
|
1374
|
+
</span>
|
|
1375
|
+
</span>
|
|
1376
|
+
</li>
|
|
1377
|
+
</ul>
|
|
1378
|
+
</div>
|
|
1379
|
+
)}
|
|
1380
|
+
<div className="absolute bottom-3 right-3 z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-3 text-xs shadow-xs">
|
|
1381
|
+
<div className="mb-2 font-semibold text-[rgb(var(--ec-page-text))]">Resources</div>
|
|
1382
|
+
<ul className="space-y-1">
|
|
1383
|
+
{collectionsInGraph.map(({ collection, count }) => {
|
|
1384
|
+
const hidden = hiddenCollections.has(collection);
|
|
1385
|
+
return (
|
|
1386
|
+
<li key={collection}>
|
|
1387
|
+
<button
|
|
1388
|
+
type="button"
|
|
1389
|
+
onClick={() => toggleCollection(collection)}
|
|
1390
|
+
className={`flex w-full items-center gap-2 rounded-sm px-1 py-0.5 text-left hover:bg-[rgb(var(--ec-accent-subtle))] ${hidden ? 'opacity-40' : ''}`}
|
|
1391
|
+
>
|
|
1392
|
+
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: nodeColor(collection) }} />
|
|
1393
|
+
<span className="text-[rgb(var(--ec-page-text))]">
|
|
1394
|
+
{COLLECTION_LABELS[collection] ?? collection} ({count})
|
|
1395
|
+
</span>
|
|
1396
|
+
</button>
|
|
1397
|
+
</li>
|
|
1398
|
+
);
|
|
1399
|
+
})}
|
|
1400
|
+
</ul>
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
);
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
export default CatalogForceGraph;
|