@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.
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &amp; 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;