@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
package/services/graphUtils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
300
|
-
if (s ===
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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}`);
|
package/services/imageService.ts
CHANGED
|
@@ -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,
|