@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
package/App.tsx ADDED
@@ -0,0 +1,480 @@
1
+ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
2
+ import { buildWikiUrl } from './utils/wikiUtils';
3
+ import { Key, Search, HelpCircle, Minimize2, Maximize2, ExternalLink } from 'lucide-react';
4
+ import Graph from './components/Graph';
5
+ import ControlPanel from './components/ControlPanel';
6
+ import Sidebar from './components/Sidebar';
7
+ import NodeContextMenu from './components/NodeContextMenu';
8
+ import AppHeader from './components/AppHeader';
9
+ import AppNotifications from './components/AppNotifications';
10
+ import AppConfirmDialog from './components/AppConfirmDialog';
11
+ import HelpOverlay from './components/HelpOverlay';
12
+ import { GraphNode, GraphLink } from './types';
13
+ import { getApiKey, getEnvCacheUrl } from './services/aiUtils';
14
+ import { useNodeClickHandler } from './hooks/useNodeClickHandler';
15
+
16
+ import { useGraphState } from './hooks/useGraphState';
17
+ import { useKioskMode } from './hooks/useKioskMode';
18
+ import { useExpansion } from './hooks/useExpansion';
19
+ import { useSearchHandlers } from './hooks/useSearchHandlers';
20
+ import { useGraphActions } from './hooks/useGraphActions';
21
+
22
+ const PeopleBrowserSidebar = lazy(() => import('./components/PeopleBrowserSidebar'));
23
+
24
+
25
+ type AppProps = {
26
+ mode?: 'standalone' | 'extension';
27
+ hideHeader?: boolean;
28
+ hideControlPanel?: boolean;
29
+ hideSidebar?: boolean;
30
+ externalSearch?: { term: string; id: number } | null;
31
+ onExternalSearchConsumed?: (id: number) => void;
32
+ onNodeNavigate?: (node: GraphNode) => void;
33
+ renderEvidencePopup?: (selectedLink: GraphLink | null, onClose: () => void) => React.ReactNode;
34
+ };
35
+
36
+ const ExtensionControls: React.FC<{
37
+ isTimelineMode: boolean;
38
+ onToggle: (val: boolean) => void;
39
+ exploreTerm: string;
40
+ setExploreTerm: (val: string) => void;
41
+ onSearch: (val: string) => void;
42
+ isCompact: boolean;
43
+ onToggleCompact: () => void;
44
+ onToggleHelp: () => void;
45
+ }> = ({
46
+ isTimelineMode, onToggle, exploreTerm, setExploreTerm,
47
+ onSearch, isCompact, onToggleCompact, onToggleHelp
48
+ }) => {
49
+ return (
50
+ <div
51
+ className="fixed top-6 left-6 flex items-center gap-3 bg-slate-900/95 p-1.5 rounded-xl border border-slate-700 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-[9999]"
52
+ >
53
+ <div className="flex bg-slate-800/50 rounded-lg p-0.5 border border-slate-700/50">
54
+ <button
55
+ onClick={() => onToggle(false)}
56
+ className={`px-3 py-1.5 rounded-md text-[9px] font-black uppercase tracking-widest transition-all duration-300 ${!isTimelineMode
57
+ ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
58
+ : 'text-slate-500 hover:text-slate-300'
59
+ }`}
60
+ >
61
+ Net
62
+ </button>
63
+ <button
64
+ onClick={() => onToggle(true)}
65
+ className={`px-3 py-1.5 rounded-md text-[9px] font-black uppercase tracking-widest transition-all duration-300 ${isTimelineMode
66
+ ? 'bg-amber-500 text-slate-950 shadow-lg shadow-amber-500/20'
67
+ : 'text-slate-500 hover:text-slate-300'
68
+ }`}
69
+ >
70
+ Time
71
+ </button>
72
+ </div>
73
+
74
+ <div className="h-6 w-[1px] bg-slate-700/50" />
75
+
76
+ <form
77
+ onSubmit={(e) => { e.preventDefault(); onSearch(exploreTerm); }}
78
+ className="relative group"
79
+ >
80
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-indigo-400 transition-colors" size={14} />
81
+ <input
82
+ type="text"
83
+ value={exploreTerm}
84
+ onChange={(e) => setExploreTerm(e.target.value)}
85
+ placeholder="Search..."
86
+ className="bg-slate-800/80 border border-slate-700 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20 w-48 sm:w-64 md:w-80 transition-all"
87
+ />
88
+ </form>
89
+
90
+ <div className="h-6 w-[1px] bg-slate-700/50" />
91
+
92
+ <div className="flex items-center gap-1">
93
+ <button
94
+ onClick={onToggleCompact}
95
+ className={`p-1.5 rounded-lg border transition-all ${isCompact ? 'bg-indigo-500/20 border-indigo-500/40 text-indigo-400' : 'bg-slate-800/80 border-slate-700 text-slate-400 hover:text-white'}`}
96
+ title="Toggle Compact Mode"
97
+ >
98
+ {isCompact ? <Maximize2 size={16} /> : <Minimize2 size={16} />}
99
+ </button>
100
+ <button
101
+ onClick={onToggleHelp}
102
+ className="p-1.5 rounded-lg bg-slate-800/80 border border-slate-700 text-slate-400 hover:text-white transition-all"
103
+ title="Help"
104
+ >
105
+ <HelpCircle size={16} />
106
+ </button>
107
+ <div className="h-6 w-[1px] bg-slate-700/50" />
108
+ <button
109
+ onClick={() => {
110
+ const isExtension = window.location.protocol === 'chrome-extension:';
111
+ const baseOrigin = isExtension ? 'https://constellations-delta.vercel.app' : window.location.origin;
112
+ const url = new URL(baseOrigin);
113
+ if (exploreTerm) url.searchParams.set('q', exploreTerm);
114
+ window.open(url.toString(), '_blank');
115
+ }}
116
+ className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 hover:bg-indigo-500/20 hover:text-indigo-300 transition-all"
117
+ title="Open in Standalone App"
118
+ >
119
+ <ExternalLink size={16} />
120
+ </button>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
125
+
126
+ const App: React.FC<AppProps> = ({
127
+ hideHeader = false,
128
+ hideControlPanel = false,
129
+ hideSidebar = false,
130
+ externalSearch = null,
131
+ onExternalSearchConsumed,
132
+ onNodeNavigate,
133
+ renderEvidencePopup
134
+ }) => {
135
+ const ENABLE_WEB_SEARCH = String((import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH || '').trim().toLowerCase() === 'true' || (import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH === '1';
136
+ const ENABLE_ACADEMIC_CORPORA = (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== 'false' && (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== '0';
137
+
138
+ const cacheBaseUrl = getEnvCacheUrl();
139
+ const cacheEnabled = !!cacheBaseUrl;
140
+
141
+ const {
142
+ isAdminMode, kioskDomains, setKioskDomains, selectedKioskDomainId, setSelectedKioskDomainId,
143
+ selectedKioskDomain, kioskSeedTerms
144
+ } = useKioskMode();
145
+
146
+ const state = useGraphState({ cacheEnabled, cacheBaseUrl });
147
+ const {
148
+ graphData, setGraphData, nodes, links, graphDataRef,
149
+ isProcessing, setIsProcessing, selectedNode, setSelectedNode,
150
+ selectedLink, setSelectedLink, isCompact, setIsCompact,
151
+ isTimelineMode, setIsTimelineMode, isTextOnly, setIsTextOnly,
152
+ dimensions, error, setError, isKeyReady, setIsKeyReady,
153
+ nodesRef, graphRef, autoExpandMoreDoneRef, searchId, setSearchId,
154
+ searchIdRef, deletePreview, setDeletePreview, pathNodeIds, setPathNodeIds,
155
+ newlyExpandedNodeIds, setNewlyExpandedNodeIds, expandingNodeId, setExpandingNodeId,
156
+ newChildNodeIds, setNewChildNodeIds, helpHover, setHelpHover,
157
+ notification, setNotification, confirmDialog, setConfirmDialog,
158
+ contextMenu, setContextMenu, panelCollapsed, setPanelCollapsed,
159
+ sidebarCollapsed, setSidebarCollapsed, sidebarToggleSignal, setSidebarToggleSignal,
160
+ peopleBrowserOpen, setPeopleBrowserOpen, savedGraphs, setSavedGraphs,
161
+ searchMode, setSearchMode, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
162
+ } = state;
163
+
164
+ const { fetchAndExpandNode, saveCacheExpansion } = useExpansion({
165
+ graphDataRef, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef: state.lockedPairRef,
166
+ nodesRef, selectedNodeRef: state.selectedNodeRef, autoExpandMoreDoneRef,
167
+ cacheEnabled, cacheBaseUrl, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH,
168
+ loadNodeImage, saveCacheNodeMeta,
169
+ setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
170
+ setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef,
171
+ setNotification,
172
+ });
173
+
174
+ const [showHelp, setShowHelp] = useState(false);
175
+
176
+ const {
177
+ exploreTerm, setExploreTerm, pathStart, setPathStart, pathEnd, setPathEnd,
178
+ handleStartSearch, handlePathSearch
179
+ } = useSearchHandlers({
180
+ graphDataRef, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef,
181
+ setLockedPair: state.setLockedPair, dimensions, cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
182
+ setNotification, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId: () => { },
183
+ showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef
184
+ });
185
+
186
+ const {
187
+ handleClear, handleClearCache, handlePrune, handleSmartDelete, handleExpandLeaves,
188
+ handleExpandMore, handleExpandAllLeafNodes, handleDeleteGraph,
189
+ handleSaveGraph, handleLoadGraph, handleImport
190
+ } = useGraphActions({
191
+ nodes, links, setGraphData, setSelectedNode, setSelectedLink,
192
+ setContextMenu, setNotification, setConfirmDialog, setDeletePreview,
193
+ setPathNodeIds, fetchAndExpandNode, setIsProcessing, searchIdRef,
194
+ cacheEnabled, cacheBaseUrl, setSavedGraphs, searchMode, exploreTerm,
195
+ pathStart, pathEnd, isCompact, isTimelineMode, isTextOnly,
196
+ setExpandingNodeId, setNewChildNodeIds
197
+ });
198
+
199
+ const onNodeClick = useNodeClickHandler({
200
+ selectedNode, setSelectedNode, setContextMenu,
201
+ graphData,
202
+ setExpandingNodeId,
203
+ 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
+ onNavigate: onNodeNavigate ? (node) => {
207
+ onNodeNavigate(node);
208
+ } : undefined,
209
+ onExpand: isTimelineMode ? undefined : fetchAndExpandNode,
210
+ onDeselect: () => {
211
+ setPathNodeIds([]);
212
+ setSelectedLink(null);
213
+ setExpandingNodeId(null);
214
+ setNewChildNodeIds(new Set());
215
+ },
216
+ onClearSecondarySelection: () => {
217
+ setSelectedLink(null);
218
+ },
219
+ getMenuPosition: (node, event) => ({ x: event?.clientX ?? 0, y: event?.clientY ?? 0 })
220
+ });
221
+
222
+ useEffect(() => {
223
+ const checkKey = async () => {
224
+ if (cacheBaseUrl) {
225
+ setIsKeyReady(true);
226
+ return;
227
+ }
228
+ const envKey = await getApiKey();
229
+ if ((window as any).aistudio) {
230
+ const hasKey = await (window as any).aistudio.hasSelectedApiKey();
231
+ setIsKeyReady(hasKey || !!envKey);
232
+ } else {
233
+ if (envKey) setIsKeyReady(true);
234
+ }
235
+ };
236
+ checkKey();
237
+ }, [setIsKeyReady]);
238
+
239
+ useEffect(() => {
240
+ if (!externalSearch?.term) return;
241
+ handleStartSearch(externalSearch.term);
242
+ if (externalSearch?.id !== undefined) onExternalSearchConsumed?.(externalSearch.id);
243
+ }, [externalSearch?.id, handleStartSearch, onExternalSearchConsumed]);
244
+
245
+ useEffect(() => {
246
+ const handlePopState = () => {
247
+ const params = new URLSearchParams(window.location.search);
248
+ setPeopleBrowserOpen(params.get('browse') === 'people');
249
+ };
250
+ window.addEventListener('popstate', handlePopState);
251
+ return () => window.removeEventListener('popstate', handlePopState);
252
+ }, [setPeopleBrowserOpen]);
253
+
254
+ // Auto-start search if ?q= parameter is present in URL
255
+ const urlQueryProcessedRef = useRef(false);
256
+ useEffect(() => {
257
+ if (urlQueryProcessedRef.current) return;
258
+
259
+ const params = new URLSearchParams(window.location.search);
260
+ const queryParam = params.get('q');
261
+ const startParam = params.get('start');
262
+ const endParam = params.get('end');
263
+
264
+ if (queryParam && isKeyReady && nodes.length === 0) {
265
+ urlQueryProcessedRef.current = true;
266
+ handleStartSearch(queryParam);
267
+ } else if (startParam && endParam && isKeyReady && nodes.length === 0) {
268
+ urlQueryProcessedRef.current = true;
269
+ setSearchMode('connect');
270
+ setPathStart(startParam);
271
+ setPathEnd(endParam);
272
+ handlePathSearch(startParam, endParam);
273
+ }
274
+ }, [isKeyReady, nodes.length, handleStartSearch, handlePathSearch, setSearchMode, setPathStart, setPathEnd]);
275
+
276
+ const applyGraphData = useCallback((data: any, sourceLabel: string) => {
277
+ try {
278
+ const savedNodes = data.nodes || [];
279
+ const savedLinks = data.links || [];
280
+ if (savedNodes.length === 0) {
281
+ setNotification({ message: `Graph "${sourceLabel}" is empty.`, type: 'error' });
282
+ return;
283
+ }
284
+ if (data.searchMode) setSearchMode(data.searchMode);
285
+ if (data.exploreTerm) setExploreTerm(data.exploreTerm);
286
+ if (data.pathStart) setPathStart(data.pathStart);
287
+ if (data.pathEnd) setPathEnd(data.pathEnd);
288
+ if (data.isCompact !== undefined) setIsCompact(data.isCompact);
289
+ if (data.isTimelineMode !== undefined) setIsTimelineMode(data.isTimelineMode);
290
+ if (data.isTextOnly !== undefined) setIsTextOnly(data.isTextOnly);
291
+
292
+ setGraphData({
293
+ nodes: savedNodes.map((n: any) => ({ ...n, isLoading: false, vx: 0, vy: 0, fx: null, fy: null })),
294
+ links: savedLinks
295
+ });
296
+ setSearchId(prev => prev + 1);
297
+ setError(null);
298
+ setNotification({ message: `Graph "${sourceLabel}" loaded!`, type: 'success' });
299
+ } catch (e) {
300
+ setError("Failed to load graph data.");
301
+ setNotification({ message: "Error loading graph.", type: 'error' });
302
+ }
303
+ }, [setNotification, setSearchMode, setExploreTerm, setPathStart, setPathEnd, setIsCompact, setIsTimelineMode, setIsTextOnly, setGraphData, setSearchId, setError]);
304
+
305
+ const handleOpenPeopleBrowser = useCallback(() => {
306
+ const newParams = new URLSearchParams(window.location.search);
307
+ newParams.set('browse', 'people');
308
+ window.history.pushState({ browse: 'people' }, '', window.location.pathname + '?' + newParams.toString());
309
+ setPeopleBrowserOpen(true);
310
+ }, [setPeopleBrowserOpen]);
311
+
312
+ if (!isKeyReady) {
313
+ return (
314
+ <div className="flex flex-col items-center justify-center w-screen h-screen bg-slate-900 text-white space-y-6">
315
+ <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
+ <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
+ <Key size={20} className="inline mr-2" /> Select API Key
318
+ </button>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ return (
324
+ <div className="w-screen h-screen bg-slate-950 overflow-hidden font-sans text-slate-200 selection:bg-indigo-500/30">
325
+ {hideControlPanel && (
326
+ <ExtensionControls
327
+ isTimelineMode={isTimelineMode}
328
+ onToggle={setIsTimelineMode}
329
+ exploreTerm={exploreTerm}
330
+ setExploreTerm={setExploreTerm}
331
+ onSearch={handleStartSearch}
332
+ isCompact={isCompact}
333
+ onToggleCompact={() => setIsCompact(!isCompact)}
334
+ onToggleHelp={() => setShowHelp(true)}
335
+ />
336
+ )}
337
+
338
+ <HelpOverlay
339
+ isOpen={showHelp}
340
+ onClose={() => setShowHelp(false)}
341
+ isExtension={hideControlPanel}
342
+ onOpenPeopleBrowser={handleOpenPeopleBrowser}
343
+ />
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
+
357
+ <div className={`relative w-full h-full transition-all duration-500 ease-in-out ${!hideHeader ? 'pt-14' : ''}`}>
358
+ <Graph
359
+ ref={graphRef}
360
+ nodes={nodes}
361
+ links={links}
362
+ onNodeClick={onNodeClick}
363
+ onLinkClick={(link) => { setSelectedLink(link); setSelectedNode(null); setContextMenu(null); }}
364
+ width={dimensions.width}
365
+ height={dimensions.height}
366
+ isCompact={isCompact}
367
+ isTimelineMode={isTimelineMode}
368
+ isTextOnly={isTextOnly}
369
+ searchId={searchId}
370
+ selectedNode={selectedNode}
371
+ highlightKeepIds={deletePreview ? deletePreview.keepIds : pathNodeIds}
372
+ highlightDropIds={deletePreview ? deletePreview.dropIds : []}
373
+ expandingNodeId={expandingNodeId}
374
+ newChildNodeIds={newChildNodeIds}
375
+ />
376
+
377
+
378
+ {!hideControlPanel && (
379
+ <ControlPanel
380
+ searchMode={searchMode}
381
+ setSearchMode={setSearchMode}
382
+ exploreTerm={exploreTerm}
383
+ setExploreTerm={setExploreTerm}
384
+ pathStart={pathStart}
385
+ setPathStart={setPathStart}
386
+ pathEnd={pathEnd}
387
+ setPathEnd={setPathEnd}
388
+ onSearch={handleStartSearch}
389
+ onPathSearch={handlePathSearch}
390
+ isAdminMode={isAdminMode}
391
+ kioskSeedTerms={kioskSeedTerms}
392
+ kioskDomains={kioskDomains}
393
+ selectedKioskDomainId={selectedKioskDomainId}
394
+ onSelectKioskDomain={(id) => { setSelectedKioskDomainId(id); setPathStart(''); setPathEnd(''); }}
395
+ onUpdateKioskDomains={setKioskDomains}
396
+ onClear={handleClear}
397
+ onClearCache={cacheEnabled ? handleClearCache : undefined}
398
+ onToggleHelp={() => setShowHelp(!showHelp)}
399
+ showHelp={showHelp}
400
+ onExpandAllLeafNodes={handleExpandAllLeafNodes}
401
+ isProcessing={isProcessing}
402
+ isCompact={isCompact}
403
+ onToggleCompact={() => setIsCompact(!isCompact)}
404
+ isTimelineMode={isTimelineMode}
405
+ onToggleTimeline={() => setIsTimelineMode(!isTimelineMode)}
406
+ isTextOnly={isTextOnly}
407
+ 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
+ isCollapsed={panelCollapsed}
418
+ onSetCollapsed={setPanelCollapsed}
419
+ onOpenPeopleBrowser={handleOpenPeopleBrowser}
420
+ />
421
+ )}
422
+
423
+ {!hideSidebar && (
424
+ <Sidebar
425
+ selectedNode={selectedNode}
426
+ selectedLink={selectedLink}
427
+ onClose={() => { setSelectedNode(null); setSelectedLink(null); setContextMenu(null); setPathNodeIds([]); }}
428
+ onCollapseChange={setSidebarCollapsed}
429
+ externalToggleSignal={sidebarToggleSignal}
430
+ isAdminMode={isAdminMode}
431
+ />
432
+ )}
433
+
434
+ {renderEvidencePopup && renderEvidencePopup(selectedLink, () => setSelectedLink(null))}
435
+
436
+ <Suspense fallback={null}>
437
+ <PeopleBrowserSidebar
438
+ isOpen={peopleBrowserOpen}
439
+ onClose={() => setPeopleBrowserOpen(false)}
440
+ onSelectPerson={(name) => {
441
+ setExploreTerm(name);
442
+ setPeopleBrowserOpen(false);
443
+ const params = new URLSearchParams(window.location.search);
444
+ params.delete('browse');
445
+ params.set('q', name);
446
+ window.history.pushState({}, '', window.location.pathname + '?' + params.toString());
447
+ handleStartSearch(name, 1);
448
+ }}
449
+ />
450
+ </Suspense>
451
+
452
+ {contextMenu && (
453
+ <NodeContextMenu
454
+ node={contextMenu.node}
455
+ x={contextMenu.x}
456
+ y={contextMenu.y}
457
+ onExpandLeaves={handleExpandLeaves}
458
+ onAddMore={handleExpandMore}
459
+ onFindBetterPhoto={handleFindBetterImage}
460
+ onDelete={handleSmartDelete}
461
+ onClose={() => setContextMenu(null)}
462
+ isProcessing={isProcessing}
463
+ />
464
+ )}
465
+
466
+ <AppNotifications notification={notification} />
467
+ <AppConfirmDialog
468
+ confirmDialog={confirmDialog}
469
+ onClose={() => {
470
+ setConfirmDialog(null);
471
+ setDeletePreview(null);
472
+ }}
473
+ />
474
+ </div>
475
+
476
+ </div>
477
+ );
478
+ };
479
+
480
+ export default App;
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { lazy, Suspense, type ComponentProps, type FC, type ReactNode } from "react";
3
+ import "./index.css";
4
+
5
+ const App = lazy(() => import("./App"));
6
+
7
+ type AppProps = ComponentProps<typeof App>;
8
+
9
+ /**
10
+ * Embeddable full-page constellations for host apps (Soundings, Trailer Vision, etc.).
11
+ * Always renders in embedded mode (ResizeObserver, handoff window hook) with full chrome.
12
+ */
13
+ export type FullPageConstellationsProps = Omit<AppProps, "embedded" | "useViewportForPanels"> & {
14
+ /**
15
+ * - `fixed-overlay` — full-viewport layer above the app (e.g. Soundings, `z-[100]`).
16
+ * - `below-app-chrome` — fills route shell below site nav; parent must supply height.
17
+ */
18
+ layout: "fixed-overlay" | "below-app-chrome";
19
+ /** Wrapped around the constellations root; use for a host nav link row, etc. */
20
+ chromeSlot?: ReactNode;
21
+ };
22
+
23
+ const defaults = {
24
+ hideControlPanel: false,
25
+ hideSidebar: false,
26
+ showExtensionWhenPanelHidden: true,
27
+ hostNavOffsetPx: 0,
28
+ } as const;
29
+
30
+ export const FullPageConstellations: FC<FullPageConstellationsProps> = ({
31
+ layout,
32
+ chromeSlot,
33
+ hideHeader = false,
34
+ hideControlPanel = defaults.hideControlPanel,
35
+ hideSidebar = defaults.hideSidebar,
36
+ showExtensionWhenPanelHidden = defaults.showExtensionWhenPanelHidden,
37
+ hostNavOffsetPx = defaults.hostNavOffsetPx,
38
+ ...rest
39
+ }) => {
40
+ const app = (
41
+ <Suspense fallback={null}>
42
+ <App
43
+ {...rest}
44
+ embedded
45
+ hideHeader={hideHeader}
46
+ useViewportForPanels={layout === "fixed-overlay"}
47
+ hideControlPanel={hideControlPanel}
48
+ hideSidebar={hideSidebar}
49
+ showExtensionWhenPanelHidden={showExtensionWhenPanelHidden}
50
+ hostNavOffsetPx={hostNavOffsetPx}
51
+ />
52
+ </Suspense>
53
+ );
54
+
55
+ if (layout === "fixed-overlay") {
56
+ return (
57
+ <div className="fixed inset-0 z-[100] min-h-0 flex flex-col">
58
+ {chromeSlot}
59
+ <div className="min-h-0 min-w-0 flex-1">
60
+ <div className="h-full min-h-0 w-full min-w-0">{app}</div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
68
+ {chromeSlot}
69
+ <div className="min-h-0 min-w-0 flex-1">
70
+ <div className="h-full min-h-0 w-full min-w-0">{app}</div>
71
+ </div>
72
+ </div>
73
+ );
74
+ };
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ const loadingBase =
6
+ "flex items-center justify-center bg-slate-950 text-sm text-slate-200";
7
+
8
+ /**
9
+ * One loading shell for all full-page constellations routes (see `useFullPageConstellationsHost`).
10
+ */
11
+ export function FullPageConstellationsHostLoading({
12
+ surface,
13
+ children = "Loading…",
14
+ }: {
15
+ /** `in-layout` = under a site nav; `overlay` = full-viewport cover (e.g. Soundings) */
16
+ surface: "in-layout" | "overlay";
17
+ children?: ReactNode;
18
+ }) {
19
+ if (surface === "overlay") {
20
+ return (
21
+ <div className={`min-h-screen ${loadingBase}`}>{children}</div>
22
+ );
23
+ }
24
+ return (
25
+ <div className={`flex h-full min-h-0 w-full ${loadingBase}`}>{children}</div>
26
+ );
27
+ }