@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- package/utils/wikiUtils.ts +34 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
type OpenAlexWork = {
|
|
2
|
+
id: string; // e.g. https://openalex.org/W...
|
|
3
|
+
title?: string;
|
|
4
|
+
display_name?: string;
|
|
5
|
+
publication_year?: number;
|
|
6
|
+
doi?: string | null; // usually "https://doi.org/..."
|
|
7
|
+
abstract_inverted_index?: Record<string, number[]>;
|
|
8
|
+
cited_by_count?: number;
|
|
9
|
+
primary_location?: {
|
|
10
|
+
source?: { display_name?: string };
|
|
11
|
+
landing_page_url?: string | null;
|
|
12
|
+
};
|
|
13
|
+
authorships?: Array<{
|
|
14
|
+
author?: { id?: string; display_name?: string };
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type OpenAlexAuthor = {
|
|
19
|
+
id: string; // e.g. https://openalex.org/A...
|
|
20
|
+
display_name?: string;
|
|
21
|
+
works_count?: number;
|
|
22
|
+
cited_by_count?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BASE = "https://api.openalex.org";
|
|
26
|
+
|
|
27
|
+
function abstractFromInvertedIndex(ii?: Record<string, number[]>) {
|
|
28
|
+
if (!ii) return "";
|
|
29
|
+
const tokens: Array<{ w: string; pos: number }> = [];
|
|
30
|
+
for (const [w, positions] of Object.entries(ii)) {
|
|
31
|
+
for (const pos of positions || []) tokens.push({ w, pos });
|
|
32
|
+
}
|
|
33
|
+
tokens.sort((a, b) => a.pos - b.pos);
|
|
34
|
+
// This is already tokenized; join with spaces.
|
|
35
|
+
return tokens.map(t => t.w).join(" ").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clean(s?: string) {
|
|
39
|
+
return String(s || "").replace(/\s+/g, " ").trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bestWorkTitle(w: OpenAlexWork) {
|
|
43
|
+
return clean(w.title || w.display_name || "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function bestWorkUrl(w: OpenAlexWork) {
|
|
47
|
+
const doi = clean(w.doi || "");
|
|
48
|
+
if (doi) return doi;
|
|
49
|
+
const landing = clean(w.primary_location?.landing_page_url || "");
|
|
50
|
+
if (landing) return landing;
|
|
51
|
+
const id = clean(w.id || "");
|
|
52
|
+
return id || undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchJson(url: string) {
|
|
56
|
+
const res = await fetch(url, {
|
|
57
|
+
headers: {
|
|
58
|
+
// Be a good citizen; OpenAlex recommends a UA/mailto in production,
|
|
59
|
+
// but keep it minimal here.
|
|
60
|
+
Accept: "application/json"
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`OpenAlex request failed: ${res.status} ${res.statusText}`);
|
|
64
|
+
return await res.json();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Simple in-memory caches (session-only)
|
|
68
|
+
const workSearchCache = new Map<string, OpenAlexWork | null>();
|
|
69
|
+
const authorSearchCache = new Map<string, OpenAlexAuthor | null>();
|
|
70
|
+
const workByIdCache = new Map<string, OpenAlexWork | null>();
|
|
71
|
+
|
|
72
|
+
export async function searchOpenAlexWork(query: string): Promise<OpenAlexWork | null> {
|
|
73
|
+
const q = clean(query);
|
|
74
|
+
if (!q) return null;
|
|
75
|
+
if (workSearchCache.has(q)) return workSearchCache.get(q) || null;
|
|
76
|
+
const url = `${BASE}/works?search=${encodeURIComponent(q)}&per-page=1`;
|
|
77
|
+
const json = await fetchJson(url);
|
|
78
|
+
const w: OpenAlexWork | undefined = json?.results?.[0];
|
|
79
|
+
const out = w?.id ? w : null;
|
|
80
|
+
workSearchCache.set(q, out);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function searchOpenAlexAuthor(name: string): Promise<OpenAlexAuthor | null> {
|
|
85
|
+
const q = clean(name);
|
|
86
|
+
if (!q) return null;
|
|
87
|
+
if (authorSearchCache.has(q)) return authorSearchCache.get(q) || null;
|
|
88
|
+
const url = `${BASE}/authors?search=${encodeURIComponent(q)}&per-page=1`;
|
|
89
|
+
const json = await fetchJson(url);
|
|
90
|
+
const a: OpenAlexAuthor | undefined = json?.results?.[0];
|
|
91
|
+
const out = a?.id ? a : null;
|
|
92
|
+
authorSearchCache.set(q, out);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getOpenAlexWork(workIdOrUrl: string): Promise<OpenAlexWork | null> {
|
|
97
|
+
const id = clean(workIdOrUrl);
|
|
98
|
+
if (!id) return null;
|
|
99
|
+
if (workByIdCache.has(id)) return workByIdCache.get(id) || null;
|
|
100
|
+
const url = id.startsWith("http") ? id.replace("openalex.org/", "api.openalex.org/") : `${BASE}/works/${encodeURIComponent(id)}`;
|
|
101
|
+
const w = (await fetchJson(url)) as OpenAlexWork;
|
|
102
|
+
const out = w?.id ? w : null;
|
|
103
|
+
workByIdCache.set(id, out);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getTopWorksForAuthor(authorIdOrUrl: string, limit = 10): Promise<OpenAlexWork[]> {
|
|
108
|
+
const id = clean(authorIdOrUrl);
|
|
109
|
+
if (!id) return [];
|
|
110
|
+
// OpenAlex expects filter=authorships.author.id:<openalex_id>
|
|
111
|
+
const filterId = id.startsWith("http") ? id : `https://openalex.org/${id}`;
|
|
112
|
+
const url = `${BASE}/works?filter=authorships.author.id:${encodeURIComponent(filterId)}&sort=cited_by_count:desc&per-page=${Math.max(1, Math.min(25, limit))}`;
|
|
113
|
+
const json = await fetchJson(url);
|
|
114
|
+
const works: OpenAlexWork[] = Array.isArray(json?.results) ? json.results : [];
|
|
115
|
+
return works.filter(w => !!w?.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function openAlexWorkToPaperNode(work: OpenAlexWork) {
|
|
119
|
+
const title = bestWorkTitle(work) || "Untitled";
|
|
120
|
+
const year = work.publication_year;
|
|
121
|
+
const venue = clean(work.primary_location?.source?.display_name || "");
|
|
122
|
+
const abs = abstractFromInvertedIndex(work.abstract_inverted_index);
|
|
123
|
+
const descParts = [
|
|
124
|
+
year ? `Published ${year}.` : "",
|
|
125
|
+
venue ? `Venue: ${venue}.` : "",
|
|
126
|
+
abs ? abs : ""
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
title,
|
|
131
|
+
type: "Paper",
|
|
132
|
+
description: descParts.join(" "),
|
|
133
|
+
year: year ?? undefined,
|
|
134
|
+
is_atomic: false,
|
|
135
|
+
meta: {
|
|
136
|
+
openAlexWorkId: work.id,
|
|
137
|
+
doi: work.doi || undefined,
|
|
138
|
+
openAlexUrl: work.id,
|
|
139
|
+
source: "openalex"
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function openAlexAuthorToAuthorNode(author: OpenAlexAuthor) {
|
|
145
|
+
const title = clean(author.display_name || "") || "Unknown Author";
|
|
146
|
+
const descParts = [
|
|
147
|
+
Number.isFinite(author.works_count) ? `${author.works_count} works (OpenAlex).` : "",
|
|
148
|
+
Number.isFinite(author.cited_by_count) ? `${author.cited_by_count} citations (OpenAlex).` : ""
|
|
149
|
+
].filter(Boolean);
|
|
150
|
+
return {
|
|
151
|
+
title,
|
|
152
|
+
type: "Author",
|
|
153
|
+
description: descParts.join(" "),
|
|
154
|
+
is_atomic: true,
|
|
155
|
+
meta: {
|
|
156
|
+
openAlexAuthorId: author.id,
|
|
157
|
+
openAlexUrl: author.id,
|
|
158
|
+
source: "openalex"
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function makeOpenAlexAuthorshipEvidence(work: OpenAlexWork, authorName: string) {
|
|
164
|
+
const paperTitle = bestWorkTitle(work) || "this paper";
|
|
165
|
+
const snippet = `${clean(authorName) || "This author"} is listed as an author of "${paperTitle}" (OpenAlex metadata).`;
|
|
166
|
+
return {
|
|
167
|
+
kind: "openalex" as const,
|
|
168
|
+
pageTitle: paperTitle,
|
|
169
|
+
snippet,
|
|
170
|
+
url: bestWorkUrl(work)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export async function fetchWikipediaImage(title: string): Promise<string | null> {
|
|
2
|
+
if (!title) return null;
|
|
3
|
+
try {
|
|
4
|
+
// 1. Try exact title with redirects
|
|
5
|
+
let endpoint = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=pageimages&format=json&pithumbsize=300&redirects&origin=*`;
|
|
6
|
+
let res = await fetch(endpoint);
|
|
7
|
+
let data = await res.json();
|
|
8
|
+
let pages = data?.query?.pages;
|
|
9
|
+
|
|
10
|
+
const getFirstPageImage = (pagesObj: any) => {
|
|
11
|
+
if (!pagesObj) return null;
|
|
12
|
+
const keys = Object.keys(pagesObj);
|
|
13
|
+
if (!keys.length) return null;
|
|
14
|
+
// Iterate to find first real page (not -1 unless that's all there is)
|
|
15
|
+
for (const key of keys) {
|
|
16
|
+
if (key !== '-1' && pagesObj[key]?.thumbnail?.source) {
|
|
17
|
+
return pagesObj[key].thumbnail.source;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let imgUrl = getFirstPageImage(pages);
|
|
24
|
+
if (imgUrl) return imgUrl;
|
|
25
|
+
|
|
26
|
+
// 2. Fallback: Search for the title if direct lookup failed
|
|
27
|
+
// "Guggenheim Museum" -> "Solomon R. Guggenheim Museum"
|
|
28
|
+
console.log(`Wiki image direct lookup failed for "${title}", trying search...`);
|
|
29
|
+
endpoint = `https://en.wikipedia.org/w/api.php?action=query&generator=search&gsrsearch=${encodeURIComponent(title)}&gsrlimit=1&prop=pageimages&format=json&pithumbsize=300&origin=*`;
|
|
30
|
+
res = await fetch(endpoint);
|
|
31
|
+
data = await res.json();
|
|
32
|
+
pages = data?.query?.pages;
|
|
33
|
+
|
|
34
|
+
return getFirstPageImage(pages);
|
|
35
|
+
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error("Wiki image fetch failed", e);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|