@johndimm/constellations 1.0.1 → 1.0.3
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 +360 -66
- package/FullPageConstellations.tsx +7 -4
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +67 -30
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +229 -250
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +2 -1
- package/components/NodeContextMenu.tsx +123 -3
- package/components/PeopleBrowserSidebar.tsx +15 -6
- package/components/Sidebar.tsx +46 -19
- package/components/TimelineView.tsx +1 -0
- package/hooks/useExpansion.ts +85 -230
- package/hooks/useGraphActions.ts +1 -0
- package/hooks/useGraphState.ts +75 -40
- package/hooks/useKioskMode.ts +1 -0
- package/hooks/useNodeClickHandler.ts +23 -15
- package/hooks/useSearchHandlers.ts +60 -21
- package/host.ts +1 -1
- package/index.css +17 -3
- package/index.tsx +5 -3
- package/package.json +4 -2
- package/services/aiService.ts +27 -0
- package/services/aiUtils.ts +285 -195
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +479 -0
- package/services/geminiService.ts +543 -736
- package/services/graphUtils.ts +128 -18
- package/services/imageService.ts +18 -0
- package/services/openAlexService.ts +1 -0
- package/services/resolveImageForTitle.ts +458 -0
- package/services/wikipediaImage.ts +1 -0
- package/services/wikipediaService.ts +79 -49
- package/sessionHandoff.ts +26 -0
- package/types.ts +3 -0
- package/utils/evidenceUtils.ts +1 -0
- package/utils/graphLogicUtils.ts +1 -0
- package/utils/wikiUtils.ts +14 -2
package/App.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
2
3
|
import { buildWikiUrl } from './utils/wikiUtils';
|
|
3
4
|
import { Key, Search, HelpCircle, Minimize2, Maximize2, ExternalLink } from 'lucide-react';
|
|
@@ -10,7 +11,7 @@ import AppNotifications from './components/AppNotifications';
|
|
|
10
11
|
import AppConfirmDialog from './components/AppConfirmDialog';
|
|
11
12
|
import HelpOverlay from './components/HelpOverlay';
|
|
12
13
|
import { GraphNode, GraphLink } from './types';
|
|
13
|
-
import { getApiKey, getEnvCacheUrl } from './services/aiUtils';
|
|
14
|
+
import { getApiKey, getEnvCacheUrl, readBundledEnv } from './services/aiUtils';
|
|
14
15
|
import { useNodeClickHandler } from './hooks/useNodeClickHandler';
|
|
15
16
|
|
|
16
17
|
import { useGraphState } from './hooks/useGraphState';
|
|
@@ -18,19 +19,59 @@ import { useKioskMode } from './hooks/useKioskMode';
|
|
|
18
19
|
import { useExpansion } from './hooks/useExpansion';
|
|
19
20
|
import { useSearchHandlers } from './hooks/useSearchHandlers';
|
|
20
21
|
import { useGraphActions } from './hooks/useGraphActions';
|
|
22
|
+
import { buildHandoffFromLiveState, type ConstellationsSessionHandoffV1 } from './sessionHandoff';
|
|
21
23
|
|
|
22
24
|
const PeopleBrowserSidebar = lazy(() => import('./components/PeopleBrowserSidebar'));
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
type AppProps = {
|
|
26
28
|
mode?: 'standalone' | 'extension';
|
|
29
|
+
/** When true, fill the parent box and size the graph with ResizeObserver instead of the viewport. */
|
|
30
|
+
embedded?: boolean;
|
|
27
31
|
hideHeader?: boolean;
|
|
28
32
|
hideControlPanel?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* When `hideControlPanel` is true, the Chrome extension still shows `ExtensionControls`.
|
|
35
|
+
* Set to `false` for host-embedded “graph only” (e.g. Soundings player) — no left rail, no micro toolbar.
|
|
36
|
+
*/
|
|
37
|
+
showExtensionWhenPanelHidden?: boolean;
|
|
29
38
|
hideSidebar?: boolean;
|
|
30
|
-
externalSearch?: { term: string; id: number } | null;
|
|
31
|
-
onExternalSearchConsumed?: (id: number) => void;
|
|
39
|
+
externalSearch?: { term: string; id: string | number } | null;
|
|
40
|
+
onExternalSearchConsumed?: (id: string | number) => void;
|
|
32
41
|
onNodeNavigate?: (node: GraphNode) => void;
|
|
33
42
|
renderEvidencePopup?: (selectedLink: GraphLink | null, onClose: () => void) => React.ReactNode;
|
|
43
|
+
/** When these strings match a node title (substring, case-insensitive), that node is expanded once per search. */
|
|
44
|
+
autoExpandMatchTitles?: string[] | null;
|
|
45
|
+
/**
|
|
46
|
+
* When set (Soundings player / constellations page), the first match from `autoExpandMatchTitles`
|
|
47
|
+
* also **selects** the node, and we wait for `nowPlayingKey` to change before re-applying.
|
|
48
|
+
* String should change when album/track from the player updates.
|
|
49
|
+
*/
|
|
50
|
+
nowPlayingKey?: string | null;
|
|
51
|
+
/** e.g. optional cleanup (e.g. document class) when leaving full-screen; use with `closeHref` for navigation. */
|
|
52
|
+
onClose?: () => void;
|
|
53
|
+
/** If set, close control is an `<a href>` (see `AppHeader`); e.g. `/` (Trailer Vision) or `/player` (Soundings). */
|
|
54
|
+
closeHref?: string;
|
|
55
|
+
/** Soundings: create a new DJ channel seeded from the right-clicked graph node. */
|
|
56
|
+
onNewChannelFromNode?: (node: GraphNode) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Restored graph from player embed (session handoff) — avoids re-running searches.
|
|
59
|
+
* Only used on full-screen mount; not for embedded mode.
|
|
60
|
+
*/
|
|
61
|
+
initialSession?: ConstellationsSessionHandoffV1 | null;
|
|
62
|
+
/**
|
|
63
|
+
* When embedded in an app that already shows a top nav (e.g. Trailer Vision ~44px), set to that
|
|
64
|
+
* height in px so fixed toolbars sit below the host nav and clicks reach the right layer.
|
|
65
|
+
*/
|
|
66
|
+
hostNavOffsetPx?: number;
|
|
67
|
+
/**
|
|
68
|
+
* When `embedded`, panels default to `position:absolute` in the constellations root. Set
|
|
69
|
+
* `true` for full-viewport overlay hosts (e.g. Soundings) so the control rail and details use
|
|
70
|
+
* viewport-anchored layout + classic max-heights; keeps blur/shadows aligned to the window edge.
|
|
71
|
+
*/
|
|
72
|
+
useViewportForPanels?: boolean;
|
|
73
|
+
/** When set, ControlPanel shows a link to the graph settings page. */
|
|
74
|
+
settingsHref?: string;
|
|
34
75
|
};
|
|
35
76
|
|
|
36
77
|
const ExtensionControls: React.FC<{
|
|
@@ -124,26 +165,57 @@ const ExtensionControls: React.FC<{
|
|
|
124
165
|
};
|
|
125
166
|
|
|
126
167
|
const App: React.FC<AppProps> = ({
|
|
168
|
+
embedded = false,
|
|
127
169
|
hideHeader = false,
|
|
128
170
|
hideControlPanel = false,
|
|
171
|
+
showExtensionWhenPanelHidden = true,
|
|
129
172
|
hideSidebar = false,
|
|
130
173
|
externalSearch = null,
|
|
131
174
|
onExternalSearchConsumed,
|
|
132
175
|
onNodeNavigate,
|
|
133
|
-
renderEvidencePopup
|
|
176
|
+
renderEvidencePopup,
|
|
177
|
+
autoExpandMatchTitles = null,
|
|
178
|
+
nowPlayingKey = null,
|
|
179
|
+
onClose,
|
|
180
|
+
closeHref,
|
|
181
|
+
onNewChannelFromNode,
|
|
182
|
+
initialSession: initialSessionProp = null,
|
|
183
|
+
hostNavOffsetPx = 0,
|
|
184
|
+
useViewportForPanels = false,
|
|
185
|
+
settingsHref,
|
|
134
186
|
}) => {
|
|
135
|
-
const
|
|
136
|
-
|
|
187
|
+
const initialSession = initialSessionProp && initialSessionProp.graph?.nodes?.length
|
|
188
|
+
? initialSessionProp
|
|
189
|
+
: null;
|
|
190
|
+
const skipPlayerBootstrapRef = useRef(!!initialSession);
|
|
191
|
+
const wsRaw = readBundledEnv('VITE_ENABLE_WEB_SEARCH');
|
|
192
|
+
const ENABLE_WEB_SEARCH = String(wsRaw).trim().toLowerCase() === 'true' || wsRaw === '1';
|
|
193
|
+
const acadRaw = readBundledEnv('VITE_ENABLE_ACADEMIC_CORPORA');
|
|
194
|
+
const ENABLE_ACADEMIC_CORPORA = acadRaw !== 'false' && acadRaw !== '0';
|
|
137
195
|
|
|
138
196
|
const cacheBaseUrl = getEnvCacheUrl();
|
|
139
197
|
const cacheEnabled = !!cacheBaseUrl;
|
|
140
198
|
|
|
199
|
+
const [graphHostEl, setGraphHostEl] = useState<HTMLDivElement | null>(null);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (embedded) return;
|
|
203
|
+
const root = document.documentElement;
|
|
204
|
+
root.classList.add("constellations-standalone");
|
|
205
|
+
return () => root.classList.remove("constellations-standalone");
|
|
206
|
+
}, [embedded]);
|
|
207
|
+
|
|
141
208
|
const {
|
|
142
209
|
isAdminMode, kioskDomains, setKioskDomains, selectedKioskDomainId, setSelectedKioskDomainId,
|
|
143
210
|
selectedKioskDomain, kioskSeedTerms
|
|
144
211
|
} = useKioskMode();
|
|
145
212
|
|
|
146
|
-
const state = useGraphState({
|
|
213
|
+
const state = useGraphState({
|
|
214
|
+
cacheEnabled,
|
|
215
|
+
cacheBaseUrl,
|
|
216
|
+
boundElement: embedded ? graphHostEl : undefined,
|
|
217
|
+
initialSession
|
|
218
|
+
});
|
|
147
219
|
const {
|
|
148
220
|
graphData, setGraphData, nodes, links, graphDataRef,
|
|
149
221
|
isProcessing, setIsProcessing, selectedNode, setSelectedNode,
|
|
@@ -158,7 +230,7 @@ const App: React.FC<AppProps> = ({
|
|
|
158
230
|
contextMenu, setContextMenu, panelCollapsed, setPanelCollapsed,
|
|
159
231
|
sidebarCollapsed, setSidebarCollapsed, sidebarToggleSignal, setSidebarToggleSignal,
|
|
160
232
|
peopleBrowserOpen, setPeopleBrowserOpen, savedGraphs, setSavedGraphs,
|
|
161
|
-
searchMode, setSearchMode, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
|
|
233
|
+
searchMode, setSearchMode, lockedPair, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
|
|
162
234
|
} = state;
|
|
163
235
|
|
|
164
236
|
const { fetchAndExpandNode, saveCacheExpansion } = useExpansion({
|
|
@@ -167,8 +239,7 @@ const App: React.FC<AppProps> = ({
|
|
|
167
239
|
cacheEnabled, cacheBaseUrl, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH,
|
|
168
240
|
loadNodeImage, saveCacheNodeMeta,
|
|
169
241
|
setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
|
|
170
|
-
setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef
|
|
171
|
-
setNotification,
|
|
242
|
+
setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef
|
|
172
243
|
});
|
|
173
244
|
|
|
174
245
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -180,7 +251,8 @@ const App: React.FC<AppProps> = ({
|
|
|
180
251
|
graphDataRef, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef,
|
|
181
252
|
setLockedPair: state.setLockedPair, dimensions, cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
|
|
182
253
|
setNotification, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId: () => { },
|
|
183
|
-
showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef
|
|
254
|
+
showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef,
|
|
255
|
+
initialSession
|
|
184
256
|
});
|
|
185
257
|
|
|
186
258
|
const {
|
|
@@ -201,8 +273,6 @@ const App: React.FC<AppProps> = ({
|
|
|
201
273
|
graphData,
|
|
202
274
|
setExpandingNodeId,
|
|
203
275
|
setNewChildNodeIds,
|
|
204
|
-
/** DevTools: see why a click opened the menu vs expanded (Vite terminal will not show this). */
|
|
205
|
-
onDebug: (message) => console.info("[Constellations]", message),
|
|
206
276
|
onNavigate: onNodeNavigate ? (node) => {
|
|
207
277
|
onNodeNavigate(node);
|
|
208
278
|
} : undefined,
|
|
@@ -219,6 +289,13 @@ const App: React.FC<AppProps> = ({
|
|
|
219
289
|
getMenuPosition: (node, event) => ({ x: event?.clientX ?? 0, y: event?.clientY ?? 0 })
|
|
220
290
|
});
|
|
221
291
|
|
|
292
|
+
const handleNodeContextMenu = useCallback((event: MouseEvent, node: GraphNode) => {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
setSelectedNode(node);
|
|
296
|
+
setContextMenu({ node, x: event.clientX, y: event.clientY });
|
|
297
|
+
}, [setSelectedNode, setContextMenu]);
|
|
298
|
+
|
|
222
299
|
useEffect(() => {
|
|
223
300
|
const checkKey = async () => {
|
|
224
301
|
if (cacheBaseUrl) {
|
|
@@ -236,11 +313,83 @@ const App: React.FC<AppProps> = ({
|
|
|
236
313
|
checkKey();
|
|
237
314
|
}, [setIsKeyReady]);
|
|
238
315
|
|
|
316
|
+
const handleStartSearchRef = useRef(handleStartSearch);
|
|
239
317
|
useEffect(() => {
|
|
318
|
+
handleStartSearchRef.current = handleStartSearch;
|
|
319
|
+
}, [handleStartSearch]);
|
|
320
|
+
const onExternalSearchConsumedRef = useRef(onExternalSearchConsumed);
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
onExternalSearchConsumedRef.current = onExternalSearchConsumed;
|
|
323
|
+
}, [onExternalSearchConsumed]);
|
|
324
|
+
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (skipPlayerBootstrapRef.current) return;
|
|
240
327
|
if (!externalSearch?.term) return;
|
|
241
|
-
|
|
242
|
-
if (externalSearch?.id !== undefined)
|
|
243
|
-
|
|
328
|
+
handleStartSearchRef.current(externalSearch.term);
|
|
329
|
+
if (externalSearch?.id !== undefined) {
|
|
330
|
+
onExternalSearchConsumedRef.current?.(externalSearch.id);
|
|
331
|
+
}
|
|
332
|
+
}, [externalSearch?.id, externalSearch?.term]);
|
|
333
|
+
|
|
334
|
+
const autoExpandDoneRef = useRef<Set<string>>(new Set());
|
|
335
|
+
const pendingNpFocusRef = useRef(false);
|
|
336
|
+
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (nowPlayingKey != null && nowPlayingKey !== "") {
|
|
339
|
+
pendingNpFocusRef.current = true;
|
|
340
|
+
} else {
|
|
341
|
+
pendingNpFocusRef.current = false;
|
|
342
|
+
}
|
|
343
|
+
}, [nowPlayingKey]);
|
|
344
|
+
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (!autoExpandMatchTitles?.length) return;
|
|
347
|
+
if (!nodes.length || isProcessing) return;
|
|
348
|
+
const syncFromPlayer = nowPlayingKey != null && nowPlayingKey !== "";
|
|
349
|
+
if (syncFromPlayer && !pendingNpFocusRef.current) return;
|
|
350
|
+
|
|
351
|
+
const searchSig = `${searchId}`;
|
|
352
|
+
for (const raw of autoExpandMatchTitles) {
|
|
353
|
+
const want = raw.trim();
|
|
354
|
+
if (!want) continue;
|
|
355
|
+
const expandOnceKey = `${searchSig}::${want.toLowerCase()}`;
|
|
356
|
+
if (autoExpandDoneRef.current.has(expandOnceKey) && !syncFromPlayer) continue;
|
|
357
|
+
|
|
358
|
+
const wl = want.toLowerCase();
|
|
359
|
+
const found = nodes.find((n) => {
|
|
360
|
+
if (n.isLoading) return false;
|
|
361
|
+
const nt = (n.title || '').toLowerCase();
|
|
362
|
+
if (syncFromPlayer) {
|
|
363
|
+
return nt === wl || nt.includes(wl) || wl.includes(nt);
|
|
364
|
+
}
|
|
365
|
+
if (n.expanded) return false;
|
|
366
|
+
return nt === wl || nt.includes(wl) || wl.includes(nt);
|
|
367
|
+
});
|
|
368
|
+
if (!found) continue;
|
|
369
|
+
|
|
370
|
+
if (syncFromPlayer) {
|
|
371
|
+
setSelectedNode(found);
|
|
372
|
+
setSelectedLink(null);
|
|
373
|
+
setContextMenu(null);
|
|
374
|
+
pendingNpFocusRef.current = false;
|
|
375
|
+
}
|
|
376
|
+
if (!autoExpandDoneRef.current.has(expandOnceKey) && !found.expanded) {
|
|
377
|
+
autoExpandDoneRef.current.add(expandOnceKey);
|
|
378
|
+
void fetchAndExpandNode(found, false, false);
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}, [
|
|
383
|
+
nodes,
|
|
384
|
+
isProcessing,
|
|
385
|
+
searchId,
|
|
386
|
+
autoExpandMatchTitles,
|
|
387
|
+
nowPlayingKey,
|
|
388
|
+
fetchAndExpandNode,
|
|
389
|
+
setSelectedNode,
|
|
390
|
+
setSelectedLink,
|
|
391
|
+
setContextMenu
|
|
392
|
+
]);
|
|
244
393
|
|
|
245
394
|
useEffect(() => {
|
|
246
395
|
const handlePopState = () => {
|
|
@@ -251,10 +400,79 @@ const App: React.FC<AppProps> = ({
|
|
|
251
400
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
252
401
|
}, [setPeopleBrowserOpen]);
|
|
253
402
|
|
|
403
|
+
const handoffSelectionRestored = useRef(false);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!initialSession?.selectedNodeId || handoffSelectionRestored.current) return;
|
|
406
|
+
const id = initialSession.selectedNodeId;
|
|
407
|
+
const n = nodes.find((x) => String(x.id) === String(id));
|
|
408
|
+
if (n) {
|
|
409
|
+
setSelectedNode(n);
|
|
410
|
+
handoffSelectionRestored.current = true;
|
|
411
|
+
}
|
|
412
|
+
}, [initialSession, nodes, setSelectedNode]);
|
|
413
|
+
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (!initialSession) return;
|
|
416
|
+
if (!nodes.length) return;
|
|
417
|
+
skipPlayerBootstrapRef.current = false;
|
|
418
|
+
}, [initialSession, nodes.length]);
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (!embedded) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
(window as any).__soundingsConstellationsGetHandoff = () => {
|
|
425
|
+
try {
|
|
426
|
+
return buildHandoffFromLiveState({
|
|
427
|
+
graph: graphDataRef.current,
|
|
428
|
+
exploreTerm,
|
|
429
|
+
pathStart,
|
|
430
|
+
pathEnd,
|
|
431
|
+
searchMode,
|
|
432
|
+
isCompact,
|
|
433
|
+
isTimelineMode,
|
|
434
|
+
isTextOnly,
|
|
435
|
+
searchId,
|
|
436
|
+
lockedPair,
|
|
437
|
+
pathNodeIds,
|
|
438
|
+
selectedNodeId: selectedNode?.id
|
|
439
|
+
});
|
|
440
|
+
} catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
return () => {
|
|
445
|
+
try { delete (window as any).__soundingsConstellationsGetHandoff; } catch { /* empty */ }
|
|
446
|
+
};
|
|
447
|
+
}, [
|
|
448
|
+
embedded,
|
|
449
|
+
graphData,
|
|
450
|
+
exploreTerm,
|
|
451
|
+
pathStart,
|
|
452
|
+
pathEnd,
|
|
453
|
+
searchMode,
|
|
454
|
+
isCompact,
|
|
455
|
+
isTimelineMode,
|
|
456
|
+
isTextOnly,
|
|
457
|
+
searchId,
|
|
458
|
+
lockedPair,
|
|
459
|
+
pathNodeIds,
|
|
460
|
+
selectedNode
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
const handlePathSearchRef = useRef(handlePathSearch);
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
handlePathSearchRef.current = handlePathSearch;
|
|
466
|
+
}, [handlePathSearch]);
|
|
467
|
+
|
|
254
468
|
// Auto-start search if ?q= parameter is present in URL
|
|
255
469
|
const urlQueryProcessedRef = useRef(false);
|
|
256
470
|
useEffect(() => {
|
|
257
471
|
if (urlQueryProcessedRef.current) return;
|
|
472
|
+
if (skipPlayerBootstrapRef.current) {
|
|
473
|
+
urlQueryProcessedRef.current = true;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
258
476
|
|
|
259
477
|
const params = new URLSearchParams(window.location.search);
|
|
260
478
|
const queryParam = params.get('q');
|
|
@@ -263,15 +481,15 @@ const App: React.FC<AppProps> = ({
|
|
|
263
481
|
|
|
264
482
|
if (queryParam && isKeyReady && nodes.length === 0) {
|
|
265
483
|
urlQueryProcessedRef.current = true;
|
|
266
|
-
|
|
484
|
+
handleStartSearchRef.current(queryParam);
|
|
267
485
|
} else if (startParam && endParam && isKeyReady && nodes.length === 0) {
|
|
268
486
|
urlQueryProcessedRef.current = true;
|
|
269
487
|
setSearchMode('connect');
|
|
270
488
|
setPathStart(startParam);
|
|
271
489
|
setPathEnd(endParam);
|
|
272
|
-
|
|
490
|
+
handlePathSearchRef.current(startParam, endParam);
|
|
273
491
|
}
|
|
274
|
-
}, [isKeyReady, nodes.length,
|
|
492
|
+
}, [isKeyReady, nodes.length, setSearchMode, setPathStart, setPathEnd]);
|
|
275
493
|
|
|
276
494
|
const applyGraphData = useCallback((data: any, sourceLabel: string) => {
|
|
277
495
|
try {
|
|
@@ -311,7 +529,7 @@ const App: React.FC<AppProps> = ({
|
|
|
311
529
|
|
|
312
530
|
if (!isKeyReady) {
|
|
313
531
|
return (
|
|
314
|
-
<div className=
|
|
532
|
+
<div className={`flex flex-col items-center justify-center bg-slate-900 text-white space-y-6 ${embedded ? "h-full w-full" : "h-screen w-screen"}`}>
|
|
315
533
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-400 via-purple-400 to-cyan-400 bg-clip-text text-transparent">Constellations</h1>
|
|
316
534
|
<button onClick={async () => { if ((window as any).aistudio) { await (window as any).aistudio.openSelectKey(); setIsKeyReady(true); } }} className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-xl font-medium transition-all hover:scale-105">
|
|
317
535
|
<Key size={20} className="inline mr-2" /> Select API Key
|
|
@@ -320,9 +538,70 @@ const App: React.FC<AppProps> = ({
|
|
|
320
538
|
);
|
|
321
539
|
}
|
|
322
540
|
|
|
541
|
+
// Fresh graph row for the open context menu (stale `node` would miss isLoading/expanded updates).
|
|
542
|
+
const contextMenuNodeLive: GraphNode | null = contextMenu
|
|
543
|
+
? (nodes.find((n) => String(n.id) === String(contextMenu.node.id)) ?? contextMenu.node)
|
|
544
|
+
: null;
|
|
545
|
+
|
|
546
|
+
// Match `pt-14` on the main column: avoid sizing the graph to full window height, which
|
|
547
|
+
// makes the SVG overflow under the bar and steal clicks. AppHeader is `absolute` inside this
|
|
548
|
+
// root (not `fixed` to the viewport) so the whole UI shares one stacking context with the graph.
|
|
549
|
+
const HEADER_PX = 56;
|
|
550
|
+
const graphWidth = dimensions.width;
|
|
551
|
+
const graphHeight = hideHeader
|
|
552
|
+
? dimensions.height
|
|
553
|
+
: Math.max(200, dimensions.height - HEADER_PX);
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* In-flow (absolute) panels: positioned relative to `main` — the host’s nav (e.g. Trailer h-11)
|
|
557
|
+
* is *outside* the constellations root, so `top-14` (below the in-root header) is enough.
|
|
558
|
+
* When `hideHeader`, there is no in-app bar — use `top-2` and pin the control rail with
|
|
559
|
+
* `constrainToParentHeight` (see `ControlPanel`) so we do not reserve a fake 56px gap.
|
|
560
|
+
*
|
|
561
|
+
* `position: fixed` panels (Sidebar, people browser) use *viewport* coordinates. When embedded
|
|
562
|
+
* with an in-app header, we used `top-[6.25rem]` for host nav + const bar; with `hideHeader`
|
|
563
|
+
* embeds, align to the content box with `top-2` like the control bar.
|
|
564
|
+
*/
|
|
565
|
+
const inHostSizedBox = embedded;
|
|
566
|
+
const headerOffsetClass = inHostSizedBox
|
|
567
|
+
? "top-0"
|
|
568
|
+
: (hostNavOffsetPx > 0 ? "top-11" : "top-0");
|
|
569
|
+
/** No in-app `AppHeader`: `top-2` for both embedded and full-viewport (e.g. Soundings / Trailer) */
|
|
570
|
+
const inFlowPanelTopClass = inHostSizedBox
|
|
571
|
+
? (hideHeader ? "top-2" : "top-14")
|
|
572
|
+
: (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-14"));
|
|
573
|
+
/** Embedded + host bar (e.g. Soundings /player) uses `hostNavOffsetPx`; full-page overlay with only
|
|
574
|
+
* Constellations header uses `top-14` / `top-16` like non-embedded. */
|
|
575
|
+
const viewportFixedTopClass = inHostSizedBox
|
|
576
|
+
? (hideHeader ? "top-2" : (hostNavOffsetPx > 0 ? "top-[6.25rem]" : "top-14"))
|
|
577
|
+
: (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-14"));
|
|
578
|
+
const peopleBrowserFixedTopClass = inHostSizedBox
|
|
579
|
+
? (hideHeader ? "top-2" : (hostNavOffsetPx > 0 ? "top-28" : "top-16"))
|
|
580
|
+
: (hostNavOffsetPx > 0 ? "top-[6.25rem]" : (hideHeader ? "top-2" : "top-16"));
|
|
581
|
+
/** In-layout absolute rails (Tight embed under host chrome). Full-viewport overlay uses `fixed` + max-h. */
|
|
582
|
+
const useViewportPanels = Boolean(embedded && useViewportForPanels);
|
|
583
|
+
const controlPanelOffsetClass = hideHeader
|
|
584
|
+
? "top-2 bottom-2"
|
|
585
|
+
: inFlowPanelTopClass;
|
|
586
|
+
const controlPanelConstrainToParent = hideHeader && !hideControlPanel && !useViewportPanels;
|
|
587
|
+
const sidebarOffsetClass = useViewportPanels
|
|
588
|
+
? viewportFixedTopClass
|
|
589
|
+
: (embedded ? inFlowPanelTopClass : viewportFixedTopClass);
|
|
590
|
+
const peopleBrowserOffsetClass = useViewportPanels
|
|
591
|
+
? peopleBrowserFixedTopClass
|
|
592
|
+
: (embedded ? inFlowPanelTopClass : peopleBrowserFixedTopClass);
|
|
593
|
+
const sidePanelUseAbsolute = embedded && !useViewportPanels;
|
|
594
|
+
const showExtensionControls =
|
|
595
|
+
hideControlPanel && showExtensionWhenPanelHidden;
|
|
596
|
+
|
|
323
597
|
return (
|
|
324
|
-
<div
|
|
325
|
-
{
|
|
598
|
+
<div
|
|
599
|
+
ref={embedded ? (n) => setGraphHostEl(n) : undefined}
|
|
600
|
+
className={`${
|
|
601
|
+
embedded ? "relative w-full h-full" : "relative h-screen w-screen"
|
|
602
|
+
} bg-slate-950 overflow-hidden font-sans text-slate-200 selection:bg-indigo-500/30`}
|
|
603
|
+
>
|
|
604
|
+
{showExtensionControls && (
|
|
326
605
|
<ExtensionControls
|
|
327
606
|
isTimelineMode={isTimelineMode}
|
|
328
607
|
onToggle={setIsTimelineMode}
|
|
@@ -338,41 +617,38 @@ const App: React.FC<AppProps> = ({
|
|
|
338
617
|
<HelpOverlay
|
|
339
618
|
isOpen={showHelp}
|
|
340
619
|
onClose={() => setShowHelp(false)}
|
|
341
|
-
isExtension={
|
|
620
|
+
isExtension={showExtensionControls}
|
|
342
621
|
onOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
343
622
|
/>
|
|
344
|
-
<AppHeader
|
|
345
|
-
showHeader={!hideHeader}
|
|
346
|
-
panelCollapsed={panelCollapsed}
|
|
347
|
-
setPanelCollapsed={setPanelCollapsed}
|
|
348
|
-
showBrowse={peopleBrowserOpen}
|
|
349
|
-
handleOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
350
|
-
selectedNode={selectedNode}
|
|
351
|
-
sidebarCollapsed={sidebarCollapsed}
|
|
352
|
-
setSidebarCollapsed={setSidebarCollapsed}
|
|
353
|
-
setSidebarToggleSignal={setSidebarToggleSignal}
|
|
354
|
-
onReset={handleClear}
|
|
355
|
-
/>
|
|
356
623
|
|
|
357
|
-
<div
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
624
|
+
<div
|
|
625
|
+
className={`relative z-0 w-full min-h-0 h-full transition-all duration-500 ease-in-out ${!hideHeader ? "pt-14" : ""}`}
|
|
626
|
+
>
|
|
627
|
+
<div className="pointer-events-auto relative z-0 min-h-0 w-full overflow-hidden" style={{ height: graphHeight, maxHeight: "100%" }}>
|
|
628
|
+
<Graph
|
|
629
|
+
ref={graphRef}
|
|
630
|
+
nodes={nodes}
|
|
631
|
+
links={links}
|
|
632
|
+
onNodeClick={onNodeClick}
|
|
633
|
+
onNodeContextMenu={handleNodeContextMenu}
|
|
634
|
+
onLinkClick={(link) => {
|
|
635
|
+
setSelectedLink(link);
|
|
636
|
+
setSelectedNode(null);
|
|
637
|
+
setContextMenu(null);
|
|
638
|
+
}}
|
|
639
|
+
width={graphWidth}
|
|
640
|
+
height={graphHeight}
|
|
641
|
+
isCompact={isCompact}
|
|
642
|
+
isTimelineMode={isTimelineMode}
|
|
643
|
+
isTextOnly={isTextOnly}
|
|
644
|
+
searchId={searchId}
|
|
645
|
+
selectedNode={selectedNode}
|
|
646
|
+
highlightKeepIds={deletePreview ? deletePreview.keepIds : pathNodeIds}
|
|
647
|
+
highlightDropIds={deletePreview ? deletePreview.dropIds : []}
|
|
648
|
+
expandingNodeId={expandingNodeId}
|
|
649
|
+
newChildNodeIds={newChildNodeIds}
|
|
650
|
+
/>
|
|
651
|
+
</div>
|
|
376
652
|
|
|
377
653
|
|
|
378
654
|
{!hideControlPanel && (
|
|
@@ -395,9 +671,16 @@ const App: React.FC<AppProps> = ({
|
|
|
395
671
|
onUpdateKioskDomains={setKioskDomains}
|
|
396
672
|
onClear={handleClear}
|
|
397
673
|
onClearCache={cacheEnabled ? handleClearCache : undefined}
|
|
674
|
+
onExpandAllLeafNodes={handleExpandAllLeafNodes}
|
|
675
|
+
onSave={handleSaveGraph}
|
|
676
|
+
onLoad={(name) => handleLoadGraph(name, applyGraphData)}
|
|
677
|
+
onDeleteGraph={handleDeleteGraph}
|
|
678
|
+
onImport={(data) => handleImport(data, applyGraphData)}
|
|
679
|
+
savedGraphs={savedGraphs}
|
|
680
|
+
helpHover={helpHover}
|
|
681
|
+
onHelpHoverChange={setHelpHover}
|
|
398
682
|
onToggleHelp={() => setShowHelp(!showHelp)}
|
|
399
683
|
showHelp={showHelp}
|
|
400
|
-
onExpandAllLeafNodes={handleExpandAllLeafNodes}
|
|
401
684
|
isProcessing={isProcessing}
|
|
402
685
|
isCompact={isCompact}
|
|
403
686
|
onToggleCompact={() => setIsCompact(!isCompact)}
|
|
@@ -405,18 +688,13 @@ const App: React.FC<AppProps> = ({
|
|
|
405
688
|
onToggleTimeline={() => setIsTimelineMode(!isTimelineMode)}
|
|
406
689
|
isTextOnly={isTextOnly}
|
|
407
690
|
onToggleTextOnly={() => setIsTextOnly(!isTextOnly)}
|
|
408
|
-
onPrune={handlePrune}
|
|
409
|
-
error={error}
|
|
410
|
-
onSave={handleSaveGraph}
|
|
411
|
-
onLoad={(name) => handleLoadGraph(name, applyGraphData)}
|
|
412
|
-
onDeleteGraph={handleDeleteGraph}
|
|
413
|
-
onImport={(e) => handleImport(e, applyGraphData)}
|
|
414
|
-
savedGraphs={savedGraphs}
|
|
415
|
-
helpHover={helpHover}
|
|
416
|
-
onHelpHoverChange={setHelpHover}
|
|
417
691
|
isCollapsed={panelCollapsed}
|
|
692
|
+
settingsHref={settingsHref}
|
|
418
693
|
onSetCollapsed={setPanelCollapsed}
|
|
419
694
|
onOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
695
|
+
offsetTopClass={controlPanelOffsetClass}
|
|
696
|
+
constrainToParentHeight={controlPanelConstrainToParent}
|
|
697
|
+
pinToViewport={useViewportPanels}
|
|
420
698
|
/>
|
|
421
699
|
)}
|
|
422
700
|
|
|
@@ -428,6 +706,8 @@ const App: React.FC<AppProps> = ({
|
|
|
428
706
|
onCollapseChange={setSidebarCollapsed}
|
|
429
707
|
externalToggleSignal={sidebarToggleSignal}
|
|
430
708
|
isAdminMode={isAdminMode}
|
|
709
|
+
useAbsoluteLayout={sidePanelUseAbsolute}
|
|
710
|
+
offsetTopClass={sidebarOffsetClass}
|
|
431
711
|
/>
|
|
432
712
|
)}
|
|
433
713
|
|
|
@@ -436,6 +716,8 @@ const App: React.FC<AppProps> = ({
|
|
|
436
716
|
<Suspense fallback={null}>
|
|
437
717
|
<PeopleBrowserSidebar
|
|
438
718
|
isOpen={peopleBrowserOpen}
|
|
719
|
+
useAbsoluteLayout={sidePanelUseAbsolute}
|
|
720
|
+
offsetTopClass={peopleBrowserOffsetClass}
|
|
439
721
|
onClose={() => setPeopleBrowserOpen(false)}
|
|
440
722
|
onSelectPerson={(name) => {
|
|
441
723
|
setExploreTerm(name);
|
|
@@ -449,14 +731,15 @@ const App: React.FC<AppProps> = ({
|
|
|
449
731
|
/>
|
|
450
732
|
</Suspense>
|
|
451
733
|
|
|
452
|
-
{contextMenu && (
|
|
734
|
+
{contextMenu && contextMenuNodeLive && (
|
|
453
735
|
<NodeContextMenu
|
|
454
|
-
node={
|
|
736
|
+
node={contextMenuNodeLive}
|
|
455
737
|
x={contextMenu.x}
|
|
456
738
|
y={contextMenu.y}
|
|
457
739
|
onExpandLeaves={handleExpandLeaves}
|
|
458
740
|
onAddMore={handleExpandMore}
|
|
459
741
|
onFindBetterPhoto={handleFindBetterImage}
|
|
742
|
+
onNewChannelFromNode={onNewChannelFromNode}
|
|
460
743
|
onDelete={handleSmartDelete}
|
|
461
744
|
onClose={() => setContextMenu(null)}
|
|
462
745
|
isProcessing={isProcessing}
|
|
@@ -473,6 +756,17 @@ const App: React.FC<AppProps> = ({
|
|
|
473
756
|
/>
|
|
474
757
|
</div>
|
|
475
758
|
|
|
759
|
+
<AppHeader
|
|
760
|
+
showHeader={!hideHeader}
|
|
761
|
+
panelCollapsed={panelCollapsed}
|
|
762
|
+
setPanelCollapsed={setPanelCollapsed}
|
|
763
|
+
selectedNode={selectedNode}
|
|
764
|
+
sidebarCollapsed={sidebarCollapsed}
|
|
765
|
+
setSidebarToggleSignal={setSidebarToggleSignal}
|
|
766
|
+
onClose={onClose}
|
|
767
|
+
closeHref={closeHref}
|
|
768
|
+
offsetTopClass={headerOffsetClass}
|
|
769
|
+
/>
|
|
476
770
|
</div>
|
|
477
771
|
);
|
|
478
772
|
};
|
|
@@ -6,13 +6,16 @@ const App = lazy(() => import("./App"));
|
|
|
6
6
|
type AppProps = ComponentProps<typeof App>;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* One code path for "full page" constellations inside a host (Soundings, Trailer Vision, etc.):
|
|
10
|
+
* always `embedded` (ResizeObserver, handoff window hook) + full control panel + details sidebar
|
|
11
|
+
* unless the host overrides. The in-app `AppHeader` (panel toggles, title, close) is shown by default;
|
|
12
|
+
* set `hideHeader` when the host already supplies equivalent chrome.
|
|
11
13
|
*/
|
|
12
14
|
export type FullPageConstellationsProps = Omit<AppProps, "embedded" | "useViewportForPanels"> & {
|
|
13
15
|
/**
|
|
14
|
-
* - `fixed-overlay` — full-viewport layer above the app (
|
|
15
|
-
* - `below-app-chrome` — fills route shell below site nav; parent must
|
|
16
|
+
* - `fixed-overlay` — e.g. Soundings: full-viewport layer above the app (`z-[100]`).
|
|
17
|
+
* - `below-app-chrome` — e.g. Trailer: fills the route shell below the site nav; parent must
|
|
18
|
+
* supply height (e.g. `h-[calc(100dvh-2.75rem)]`).
|
|
16
19
|
*/
|
|
17
20
|
layout: "fixed-overlay" | "below-app-chrome";
|
|
18
21
|
/** Wrapped around the constellations root; use for a host nav link row, etc. */
|