@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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { GraphNode } from '../types';
|
|
3
|
-
import { Maximize, Plus, Sparkles, Trash2 } from 'lucide-react';
|
|
4
|
+
import { ListMusic, Loader2, Maximize, Plus, Sparkles, Trash2 } from 'lucide-react';
|
|
4
5
|
|
|
5
6
|
interface NodeContextMenuProps {
|
|
6
7
|
node: GraphNode;
|
|
@@ -8,7 +9,9 @@ interface NodeContextMenuProps {
|
|
|
8
9
|
y: number;
|
|
9
10
|
onExpandLeaves: (node: GraphNode) => void;
|
|
10
11
|
onAddMore: (node: GraphNode) => void;
|
|
11
|
-
onFindBetterPhoto: (nodeId: number) => void;
|
|
12
|
+
onFindBetterPhoto: (nodeId: number | string) => void;
|
|
13
|
+
/** When set (e.g. Soundings player), create a new channel seeded from this node. */
|
|
14
|
+
onNewChannelFromNode?: (node: GraphNode) => void;
|
|
12
15
|
onDelete: (node: GraphNode) => void;
|
|
13
16
|
onClose: () => void;
|
|
14
17
|
isProcessing?: boolean;
|
|
@@ -21,6 +24,7 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
21
24
|
onExpandLeaves,
|
|
22
25
|
onAddMore,
|
|
23
26
|
onFindBetterPhoto,
|
|
27
|
+
onNewChannelFromNode,
|
|
24
28
|
onDelete,
|
|
25
29
|
onClose,
|
|
26
30
|
isProcessing
|
|
@@ -30,12 +34,104 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
30
34
|
onClose();
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* During fetch, `useExpansion` sets both `expanded` and `isLoading` on the parent,
|
|
39
|
+
* so we must not require `!expanded` — use `isLoading` only.
|
|
40
|
+
* (The menu receives the live node from App so `isLoading` stays current while open.)
|
|
41
|
+
*/
|
|
42
|
+
const expansionInProgress = Boolean(node.isLoading);
|
|
43
|
+
/** While this node is expanding, do not block "new channel" on global isProcessing. */
|
|
44
|
+
const newChannelDisabled = isProcessing && !expansionInProgress;
|
|
45
|
+
|
|
33
46
|
// Calculate position to keep menu on screen
|
|
34
47
|
const menuWidth = 220;
|
|
35
|
-
const menuHeight =
|
|
48
|
+
const menuHeight = expansionInProgress
|
|
49
|
+
? (onNewChannelFromNode ? 150 : 100)
|
|
50
|
+
: onNewChannelFromNode
|
|
51
|
+
? 230
|
|
52
|
+
: 180;
|
|
36
53
|
const adjustedX = Math.min(x, window.innerWidth - menuWidth - 20);
|
|
37
54
|
const adjustedY = Math.min(y, window.innerHeight - menuHeight - 20);
|
|
38
55
|
|
|
56
|
+
if (expansionInProgress) {
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<div
|
|
60
|
+
className="fixed inset-0 z-40"
|
|
61
|
+
style={{ position: 'fixed', inset: 0, zIndex: 999998 }}
|
|
62
|
+
onClick={onClose}
|
|
63
|
+
/>
|
|
64
|
+
<div
|
|
65
|
+
className="fixed z-50"
|
|
66
|
+
style={{
|
|
67
|
+
position: 'fixed',
|
|
68
|
+
zIndex: 999999,
|
|
69
|
+
left: `${adjustedX}px`,
|
|
70
|
+
top: `${adjustedY}px`,
|
|
71
|
+
minWidth: '240px',
|
|
72
|
+
maxWidth: 'min(360px, 92vw)',
|
|
73
|
+
padding: '10px 10px 8px',
|
|
74
|
+
borderRadius: '10px',
|
|
75
|
+
backgroundColor: 'rgba(15, 23, 42, 0.98)',
|
|
76
|
+
border: '1px solid #334155',
|
|
77
|
+
boxShadow: '0 20px 45px rgba(0,0,0,0.35)',
|
|
78
|
+
color: '#f8fafc'
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
style={{
|
|
83
|
+
display: 'flex',
|
|
84
|
+
alignItems: 'flex-start',
|
|
85
|
+
gap: '8px',
|
|
86
|
+
marginBottom: onNewChannelFromNode ? 10 : 0,
|
|
87
|
+
fontSize: '12px',
|
|
88
|
+
lineHeight: 1.35,
|
|
89
|
+
color: '#94a3b8'
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<Loader2 size={16} className="text-indigo-400 shrink-0 mt-0.5 animate-spin" />
|
|
93
|
+
<div>
|
|
94
|
+
<div className="text-slate-200 font-medium text-[13px] line-clamp-2" title={node.title}>
|
|
95
|
+
{node.title}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="mt-0.5">Expanding connections…</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{onNewChannelFromNode && (
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => handleAction(() => onNewChannelFromNode(node))}
|
|
103
|
+
disabled={newChannelDisabled}
|
|
104
|
+
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
|
105
|
+
type="button"
|
|
106
|
+
style={{
|
|
107
|
+
width: '100%',
|
|
108
|
+
padding: '8px 12px',
|
|
109
|
+
textAlign: 'left',
|
|
110
|
+
fontSize: '13px',
|
|
111
|
+
color: 'inherit',
|
|
112
|
+
background: 'rgba(34, 211, 238, 0.08)',
|
|
113
|
+
border: '1px solid rgba(34, 211, 238, 0.25)',
|
|
114
|
+
borderRadius: '8px',
|
|
115
|
+
display: 'flex',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: '10px',
|
|
118
|
+
cursor: newChannelDisabled ? 'not-allowed' : 'pointer'
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<ListMusic size={16} className="text-cyan-400" />
|
|
122
|
+
<span>New channel from this node</span>
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
{!onNewChannelFromNode && (
|
|
126
|
+
<p style={{ fontSize: '11px', color: '#64748b', margin: 0, lineHeight: 1.4 }}>
|
|
127
|
+
Open the menu again after expansion for more actions.
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
39
135
|
return (
|
|
40
136
|
<>
|
|
41
137
|
{/* Backdrop to close menu on click outside */}
|
|
@@ -131,6 +227,30 @@ const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|
|
131
227
|
<span>Find Better Photo</span>
|
|
132
228
|
</button>
|
|
133
229
|
|
|
230
|
+
{onNewChannelFromNode && (
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => handleAction(() => onNewChannelFromNode(node))}
|
|
233
|
+
disabled={newChannelDisabled}
|
|
234
|
+
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
|
235
|
+
style={{
|
|
236
|
+
width: '100%',
|
|
237
|
+
padding: '8px 12px',
|
|
238
|
+
textAlign: 'left',
|
|
239
|
+
fontSize: '13px',
|
|
240
|
+
color: 'inherit',
|
|
241
|
+
background: 'transparent',
|
|
242
|
+
border: 'none',
|
|
243
|
+
display: 'flex',
|
|
244
|
+
alignItems: 'center',
|
|
245
|
+
gap: '10px',
|
|
246
|
+
cursor: newChannelDisabled ? 'not-allowed' : 'pointer'
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<ListMusic size={16} className="text-cyan-400" />
|
|
250
|
+
<span>New channel from node</span>
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
|
|
134
254
|
<div style={{ height: '1px', background: '#334155', margin: '6px 0' }} />
|
|
135
255
|
|
|
136
256
|
<button
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
3
|
import { Search, X, Filter, ChevronRight } from 'lucide-react';
|
|
3
4
|
|
|
@@ -36,6 +37,10 @@ interface PeopleBrowserSidebarProps {
|
|
|
36
37
|
isOpen: boolean;
|
|
37
38
|
onClose: () => void;
|
|
38
39
|
onSelectPerson: (personName: string) => void;
|
|
40
|
+
/** With `useAbsoluteLayout`, use `top-14` in the constellations `main`; with `fixed`, use viewport top. */
|
|
41
|
+
offsetTopClass?: string;
|
|
42
|
+
/** When true, position inside the graph `main` (embedded hosts); avoids broken `fixed` in iframes/clipped roots. */
|
|
43
|
+
useAbsoluteLayout?: boolean;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
const sumPageViews = (pageviews: Record<string, number> | undefined) => {
|
|
@@ -49,7 +54,7 @@ const sumPageViews = (pageviews: Record<string, number> | undefined) => {
|
|
|
49
54
|
const cleanCategoryLabel = (category: string) =>
|
|
50
55
|
category.replace(/^Category:/i, '').replace(/_/g, ' ');
|
|
51
56
|
|
|
52
|
-
const PeopleBrowserSidebar: React.FC<PeopleBrowserSidebarProps> = ({ isOpen, onClose, onSelectPerson }) => {
|
|
57
|
+
const PeopleBrowserSidebar: React.FC<PeopleBrowserSidebarProps> = ({ isOpen, onClose, onSelectPerson, offsetTopClass = "top-16", useAbsoluteLayout = false }) => {
|
|
53
58
|
const [people, setPeople] = useState<Person[]>([]);
|
|
54
59
|
const [loading, setLoading] = useState(false);
|
|
55
60
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -456,15 +461,19 @@ const PeopleBrowserSidebar: React.FC<PeopleBrowserSidebarProps> = ({ isOpen, onC
|
|
|
456
461
|
|
|
457
462
|
if (!isOpen) return null;
|
|
458
463
|
|
|
459
|
-
|
|
460
|
-
const panelClasses =
|
|
464
|
+
const pos = useAbsoluteLayout ? "absolute bottom-0" : "fixed";
|
|
465
|
+
const panelClasses = `${pos} right-3 sm:right-4 z-[60] transition-transform duration-300 ease-in-out ${isCollapsed ? "translate-x-[calc(100%+2rem)]" : "translate-x-0"} ${offsetTopClass}`;
|
|
461
466
|
const panelStyle = isMobile
|
|
462
|
-
? { width:
|
|
463
|
-
: { width:
|
|
467
|
+
? { width: "calc(100% - 1.5rem)", maxWidth: "28rem" }
|
|
468
|
+
: { width: "28rem" };
|
|
464
469
|
|
|
465
470
|
return (
|
|
466
471
|
<div className={panelClasses} style={panelStyle}>
|
|
467
|
-
<div
|
|
472
|
+
<div
|
|
473
|
+
className={`bg-slate-900/95 backdrop-blur-xl rounded-xl border border-slate-700 shadow-2xl relative pointer-events-auto flex flex-col ${
|
|
474
|
+
useAbsoluteLayout ? "max-h-[calc(100%-1.5rem)]" : "max-h-[calc(100vh-2rem)]"
|
|
475
|
+
}`}
|
|
476
|
+
>
|
|
468
477
|
{/* Header */}
|
|
469
478
|
<div className="p-4 border-b border-slate-700 flex-shrink-0">
|
|
470
479
|
<div className="flex items-center justify-between mb-3">
|
package/components/Sidebar.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import React, { useState, useEffect, useRef } from 'react';
|
|
2
3
|
import { GraphNode, GraphLink } from '../types';
|
|
3
4
|
import { X, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
@@ -11,11 +12,23 @@ interface SidebarProps {
|
|
|
11
12
|
externalToggleSignal?: number;
|
|
12
13
|
isAdminMode?: boolean;
|
|
13
14
|
forceExpanded?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Top offset: with `useAbsoluteLayout`, this is from the constellations `main` (use `top-14`).
|
|
17
|
+
* With `position: fixed`, use viewport space (e.g. `top-14` standalone or `top-[6.25rem]` over a host).
|
|
18
|
+
*/
|
|
19
|
+
offsetTopClass?: string;
|
|
20
|
+
/**
|
|
21
|
+
* When true (e.g. embedded in Trailer), use `position: absolute` in the constellations root so
|
|
22
|
+
* the panel is not `fixed` to the wrong viewport/clip. Must match the control bar (`top-14` in `main`).
|
|
23
|
+
*/
|
|
24
|
+
useAbsoluteLayout?: boolean;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
const Sidebar: React.FC<SidebarProps> = ({ selectedNode, selectedLink, onClose, onCollapseChange, externalToggleSignal, isAdminMode, forceExpanded }) => {
|
|
27
|
+
const Sidebar: React.FC<SidebarProps> = ({ selectedNode, selectedLink, onClose, onCollapseChange, externalToggleSignal, isAdminMode, forceExpanded, offsetTopClass = "top-14", useAbsoluteLayout = false }) => {
|
|
17
28
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
18
|
-
const [isMobile, setIsMobile] = useState(
|
|
29
|
+
const [isMobile, setIsMobile] = useState(
|
|
30
|
+
() => typeof window !== "undefined" && window.innerWidth < 768
|
|
31
|
+
);
|
|
19
32
|
const [showFullSummary, setShowFullSummary] = useState(false);
|
|
20
33
|
const userManuallyCollapsedRef = useRef(false);
|
|
21
34
|
const lastToggleSignalRef = useRef<number | undefined>(undefined);
|
|
@@ -60,7 +73,7 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedNode, selectedLink, onClose,
|
|
|
60
73
|
setShowFullSummary(false);
|
|
61
74
|
}, [selectedNode, selectedLink, isMobile, forceExpanded]);
|
|
62
75
|
|
|
63
|
-
// External toggle (from header
|
|
76
|
+
// External toggle (from header) — use functional setState (effect must not call a stale handler)
|
|
64
77
|
useEffect(() => {
|
|
65
78
|
if (externalToggleSignal === undefined) return;
|
|
66
79
|
if (lastToggleSignalRef.current === undefined) {
|
|
@@ -69,15 +82,20 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedNode, selectedLink, onClose,
|
|
|
69
82
|
}
|
|
70
83
|
if (externalToggleSignal !== lastToggleSignalRef.current) {
|
|
71
84
|
lastToggleSignalRef.current = externalToggleSignal;
|
|
72
|
-
|
|
85
|
+
setIsCollapsed((c) => {
|
|
86
|
+
const next = !c;
|
|
87
|
+
userManuallyCollapsedRef.current = next;
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
73
90
|
}
|
|
74
91
|
}, [externalToggleSignal]);
|
|
75
92
|
|
|
76
93
|
const handleToggleCollapse = () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
setIsCollapsed((c) => {
|
|
95
|
+
const next = !c;
|
|
96
|
+
userManuallyCollapsedRef.current = next;
|
|
97
|
+
return next;
|
|
98
|
+
});
|
|
81
99
|
};
|
|
82
100
|
|
|
83
101
|
if (!selectedNode && !selectedLink) return null;
|
|
@@ -86,35 +104,44 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedNode, selectedLink, onClose,
|
|
|
86
104
|
const isPerson = selectedNode ? (selectedNode.is_atomic === true || selectedNode.is_person === true || (selectedNode.type.toLowerCase() === 'person' || selectedNode.type.toLowerCase() === 'actor')) : false;
|
|
87
105
|
|
|
88
106
|
// Unified side panel styling - slides right on both mobile and desktop
|
|
89
|
-
//
|
|
90
|
-
// When collapsed, we translate most of it away but leave 24px (1.5rem-ish) for the handle.
|
|
107
|
+
// When embedded, `absolute` + same `top` as control bar avoids `fixed` viewport/clip bugs in hosts.
|
|
91
108
|
const effectiveMobile = forceExpanded ? false : isMobile;
|
|
92
|
-
const panelWidth = effectiveMobile
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
const panelWidth = effectiveMobile
|
|
110
|
+
? useAbsoluteLayout
|
|
111
|
+
? "calc(100% - 1.5rem)"
|
|
112
|
+
: "calc(100vw - 1.5rem)"
|
|
113
|
+
: "26rem";
|
|
114
|
+
const pos = useAbsoluteLayout ? "absolute" : "fixed";
|
|
115
|
+
const panelClasses = `${pos} bottom-0 right-0 z-[55] transition-transform duration-300 ease-in-out ${isCollapsed ? "translate-x-[calc(100%-24px)]" : "translate-x-0"} ${offsetTopClass}`;
|
|
116
|
+
const panelStyle: React.CSSProperties = {
|
|
117
|
+
width: panelWidth,
|
|
118
|
+
maxWidth: "28rem",
|
|
119
|
+
paddingRight: effectiveMobile ? "0.75rem" : "1rem",
|
|
120
|
+
};
|
|
95
121
|
|
|
96
122
|
return (
|
|
97
123
|
<>
|
|
98
124
|
<div className={panelClasses} style={panelStyle}>
|
|
99
|
-
<div className="bg-slate-900/95 backdrop-blur-xl rounded-xl border border-slate-700 shadow-2xl relative pointer-events-auto flex
|
|
125
|
+
<div className="bg-slate-900/95 backdrop-blur-xl rounded-xl border border-slate-700 shadow-2xl relative pointer-events-auto flex h-full min-h-0 flex-col overflow-hidden p-4 sm:p-6">
|
|
100
126
|
{/* Persistent Toggle Handle */}
|
|
101
127
|
<button
|
|
128
|
+
type="button"
|
|
102
129
|
onClick={handleToggleCollapse}
|
|
103
130
|
className={`absolute top-1/2 -translate-y-1/2 -left-8 w-8 h-24 bg-slate-800 border border-slate-700 border-r-0 rounded-l-xl flex flex-col items-center justify-center text-slate-400 hover:text-white transition-all group shadow-xl ${isCollapsed ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
|
104
|
-
title={isCollapsed ? "Expand
|
|
131
|
+
title={isCollapsed ? "Expand details panel" : "Collapse details panel"}
|
|
105
132
|
>
|
|
106
133
|
{isCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
|
|
107
134
|
<div className="[writing-mode:vertical-lr] text-[9px] uppercase tracking-tighter mt-1 font-bold">Details</div>
|
|
108
135
|
</button>
|
|
109
136
|
|
|
110
|
-
<div className="flex-1 overflow-
|
|
111
|
-
<div className="
|
|
112
|
-
<h2 className="text-xl font-bold text-white
|
|
137
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto pr-1 custom-scrollbar">
|
|
138
|
+
<div className="mb-3 shrink-0">
|
|
139
|
+
<h2 className="text-xl font-bold leading-tight text-white">
|
|
113
140
|
{selectedNode ? selectedNode.title : "Connection Details"}
|
|
114
141
|
</h2>
|
|
115
142
|
</div>
|
|
116
143
|
|
|
117
|
-
<div className="
|
|
144
|
+
<div className="min-h-0 space-y-4 pb-1">
|
|
118
145
|
{/* Selected Edge Evidence (when user clicks an edge) */}
|
|
119
146
|
{selectedLink && (
|
|
120
147
|
<div className="p-3 bg-slate-800/40 rounded-lg border border-slate-600/40">
|