@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.
Files changed (40) hide show
  1. package/App.tsx +360 -66
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +67 -30
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +229 -250
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +2 -1
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +85 -230
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +60 -21
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/index.tsx +5 -3
  23. package/package.json +4 -2
  24. package/services/aiService.ts +27 -0
  25. package/services/aiUtils.ts +285 -195
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +479 -0
  29. package/services/geminiService.ts +543 -736
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +79 -49
  36. package/sessionHandoff.ts +26 -0
  37. package/types.ts +3 -0
  38. package/utils/evidenceUtils.ts +1 -0
  39. package/utils/graphLogicUtils.ts +1 -0
  40. 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 = 180;
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
- // Position at the same location as regular sidebar, but with higher z-index when open
460
- const panelClasses = `fixed top-16 right-3 sm:right-4 z-[60] transition-transform duration-300 ease-in-out ${isCollapsed ? 'translate-x-[calc(100%+2rem)]' : 'translate-x-0'}`;
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: 'calc(100% - 1.5rem)', maxWidth: '28rem' }
463
- : { width: '28rem' };
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 className="bg-slate-900/95 backdrop-blur-xl rounded-xl border border-slate-700 shadow-2xl relative pointer-events-auto flex flex-col max-h-[calc(100vh-2rem)]">
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">
@@ -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(window.innerWidth < 768);
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 button)
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
- handleToggleCollapse();
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
- const newCollapsed = !isCollapsed;
78
- setIsCollapsed(newCollapsed);
79
- // Track that user manually collapsed it
80
- userManuallyCollapsedRef.current = newCollapsed;
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
- // Side panel styling - always slides right.
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 ? 'calc(100vw - 1.5rem)' : '26rem';
93
- const panelClasses = `fixed top-16 right-0 z-50 transition-transform duration-300 ease-in-out ${isCollapsed ? 'translate-x-[calc(100%-24px)]' : 'translate-x-0'}`;
94
- const panelStyle = { width: panelWidth, maxWidth: '28rem', paddingRight: effectiveMobile ? '0.75rem' : '1rem' };
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 flex-col p-6 h-[calc(100vh-6rem)] overflow-visible">
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 sidebar" : "Collapse sidebar"}
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-visible">
111
- <div className="flex justify-between items-start mb-4">
112
- <h2 className="text-xl font-bold text-white leading-tight">
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="space-y-4 overflow-y-auto pr-1">
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">
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React from 'react';
2
3
  // This component has been deprecated and its functionality integrated into Graph.tsx
3
4
  const TimelineView: React.FC = () => null;