@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- 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
|
+
};
|