@johndimm/constellations 1.0.0 → 1.0.2

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 (39) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -5
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  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/embedded.css +38 -0
  15. package/hooks/useExpansion.ts +61 -229
  16. package/hooks/useGraphActions.ts +1 -0
  17. package/hooks/useGraphState.ts +75 -40
  18. package/hooks/useKioskMode.ts +1 -0
  19. package/hooks/useNodeClickHandler.ts +23 -15
  20. package/hooks/useSearchHandlers.ts +57 -19
  21. package/host.ts +1 -1
  22. package/index.css +17 -3
  23. package/package.json +4 -1
  24. package/services/aiService.ts +23 -0
  25. package/services/aiUtils.ts +216 -207
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +467 -0
  29. package/services/geminiService.ts +532 -733
  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 +56 -46
  36. package/types.ts +3 -0
  37. package/utils/evidenceUtils.ts +1 -0
  38. package/utils/graphLogicUtils.ts +1 -0
  39. package/utils/wikiUtils.ts +14 -2
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { GraphNode, GraphLink } from '../types';
2
3
 
3
4
  // Normalize string for deduplication:
@@ -80,6 +81,90 @@ export const baseDedupeKey = (node: { title: string; type?: string; is_atomic?:
80
81
  return `c|${normTitle}|${normType}`;
81
82
  };
82
83
 
84
+ const linkEndpointsRaw = (l: GraphLink) => {
85
+ const get = (e: string | number | GraphNode) => {
86
+ if (e != null && typeof e === "object" && "id" in (e as object)) return String((e as GraphNode).id);
87
+ return String(e);
88
+ };
89
+ return { a: get(l.source), b: get(l.target) };
90
+ };
91
+
92
+ /**
93
+ * Re-attach work/composite nodes that have no edges (often from bipartite filtering or
94
+ * id-remap glitches) to the most connected person in the session — typical discography layout.
95
+ * Safe for small exploration graphs; conservative gates avoid cross-topic path searches.
96
+ */
97
+ export const spliceOrphanCompositesToPersonHub = (
98
+ nodes: GraphNode[],
99
+ links: GraphLink[]
100
+ ): { nodes: GraphNode[]; links: GraphLink[] } => {
101
+ if (nodes.length < 2) return { nodes, links };
102
+
103
+ const pairKey = (x: string, y: string) => (x < y ? `${x}↔${y}` : `${y}↔${x}`);
104
+
105
+ const isPerson = (n: GraphNode) => {
106
+ const t = (n.type || "").toLowerCase();
107
+ if (t === "person" || t === "actor" || t === "author" || t === "musician" || t === "singer") return true;
108
+ if ((n as { is_person?: boolean }).is_person) return true;
109
+ if (n.is_atomic === true) {
110
+ if (["album", "film", "movie", "song", "record", "book", "single", "track", "opera", "play"].some((w) => t.includes(w))) {
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ return false;
116
+ };
117
+
118
+ const degree = new Map<string, number>();
119
+ const existingPairs = new Set<string>();
120
+ for (const l of links) {
121
+ const { a, b } = linkEndpointsRaw(l);
122
+ if (!a || !b || a === b || a === "undefined" || b === "undefined") continue;
123
+ existingPairs.add(pairKey(a, b));
124
+ degree.set(a, (degree.get(a) || 0) + 1);
125
+ degree.set(b, (degree.get(b) || 0) + 1);
126
+ }
127
+
128
+ const personHubs = nodes.filter((n) => isPerson(n) && (degree.get(String(n.id)) || 0) > 0);
129
+ if (personHubs.length === 0) return { nodes, links };
130
+ personHubs.sort(
131
+ (x, y) => (degree.get(String(y.id)) || 0) - (degree.get(String(x.id)) || 0)
132
+ );
133
+ if (personHubs.length >= 2) {
134
+ const d0 = degree.get(String(personHubs[0].id)) || 0;
135
+ const d1 = degree.get(String(personHubs[1].id)) || 0;
136
+ if (d0 === d1) return { nodes, links };
137
+ }
138
+ const hub = personHubs[0];
139
+ const hubDeg = degree.get(String(hub.id)) || 0;
140
+ if (hubDeg < 2) return { nodes, links };
141
+
142
+ const orphans = nodes.filter((n) => {
143
+ if (isPerson(n)) return false;
144
+ return (degree.get(String(n.id)) || 0) === 0;
145
+ });
146
+ if (orphans.length === 0) return { nodes, links };
147
+ if (orphans.length > 24) return { nodes, links };
148
+
149
+ const added: GraphLink[] = [];
150
+ for (const o of orphans) {
151
+ if (String(o.id) === String(hub.id)) continue;
152
+ const a = String(hub.id);
153
+ const b = String(o.id);
154
+ const pk = pairKey(a, b);
155
+ if (existingPairs.has(pk)) continue;
156
+ existingPairs.add(pk);
157
+ added.push({
158
+ id: `inferred-${a}-${b}`,
159
+ source: hub.id,
160
+ target: o.id,
161
+ evidence: { kind: "none" as const }
162
+ });
163
+ }
164
+ if (added.length === 0) return { nodes, links };
165
+ return { nodes, links: [...links, ...added] };
166
+ };
167
+
83
168
  // Merge duplicate nodes (same normalized title/type) and remap links accordingly.
84
169
  export const dedupeGraph = (
85
170
  nodes: GraphNode[],
@@ -106,7 +191,7 @@ export const dedupeGraph = (
106
191
  const prefer = existing.wikipedia_id ? existing : incoming;
107
192
  return {
108
193
  ...prefer,
109
- type: mergeType(existing.type, incoming.type),
194
+ type: mergeType(existing.type, incoming.type) || existing.type || incoming.type || 'Node',
110
195
  imageUrl: existing.imageUrl || incoming.imageUrl || undefined,
111
196
  imageChecked: existing.imageChecked || incoming.imageChecked || !!existing.imageUrl || !!incoming.imageUrl,
112
197
  wikiSummary: existing.wikiSummary || incoming.wikiSummary || undefined,
@@ -183,8 +268,12 @@ export const dedupeGraph = (
183
268
 
184
269
  const nodesOut = Array.from(dedupMap.values());
185
270
 
186
- const remapId = (value: number | string | GraphNode) => {
187
- const id = String(typeof value === 'object' ? value.id : value);
271
+ const remapId = (value: number | string | GraphNode | null | undefined) => {
272
+ const raw =
273
+ value != null && typeof value === 'object' && 'id' in (value as object)
274
+ ? (value as GraphNode).id
275
+ : (value as number | string);
276
+ const id = String(raw);
188
277
  return idRemap.get(id) ?? id;
189
278
  };
190
279
 
@@ -205,7 +294,36 @@ export const dedupeGraph = (
205
294
  });
206
295
  });
207
296
 
208
- return { nodes: nodesOut, links: linksOut };
297
+ // Drop links whose endpoints are not in the deduped node set (stale ids after merge/prune).
298
+ // Graph.tsx only draws validLinks when both ends exist; ghost edges must not pollute state.
299
+ const nodeIdSet = new Set(nodesOut.map((n) => String(n.id)));
300
+ const linksFiltered = linksOut.filter((l) => {
301
+ const a = String(l.source);
302
+ const b = String(l.target);
303
+ return nodeIdSet.has(a) && nodeIdSet.has(b);
304
+ });
305
+
306
+ const spliced = spliceOrphanCompositesToPersonHub(nodesOut, linksFiltered);
307
+
308
+ // Final pass: remove any nodes that are still isolated (degree 0) after splice.
309
+ // These arise when dedup ID remapping drops links, leaving both composite and atomic orphans.
310
+ // Only prune when there are multiple nodes — a single-node graph (e.g. search just started)
311
+ // must be preserved.
312
+ if (spliced.nodes.length > 1) {
313
+ const finalDegree = new Map<string, number>();
314
+ spliced.links.forEach(l => {
315
+ const s = String(typeof l.source === 'object' ? (l.source as any).id : l.source);
316
+ const t = String(typeof l.target === 'object' ? (l.target as any).id : l.target);
317
+ finalDegree.set(s, (finalDegree.get(s) || 0) + 1);
318
+ finalDegree.set(t, (finalDegree.get(t) || 0) + 1);
319
+ });
320
+ const connected = spliced.nodes.filter(n => (finalDegree.get(String(n.id)) || 0) > 0);
321
+ if (connected.length > 0) {
322
+ return { nodes: connected, links: spliced.links };
323
+ }
324
+ }
325
+
326
+ return spliced;
209
327
  };
210
328
 
211
329
  type ExpansionTarget = GraphNode & {
@@ -222,7 +340,6 @@ export const mergeExpansionGraph = (params: {
222
340
  seedFromParent?: boolean;
223
341
  }): { nodes: GraphNode[]; links: GraphLink[] } => {
224
342
  const { nodes, links, parent, targets, seedFromParent = true } = params;
225
- const existingNodeIds = new Set(nodes.map(n => String(n.id)));
226
343
  const nodeMap = new Map<string, GraphNode>(nodes.map(n => [String(n.id), n]));
227
344
 
228
345
  const parentIsAtomic = !!(parent.is_atomic ?? parent.is_person ?? (parent.type || '').toLowerCase() === 'person');
@@ -293,18 +410,15 @@ export const mergeExpansionGraph = (params: {
293
410
 
294
411
  // console.warn(`🔧 [mergeExpansionGraph] Created ${candidateLinks.length} candidate links`);
295
412
 
413
+ const parentId = String(parent.id);
296
414
  const bipartiteSafeCandidates = candidateLinks.filter(l => {
297
415
  const s = String(typeof l.source === 'object' ? l.source.id : l.source);
298
416
  const t = String(typeof l.target === 'object' ? l.target.id : l.target);
299
- // Match useExpansion: edges incident to the expanded parent trust the AI/cache (avoid empty merges when is_atomic drifted in the DB/UI).
300
- if (s === String(parent.id) || t === String(parent.id)) return true;
417
+ // Expansion edges always go from this parent; trust them even if is_atomic is noisy.
418
+ if (s === parentId) return true;
301
419
  const sa = isAtomicForId.get(s);
302
420
  const ta = isAtomicForId.get(t);
303
- const pass = (sa === undefined || ta === undefined) || (sa !== ta);
304
- if (!pass) {
305
- // console.warn(`🔧 [mergeExpansionGraph] Link ${s}->${t} FILTERED: parent isAtomic=${sa}, child isAtomic=${ta}`);
306
- }
307
- return pass;
421
+ return (sa === undefined || ta === undefined) || (sa !== ta);
308
422
  });
309
423
 
310
424
  // console.warn(`🔧 [mergeExpansionGraph] After bipartite filter: ${bipartiteSafeCandidates.length} links`);
@@ -332,13 +446,9 @@ export const mergeExpansionGraph = (params: {
332
446
  });
333
447
  const prunedNodes = updatedNodes.filter(n => {
334
448
  if (String(n.id) === String(parent.id)) return true;
335
- if (existingNodeIds.has(String(n.id))) return true;
336
449
  const deg = degree.get(String(n.id)) || 0;
337
- const keep = deg > 0;
338
- if (!keep && !existingNodeIds.has(String(n.id))) {
339
- // console.warn(`🔧 [mergeExpansionGraph] Node "${n.title}" (${n.id}) PRUNED: degree=${deg}`);
340
- }
341
- return keep;
450
+ // Do NOT keep "existing" graph nodes with no edges — that leaves floating islands.
451
+ return deg > 0;
342
452
  });
343
453
 
344
454
  // console.warn(`🔧 [mergeExpansionGraph] After pruning: ${prunedNodes.length} nodes from ${updatedNodes.length}`);
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { getEffectiveCacheBaseUrl } from './cacheService';
2
3
 
3
4
  export type ServerImageResult = {
@@ -7,6 +8,23 @@ export type ServerImageResult = {
7
8
  pageTitle?: string;
8
9
  };
9
10
 
11
+ /**
12
+ * Base URL for `GET /api/image` in the browser.
13
+ * Prefer the current page (e.g. Next.js Soundings implements this route). The graph
14
+ * cache server is for expansions / persistence; image lookup should not depend on it
15
+ * when the host app can resolve Wikipedia images itself.
16
+ */
17
+ export const getImageApiBaseUrl = (cacheBaseUrl: string | undefined): string => {
18
+ if (typeof window !== 'undefined') {
19
+ return window.location.origin;
20
+ }
21
+ return (
22
+ (cacheBaseUrl && cacheBaseUrl.replace(/\/$/, '')) ||
23
+ getEffectiveCacheBaseUrl() ||
24
+ ''
25
+ );
26
+ };
27
+
10
28
  export const fetchServerImage = async (
11
29
  title: string,
12
30
  context?: string,
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  type OpenAlexWork = {
2
3
  id: string; // e.g. https://openalex.org/W...
3
4
  title?: string;