@johndimm/constellations 1.0.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.
Files changed (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,414 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { GraphNode, GraphLink } from '../types';
3
+ import { LockedPair, findWikipediaTitle } from '../services/geminiService';
4
+ import { fetchServerImage } from '../services/imageService';
5
+ import { dedupeGraph } from '../services/graphUtils';
6
+ import { GraphHandle } from '../components/Graph';
7
+
8
+ interface UseGraphStateOptions {
9
+ cacheEnabled: boolean;
10
+ cacheBaseUrl: string;
11
+ }
12
+
13
+ export function useGraphState(options: UseGraphStateOptions) {
14
+ const { cacheEnabled, cacheBaseUrl } = options;
15
+
16
+ const [graphData, setGraphData] = useState<{ nodes: GraphNode[], links: GraphLink[] }>({ nodes: [], links: [] });
17
+ const { nodes, links } = graphData;
18
+ const graphDataRef = useRef(graphData);
19
+
20
+ useEffect(() => {
21
+ graphDataRef.current = graphData;
22
+ }, [graphData]);
23
+
24
+ const [isProcessing, setIsProcessing] = useState(false);
25
+ const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
26
+ const [selectedLink, setSelectedLink] = useState<GraphLink | null>(null);
27
+
28
+ const [isCompact, setIsCompact] = useState(false);
29
+ const [isTimelineMode, setIsTimelineMode] = useState(false);
30
+ const [isTextOnly, setIsTextOnly] = useState(false);
31
+ const [searchMode, setSearchMode] = useState<'explore' | 'connect'>('explore');
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [isKeyReady, setIsKeyReady] = useState(false);
34
+ const [searchId, setSearchId] = useState(0);
35
+ const searchIdRef = useRef(0);
36
+
37
+ useEffect(() => {
38
+ searchIdRef.current = searchId;
39
+ }, [searchId]);
40
+
41
+ const [lockedPair, setLockedPair] = useState<LockedPair>({ atomicType: "Person", compositeType: "Event" });
42
+ const lockedPairRef = useRef<LockedPair>(lockedPair);
43
+ useEffect(() => { lockedPairRef.current = lockedPair; }, [lockedPair]);
44
+
45
+ const nodesRef = useRef<GraphNode[]>([]);
46
+ useEffect(() => {
47
+ nodesRef.current = nodes;
48
+ }, [nodes]);
49
+
50
+ const selectedNodeRef = useRef<GraphNode | null>(null);
51
+ useEffect(() => {
52
+ selectedNodeRef.current = selectedNode;
53
+ }, [selectedNode]);
54
+
55
+ const autoExpandMoreDoneRef = useRef<Set<string | number>>(new Set());
56
+
57
+ const [deletePreview, setDeletePreview] = useState<{ keepIds: (number | string)[], dropIds: (number | string)[] } | null>(null);
58
+ const [pathNodeIds, setPathNodeIds] = useState<(number | string)[]>([]);
59
+ const [newlyExpandedNodeIds, setNewlyExpandedNodeIds] = useState<(number | string)[]>([]);
60
+ const [expandingNodeId, setExpandingNodeId] = useState<number | string | null>(null);
61
+ const [newChildNodeIds, setNewChildNodeIds] = useState<Set<number | string>>(new Set());
62
+ const [helpHover, setHelpHover] = useState<string | null>(null);
63
+
64
+ const [notification, setNotification] = useState<{ message: string, type: 'success' | 'error' } | null>(null);
65
+ useEffect(() => {
66
+ if (!notification) return;
67
+ const timer = setTimeout(() => setNotification(null), 5000);
68
+ return () => clearTimeout(timer);
69
+ }, [notification]);
70
+
71
+ const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean, message: string, onConfirm: () => void } | null>(null);
72
+ const [contextMenu, setContextMenu] = useState<{ node: GraphNode; x: number; y: number } | null>(null);
73
+
74
+ const [panelCollapsed, setPanelCollapsed] = useState(false);
75
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
76
+ const [sidebarToggleSignal, setSidebarToggleSignal] = useState(0);
77
+ const [peopleBrowserOpen, setPeopleBrowserOpen] = useState(false);
78
+ const [savedGraphs, setSavedGraphs] = useState<string[]>([]);
79
+
80
+ const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight });
81
+ useEffect(() => {
82
+ const handleResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight });
83
+ window.addEventListener('resize', handleResize);
84
+ return () => window.removeEventListener('resize', handleResize);
85
+ }, []);
86
+
87
+ const graphRef = useRef<GraphHandle>(null);
88
+
89
+ // Prevent image "flapping"
90
+ const imageReqTokenRef = useRef<Map<string | number, number>>(new Map());
91
+
92
+ const saveCacheNodeMeta = useCallback(async (
93
+ nodeId: number | string,
94
+ meta: {
95
+ imageUrl?: string | null,
96
+ wikiSummary?: string | null,
97
+ wikipedia_id?: string | null,
98
+ mentioningPageTitles?: string[] | null
99
+ },
100
+ fallbackNode?: Partial<GraphNode> & { id: number | string; type?: string; title: string }
101
+ ) => {
102
+ if (!cacheEnabled) return;
103
+ const node = nodesRef.current.find(n => String(n.id) === String(nodeId)) || fallbackNode;
104
+ if (!node || !node.type) return;
105
+ try {
106
+ const metaToSend: any = {};
107
+ const img = meta.imageUrl ?? (node as any).imageUrl;
108
+ const wiki = meta.wikiSummary ?? (node as any).wikiSummary;
109
+ const wikiId = meta.wikipedia_id ?? (node as any).wikipedia_id;
110
+ const mentioning = meta.mentioningPageTitles ?? (node as any).mentioningPageTitles;
111
+ if (img) metaToSend.imageUrl = img;
112
+ if (wiki) metaToSend.wikiSummary = wiki;
113
+ if (wikiId) metaToSend.wikipedia_id = wikiId;
114
+ if (mentioning) metaToSend.mentioningPageTitles = mentioning;
115
+ await fetch(new URL("/node", cacheBaseUrl).toString(), {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ id: node.id,
120
+ title: node.title,
121
+ type: node.type,
122
+ description: node.description || "",
123
+ year: node.year ?? null,
124
+ meta: metaToSend,
125
+ wikipedia_id: wikiId || node.wikipedia_id
126
+ })
127
+ });
128
+ } catch (e) {
129
+ // console.warn("Cache node save failed", e);
130
+ }
131
+ }, [cacheEnabled, cacheBaseUrl]);
132
+
133
+ const loadNodeImage = useCallback(async (
134
+ nodeId: number | string,
135
+ title: string,
136
+ context?: string,
137
+ fallbackNode?: Partial<GraphNode> & { id: number | string; type?: string; title: string },
138
+ opts?: { force?: boolean }
139
+ ) => {
140
+ if (isTextOnly) return;
141
+
142
+ const force = !!opts?.force;
143
+ const current = graphDataRef.current.nodes.find(n => String(n.id) === String(nodeId));
144
+ if (!force) {
145
+ if (current?.imageUrl) return;
146
+ if (current?.fetchingImage) return;
147
+ if (current?.imageChecked) return;
148
+ }
149
+
150
+ const nextToken = (imageReqTokenRef.current.get(String(nodeId)) || 0) + 1;
151
+ imageReqTokenRef.current.set(String(nodeId), nextToken);
152
+
153
+ setGraphData(prev => ({
154
+ ...prev,
155
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? { ...n, fetchingImage: true, imageChecked: true } : n)
156
+ }));
157
+
158
+ const imageBaseUrl = cacheEnabled ? cacheBaseUrl : window.location.origin;
159
+ const effectiveType = context || current?.type || fallbackNode?.type || "";
160
+ const effectiveYear = (current?.year ?? (fallbackNode as any)?.year) as number | undefined;
161
+
162
+ // Avoid ambiguity for common screen-work titles like "The Killing" (1956 film vs 2011 TV series).
163
+ // Prefer a Wikipedia-style disambiguated title when we have a year and the title is not already disambiguated.
164
+ const looksDisambiguated = /\([^)]*\)/.test(title);
165
+ const typeLower = String(effectiveType).toLowerCase();
166
+ const isFilmLike = /\b(film|movie)\b/.test(typeLower);
167
+ const isTvLike = /\b(tv|television|series|miniseries|show)\b/.test(typeLower);
168
+ const isScreenLike = isFilmLike || isTvLike;
169
+ const titleForImage =
170
+ !looksDisambiguated && isScreenLike && effectiveYear
171
+ ? `${title} (${effectiveYear} ${isFilmLike ? "film" : "TV series"})`
172
+ : title;
173
+ const contextForImage =
174
+ effectiveYear && isScreenLike
175
+ ? `${effectiveType} ${effectiveYear}`.trim()
176
+ : effectiveType;
177
+
178
+ const imageResult = await fetchServerImage(titleForImage, contextForImage, imageBaseUrl);
179
+ if ((imageReqTokenRef.current.get(String(nodeId)) || 0) !== nextToken) return;
180
+
181
+ if (imageResult.url) {
182
+ setGraphData(prev => ({
183
+ ...prev,
184
+ nodes: prev.nodes.map(n => {
185
+ if (String(n.id) !== String(nodeId)) return n;
186
+ if (!force && n.imageUrl) return { ...n, fetchingImage: false, imageChecked: true };
187
+ return {
188
+ ...n,
189
+ imageUrl: imageResult.url,
190
+ image_wikipedia_id: (imageResult as any).pageId?.toString(),
191
+ image_wikipedia_title: (imageResult as any).pageTitle,
192
+ fetchingImage: false,
193
+ imageChecked: true
194
+ };
195
+ })
196
+ }));
197
+ // Persist the new image to the cache
198
+ const wikiId = (imageResult as any).pageId?.toString();
199
+ saveCacheNodeMeta(nodeId, { imageUrl: imageResult.url, wikipedia_id: wikiId }, fallbackNode);
200
+ } else {
201
+ setGraphData(prev => ({
202
+ ...prev,
203
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? { ...n, fetchingImage: false, imageChecked: true } : n)
204
+ }));
205
+ }
206
+ }, [isTextOnly, cacheEnabled, cacheBaseUrl, saveCacheNodeMeta]);
207
+
208
+ const handleFindBetterImage = useCallback(async (nodeId: number | string) => {
209
+ const node = graphDataRef.current.nodes.find(n => String(n.id) === String(nodeId));
210
+ if (!node) return;
211
+
212
+ setGraphData(prev => ({
213
+ ...prev,
214
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? { ...n, fetchingImage: true } : n)
215
+ }));
216
+
217
+ setNotification({ message: `AI is looking for ${node.title}'s correct photo...`, type: 'success' });
218
+
219
+ try {
220
+ try {
221
+ const imgCache: Map<string, string | null> | undefined = (window as any).__wikiImageCache;
222
+ if (imgCache && typeof imgCache.delete === 'function') {
223
+ imgCache.delete(node.title.trim().toLowerCase());
224
+ }
225
+ } catch { }
226
+
227
+ const aiSuggestion = await findWikipediaTitle(node.title, node.description);
228
+ if (aiSuggestion) {
229
+ const { title: betterTitle, imageHint } = aiSuggestion;
230
+ try {
231
+ const imgCache: Map<string, string | null> | undefined = (window as any).__wikiImageCache;
232
+ if (imgCache && typeof imgCache.delete === 'function') {
233
+ if (betterTitle) imgCache.delete(betterTitle.trim().toLowerCase());
234
+ if (imageHint) imgCache.delete(imageHint.trim().toLowerCase());
235
+ }
236
+ } catch { }
237
+
238
+ if (imageHint) {
239
+ const imageBaseUrl = cacheEnabled ? cacheBaseUrl : window.location.origin;
240
+ const imageResult = await fetchServerImage(imageHint, node.type, imageBaseUrl);
241
+ if (imageResult.url) {
242
+ setGraphData(prev => ({
243
+ ...prev,
244
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? {
245
+ ...n,
246
+ imageUrl: imageResult.url,
247
+ image_wikipedia_id: (imageResult as any).pageId?.toString(),
248
+ image_wikipedia_title: (imageResult as any).pageTitle,
249
+ fetchingImage: false,
250
+ imageChecked: true
251
+ } : n)
252
+ }));
253
+ // Persist the new image to the cache
254
+ const wikiId = (imageResult as any).pageId?.toString();
255
+ saveCacheNodeMeta(nodeId, { imageUrl: imageResult.url, wikipedia_id: wikiId });
256
+ setNotification({ message: "Better photo found via AI hint!", type: 'success' });
257
+ return;
258
+ }
259
+ }
260
+
261
+ await loadNodeImage(nodeId, betterTitle, node.type, undefined, { force: true });
262
+ const updated = graphDataRef.current.nodes.find(n => String(n.id) === String(nodeId));
263
+ if (updated?.imageUrl) {
264
+ setNotification({ message: "Better photo found!", type: 'success' });
265
+ return;
266
+ }
267
+ }
268
+
269
+ await loadNodeImage(nodeId, node.title, node.type, undefined, { force: true });
270
+ const updated = graphDataRef.current.nodes.find(n => String(n.id) === String(nodeId));
271
+ if (updated?.imageUrl) {
272
+ setNotification({ message: "Photo updated!", type: 'success' });
273
+ return;
274
+ }
275
+
276
+ const imageBaseUrl = cacheEnabled ? cacheBaseUrl : window.location.origin;
277
+ const serverResult = await fetchServerImage(node.title, node.type, imageBaseUrl);
278
+ if (serverResult.url) {
279
+ setGraphData(prev => ({
280
+ ...prev,
281
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? {
282
+ ...n,
283
+ imageUrl: serverResult.url,
284
+ fetchingImage: false,
285
+ imageChecked: true
286
+ } : n)
287
+ }));
288
+ saveCacheNodeMeta(nodeId, { imageUrl: serverResult.url });
289
+ setNotification({ message: "Image found via server lookup.", type: 'success' });
290
+ return;
291
+ }
292
+
293
+ setNotification({ message: "No better photo found.", type: 'error' });
294
+ } catch (e) {
295
+ // console.error("Find better image failed", e);
296
+ setNotification({ message: "Failed to find better photo.", type: 'error' });
297
+ } finally {
298
+ setGraphData(prev => ({
299
+ ...prev,
300
+ nodes: prev.nodes.map(n => String(n.id) === String(nodeId) ? { ...n, fetchingImage: false } : n)
301
+ }));
302
+ }
303
+ }, [cacheEnabled, cacheBaseUrl, loadNodeImage, saveCacheNodeMeta, setNotification]);
304
+
305
+ // Global safety net: dedupe graph whenever nodes/links change
306
+ useEffect(() => {
307
+ const deduped = dedupeGraph(nodes, links);
308
+ const normalizedNodes = deduped.nodes.map(n => {
309
+ if (n.is_atomic === undefined && typeof (n as any).is_person === 'boolean') {
310
+ return { ...n, is_atomic: (n as any).is_person };
311
+ }
312
+ return n;
313
+ });
314
+
315
+ const nodesChanged =
316
+ normalizedNodes.length !== nodes.length ||
317
+ normalizedNodes.some((n, i) => n.id !== nodes[i]?.id || n.is_atomic !== nodes[i]?.is_atomic);
318
+ const linksChanged =
319
+ deduped.links.length !== links.length ||
320
+ deduped.links.some((l, i) => l.id !== links[i]?.id);
321
+
322
+ if (nodesChanged || linksChanged) {
323
+ setGraphData({ nodes: normalizedNodes, links: deduped.links });
324
+ }
325
+ }, [nodes, links]);
326
+
327
+ // Load saved graphs on init
328
+ useEffect(() => {
329
+ const loadSavedGraphs = async () => {
330
+ if (cacheEnabled && cacheBaseUrl) {
331
+ try {
332
+ const res = await fetch(new URL("/graphs", cacheBaseUrl).toString());
333
+ if (res.ok) {
334
+ const data = await res.json();
335
+ // Endpoint returns array of { name, updated_at }
336
+ const serverGraphs = data.map((g: any) => g.name);
337
+ setSavedGraphs(serverGraphs.sort());
338
+ }
339
+ } catch (e) {
340
+ // console.warn("Failed to fetch saved graphs from server", e);
341
+ }
342
+ }
343
+ };
344
+ loadSavedGraphs();
345
+ }, [cacheEnabled, cacheBaseUrl]);
346
+
347
+ return {
348
+ graphData,
349
+ setGraphData,
350
+ nodes,
351
+ links,
352
+ graphDataRef,
353
+ isProcessing,
354
+ setIsProcessing,
355
+ selectedNode,
356
+ setSelectedNode,
357
+ selectedLink,
358
+ setSelectedLink,
359
+ isCompact,
360
+ setIsCompact,
361
+ isTimelineMode,
362
+ setIsTimelineMode,
363
+ isTextOnly,
364
+ setIsTextOnly,
365
+ error,
366
+ setError,
367
+ isKeyReady,
368
+ setIsKeyReady,
369
+ searchId,
370
+ setSearchId,
371
+ searchIdRef,
372
+ lockedPair,
373
+ setLockedPair,
374
+ lockedPairRef,
375
+ nodesRef,
376
+ selectedNodeRef,
377
+ autoExpandMoreDoneRef,
378
+ loadNodeImage,
379
+ handleFindBetterImage,
380
+ saveCacheNodeMeta,
381
+ deletePreview,
382
+ setDeletePreview,
383
+ pathNodeIds,
384
+ setPathNodeIds,
385
+ newlyExpandedNodeIds,
386
+ setNewlyExpandedNodeIds,
387
+ expandingNodeId,
388
+ setExpandingNodeId,
389
+ newChildNodeIds,
390
+ setNewChildNodeIds,
391
+ helpHover,
392
+ setHelpHover,
393
+ notification,
394
+ setNotification,
395
+ confirmDialog,
396
+ setConfirmDialog,
397
+ contextMenu,
398
+ setContextMenu,
399
+ panelCollapsed,
400
+ setPanelCollapsed,
401
+ sidebarCollapsed,
402
+ setSidebarCollapsed,
403
+ sidebarToggleSignal,
404
+ setSidebarToggleSignal,
405
+ peopleBrowserOpen,
406
+ setPeopleBrowserOpen,
407
+ dimensions,
408
+ graphRef,
409
+ savedGraphs,
410
+ setSavedGraphs,
411
+ searchMode,
412
+ setSearchMode
413
+ };
414
+ }
@@ -0,0 +1,47 @@
1
+ import { useState, useEffect } from 'react';
2
+ import {
3
+ KioskDomain,
4
+ hasLocalKioskDomains,
5
+ loadKioskDomains,
6
+ saveKioskDomains,
7
+ loadSelectedKioskDomainId,
8
+ saveSelectedKioskDomainId
9
+ } from '../kioskDomains';
10
+
11
+ export function useKioskMode() {
12
+ // Admin mode: enables editing kiosk domains in-app (requires keyboard/mouse)
13
+ const [isAdminMode] = useState(() => {
14
+ try {
15
+ const params = new URLSearchParams(window.location.search);
16
+ return params.get('admin') === '1';
17
+ } catch {
18
+ return false;
19
+ }
20
+ });
21
+
22
+ const [kioskDomains, setKioskDomains] = useState<KioskDomain[]>(() => loadKioskDomains());
23
+ const [selectedKioskDomainId, setSelectedKioskDomainId] = useState<string>(() =>
24
+ loadSelectedKioskDomainId(loadKioskDomains())
25
+ );
26
+
27
+ useEffect(() => {
28
+ // Persistence is currently disabled.
29
+ // const persistEnabled = isAdminMode || hasLocalKioskDomains();
30
+ // if (!persistEnabled) return;
31
+ // try { saveKioskDomains(kioskDomains); } catch { }
32
+ // try { saveSelectedKioskDomainId(selectedKioskDomainId); } catch { }
33
+ }, [kioskDomains, selectedKioskDomainId, isAdminMode]);
34
+
35
+ const selectedKioskDomain = kioskDomains.find(d => d.id === selectedKioskDomainId) || kioskDomains[0];
36
+ const kioskSeedTerms = selectedKioskDomain?.terms || [];
37
+
38
+ return {
39
+ isAdminMode,
40
+ kioskDomains,
41
+ setKioskDomains,
42
+ selectedKioskDomainId,
43
+ setSelectedKioskDomainId,
44
+ selectedKioskDomain,
45
+ kioskSeedTerms
46
+ };
47
+ }
@@ -0,0 +1,172 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { GraphNode, GraphLink } from '../types';
3
+
4
+ export type NodeContextMenuState = { node: GraphNode; x: number; y: number };
5
+
6
+ export type NodeClickHandlers = {
7
+ selectedNode: GraphNode | null;
8
+ setSelectedNode: (node: GraphNode | null) => void;
9
+ setContextMenu: (menu: NodeContextMenuState | null) => void;
10
+ graphData?: { nodes: GraphNode[]; links: GraphLink[] };
11
+ setExpandingNodeId?: (id: string | number | null) => void;
12
+ setNewChildNodeIds?: (ids: Set<string>) => void;
13
+ onDebug?: (message: string) => void;
14
+ onDeselect?: () => void;
15
+ onClearSecondarySelection?: () => void;
16
+ onRetryImage?: (node: GraphNode) => void;
17
+ onConnectSelect?: (node: GraphNode) => void;
18
+ onExpandedSelect?: (node: GraphNode) => void;
19
+ onExpand?: (node: GraphNode) => void | Promise<void>;
20
+ onNavigate?: (node: GraphNode) => void;
21
+ selectOnFirstClick?: boolean;
22
+ shouldExpand?: (node: GraphNode) => boolean;
23
+ getMenuPosition?: (node: GraphNode, event?: MouseEvent) => { x: number; y: number };
24
+ };
25
+
26
+ export const useNodeClickHandler = ({
27
+ selectedNode,
28
+ setSelectedNode,
29
+ setContextMenu,
30
+ graphData,
31
+ setExpandingNodeId,
32
+ setNewChildNodeIds,
33
+ onDebug,
34
+ onDeselect,
35
+ onClearSecondarySelection,
36
+ onRetryImage,
37
+ onConnectSelect,
38
+ onExpandedSelect,
39
+ onExpand,
40
+ onNavigate,
41
+ selectOnFirstClick = true,
42
+ shouldExpand,
43
+ getMenuPosition
44
+ }: NodeClickHandlers) => {
45
+ const lastSelectedIdRef = useRef<number | string | null>(selectedNode?.id ?? null);
46
+ const lastClickRef = useRef<{ id: number | string; at: number } | null>(null);
47
+
48
+ useEffect(() => {
49
+ lastSelectedIdRef.current = selectedNode?.id ?? null;
50
+ }, [selectedNode]);
51
+
52
+ return useCallback(async (node: GraphNode | null, event?: MouseEvent) => {
53
+ if (!node) {
54
+ setSelectedNode(null);
55
+ setContextMenu(null);
56
+ lastSelectedIdRef.current = null;
57
+ onDebug?.('click: none -> clear selection');
58
+ onDeselect?.();
59
+ return;
60
+ }
61
+
62
+ onRetryImage?.(node);
63
+ onConnectSelect?.(node);
64
+
65
+ const now = Date.now();
66
+ const isDoubleClick = !!event && typeof event.detail === 'number' && event.detail >= 2;
67
+
68
+ setContextMenu(null);
69
+ onClearSecondarySelection?.();
70
+
71
+ if (node.expanded || node.isLoading) {
72
+ setSelectedNode(node);
73
+ lastSelectedIdRef.current = node.id;
74
+ onExpandedSelect?.(node);
75
+ onNavigate?.(node);
76
+ lastClickRef.current = { id: node.id, at: now };
77
+ onDebug?.(`click: ${node.title} -> select expanded:${!!node.expanded} loading:${!!node.isLoading}`);
78
+
79
+ // Highlight the clicked node and all its connected nodes
80
+ if (graphData && setExpandingNodeId && setNewChildNodeIds) {
81
+ const connectedNodeIds: string[] = [];
82
+ graphData.links.forEach(link => {
83
+ const sourceId = String(typeof link.source === 'object' ? (link.source as any).id : link.source);
84
+ const targetId = String(typeof link.target === 'object' ? (link.target as any).id : link.target);
85
+
86
+ if (String(sourceId) === String(node.id)) {
87
+ connectedNodeIds.push(String(targetId));
88
+ } else if (String(targetId) === String(node.id)) {
89
+ connectedNodeIds.push(String(sourceId));
90
+ }
91
+ });
92
+
93
+ setExpandingNodeId(node.id);
94
+ setNewChildNodeIds(new Set(connectedNodeIds));
95
+ onDebug?.(`highlight: ${node.title} + ${connectedNodeIds.length} connected nodes`);
96
+ }
97
+
98
+ /** Double-click on an already-expanded node opens the context menu (was unreachable next block). */
99
+ if (isDoubleClick && node.expanded && !node.isLoading) {
100
+ const pos = getMenuPosition
101
+ ? getMenuPosition(node, event)
102
+ : { x: event?.clientX ?? window.innerWidth / 2, y: event?.clientY ?? window.innerHeight / 2 };
103
+ setContextMenu({ node, x: pos.x, y: pos.y });
104
+ onDebug?.(`click: ${node.title} -> menu (double-click)`);
105
+ lastClickRef.current = { id: node.id, at: now };
106
+ }
107
+
108
+ return;
109
+ }
110
+
111
+ /**
112
+ * Unexpanded leaf: both single-click and double-click expand.
113
+ * A lone programmatic/synthetic click with detail>=2 used to branch to the menu-only path and skip onExpand.
114
+ */
115
+
116
+ if (selectOnFirstClick) {
117
+ setSelectedNode(node);
118
+ lastSelectedIdRef.current = node.id;
119
+ lastClickRef.current = { id: node.id, at: now };
120
+ onDebug?.(`click: ${node.title} -> select`);
121
+
122
+ // Highlight the clicked node and all its connected nodes
123
+ if (graphData && setExpandingNodeId && setNewChildNodeIds) {
124
+ const connectedNodeIds: string[] = [];
125
+ graphData.links.forEach(link => {
126
+ const sourceId = String(typeof link.source === 'object' ? (link.source as any).id : link.source);
127
+ const targetId = String(typeof link.target === 'object' ? (link.target as any).id : link.target);
128
+
129
+ if (String(sourceId) === String(node.id)) {
130
+ connectedNodeIds.push(String(targetId));
131
+ } else if (String(targetId) === String(node.id)) {
132
+ connectedNodeIds.push(String(sourceId));
133
+ }
134
+ });
135
+
136
+ setExpandingNodeId(node.id);
137
+ setNewChildNodeIds(new Set(connectedNodeIds));
138
+ onDebug?.(`highlight: ${node.title} + ${connectedNodeIds.length} connected nodes`);
139
+ }
140
+ }
141
+
142
+ onNavigate?.(node);
143
+
144
+ if (!onExpand) return;
145
+ const should = shouldExpand ? shouldExpand(node) : !(node.expanded || node.isLoading);
146
+ if (!should) {
147
+ onDebug?.(`click: ${node.title} -> skip expand`);
148
+ return;
149
+ }
150
+ onDebug?.(`click: ${node.title} -> expand`);
151
+ await onExpand(node);
152
+ lastClickRef.current = { id: node.id, at: Date.now() };
153
+ }, [
154
+ selectedNode,
155
+ setSelectedNode,
156
+ setContextMenu,
157
+ graphData,
158
+ setExpandingNodeId,
159
+ setNewChildNodeIds,
160
+ onDebug,
161
+ onDeselect,
162
+ onClearSecondarySelection,
163
+ onRetryImage,
164
+ onConnectSelect,
165
+ onExpandedSelect,
166
+ onExpand,
167
+ onNavigate,
168
+ selectOnFirstClick,
169
+ shouldExpand,
170
+ getMenuPosition
171
+ ]);
172
+ };