@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,369 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { GraphNode, GraphLink } from '../types';
|
|
3
|
+
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/geminiService';
|
|
4
|
+
import { fetchWikipediaSummary } from '../services/wikipediaService';
|
|
5
|
+
import { dedupeGraph, normalizeForDedup } from '../services/graphUtils';
|
|
6
|
+
import { clampToViewport } from '../utils/graphLogicUtils';
|
|
7
|
+
import { buildWikiUrl } from '../utils/wikiUtils';
|
|
8
|
+
|
|
9
|
+
interface PathResponse {
|
|
10
|
+
path: any[];
|
|
11
|
+
found: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseSearchHandlersOptions {
|
|
15
|
+
graphDataRef: React.MutableRefObject<{ nodes: GraphNode[], links: GraphLink[] }>;
|
|
16
|
+
setGraphData: React.Dispatch<React.SetStateAction<{ nodes: GraphNode[], links: GraphLink[] }>>;
|
|
17
|
+
setIsProcessing: (val: boolean) => void;
|
|
18
|
+
setError: (val: string | null) => void;
|
|
19
|
+
setSearchId: (id: number | ((prev: number) => number)) => void;
|
|
20
|
+
searchIdRef: React.MutableRefObject<number>;
|
|
21
|
+
setLockedPair: (pair: LockedPair) => void;
|
|
22
|
+
dimensions: { width: number, height: number };
|
|
23
|
+
cacheEnabled: boolean;
|
|
24
|
+
cacheBaseUrl: string;
|
|
25
|
+
loadNodeImage: (nodeId: number | string, title: string) => Promise<void>;
|
|
26
|
+
fetchAndExpandNode: (node: GraphNode, isInitial?: boolean, forceMore?: boolean, nodesOverride?: GraphNode[], linksOverride?: GraphLink[]) => Promise<void>;
|
|
27
|
+
setNotification: (notif: { message: string, type: 'success' | 'error' } | null) => void;
|
|
28
|
+
setSelectedNode: (node: GraphNode | null) => void;
|
|
29
|
+
setSelectedLink: (link: GraphLink | null) => void;
|
|
30
|
+
setPathNodeIds: (ids: (number | string)[]) => void;
|
|
31
|
+
setPendingAutoExpandId: (id: number | string | null) => void;
|
|
32
|
+
showControlPanel: boolean;
|
|
33
|
+
selectedKioskDomain: any;
|
|
34
|
+
graphRef: React.RefObject<any>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
38
|
+
const {
|
|
39
|
+
graphDataRef, setGraphData, setIsProcessing, setError,
|
|
40
|
+
setSearchId, searchIdRef, setLockedPair, dimensions,
|
|
41
|
+
cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
|
|
42
|
+
setNotification, setSelectedNode, setSelectedLink, setPathNodeIds,
|
|
43
|
+
setPendingAutoExpandId, showControlPanel, selectedKioskDomain, graphRef
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const [exploreTerm, setExploreTerm] = useState('');
|
|
47
|
+
const [pathStart, setPathStart] = useState('');
|
|
48
|
+
const [pathEnd, setPathEnd] = useState('');
|
|
49
|
+
|
|
50
|
+
const upsertNodeLocal = useCallback(async (title: string, type: string, description: string, wiki: any) => {
|
|
51
|
+
let nodeData: any = null;
|
|
52
|
+
if (cacheEnabled) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(new URL("/node", cacheBaseUrl).toString(), {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
title: title.trim(),
|
|
59
|
+
type,
|
|
60
|
+
description: wiki.extract || description,
|
|
61
|
+
wikipedia_id: wiki.pageid?.toString()
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
nodeData = await res.json();
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.warn("Cache server unreachable", e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!nodeData) {
|
|
73
|
+
nodeData = {
|
|
74
|
+
id: wiki.pageid || Math.floor(Math.random() * 1000000),
|
|
75
|
+
title: title.trim(),
|
|
76
|
+
type,
|
|
77
|
+
description: wiki.extract || description,
|
|
78
|
+
wikipedia_id: wiki.pageid?.toString()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return nodeData;
|
|
82
|
+
}, [cacheEnabled, cacheBaseUrl]);
|
|
83
|
+
|
|
84
|
+
const handleStartSearch = useCallback(async (term: string, recursiveDepth = 0) => {
|
|
85
|
+
setIsProcessing(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
const nextSearchId = searchIdRef.current + 1;
|
|
88
|
+
searchIdRef.current = nextSearchId;
|
|
89
|
+
setSearchId(nextSearchId);
|
|
90
|
+
setPathNodeIds([]);
|
|
91
|
+
setSelectedLink(null);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const startC = await classifyStartPair(term);
|
|
95
|
+
const chosenPair: LockedPair = { atomicType: startC.atomicType, compositeType: startC.compositeType };
|
|
96
|
+
setLockedPair(chosenPair);
|
|
97
|
+
let { type, description, isAtomic, reasoning } = startC;
|
|
98
|
+
|
|
99
|
+
// CRITICAL FIX: Only use kiosk domain context if the user hasn't provided a specific disambiguated term.
|
|
100
|
+
// "Republic (Plato)" should NEVER get "Actors / Movies / TV" context.
|
|
101
|
+
const hasDisambiguation = term.includes('(') && term.includes(')');
|
|
102
|
+
const wikiContext = (showControlPanel && !hasDisambiguation) ? selectedKioskDomain?.label : undefined;
|
|
103
|
+
|
|
104
|
+
const wiki = await fetchWikipediaSummary(term, wikiContext);
|
|
105
|
+
const canonicalTitle = (wiki.title || term).trim();
|
|
106
|
+
|
|
107
|
+
// We no longer rewrite the user's query to the Wikipedia title.
|
|
108
|
+
// This ensures "Republic (book)" stays as "Republic (book)" in the UI.
|
|
109
|
+
setExploreTerm(term);
|
|
110
|
+
|
|
111
|
+
const nodeData = await upsertNodeLocal(canonicalTitle, type, description || '', wiki);
|
|
112
|
+
|
|
113
|
+
const startNode: GraphNode = {
|
|
114
|
+
id: nodeData.id,
|
|
115
|
+
title: canonicalTitle,
|
|
116
|
+
type,
|
|
117
|
+
is_atomic: isAtomic,
|
|
118
|
+
wikipedia_id: wiki.pageid?.toString(),
|
|
119
|
+
description: wiki.extract || description || '',
|
|
120
|
+
x: dimensions.width / 2,
|
|
121
|
+
y: dimensions.height / 2,
|
|
122
|
+
expanded: false,
|
|
123
|
+
wikiSummary: wiki.extract || undefined,
|
|
124
|
+
classification_reasoning: reasoning,
|
|
125
|
+
atomic_type: chosenPair.atomicType,
|
|
126
|
+
composite_type: chosenPair.compositeType,
|
|
127
|
+
imageUrl: nodeData.imageUrl || nodeData.image_url,
|
|
128
|
+
...nodeData
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
setGraphData({ nodes: [startNode], links: [] });
|
|
132
|
+
setSelectedNode(startNode);
|
|
133
|
+
loadNodeImage(startNode.id, startNode.title);
|
|
134
|
+
await fetchAndExpandNode(startNode, true, false, [startNode], []);
|
|
135
|
+
|
|
136
|
+
if (recursiveDepth > 0) setPendingAutoExpandId(startNode.id);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error("Search error:", e);
|
|
139
|
+
setError("Search failed.");
|
|
140
|
+
} finally {
|
|
141
|
+
setIsProcessing(false);
|
|
142
|
+
}
|
|
143
|
+
}, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setLockedPair, loadNodeImage, fetchAndExpandNode, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId, showControlPanel, selectedKioskDomain, upsertNodeLocal]);
|
|
144
|
+
|
|
145
|
+
const handlePathSearch = useCallback(async (start: string, end: string) => {
|
|
146
|
+
setIsProcessing(true);
|
|
147
|
+
setError(null);
|
|
148
|
+
setNotification({ message: `Exploring "${start}" and "${end}"...`, type: 'success' });
|
|
149
|
+
|
|
150
|
+
const nextSearchId = searchIdRef.current + 1;
|
|
151
|
+
searchIdRef.current = nextSearchId;
|
|
152
|
+
setSearchId(nextSearchId);
|
|
153
|
+
setPathNodeIds([]);
|
|
154
|
+
setSelectedLink(null);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const [startWiki, endWiki, startC, endC] = await Promise.all([
|
|
158
|
+
fetchWikipediaSummary(start),
|
|
159
|
+
fetchWikipediaSummary(end),
|
|
160
|
+
classifyEntity(start),
|
|
161
|
+
classifyEntity(end)
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const [startNodeData, endNodeData] = await Promise.all([
|
|
165
|
+
upsertNodeLocal(start, startC.type, startC.description || '', startWiki),
|
|
166
|
+
upsertNodeLocal(end, endC.type, endC.description || '', endWiki)
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const startNode: GraphNode = {
|
|
170
|
+
id: startNodeData.id, title: start.trim(), type: startC.type, is_atomic: startC.isAtomic,
|
|
171
|
+
wikipedia_id: startWiki.pageid?.toString(), description: startWiki.extract || startC.description || '',
|
|
172
|
+
x: dimensions.width / 4, y: dimensions.height / 2, fx: dimensions.width / 4, fy: dimensions.height / 2,
|
|
173
|
+
expanded: false, wikiSummary: startWiki.extract || undefined,
|
|
174
|
+
imageUrl: startNodeData.imageUrl || startNodeData.image_url,
|
|
175
|
+
...startNodeData
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const endNode: GraphNode = {
|
|
179
|
+
id: endNodeData.id, title: end.trim(), type: endC.type, is_atomic: endC.isAtomic,
|
|
180
|
+
wikipedia_id: endWiki.pageid?.toString(), description: endWiki.extract || endC.description || '',
|
|
181
|
+
x: (dimensions.width * 3) / 4, y: dimensions.height / 2, fx: (dimensions.width * 3) / 4, fy: dimensions.height / 2,
|
|
182
|
+
expanded: false, wikiSummary: endWiki.extract || undefined,
|
|
183
|
+
imageUrl: endNodeData.imageUrl || endNodeData.image_url,
|
|
184
|
+
...endNodeData
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
setGraphData({ nodes: [startNode, endNode], links: [] });
|
|
188
|
+
setSelectedNode(startNode);
|
|
189
|
+
loadNodeImage(startNode.id, startNode.title);
|
|
190
|
+
loadNodeImage(endNode.id, endNode.title);
|
|
191
|
+
|
|
192
|
+
let pathData: PathResponse | null = null;
|
|
193
|
+
let usingDatabase = false;
|
|
194
|
+
|
|
195
|
+
if (cacheEnabled) {
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(new URL(`/path?startId=${startNode.id}&endId=${endNode.id}&maxDepth=10`, cacheBaseUrl).toString());
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
const dbPath = await res.json();
|
|
200
|
+
if (dbPath.found && dbPath.path && dbPath.path.length >= 2) {
|
|
201
|
+
pathData = { path: dbPath.path, found: true };
|
|
202
|
+
(pathData as any)._dbPath = true;
|
|
203
|
+
usingDatabase = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (e) { }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!pathData) {
|
|
210
|
+
setNotification({ message: "Finding hidden connections...", type: 'success' });
|
|
211
|
+
pathData = await fetchConnectionPath(start, end, { startWiki: startWiki.extract || undefined, endWiki: endWiki.extract || undefined });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!pathData || !pathData.path || pathData.path.length < 2) {
|
|
215
|
+
setError("No path found.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isDbPath = (pathData as any)._dbPath === true;
|
|
220
|
+
const pathNodeIdsList: (number | string)[] = [];
|
|
221
|
+
let currentTailId = startNode.id;
|
|
222
|
+
|
|
223
|
+
if (isDbPath) {
|
|
224
|
+
const dbNodes = pathData.path as any[];
|
|
225
|
+
dbNodes.forEach(n => pathNodeIdsList.push(n.id));
|
|
226
|
+
setGraphData(current => {
|
|
227
|
+
const updatedNodes = [...current.nodes];
|
|
228
|
+
const updatedLinks = [...current.links];
|
|
229
|
+
dbNodes.forEach((dbNode, i) => {
|
|
230
|
+
let existingNode = updatedNodes.find(n => String(n.id) === String(dbNode.id));
|
|
231
|
+
if (!existingNode) {
|
|
232
|
+
const nodeX = i === 0 ? (startNode.x || dimensions.width / 4) : (updatedNodes[i - 1]?.x || dimensions.width / 2) + (Math.random() - 0.5) * 150;
|
|
233
|
+
const nodeY = i === 0 ? (startNode.y || dimensions.height / 2) : (updatedNodes[i - 1]?.y || dimensions.height / 2) + (Math.random() - 0.5) * 150;
|
|
234
|
+
const clamped = clampToViewport(nodeX, nodeY, 80);
|
|
235
|
+
existingNode = { id: dbNode.id, title: dbNode.title, type: dbNode.type, x: clamped.x, y: clamped.y, fx: clamped.x, fy: clamped.y, expanded: false, ...dbNode };
|
|
236
|
+
updatedNodes.push(existingNode);
|
|
237
|
+
loadNodeImage(dbNode.id, existingNode.title);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
for (let i = 0; i < dbNodes.length - 1; i++) {
|
|
241
|
+
const a = dbNodes[i].id;
|
|
242
|
+
const b = dbNodes[i + 1].id;
|
|
243
|
+
if (!updatedLinks.some(l => {
|
|
244
|
+
const sid = String(typeof l.source === 'object' ? l.source.id : l.source);
|
|
245
|
+
const tid = String(typeof l.target === 'object' ? l.target.id : l.target);
|
|
246
|
+
return (sid === String(a) && tid === String(b)) || (sid === String(b) && tid === String(a));
|
|
247
|
+
})) {
|
|
248
|
+
updatedLinks.push({ source: a, target: b, id: `${a}-${b}` });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return dedupeGraph(updatedNodes, updatedLinks);
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
pathNodeIdsList.push(startNode.id);
|
|
255
|
+
for (let i = 1; i < pathData.path.length; i++) {
|
|
256
|
+
const step = pathData.path[i];
|
|
257
|
+
setNotification({ message: `Stitching path... step ${i} of ${pathData.path.length - 1}: ${step.id}`, type: 'success' });
|
|
258
|
+
const stepWiki = await fetchWikipediaSummary(step.id);
|
|
259
|
+
const stepNodeData = await upsertNodeLocal(stepWiki.title || step.id, step.type, step.description, stepWiki);
|
|
260
|
+
const resolvedId = stepNodeData.id;
|
|
261
|
+
|
|
262
|
+
const fromId = currentTailId;
|
|
263
|
+
const toId = resolvedId;
|
|
264
|
+
const justification = step.justification || "";
|
|
265
|
+
|
|
266
|
+
setGraphData(current => {
|
|
267
|
+
const tailNode = current.nodes.find(n => String(n.id) === String(fromId));
|
|
268
|
+
const clamped = clampToViewport((tailNode?.x || 400) + (Math.random() - 0.5) * 150, (tailNode?.y || 400) + (Math.random() - 0.5) * 150, 80);
|
|
269
|
+
const newNode: GraphNode = {
|
|
270
|
+
id: toId, title: stepWiki.title || step.id, type: step.type, description: step.description,
|
|
271
|
+
x: clamped.x, y: clamped.y, fx: clamped.x, fy: clamped.y, expanded: false,
|
|
272
|
+
wikipedia_id: stepWiki.pageid?.toString(),
|
|
273
|
+
imageUrl: stepNodeData.imageUrl || stepNodeData.image_url,
|
|
274
|
+
...stepNodeData
|
|
275
|
+
};
|
|
276
|
+
const updatedNodes = current.nodes.some(n => String(n.id) === String(toId)) ? current.nodes.map(n => String(n.id) === String(toId) ? newNode : n) : [...current.nodes, newNode];
|
|
277
|
+
const updatedLinks = [...current.links, {
|
|
278
|
+
source: fromId,
|
|
279
|
+
target: toId,
|
|
280
|
+
id: `${fromId}-${toId}`,
|
|
281
|
+
label: justification,
|
|
282
|
+
evidence: {
|
|
283
|
+
kind: 'ai',
|
|
284
|
+
pageTitle: stepWiki.title || step.id,
|
|
285
|
+
snippet: justification,
|
|
286
|
+
url: buildWikiUrl(stepWiki.title || step.id)
|
|
287
|
+
}
|
|
288
|
+
}];
|
|
289
|
+
loadNodeImage(toId, newNode.title);
|
|
290
|
+
// CRITICAL: Dedupe immediately so that if this node merged with an existing one,
|
|
291
|
+
// we know the correct ID for the next link in the chain.
|
|
292
|
+
return dedupeGraph(updatedNodes, updatedLinks);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Wait a moment for state to settle, then find the RESOLVED id of the node we just added.
|
|
296
|
+
// This handles cases where baseDedupeKey merged our new node into an existing one.
|
|
297
|
+
await new Promise(r => setTimeout(r, 100));
|
|
298
|
+
const latestGraph = graphDataRef.current;
|
|
299
|
+
const foundNode = latestGraph.nodes.find(n => {
|
|
300
|
+
const wikiIdResult = String(n.wikipedia_id || "");
|
|
301
|
+
const wikiIdStep = String(stepWiki.pageid || "");
|
|
302
|
+
if (wikiIdResult && wikiIdStep && wikiIdResult === wikiIdStep) return true;
|
|
303
|
+
return normalizeForDedup(n.title) === normalizeForDedup(stepWiki.title || step.id);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (foundNode) {
|
|
307
|
+
currentTailId = foundNode.id;
|
|
308
|
+
if (!pathNodeIdsList.includes(foundNode.id)) pathNodeIdsList.push(foundNode.id);
|
|
309
|
+
} else {
|
|
310
|
+
// Fallback if lookup failed (shouldn't happen with immediate dedupe)
|
|
311
|
+
currentTailId = toId;
|
|
312
|
+
if (!pathNodeIdsList.includes(toId)) pathNodeIdsList.push(toId);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!pathNodeIdsList.some(id => String(id) === String(endNode.id))) pathNodeIdsList.push(endNode.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await new Promise(r => setTimeout(r, 300));
|
|
319
|
+
|
|
320
|
+
// EXPAND START AND END NODES (ONLY) using their FINAL resolved IDs
|
|
321
|
+
const finalGraph = graphDataRef.current;
|
|
322
|
+
const finalStartNode = finalGraph.nodes.find(n =>
|
|
323
|
+
(n.wikipedia_id && String(n.wikipedia_id) === String(startNode.wikipedia_id)) ||
|
|
324
|
+
normalizeForDedup(n.title) === normalizeForDedup(startNode.title)
|
|
325
|
+
);
|
|
326
|
+
const finalEndNode = finalGraph.nodes.find(n =>
|
|
327
|
+
(n.wikipedia_id && String(n.wikipedia_id) === String(endNode.wikipedia_id)) ||
|
|
328
|
+
normalizeForDedup(n.title) === normalizeForDedup(endNode.title)
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (finalStartNode) fetchAndExpandNode(finalStartNode);
|
|
332
|
+
if (finalEndNode) fetchAndExpandNode(finalEndNode);
|
|
333
|
+
|
|
334
|
+
// FINAL RESOLUTION OF ALL PATH IDs
|
|
335
|
+
// This ensures that pathNodeIdsList contains only stable IDs present in the final graph.
|
|
336
|
+
const resolvedPathIds = pathNodeIdsList.map(originalId => {
|
|
337
|
+
const node = finalGraph.nodes.find(n => {
|
|
338
|
+
if (String(n.id) === String(originalId)) return true;
|
|
339
|
+
// Check if it was merged via wikipedia_id
|
|
340
|
+
const nodeInOriginalPath = pathData?.path.find((p: any) => String(p.id) === String(originalId));
|
|
341
|
+
if (nodeInOriginalPath && n.wikipedia_id && nodeInOriginalPath.wikipedia_id && String(n.wikipedia_id) === String(nodeInOriginalPath.wikipedia_id)) return true;
|
|
342
|
+
// Check if it was merged via title
|
|
343
|
+
if (nodeInOriginalPath && normalizeForDedup(n.title) === normalizeForDedup(nodeInOriginalPath.title || nodeInOriginalPath.id)) return true;
|
|
344
|
+
return false;
|
|
345
|
+
});
|
|
346
|
+
return node ? node.id : originalId;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const nodeIdsInGraph = new Set(finalGraph.nodes.map(n => String(n.id)));
|
|
350
|
+
const finalPathIds = Array.from(new Set(resolvedPathIds)).filter(id => nodeIdsInGraph.has(String(id)));
|
|
351
|
+
|
|
352
|
+
setGraphData(current => ({
|
|
353
|
+
...current,
|
|
354
|
+
nodes: current.nodes.map(n => ({ ...n, fx: null, fy: null }))
|
|
355
|
+
}));
|
|
356
|
+
setPathNodeIds([...finalPathIds]);
|
|
357
|
+
setNotification({ message: "Path discovery complete!", type: 'success' });
|
|
358
|
+
if (finalPathIds.length) setTimeout(() => graphRef.current?.centerOnNode(finalPathIds[Math.floor(finalPathIds.length / 2)]), 200);
|
|
359
|
+
|
|
360
|
+
} catch (e) {
|
|
361
|
+
console.error("Path error:", e);
|
|
362
|
+
setError("Path search failed.");
|
|
363
|
+
} finally {
|
|
364
|
+
setIsProcessing(false);
|
|
365
|
+
}
|
|
366
|
+
}, [dimensions, cacheEnabled, cacheBaseUrl, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef, setNotification, loadNodeImage, fetchAndExpandNode, setSelectedNode, setPathNodeIds, graphRef, upsertNodeLocal]);
|
|
367
|
+
|
|
368
|
+
return { exploreTerm, setExploreTerm, pathStart, setPathStart, pathEnd, setPathEnd, handleStartSearch, handlePathSearch };
|
|
369
|
+
}
|
package/host.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single import surface for full-page constellations inside host apps (Soundings, Trailer Vision, …).
|
|
5
|
+
* — `FullPageConstellations` (layout + App wiring)
|
|
6
|
+
* — `useFullPageConstellationsHost` (URL + optional player bridge)
|
|
7
|
+
* — `newChannelFromGraphNode` (sessionStorage + navigate)
|
|
8
|
+
* — `FullPageConstellationsHostLoading`
|
|
9
|
+
*/
|
|
10
|
+
export { default as App } from "./App";
|
|
11
|
+
export { FullPageConstellations } from "./FullPageConstellations";
|
|
12
|
+
export type { FullPageConstellationsProps } from "./FullPageConstellations";
|
|
13
|
+
export { useFullPageConstellationsHost } from "./useFullPageConstellationsHost";
|
|
14
|
+
export type { NowPlayingSnapshot } from "./useFullPageConstellationsHost";
|
|
15
|
+
export { FullPageConstellationsHostLoading } from "./FullPageConstellationsHostShell";
|
|
16
|
+
export { newChannelFromGraphNode } from "./utils/graphNodeToChannelNotes";
|
package/index.css
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
* {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html,
|
|
8
|
+
body,
|
|
9
|
+
#root {
|
|
10
|
+
position: fixed;
|
|
11
|
+
inset: 0;
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
max-width: 100%;
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: 0;
|
|
17
|
+
overflow: hidden !important;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
font-family: 'Inter', sans-serif;
|
|
22
|
+
background-color: #0f172a;
|
|
23
|
+
color: #e2e8f0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Custom scrollbar for panels */
|
|
27
|
+
::-webkit-scrollbar {
|
|
28
|
+
width: 6px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
::-webkit-scrollbar-track {
|
|
32
|
+
background: #1e293b;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
::-webkit-scrollbar-thumb {
|
|
36
|
+
background: #475569;
|
|
37
|
+
border-radius: 3px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.custom-scrollbar::-webkit-scrollbar {
|
|
41
|
+
width: 4px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.custom-scrollbar::-webkit-scrollbar-track {
|
|
45
|
+
background: transparent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
49
|
+
background: #475569;
|
|
50
|
+
border-radius: 2px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
|
54
|
+
background: #64748b;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes fade-in {
|
|
58
|
+
from {
|
|
59
|
+
opacity: 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
to {
|
|
63
|
+
opacity: 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@keyframes scale-in {
|
|
68
|
+
from {
|
|
69
|
+
transform: scale(0.95);
|
|
70
|
+
opacity: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
to {
|
|
74
|
+
transform: scale(1);
|
|
75
|
+
opacity: 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@keyframes fade-in-up {
|
|
80
|
+
from {
|
|
81
|
+
transform: translate(-50%, 20px);
|
|
82
|
+
opacity: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
to {
|
|
86
|
+
transform: translate(-50%, 0);
|
|
87
|
+
opacity: 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.animate-fade-in {
|
|
92
|
+
animation: fade-in 0.2s ease-out forwards;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.animate-scale-in {
|
|
96
|
+
animation: scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.animate-fade-in-up {
|
|
100
|
+
animation: fade-in-up 0.3s ease-out forwards;
|
|
101
|
+
}
|
package/index.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import './index.css';
|
|
5
|
+
|
|
6
|
+
const rootElement = document.getElementById('root');
|
|
7
|
+
if (!rootElement) {
|
|
8
|
+
throw new Error("Could not find root element to mount to");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const root = ReactDOM.createRoot(rootElement);
|
|
12
|
+
root.render(
|
|
13
|
+
<React.StrictMode>
|
|
14
|
+
<App />
|
|
15
|
+
</React.StrictMode>
|
|
16
|
+
);
|