@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
@@ -0,0 +1,325 @@
1
+ import React, { useCallback } from 'react';
2
+ import { GraphNode, GraphLink } from '../types';
3
+
4
+ interface UseGraphActionsOptions {
5
+ nodes: GraphNode[];
6
+ links: GraphLink[];
7
+ setGraphData: React.Dispatch<React.SetStateAction<{ nodes: GraphNode[], links: GraphLink[] }>>;
8
+ setSelectedNode: (node: GraphNode | null) => void;
9
+ setSelectedLink: (link: GraphLink | null) => void;
10
+ setContextMenu: (menu: any) => void;
11
+ setNotification: (notif: any) => void;
12
+ setConfirmDialog: (dialog: any) => void;
13
+ setDeletePreview: (preview: any) => void;
14
+ setPathNodeIds: (ids: (number | string)[]) => void;
15
+ fetchAndExpandNode: (node: GraphNode, isInitial?: boolean, forceMore?: boolean) => Promise<void>;
16
+ setIsProcessing: (val: boolean) => void;
17
+ searchIdRef: React.MutableRefObject<number>;
18
+ cacheEnabled: boolean;
19
+ cacheBaseUrl: string;
20
+ setSavedGraphs: React.Dispatch<React.SetStateAction<string[]>>;
21
+ searchMode: 'explore' | 'connect';
22
+ exploreTerm: string;
23
+ pathStart: string;
24
+ pathEnd: string;
25
+ isCompact: boolean;
26
+ isTimelineMode: boolean;
27
+ isTextOnly: boolean;
28
+ setExpandingNodeId: (id: number | string | null) => void;
29
+ setNewChildNodeIds: (ids: Set<string | number>) => void;
30
+ }
31
+
32
+ export function useGraphActions(options: UseGraphActionsOptions) {
33
+ const {
34
+ nodes, links, setGraphData, setSelectedNode, setSelectedLink,
35
+ setContextMenu, setNotification, setConfirmDialog, setDeletePreview,
36
+ setPathNodeIds, fetchAndExpandNode, setIsProcessing, searchIdRef,
37
+ cacheEnabled, cacheBaseUrl, setSavedGraphs, searchMode, exploreTerm,
38
+ pathStart, pathEnd, isCompact, isTimelineMode, isTextOnly,
39
+ setExpandingNodeId, setNewChildNodeIds
40
+ } = options;
41
+
42
+ const handleClear = useCallback(() => {
43
+ setGraphData({ nodes: [], links: [] });
44
+ setSelectedNode(null);
45
+ setSelectedLink(null);
46
+ setPathNodeIds([]);
47
+ }, [setGraphData, setSelectedNode, setSelectedLink, setPathNodeIds]);
48
+
49
+ const handleClearCache = useCallback(async () => {
50
+ if (!cacheEnabled) {
51
+ setNotification({ message: 'Cache is not enabled.', type: 'error' });
52
+ return;
53
+ }
54
+
55
+ setConfirmDialog({
56
+ isOpen: true,
57
+ message: 'Clear all cached API data? This will force fresh data from the LLM on next expansion.',
58
+ onConfirm: async () => {
59
+ try {
60
+ setIsProcessing(true);
61
+ const res = await fetch(new URL('/cache/clear', cacheBaseUrl).toString(), {
62
+ method: 'DELETE'
63
+ });
64
+ if (!res.ok) throw new Error('Failed to clear cache');
65
+ setNotification({ message: 'Cache cleared successfully!', type: 'success' });
66
+ } catch (e) {
67
+ console.error('Cache clear failed:', e);
68
+ setNotification({ message: 'Failed to clear cache.', type: 'error' });
69
+ } finally {
70
+ setIsProcessing(false);
71
+ }
72
+ }
73
+ });
74
+ }, [cacheEnabled, cacheBaseUrl, setConfirmDialog, setNotification, setIsProcessing]);
75
+
76
+
77
+ const handlePrune = useCallback(() => {
78
+ const leafIds = nodes.filter(n => {
79
+ const isSource = links.some(l => {
80
+ const sid = String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source);
81
+ return sid === String(n.id);
82
+ });
83
+ return !isSource;
84
+ }).map(n => n.id);
85
+
86
+ setGraphData(prev => ({
87
+ nodes: prev.nodes.filter(n => !leafIds.some(id => String(id) === String(n.id))),
88
+ links: prev.links.filter(l => {
89
+ const s = String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source);
90
+ const t = String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target);
91
+ return !leafIds.some(id => String(id) === s) && !leafIds.some(id => String(id) === t);
92
+ })
93
+ }));
94
+ setNotification({ message: 'Removed leaf nodes.', type: 'success' });
95
+ }, [nodes, links, setGraphData, setNotification]);
96
+
97
+ const computeDeleteOutcome = (nodeId: number | string) => {
98
+ const keeps = new Set<string>();
99
+ const stack = nodes.filter(n => {
100
+ const isRoot = !links.some(l => {
101
+ const tid = String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target);
102
+ return tid === String(n.id);
103
+ });
104
+ return isRoot && String(n.id) !== String(nodeId);
105
+ }).map(n => n.id);
106
+ stack.forEach(id => keeps.add(String(id)));
107
+ while (stack.length > 0) {
108
+ const curr = stack.pop()!;
109
+ links.forEach(l => {
110
+ const s = String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source);
111
+ const t = String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target);
112
+ if (s === curr && !keeps.has(t) && t !== String(nodeId)) {
113
+ keeps.add(t);
114
+ stack.push(t);
115
+ }
116
+ });
117
+ }
118
+ const dropIds = nodes.map(n => n.id).filter(id => !keeps.has(String(id)));
119
+ return { keepIds: Array.from(keeps), dropIds };
120
+ };
121
+
122
+ const handleSmartDelete = useCallback((node: GraphNode) => {
123
+ if (!node) return;
124
+ const nodeLabel = node.title || `Node ${node.id}`;
125
+ const outcome = computeDeleteOutcome(node.id);
126
+ setDeletePreview(outcome);
127
+ setConfirmDialog({
128
+ isOpen: true,
129
+ message: `Delete "${nodeLabel}" and its sub-tree (${outcome.dropIds.length} nodes total)?`,
130
+ onConfirm: () => {
131
+ setGraphData(prev => ({
132
+ nodes: prev.nodes.filter(n => outcome.keepIds.some(id => String(id) === String(n.id))),
133
+ links: prev.links.filter(l => {
134
+ const s = String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source);
135
+ const t = String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target);
136
+ return outcome.keepIds.some(id => String(id) === s) && outcome.keepIds.some(id => String(id) === t);
137
+ })
138
+ }));
139
+ setSelectedNode(null);
140
+ setDeletePreview(null);
141
+ setNotification({ message: `Deleted ${node.title} and subtree.`, type: 'success' });
142
+ }
143
+ });
144
+ }, [nodes, links, setDeletePreview, setConfirmDialog, setGraphData, setSelectedNode, setNotification]);
145
+
146
+ const handleExpandLeaves = useCallback(async (node: GraphNode) => {
147
+ const leafLinks = links.filter(l => String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source) === String(node.id));
148
+ const leafIds = leafLinks.map(l => String(typeof l.target === 'object' ? (l.target as GraphNode).id : l.target));
149
+ const unexpandedLeafIds = leafIds.filter(id => {
150
+ const n = nodes.find(nn => String(nn.id) === String(id));
151
+ return n && !n.expanded && !n.isLoading;
152
+ });
153
+
154
+ if (unexpandedLeafIds.length === 0) {
155
+ setNotification({ message: "All connections already expanded.", type: 'success' });
156
+ return;
157
+ }
158
+
159
+ setNotification({ message: `Expanding ${unexpandedLeafIds.length} connections...`, type: 'success' });
160
+ for (const id of unexpandedLeafIds) {
161
+ const n = nodes.find(nn => String(nn.id) === String(id));
162
+ if (n) await fetchAndExpandNode(n, false, false);
163
+ }
164
+ setNotification({ message: `Completed expansion of ${unexpandedLeafIds.length} connections.`, type: 'success' });
165
+
166
+ // Return graph to full brightness by clearing selection and highlighting
167
+ setSelectedNode(null);
168
+ setExpandingNodeId(null);
169
+ setNewChildNodeIds(new Set());
170
+ }, [nodes, links, fetchAndExpandNode, setNotification, setSelectedNode, setExpandingNodeId, setNewChildNodeIds]);
171
+
172
+ const handleExpandMore = useCallback((node: GraphNode) => {
173
+ fetchAndExpandNode(node, false, true);
174
+ }, [fetchAndExpandNode]);
175
+
176
+ const handleExpandAllLeafNodes = useCallback(async () => {
177
+ const unexpandedLeafNodes = nodes.filter(n => {
178
+ const isSource = links.some(l => String(typeof l.source === 'object' ? (l.source as GraphNode).id : l.source) === String(n.id));
179
+ return !isSource && !n.expanded && !n.isLoading;
180
+ });
181
+
182
+ if (unexpandedLeafNodes.length === 0) {
183
+ setNotification({ message: "Current graph is fully expanded.", type: 'success' });
184
+ return;
185
+ }
186
+
187
+ const count = unexpandedLeafNodes.length;
188
+ setNotification({ message: `Batch expanding ${count} leaf nodes...`, type: 'success' });
189
+ for (const n of unexpandedLeafNodes) {
190
+ await fetchAndExpandNode(n, false, false);
191
+ }
192
+ setNotification({ message: `Completed batch expansion of ${count} nodes.`, type: 'success' });
193
+
194
+ // Return graph to full brightness by clearing selection and highlighting
195
+ setSelectedNode(null);
196
+ setExpandingNodeId(null);
197
+ setNewChildNodeIds(new Set());
198
+ }, [nodes, links, fetchAndExpandNode, setNotification, setSelectedNode, setExpandingNodeId, setNewChildNodeIds]);
199
+
200
+ const handleDeleteGraph = useCallback((name: string) => {
201
+ setConfirmDialog({
202
+ isOpen: true,
203
+ message: `Are you sure you want to delete "${name}"?`,
204
+ onConfirm: async () => {
205
+ if (cacheEnabled) {
206
+ try {
207
+ const res = await fetch(new URL(`/graphs/${encodeURIComponent(name)}`, cacheBaseUrl).toString(), {
208
+ method: "DELETE"
209
+ });
210
+ if (!res.ok) throw new Error("Database delete failed");
211
+ setSavedGraphs(prev => prev.filter(n => n !== name));
212
+ setNotification({ message: `Graph "${name}" deleted.`, type: 'success' });
213
+ } catch (e) {
214
+ console.error("Database delete failed", e);
215
+ setNotification({ message: "Failed to delete graph.", type: 'error' });
216
+ }
217
+ }
218
+ }
219
+ });
220
+ }, [cacheEnabled, cacheBaseUrl, setConfirmDialog, setSavedGraphs, setNotification]);
221
+
222
+ const handleSaveGraph = useCallback(async (nameOrSpecial?: string) => {
223
+ if (nameOrSpecial === '__COPY_LINK__') {
224
+ const baseUrl = window.location.origin + window.location.pathname;
225
+ const url = new URL(baseUrl);
226
+ if (searchMode === 'connect') {
227
+ if (pathStart) url.searchParams.set('start', pathStart);
228
+ if (pathEnd) url.searchParams.set('end', pathEnd);
229
+ } else if (exploreTerm) {
230
+ url.searchParams.set('q', exploreTerm);
231
+ }
232
+ const shareUrl = url.toString();
233
+
234
+ try {
235
+ await navigator.clipboard.writeText(shareUrl);
236
+ setNotification({ message: `Link copied to clipboard!`, type: 'success' });
237
+ } catch (e) {
238
+ // Fallback for older browsers
239
+ const textarea = document.createElement('textarea');
240
+ textarea.value = shareUrl;
241
+ document.body.appendChild(textarea);
242
+ textarea.select();
243
+ try {
244
+ document.execCommand('copy');
245
+ setNotification({ message: `Link copied to clipboard!`, type: 'success' });
246
+ } catch (err) {
247
+ console.error('Copy fallback failed:', err);
248
+ setNotification({ message: `Failed to copy link.`, type: 'error' });
249
+ }
250
+ document.body.removeChild(textarea);
251
+ }
252
+ return;
253
+ }
254
+
255
+ const name = nameOrSpecial || prompt("Enter a name for this graph:");
256
+ if (!name) return;
257
+
258
+ const data = {
259
+ nodes, links, searchMode, exploreTerm, pathStart, pathEnd,
260
+ isCompact, isTimelineMode, isTextOnly,
261
+ timestamp: Date.now()
262
+ };
263
+
264
+ if (cacheEnabled) {
265
+ try {
266
+ await fetch(new URL("/graphs", cacheBaseUrl).toString(), {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ name, data })
270
+ });
271
+ setSavedGraphs(prev => Array.from(new Set([...prev, name])));
272
+ setNotification({ message: `Graph "${name}" saved!`, type: 'success' });
273
+ } catch (e) {
274
+ console.error("Database save failed", e);
275
+ setNotification({ message: "Failed to save graph.", type: 'error' });
276
+ }
277
+ }
278
+ }, [nodes, links, searchMode, exploreTerm, pathStart, pathEnd, isCompact, isTimelineMode, isTextOnly, cacheEnabled, cacheBaseUrl, setSavedGraphs, setNotification]);
279
+
280
+ const handleLoadGraph = useCallback(async (name: string, applyGraphData: (data: any, label: string) => void) => {
281
+ if (cacheEnabled) {
282
+ try {
283
+ const res = await fetch(new URL(`/graphs/${encodeURIComponent(name)}`, cacheBaseUrl).toString());
284
+ if (res.ok) {
285
+ const json = await res.json();
286
+ applyGraphData(json, name);
287
+ } else {
288
+ throw new Error("Graph not found");
289
+ }
290
+ } catch (e) {
291
+ console.warn("Database load failed", e);
292
+ setNotification({ message: `Failed to load "${name}".`, type: 'error' });
293
+ }
294
+ }
295
+ }, [cacheEnabled, cacheBaseUrl, setNotification]);
296
+
297
+ const handleImport = useCallback((e: React.ChangeEvent<HTMLInputElement>, applyGraphData: (data: any, label: string) => void) => {
298
+ const file = e.target.files?.[0];
299
+ if (!file) return;
300
+ const reader = new FileReader();
301
+ reader.onload = (event) => {
302
+ try {
303
+ const data = JSON.parse(event.target?.result as string);
304
+ applyGraphData(data, file.name);
305
+ } catch (err) {
306
+ setNotification({ message: "Invalid JSON json file.", type: 'error' });
307
+ }
308
+ };
309
+ reader.readAsText(file);
310
+ }, [setNotification]);
311
+
312
+ return {
313
+ handleClear,
314
+ handleClearCache,
315
+ handlePrune,
316
+ handleSmartDelete,
317
+ handleExpandLeaves,
318
+ handleExpandMore,
319
+ handleExpandAllLeafNodes,
320
+ handleDeleteGraph,
321
+ handleSaveGraph,
322
+ handleLoadGraph,
323
+ handleImport
324
+ };
325
+ }