murtaza-2007 commited on
Commit
658d200
·
1 Parent(s): bc32a44

Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph

Browse files
Files changed (22) hide show
  1. CLAUDE.md +65 -30
  2. README.md +6 -5
  3. adapters/__init__.py +3 -4
  4. adapters/github.py +0 -156
  5. adapters/openalex.py +63 -8
  6. adapters/wikipedia.py +17 -0
  7. app.js +546 -313
  8. config.py +1 -3
  9. core/source.py +15 -3
  10. core/store.py +54 -1
  11. core/types.py +2 -2
  12. graph2d.js +619 -0
  13. graph3d.js +0 -403
  14. htmlbackup.txt +0 -0
  15. index.html +92 -27
  16. ingest/__init__.py +1 -1
  17. ingest/cli.py +1 -1
  18. mainpybackup.txt +0 -799
  19. requirements.txt +2 -0
  20. server.py +259 -2
  21. styles.css +270 -30
  22. vercel.json +1 -1
CLAUDE.md CHANGED
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
8
  # Start the backend (from the repo root)
9
  uvicorn main:app --reload --port 8000
10
 
11
- # Frontend is a static page (index.html / styles.css / app.js / graph3d.js).
12
  # Serve it next to the backend:
13
  python -m http.server 8081 # app.js auto-detects localhost backends
14
 
@@ -42,8 +42,7 @@ core/ the engine — knows NOTHING about any specific dataset
42
  llm.py OPTIONAL Gemini layer — narrates evidence; never required
43
  adapters/ one file per dataset; registration on import
44
  wikipedia.py live — links/linkshere, disambiguation redirect
45
- openalex.py live — citations
46
- github.py live — dependency manifests (forward-only)
47
  finance.py ingested — typed financial knowledge graph
48
  news.py ingested — entity/article co-mention graph
49
  biomed.py ingested — Hetionet
@@ -160,7 +159,7 @@ Update graph → Index**, each stage a plain function in `pipeline.py`.
160
 
161
  Graph output lands in the shared store under source "news" (entities +
162
  capped freshest article nodes, `co_mentioned`/`mentions` edges), so the
163
- navigator/discover/3D UI work on it unchanged. **News is `hidden = True`**
164
  (core/source.py) — registered and queryable but excluded from the
165
  standalone source picker, because it's consumed as an overlay *inside*
166
  Finance (the News/Discussion toggles call `/api/news/entity`), not
@@ -168,21 +167,50 @@ browsed on its own.
168
 
169
  ### Finance product surface (server.py)
170
 
171
- Three endpoints beyond search power the Finance explorer UI:
 
 
 
 
 
 
172
  - `/api/node` — one resolved node's stored features (price series, kind,
173
  sector). Company/macro nodes store ~120 daily closes normalized to 100
174
- at the window start (`_series_features` in ingest/finance.py), so the
175
- Compare panel can overlay two price lines and compute their correlation
176
- client-side.
177
  - `/api/exposure` — weighted 1- and 2-hop propagation over the typed
178
  edges ("if X moves, who's affected?"); every result carries the
179
  strongest chain as evidence. Skips the country hub (adds noise).
180
- - `/api/neighbors` — returns each node's `kind` for the 3D view's
181
  kind-based coloring and progressive click-to-expand.
182
-
183
- The frontend Compare panel (app.js) fuses `/api/relate` + `/api/node`:
184
- connection strength, plain-language "why connected" evidence, an SVG
185
- price chart with computed correlation, and a hand-off to the pathfinder.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  ### Optional Gemini layer (core/llm.py)
188
 
@@ -232,31 +260,38 @@ on small graphs a node recurs dozens of times per 8192-pair batch, the
232
  summed `np.add.at` updates act like a huge effective learning rate, and
233
  the matrices diverge to float32 overflow without it.
234
 
235
- ### Frontend: index.html + styles.css + app.js + graph3d.js
236
 
237
  Single non-module scripts — handlers referenced by inline `onclick=` must
238
  stay plain function declarations (they live on `window` implicitly; note
239
  `let` top-level bindings are NOT reachable as `window.x` from console).
240
 
241
- - `graph3d.js` — the 3D explorer (`window.GV`), built on `3d-force-graph`
242
- (+ a standalone three.js UMD loaded first because `three-spritetext`
243
- needs a global `THREE`). Reads the same `nodes`/`edges` state the WS
244
- handlers maintain; app.js `render()` just calls `GV.sync(...)`. Features:
245
- small node/link geometry, damped orbit controls, auto-`zoomToFit` that
246
- backs off when the user grabs the camera (resumed by `GV.fit()`), label
247
- LOD (sprites only on endpoints/centre/path), view filters
248
- (all/explored/path via `ctx.filter`), post-found dimming, click-to-expand
249
- (`opts.onExpand`), edge tooltips from `edge.display`. Falls back to the
250
- 2D D3 renderer if the CDN libs fail.
 
 
 
251
  - `app.js` — `AC_BACKEND` auto-detects localhost vs the HF Spaces URL;
252
- multi-source picker from `/api/sources`; `expandNodeFromGraph()` calls
253
- `/api/neighbors` (progressive expansion, caps + dedup);
254
- `setGraphFilter`/`fitGraphView` drive the `#graph-controls` bar;
255
- `nodeUrl()` gates external links to Wikipedia-source paths only.
 
 
 
 
256
  - Discover panel opens with a plain-language explanation of hidden
257
  connections and every result carries a "Why: …" evidence line.
258
- - `vercel.json` CSP must keep `https://cdn.jsdelivr.net` in `script-src`
259
- and `worker-src 'self' blob:` for the 3D libs.
260
 
261
  ## Key constants (config.py)
262
 
 
8
  # Start the backend (from the repo root)
9
  uvicorn main:app --reload --port 8000
10
 
11
+ # Frontend is a static page (index.html / styles.css / app.js / graph2d.js).
12
  # Serve it next to the backend:
13
  python -m http.server 8081 # app.js auto-detects localhost backends
14
 
 
42
  llm.py OPTIONAL Gemini layer — narrates evidence; never required
43
  adapters/ one file per dataset; registration on import
44
  wikipedia.py live — links/linkshere, disambiguation redirect
45
+ openalex.py live — citations; DOI/arXiv resolve; paper metadata
 
46
  finance.py ingested — typed financial knowledge graph
47
  news.py ingested — entity/article co-mention graph
48
  biomed.py ingested — Hetionet
 
159
 
160
  Graph output lands in the shared store under source "news" (entities +
161
  capped freshest article nodes, `co_mentioned`/`mentions` edges), so the
162
+ navigator/discover/graph UI work on it unchanged. **News is `hidden = True`**
163
  (core/source.py) — registered and queryable but excluded from the
164
  standalone source picker, because it's consumed as an overlay *inside*
165
  Finance (the News/Discussion toggles call `/api/news/entity`), not
 
167
 
168
  ### Finance product surface (server.py)
169
 
170
+ The Finance module leads with a **Company Profile** (the primary surface),
171
+ graph secondary. Endpoints beyond search:
172
+ - `/api/company` — one company's full dossier aggregated in a single call:
173
+ price series + key facts (sector, CEO, HQ, ETF count) and every typed
174
+ edge grouped into researcher buckets (peers = rivals + sector members,
175
+ suppliers/customers, correlated `co_moves` with corr, macro exposure,
176
+ ETF/investor ownership). Powers the `#profile-panel`.
177
  - `/api/node` — one resolved node's stored features (price series, kind,
178
  sector). Company/macro nodes store ~120 daily closes normalized to 100
179
+ at the window start (`_series_features` in ingest/finance.py).
 
 
180
  - `/api/exposure` — weighted 1- and 2-hop propagation over the typed
181
  edges ("if X moves, who's affected?"); every result carries the
182
  strongest chain as evidence. Skips the country hub (adds noise).
183
+ - `/api/neighbors` — returns each node's `kind` for the graph view's
184
  kind-based coloring and progressive click-to-expand.
185
+ - `/api/suggest` — domain-aware autocomplete (see below).
186
+
187
+ The Compare panel (app.js) fuses `/api/relate` + `/api/node`: connection
188
+ strength, plain-language "why connected" evidence, an SVG price chart with
189
+ computed correlation, and a hand-off to the pathfinder.
190
+
191
+ ### Research product surface — Citation Explorer (server.py)
192
+
193
+ The Research (OpenAlex) module leads with a **Citation Explorer**, not the
194
+ old connect-two-papers pathfinding:
195
+ - `/api/paper?q=` — resolve a paper (title, DOI, arXiv id or URL) and
196
+ return its metadata (authors/year/venue/citation-count/abstract) plus
197
+ `references` (works it cites) and `citations` (works citing it), each a
198
+ metadata card. Citing works are sorted most-cited-first.
199
+ - `POST /api/paper/upload` — resolve a paper from an uploaded PDF: `pypdf`
200
+ extracts a DOI / arXiv id / title from the first pages, then the same
201
+ assembly runs. Size-capped + rate-limited; CORS allows POST for this.
202
+ The `#paper-panel` renders the dossier and "Build citation graph" seeds
203
+ the 2D graph (paper centre, references + citations, directed `cites`
204
+ edges); clicking any paper node expands BOTH citation directions.
205
+
206
+ ### Domain-aware recommendations (`/api/suggest`)
207
+
208
+ Autocomplete draws from the ACTIVE source's own vocabulary via
209
+ `GraphSource.suggest()` — companies/tickers for finance, papers for
210
+ research, diseases/genes for biology, articles for Wikipedia — instead of
211
+ the old frontend-hardcoded Wikipedia opensearch. Store-backed sources
212
+ answer from `GraphStore.suggest_titles` (prefix-ranked); live sources
213
+ override (Wikipedia opensearch, OpenAlex works search).
214
 
215
  ### Optional Gemini layer (core/llm.py)
216
 
 
260
  summed `np.add.at` updates act like a huge effective learning rate, and
261
  the matrices diverge to float32 overflow without it.
262
 
263
+ ### Frontend: index.html + styles.css + app.js + graph2d.js
264
 
265
  Single non-module scripts — handlers referenced by inline `onclick=` must
266
  stay plain function declarations (they live on `window` implicitly; note
267
  `let` top-level bindings are NOT reachable as `window.x` from console).
268
 
269
+ - `graph2d.js` — the 2D graph explorer (`window.GV`), an HTML-canvas +
270
+ d3-force renderer (replaced the old WebGL/three.js `graph3d.js`). Runs
271
+ the force sim directly over the SAME node objects app.js keeps in
272
+ `nodes`, so the WS flow is unchanged; app.js `render()` just calls
273
+ `GV.sync(nodes, edges, ctx)`. Features: curved (quadratic-bezier) edges,
274
+ kind-based colour + gentle clustering, label LOD, focus mode (click a
275
+ node spotlight its neighbourhood), progressive click-to-expand
276
+ (`opts.onExpand`), edge tooltips from `edge.display`, and an auto-fit
277
+ camera that FOLLOWS the graph as it grows and backs off when the user
278
+ grabs it (resume with `GV.fit()`). **The auto-fit eases `transform` with
279
+ a cheap per-frame lerp — never spawn d3 zoom transitions in the rAF loop;
280
+ stacking them once pegged the main thread.** Labels use `n.title || n.id`
281
+ (papers carry an opaque `W…` id, so title is required).
282
  - `app.js` — `AC_BACKEND` auto-detects localhost vs the HF Spaces URL;
283
+ multi-source picker from `/api/sources`; domain-aware autocomplete via
284
+ `/api/suggest`; the Finance **Company Profile** panel (`/api/company`)
285
+ and Research **Citation Explorer** panel (`/api/paper`, `/api/paper/upload`)
286
+ are the primary per-domain surfaces, with the graph as a secondary
287
+ explorer. `expandNodeFromGraph()` calls `/api/neighbors` (or, for papers,
288
+ pulls both citation directions); `setGraphFilter`/`fitGraphView` drive
289
+ the `#graph-controls` bar; `nodeUrl()` gates external links to
290
+ Wikipedia-source paths only.
291
  - Discover panel opens with a plain-language explanation of hidden
292
  connections and every result carries a "Why: …" evidence line.
293
+ - `vercel.json` CSP only needs `https://cdnjs.cloudflare.com` in
294
+ `script-src` (d3); the 3D libs and their `worker-src blob:` are gone.
295
 
296
  ## Key constants (config.py)
297
 
README.md CHANGED
@@ -15,18 +15,19 @@ Aurelius is a general-purpose **graph intelligence engine**: pick two things
15
  in any connected dataset and it walks the real links between them, ranking
16
  every step by meaning — on-device embeddings and graph search, with an
17
  **optional** Gemini layer that narrates the results in plain English —
18
- while the live search animates as an interactive 3D graph. The AI layer is
19
  purely additive: with no key the app runs fully, and every AI panel falls
20
  back to a small notice with the standard result still shown.
21
 
22
  It started as a Wikipedia path-finder. Today every dataset is a plug-in
23
- **adapter** behind one protocol, so the same engine navigates:
 
 
24
 
25
  | Source | Mode | Graph |
26
  |---|---|---|
27
  | Wikipedia | live | article links (fetched on demand) |
28
- | Research papers (OpenAlex) | live | citations |
29
- | GitHub | live | dependency manifests + same-owner repos |
30
  | **Finance** (Yahoo Finance) | ingested | companies, ETFs, sectors, executives, countries & macro indicators with typed edges: supply chains, ownership, competition, holdings, and *computed* return correlations |
31
  | **News** (News Intelligence) | ingested | entities + articles from live coverage, co-mention relationships, evolving stories |
32
  | Biomedical (Hetionet) | ingested | genes–compounds–diseases |
@@ -44,7 +45,7 @@ embeddings, story grouping, and ranked search — exposed at `/api/news/*`
44
  for any domain module to consume.
45
 
46
  The frontend is a single static page (`index.html` / `styles.css` /
47
- `app.js` / `graph3d.js`) deployed on Vercel. The backend is this FastAPI
48
  service: an in-process `sentence-transformers` model plus the
49
  source-agnostic navigator, streamed to the browser over a WebSocket.
50
 
 
15
  in any connected dataset and it walks the real links between them, ranking
16
  every step by meaning — on-device embeddings and graph search, with an
17
  **optional** Gemini layer that narrates the results in plain English —
18
+ while the live search animates as an interactive 2D graph. The AI layer is
19
  purely additive: with no key the app runs fully, and every AI panel falls
20
  back to a small notice with the standard result still shown.
21
 
22
  It started as a Wikipedia path-finder. Today every dataset is a plug-in
23
+ **adapter** behind one protocol, and each domain leads with a purpose-built
24
+ research surface — a **company profile** for finance, a **citation
25
+ explorer** for papers — with the graph as a secondary explorer:
26
 
27
  | Source | Mode | Graph |
28
  |---|---|---|
29
  | Wikipedia | live | article links (fetched on demand) |
30
+ | Research papers (OpenAlex) | live | citations — explore any paper's references & citing works (by title, DOI, arXiv id or uploaded PDF) |
 
31
  | **Finance** (Yahoo Finance) | ingested | companies, ETFs, sectors, executives, countries & macro indicators with typed edges: supply chains, ownership, competition, holdings, and *computed* return correlations |
32
  | **News** (News Intelligence) | ingested | entities + articles from live coverage, co-mention relationships, evolving stories |
33
  | Biomedical (Hetionet) | ingested | genes–compounds–diseases |
 
45
  for any domain module to consume.
46
 
47
  The frontend is a single static page (`index.html` / `styles.css` /
48
+ `app.js` / `graph2d.js`) deployed on Vercel. The backend is this FastAPI
49
  service: an in-process `sentence-transformers` model plus the
50
  source-agnostic navigator, streamed to the browser over a WebSocket.
51
 
adapters/__init__.py CHANGED
@@ -2,16 +2,15 @@
2
  GraphSource implementation with core.source.
3
 
4
  Importing this package registers every available adapter. Adapters that
5
- need ingested data (biomed, news, finance, github) register themselves
6
- regardless; they answer with a helpful error until an ingest run has
7
- populated the store.
8
  """
9
 
10
  from core.source import get_source, list_sources, register # noqa: F401
11
 
12
  from . import wikipedia # noqa: F401 (live)
13
  from . import openalex # noqa: F401 (live)
14
- from . import github # noqa: F401 (live crawl + optional ingest)
15
  from . import biomed # noqa: F401 (ingested — Hetionet)
16
  from . import news # noqa: F401 (ingested — GDELT/NewsAPI)
17
  from . import finance # noqa: F401 (ingested — Yahoo Finance)
 
2
  GraphSource implementation with core.source.
3
 
4
  Importing this package registers every available adapter. Adapters that
5
+ need ingested data (biomed, news, finance) register themselves regardless;
6
+ they answer with a helpful error until an ingest run has populated the
7
+ store.
8
  """
9
 
10
  from core.source import get_source, list_sources, register # noqa: F401
11
 
12
  from . import wikipedia # noqa: F401 (live)
13
  from . import openalex # noqa: F401 (live)
 
14
  from . import biomed # noqa: F401 (ingested — Hetionet)
15
  from . import news # noqa: F401 (ingested — GDELT/NewsAPI)
16
  from . import finance # noqa: F401 (ingested — Yahoo Finance)
adapters/github.py DELETED
@@ -1,156 +0,0 @@
1
- """Aurelius adapter — GitHub (live mode): the repository dependency graph.
2
-
3
- Nodes are repositories; edges are "depends on / relates to" links derived
4
- from a repo's dependency manifests and its owner's other repos. The demo
5
- use-case is impact analysis and ecosystem navigation: connect two repos,
6
- or discover() adjacent projects two hops out that you don't directly
7
- depend on.
8
-
9
- id = "owner/repo" (GitHub's own canonical id); title = the same.
10
- Backlinks (who-depends-on-me) are not available from the REST API without
11
- a full crawl, so supports_backlinks=False → the navigator runs forward-
12
- only for this source (direct-hit meeting only, no goal zone).
13
-
14
- Set GITHUB_TOKEN to lift the anonymous 60 req/hr limit to 5000/hr.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import re
20
- from typing import Optional
21
-
22
- import httpx
23
-
24
- from config import GITHUB_API, GITHUB_TOKEN, CONTACT_EMAIL
25
- from core.source import GraphSource, register
26
- from core.types import Edge, NodeInfo, NodeRef
27
-
28
- _HEADERS = {"User-Agent": f"Aurelius/1.0 ({CONTACT_EMAIL})",
29
- "Accept": "application/vnd.github+json"}
30
- if GITHUB_TOKEN:
31
- _HEADERS["Authorization"] = f"Bearer {GITHUB_TOKEN}"
32
-
33
- # owner/repo references inside dependency manifests and READMEs.
34
- _REPO_RE = re.compile(r"github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
35
- # common manifest paths worth scanning for dependency edges
36
- _MANIFESTS = ("package.json", "requirements.txt", "pyproject.toml",
37
- "go.mod", "Cargo.toml")
38
-
39
-
40
- class GitHubSource(GraphSource):
41
- name = "github"
42
- description = "GitHub — repositories linked by dependencies and authorship."
43
- edge_types = ("depends_on", "same_owner")
44
- supports_backlinks = False # who-depends-on-me needs a full crawl
45
-
46
- def __init__(self):
47
- self._client: Optional[httpx.AsyncClient] = None
48
- self._info_cache: dict[str, NodeInfo] = {}
49
-
50
- def _http(self) -> httpx.AsyncClient:
51
- if self._client is None or self._client.is_closed:
52
- self._client = httpx.AsyncClient(headers=_HEADERS,
53
- follow_redirects=True,
54
- timeout=httpx.Timeout(25.0))
55
- return self._client
56
-
57
- async def _get(self, path: str, params: dict | None = None):
58
- try:
59
- r = await self._http().get(f"{GITHUB_API}{path}", params=params or {})
60
- if r.status_code != 200:
61
- return None
62
- return r.json()
63
- except Exception as e:
64
- print(f"[github] {e}")
65
- return None
66
-
67
- def _ref(self, full_name: str, desc: str = "") -> NodeRef:
68
- n = NodeRef(self.name, full_name.lower(), full_name)
69
- if desc:
70
- self._info_cache[n.id] = NodeInfo(
71
- text=f"{full_name}. {desc}", summary=desc[:220])
72
- return n
73
-
74
- async def resolve(self, query: str) -> Optional[NodeRef]:
75
- q = query.strip()
76
- m = _REPO_RE.search(q)
77
- if m:
78
- q = m.group(1)
79
- if "/" in q and " " not in q:
80
- data = await self._get(f"/repos/{q}")
81
- if data and data.get("full_name"):
82
- return self._ref(data["full_name"], data.get("description") or "")
83
- data = await self._get("/search/repositories",
84
- {"q": q, "sort": "stars", "per_page": 1})
85
- items = (data or {}).get("items", [])
86
- if not items:
87
- return None
88
- return self._ref(items[0]["full_name"], items[0].get("description") or "")
89
-
90
- async def _manifest_repo_refs(self, full_name: str) -> set[str]:
91
- found: set[str] = set()
92
- for fname in _MANIFESTS:
93
- data = await self._get(f"/repos/{full_name}/contents/{fname}")
94
- if not data or "content" not in data:
95
- continue
96
- import base64
97
- try:
98
- text = base64.b64decode(data["content"]).decode("utf-8", "ignore")
99
- except Exception:
100
- continue
101
- for m in _REPO_RE.finditer(text):
102
- found.add(m.group(1))
103
- return found
104
-
105
- async def neighbors(self, n: NodeRef, *,
106
- hunt_id: str | None = None,
107
- priority_ids: set[str] | None = None) -> list[Edge]:
108
- full_name = self._info_cache.get(n.id) and n.title or n.title
109
- edges: list[Edge] = []
110
- seen: set[str] = set()
111
-
112
- # 1. Dependency edges from manifests.
113
- for repo in await self._manifest_repo_refs(n.title):
114
- key = repo.lower()
115
- if key == n.id or key in seen:
116
- continue
117
- seen.add(key)
118
- edges.append(Edge(src=n, dst=self._ref(repo), type="depends_on"))
119
-
120
- # 2. Same-owner edges (the owner's other popular repos) — keeps the
121
- # graph connected when a repo has no parseable manifest.
122
- owner = n.title.split("/")[0]
123
- data = await self._get(f"/users/{owner}/repos",
124
- {"sort": "stars", "per_page": 30})
125
- for repo in data or []:
126
- fn = repo.get("full_name", "")
127
- key = fn.lower()
128
- if not fn or key == n.id or key in seen:
129
- continue
130
- seen.add(key)
131
- edges.append(Edge(src=n,
132
- dst=self._ref(fn, repo.get("description") or ""),
133
- type="same_owner", weight=0.5))
134
- return edges
135
-
136
- async def node_info(self, n: NodeRef, rich: bool = False) -> NodeInfo:
137
- if n.id in self._info_cache:
138
- return self._info_cache[n.id]
139
- data = await self._get(f"/repos/{n.title}")
140
- if data:
141
- desc = data.get("description") or ""
142
- topics = " ".join(data.get("topics", []) or [])
143
- text = f"{n.title}. {desc} {topics}".strip()
144
- info = NodeInfo(text=text, summary=desc[:220])
145
- self._info_cache[n.id] = info
146
- return info
147
- return NodeInfo(text=n.title)
148
-
149
- async def node_infos(self, ns: list[NodeRef]) -> list[NodeInfo]:
150
- return [self._info_cache.get(n.id, NodeInfo(text=n.title)) for n in ns]
151
-
152
- async def sample_pair(self) -> Optional[tuple[str, str]]:
153
- return ("pytorch/pytorch", "huggingface/transformers")
154
-
155
-
156
- register(GitHubSource())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
adapters/openalex.py CHANGED
@@ -14,6 +14,7 @@ incoming citations (cited_by). Both are real directed edges.
14
  from __future__ import annotations
15
 
16
  import asyncio
 
17
  from typing import Optional
18
 
19
  import httpx
@@ -23,7 +24,11 @@ from core.source import GraphSource, register
23
  from core.types import Edge, NodeInfo, NodeRef
24
 
25
  _UA = {"User-Agent": f"Aurelius/1.0 (mailto:{CONTACT_EMAIL})"}
26
- _SELECT = "id,display_name,publication_year,abstract_inverted_index,referenced_works"
 
 
 
 
27
 
28
 
29
  def _short_id(oa_id: str) -> str:
@@ -31,6 +36,18 @@ def _short_id(oa_id: str) -> str:
31
  return oa_id.rsplit("/", 1)[-1]
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def _deinvert_abstract(inv: dict | None, cap: int = 60) -> str:
35
  """OpenAlex ships abstracts as an inverted index {word: [positions]};
36
  reconstruct the first `cap` words for embedding text."""
@@ -80,26 +97,43 @@ class OpenAlexSource(GraphSource):
80
  sid = _short_id(work.get("id", ""))
81
  title = work.get("display_name") or sid
82
  yr = work.get("publication_year")
 
 
 
83
  abstract = _deinvert_abstract(work.get("abstract_inverted_index"))
84
  text = f"{title}." + (f" ({yr})." if yr else "")
85
  if abstract:
86
  text += f" {abstract}"
87
- self._info_cache[sid] = NodeInfo(text=text,
88
- summary=abstract[:220],
89
- features={"year": yr or 0})
 
90
 
91
  async def resolve(self, query: str) -> Optional[NodeRef]:
92
  q = query.strip()
93
- # Direct id?
94
  if q.upper().startswith("W") and q[1:].isdigit():
95
  data = await self._get(f"/works/{q.upper()}", {"select": _SELECT})
96
  if data.get("id"):
97
  self._stash_info(data)
98
  return self._ref(data)
99
- # Otherwise full-text search, most-cited first.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  data = await self._get("/works", {
101
- "search": q, "per-page": 1, "select": _SELECT,
102
- "sort": "cited_by_count:desc"})
103
  results = data.get("results", [])
104
  if not results:
105
  return None
@@ -146,6 +180,27 @@ class OpenAlexSource(GraphSource):
146
  async def node_infos(self, ns: list[NodeRef]) -> list[NodeInfo]:
147
  return [self._info_cache.get(n.id, NodeInfo(text=n.title)) for n in ns]
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  async def sample_pair(self) -> Optional[tuple[str, str]]:
150
  return ("attention is all you need", "ImageNet classification")
151
 
 
14
  from __future__ import annotations
15
 
16
  import asyncio
17
+ import re
18
  from typing import Optional
19
 
20
  import httpx
 
24
  from core.types import Edge, NodeInfo, NodeRef
25
 
26
  _UA = {"User-Agent": f"Aurelius/1.0 (mailto:{CONTACT_EMAIL})"}
27
+ _SELECT = ("id,display_name,publication_year,cited_by_count,authorships,"
28
+ "primary_location,abstract_inverted_index,referenced_works")
29
+
30
+ _DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s\"'<>]+)", re.IGNORECASE)
31
+ _ARXIV_RE = re.compile(r"arxiv[:/]\s*(\d{4}\.\d{4,5})(v\d+)?", re.IGNORECASE)
32
 
33
 
34
  def _short_id(oa_id: str) -> str:
 
36
  return oa_id.rsplit("/", 1)[-1]
37
 
38
 
39
+ def _authors(work: dict, cap: int = 3) -> list[str]:
40
+ names = [a.get("author", {}).get("display_name", "")
41
+ for a in (work.get("authorships") or [])]
42
+ return [n for n in names if n][:cap]
43
+
44
+
45
+ def _venue(work: dict) -> str:
46
+ loc = work.get("primary_location") or {}
47
+ src = loc.get("source") or {}
48
+ return src.get("display_name", "") or ""
49
+
50
+
51
  def _deinvert_abstract(inv: dict | None, cap: int = 60) -> str:
52
  """OpenAlex ships abstracts as an inverted index {word: [positions]};
53
  reconstruct the first `cap` words for embedding text."""
 
97
  sid = _short_id(work.get("id", ""))
98
  title = work.get("display_name") or sid
99
  yr = work.get("publication_year")
100
+ authors = _authors(work)
101
+ venue = _venue(work)
102
+ cited = work.get("cited_by_count") or 0
103
  abstract = _deinvert_abstract(work.get("abstract_inverted_index"))
104
  text = f"{title}." + (f" ({yr})." if yr else "")
105
  if abstract:
106
  text += f" {abstract}"
107
+ self._info_cache[sid] = NodeInfo(
108
+ text=text, summary=abstract[:280],
109
+ features={"year": yr or 0, "authors": authors, "venue": venue,
110
+ "cited_by_count": cited, "kind": "paper"})
111
 
112
  async def resolve(self, query: str) -> Optional[NodeRef]:
113
  q = query.strip()
114
+ # Direct OpenAlex id?
115
  if q.upper().startswith("W") and q[1:].isdigit():
116
  data = await self._get(f"/works/{q.upper()}", {"select": _SELECT})
117
  if data.get("id"):
118
  self._stash_info(data)
119
  return self._ref(data)
120
+ # DOI (bare or as a doi.org URL)?
121
+ m = _DOI_RE.search(q)
122
+ if m:
123
+ data = await self._get(f"/works/doi:{m.group(1)}", {"select": _SELECT})
124
+ if data.get("id"):
125
+ self._stash_info(data)
126
+ return self._ref(data)
127
+ # arXiv id (arXiv:2301.12345 or an arxiv.org/abs/... URL)?
128
+ am = _ARXIV_RE.search(q)
129
+ if am:
130
+ q = f"arXiv:{am.group(1)}"
131
+ # Otherwise full-text search by RELEVANCE (default sort). Sorting by
132
+ # citation count here is wrong for a title lookup — it returns the
133
+ # most-cited paper that loosely matches the words, not the paper the
134
+ # user named.
135
  data = await self._get("/works", {
136
+ "search": q, "per-page": 1, "select": _SELECT})
 
137
  results = data.get("results", [])
138
  if not results:
139
  return None
 
180
  async def node_infos(self, ns: list[NodeRef]) -> list[NodeInfo]:
181
  return [self._info_cache.get(n.id, NodeInfo(text=n.title)) for n in ns]
182
 
183
+ async def suggest(self, query: str, limit: int = 8) -> list[dict]:
184
+ q = query.strip()
185
+ if len(q) < 3:
186
+ return []
187
+ data = await self._get("/works", {
188
+ "search": q, "per-page": min(limit, 25),
189
+ "select": "id,display_name,publication_year,authorships"})
190
+ out = []
191
+ for w in data.get("results", []):
192
+ sid = _short_id(w.get("id", ""))
193
+ yr = w.get("publication_year")
194
+ auths = w.get("authorships") or []
195
+ first = (auths[0].get("author", {}).get("display_name", "")
196
+ if auths else "")
197
+ if first and len(auths) > 1:
198
+ first += " et al."
199
+ sub = " · ".join(x for x in [first, str(yr) if yr else ""] if x)
200
+ out.append({"id": sid, "title": w.get("display_name") or sid,
201
+ "kind": "paper", "subtitle": sub or None})
202
+ return out
203
+
204
  async def sample_pair(self) -> Optional[tuple[str, str]]:
205
  return ("attention is all you need", "ImageNet classification")
206
 
adapters/wikipedia.py CHANGED
@@ -305,6 +305,23 @@ class WikipediaSource(GraphSource):
305
  summary=sd))
306
  return out
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  # ── niceties ─────────────────────────────────────────────────────────
309
  async def edge_display(self, src: NodeRef, dst: NodeRef) -> Optional[str]:
310
  """Visible text of the wikilink on src pointing at dst, when piped
 
305
  summary=sd))
306
  return out
307
 
308
+ # ── recommendations ──────────────────────────────────────────────────
309
+ async def suggest(self, query: str, limit: int = 8) -> list[dict]:
310
+ q = query.strip()
311
+ if len(q) < 2:
312
+ return []
313
+ data = await self._get({"action": "opensearch", "search": q,
314
+ "limit": limit, "namespace": 0,
315
+ "format": "json", "utf8": 1})
316
+ titles = data[1] if isinstance(data, list) and len(data) > 1 else []
317
+ descs = data[2] if isinstance(data, list) and len(data) > 2 else []
318
+ out = []
319
+ for i, t in enumerate(titles):
320
+ sub = (descs[i] if i < len(descs) else "") or ""
321
+ out.append({"id": t, "title": t, "kind": "article",
322
+ "subtitle": sub[:80] or None})
323
+ return out
324
+
325
  # ── niceties ─────────────────────────────────────────────────────────
326
  async def edge_display(self, src: NodeRef, dst: NodeRef) -> Optional[str]:
327
  """Visible text of the wikilink on src pointing at dst, when piped
app.js CHANGED
@@ -4,8 +4,6 @@ let nodes = {};
4
  let edges = [];
5
  let centreNode = null;
6
  let pathNodes = new Set();
7
- let simulation = null;
8
- let svgEl, gEl;
9
  let width, height;
10
  let running = false;
11
  let _searchGen = 0;
@@ -138,6 +136,15 @@ function updateSourceUI() {
138
  const cmpFab = document.getElementById('compare-fab');
139
  if (cmpFab) cmpFab.style.display =
140
  (currentSource === 'finance' || currentSource === 'biomed') ? '' : 'none';
 
 
 
 
 
 
 
 
 
141
  }
142
 
143
  function renderLensPicker() {
@@ -316,7 +323,6 @@ const _isLocalHost = ['localhost', '127.0.0.1'].includes(location.hostname);
316
  const AC_BACKEND = _isLocalHost ? 'http://localhost:8000'
317
  : 'https://mvali77-aurelius.hf.space';
318
  const AC_BACKEND_WS = AC_BACKEND.replace(/^http/, 'ws');
319
- const WIKI_OPENSEARCH = 'https://en.wikipedia.org/w/api.php';
320
 
321
  // ════════════════════════════════════════════════════════════════════════════
322
  // Multi-source: load the registered adapters and let the user pick one.
@@ -345,7 +351,7 @@ function renderSourcePickers() {
345
 
346
  function sourceLabel(name) {
347
  const map = {
348
- wikipedia: 'Wikipedia', openalex: 'Research Papers', github: 'GitHub',
349
  biomed: 'Biology', news: 'News', finance: 'Finance',
350
  };
351
  return map[name] || name;
@@ -356,19 +362,47 @@ const EXAMPLE_PAIRS = {
356
  wikipedia: [['Nagpur', 'Mars'], ['Cleopatra', 'Bitcoin'], ['Jazz', 'Quantum mechanics']],
357
  finance: [['NVDA', 'Ford'], ['Tim Cook', 'Crude Oil'], ['Berkshire Hathaway', 'Apple']],
358
  biomed: [['Levodopa', "Alzheimer's disease"], ['BRCA1', 'Apoptosis'], ['Tamoxifen', 'DNA repair']],
359
- openalex: [['Attention is all you need', 'ImageNet']],
360
- github: [['facebook/react', 'vercel/next.js']],
361
  };
362
 
363
  function renderExampleChips() {
364
  const box = document.getElementById('hero-examples');
365
  if (!box) return;
366
  const pairs = EXAMPLE_PAIRS[currentSource] || [];
367
- if (!pairs.length) { box.innerHTML = ''; return; }
368
- box.innerHTML = '<span class="hero-ex-label">Try</span>'
369
- + pairs.map(([a, b]) =>
370
- `<button class="hero-ex-chip" onclick="runExample('${a.replace(/'/g, "\\'")}','${b.replace(/'/g, "\\'")}')">`
371
- + `${escHtml(a)} <span>&rarr;</span> ${escHtml(b)}</button>`).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  }
373
 
374
  function runExample(a, b) {
@@ -617,6 +651,412 @@ function compareToPath(a, b) {
617
  startSearch();
618
  }
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  // ════════════════════════════════════════════════════════════════════════════
621
  // News / Reddit overlays — fold live coverage onto the focused company.
622
  // ════════════════════════════════════════════════════════════════════════════
@@ -700,11 +1140,11 @@ function renderOverlay(data, query) {
700
  }
701
 
702
  /**
703
- * Debounced fetch directly to Wikipedia's opensearch API. Called on every
704
- * keystroke in a hero input field, but the actual network request is
705
- * delayed by AC_DEBOUNCE_MS so rapid typing doesn't spam the API. No
706
- * backend involved CORS-enabled, instant, and accurate for any article
707
- * (not limited to a curated seed list).
708
  */
709
  function triggerAutocomplete(fieldName) {
710
  const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
@@ -719,14 +1159,17 @@ function triggerAutocomplete(fieldName) {
719
 
720
  acDebounceTimers[fieldName] = setTimeout(async () => {
721
  try {
 
 
 
 
722
  const params = new URLSearchParams({
723
- action: 'opensearch', search: query, limit: '5',
724
- namespace: '0', format: 'json', origin: '*',
725
  });
726
- const res = await fetch(`${WIKI_OPENSEARCH}?${params}`);
727
  if (!res.ok) { closeAcDropdown(fieldName); return; }
728
  const data = await res.json();
729
- const suggestions = Array.isArray(data) && data.length > 1 ? data[1] : [];
730
 
731
  // The user may have kept typing while this request was in flight —
732
  // if the field no longer matches what we searched for, drop the
@@ -746,6 +1189,24 @@ function triggerAutocomplete(fieldName) {
746
  }, AC_DEBOUNCE_MS);
747
  }
748
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
  /**
750
  * Escape a string for safe interpolation into innerHTML. Article titles come
751
  * from the Wikipedia API and can't currently contain "<"/">" (MediaWiki
@@ -773,17 +1234,24 @@ function renderAcDropdown(fieldName) {
773
  return;
774
  }
775
 
776
- dropdown.innerHTML = items.map((title, i) => `
 
 
 
 
 
 
777
  <div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}"
778
  data-idx="${i}" data-title="${escHtml(title)}">
779
- <svg class="ac-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M4 2h6l3 3v9H4V2z"/><path d="M10 2v3h3"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="10" y2="10"/></svg>${escHtml(title)}
780
- </div>
781
- `).join('');
 
 
 
 
782
  dropdown.classList.add('visible');
783
- // BUGFIX: previously used inline onclick="selectAcSuggestion('${fieldName}', ${JSON.stringify(title)})"
784
- // which broke because JSON.stringify's double-quotes collided with the outer HTML
785
- // attribute's double-quotes, corrupting the attribute and silently failing on click.
786
- // Event delegation with data-title avoids the quote-collision entirely.
787
  dropdown.querySelectorAll('.ac-item').forEach(el => {
788
  el.addEventListener('click', () => selectAcSuggestion(fieldName, el.dataset.title));
789
  });
@@ -799,8 +1267,11 @@ function closeAcDropdown(fieldName) {
799
  acActiveIndex[fieldName] = -1;
800
  }
801
 
802
- /** Called when the user clicks (or presses Enter on) a suggestion row. */
803
- function selectAcSuggestion(fieldName, title) {
 
 
 
804
  const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
805
  document.getElementById(inputId).value = title;
806
  closeAcDropdown(fieldName);
@@ -932,20 +1403,19 @@ function _syncMobileHandles() {
932
 
933
  // Collapsing/expanding either bar resizes #canvas-wrap via CSS transition,
934
  // but that's an internal layout change, not a viewport resize — it never
935
- // fires the `resize` listener that normally keeps the d3 force simulation
936
- // centered. Re-measure once the 0.32s transition (styles.css) finishes, or
937
- // the graph stays centered on the stale pre-toggle canvas size.
938
  setTimeout(_resyncCanvasSize, 340);
939
  }
940
 
941
  function _resyncCanvasSize() {
942
- if (!simulation) return;
943
  const cw = document.getElementById('canvas-wrap');
944
  if (!cw) return;
945
  width = cw.clientWidth;
946
  height = cw.clientHeight;
947
- simulation.force('center', d3.forceCenter(width / 2, height / 2));
948
- simulation.alpha(0.3).restart();
949
  }
950
 
951
  // ── Search ────────────────────────────────────────────────────────────────────
@@ -1068,23 +1538,21 @@ function resetAll(showHero = true) {
1068
  resetHeroDisplay();
1069
  }
1070
 
1071
- initSVG();
1072
  }
1073
 
1074
- // ── Renderer selection: 3D (WebGL) when available, else 2D SVG fallback ──────
1075
- // use3D is decided once on first init. When true, all the D3-specific
1076
- // functions below (render/ticked/panToNode/drag) delegate to the GV 3D
1077
- // controller in graph3d.js; the WebSocket message handlers are unchanged
1078
- // because they only ever touch the shared `nodes`/`edges` state + render().
1079
- let use3D = false;
1080
- let _gv3dReady = false;
1081
 
1082
  function _graphCtx() {
1083
  return { pathNodes, foundPath, centreNode, filter: graphFilter,
1084
  lens: _lensTypes(), running };
1085
  }
1086
 
1087
- // ── Graph view filters + fit (3D explorer controls) ──────────────────────────
1088
  function setGraphFilter(mode) {
1089
  graphFilter = mode;
1090
  document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
@@ -1093,7 +1561,7 @@ function setGraphFilter(mode) {
1093
  }
1094
 
1095
  function fitGraphView() {
1096
- if (use3D && window.GV) window.GV.fit();
1097
  }
1098
 
1099
  // ── Progressive expansion: click a node → pull its real neighbors in ─────────
@@ -1101,6 +1569,9 @@ function fitGraphView() {
1101
  // the stream). Every edge carries its relationship type + evidence string,
1102
  // which the 3D view surfaces as an edge tooltip.
1103
  async function expandNodeFromGraph(id) {
 
 
 
1104
  if (running || !nodes[id] || expandedNodes.has(id)) return;
1105
  setStatus(`Expanding "${id}"…`);
1106
  try {
@@ -1152,256 +1623,29 @@ async function expandNodeFromGraph(id) {
1152
  }
1153
 
1154
  // ── Init graph ──────────────────────────────────────────────────────────────
1155
- function initSVG() {
1156
- width = document.getElementById('canvas-wrap').clientWidth;
1157
- height = document.getElementById('canvas-wrap').clientHeight;
1158
-
1159
- const el3d = document.getElementById('graph3d');
1160
- const svgNode = document.getElementById('graph');
1161
- use3D = !!(window.GV && window.GV.available() && el3d);
1162
-
1163
  const gcBar = document.getElementById('graph-controls');
1164
- if (use3D) {
1165
- if (svgNode) svgNode.style.display = 'none';
1166
- el3d.style.display = 'block';
1167
- if (!_gv3dReady) _gv3dReady = window.GV.init(el3d, { onExpand: expandNodeFromGraph });
1168
- if (_gv3dReady) {
1169
- window.GV.reset();
1170
- if (gcBar) gcBar.style.display = 'flex';
1171
- renderLensPicker();
1172
- return;
1173
- }
1174
- use3D = false; // init failed — fall through to SVG
1175
- }
1176
- if (gcBar) gcBar.style.display = 'none';
1177
- if (el3d) el3d.style.display = 'none';
1178
- if (svgNode) svgNode.style.display = 'block';
1179
-
1180
- svgEl = d3.select('#graph');
1181
- svgEl.selectAll('*').remove();
1182
-
1183
- // Define glow filter
1184
- const defs = svgEl.append('defs');
1185
- const glow = defs.append('filter')
1186
- .attr('id', 'glow')
1187
- .attr('x', '-30%')
1188
- .attr('y', '-30%')
1189
- .attr('width', '160%')
1190
- .attr('height', '160%');
1191
- glow.append('feGaussianBlur')
1192
- .attr('stdDeviation', '6')
1193
- .attr('result', 'blur');
1194
- glow.append('feMerge')
1195
- .append('feMergeNode').attr('in', 'blur');
1196
- glow.select('feMerge')
1197
- .append('feMergeNode').attr('in', 'SourceGraphic');
1198
-
1199
- const zoom = d3.zoom()
1200
- .scaleExtent([0.2, 4])
1201
- .on('zoom', e => gEl.attr('transform', e.transform));
1202
- svgEl.call(zoom);
1203
-
1204
- gEl = svgEl.append('g').attr('id', 'main-g');
1205
- gEl.append('g').attr('id', 'edge-layer');
1206
- gEl.append('g').attr('id', 'node-layer');
1207
- gEl.append('g').attr('id', 'label-layer');
1208
-
1209
- simulation = d3.forceSimulation()
1210
- .force('link', d3.forceLink().id(d => d.id).distance(120).strength(0.4))
1211
- // distanceMax caps how far the mutual-repulsion force reaches between any
1212
- // two nodes. Without it, a long-running search (up to 60 expansions, 25
1213
- // new nodes per step) keeps accumulating cumulative repulsion across the
1214
- // WHOLE graph — every node keeps pushing every other node apart forever,
1215
- // so the cluster's total footprint grows without bound as more nodes
1216
- // appear, even though the camera's own zoom level never changes. That
1217
- // unbounded sprawl is what reads as "the page automatically zooms out" —
1218
- // capping the repulsion's reach keeps the graph's footprint roughly
1219
- // stable once nodes are a few hundred px apart, instead of ballooning.
1220
- .force('charge', d3.forceManyBody().strength(-300).distanceMax(420))
1221
- .force('center', d3.forceCenter(width / 2, height / 2))
1222
- .force('collide', d3.forceCollide(44))
1223
- .alphaDecay(0.02)
1224
- .on('tick', ticked);
1225
- }
1226
-
1227
- // ── D3 tick ───────────────────────────────────────────────────────────────────
1228
- function ticked() {
1229
- d3.selectAll('.edge-line')
1230
- .attr('x1', d => (nodes[d.from] || {}).x || 0)
1231
- .attr('y1', d => (nodes[d.from] || {}).y || 0)
1232
- .attr('x2', d => (nodes[d.to] || {}).x || 0)
1233
- .attr('y2', d => (nodes[d.to] || {}).y || 0);
1234
-
1235
- d3.selectAll('.node-circle')
1236
- .attr('cx', d => d.x)
1237
- .attr('cy', d => d.y);
1238
-
1239
- d3.selectAll('.node-label-el')
1240
- .attr('x', d => d.x)
1241
- .attr('y', d => d.y + nodeRadius(d) + 15);
1242
- }
1243
-
1244
- function nodeRadius(d) {
1245
- if (d.state === 'centre') return 22;
1246
- if (d.state === 'target') return 18;
1247
- if (pathNodes.has(d.id)) return 15;
1248
- return 12;
1249
- }
1250
-
1251
- function nodeColor(d) {
1252
- const s = getComputedStyle(document.documentElement);
1253
- if (d.state === 'centre') return s.getPropertyValue('--node-centre').trim();
1254
- if (d.state === 'target') return s.getPropertyValue('--node-target').trim();
1255
- if (d.state === 'closed') return '#374151';
1256
- if (d.state === 'gated') return '#4b2020';
1257
- if (pathNodes.has(d.id)) return foundPath.length ? '#34d399' : s.getPropertyValue('--node-path').trim();
1258
- return s.getPropertyValue('--node-open').trim();
1259
- }
1260
-
1261
- function edgeColor(d) {
1262
- if (d.isPath) return foundPath.length ? 'rgba(52,211,153,0.9)' : 'rgba(249,115,22,0.85)';
1263
- const fromNode = nodes[d.from];
1264
- if (fromNode && fromNode.state === 'closed') return 'rgba(124,106,247,0.18)';
1265
- return 'rgba(124,106,247,0.38)';
1266
- }
1267
- function edgeWidth(d) { return d.isPath ? 3 : 1.2; }
1268
-
1269
- // ── Render ────────────────────────────────────────────────────────────────────
1270
- function render() {
1271
- if (use3D) { window.GV.sync(nodes, edges, _graphCtx()); return; }
1272
-
1273
- const nodeArr = Object.values(nodes);
1274
-
1275
- const edgeSel = d3.select('#edge-layer')
1276
- .selectAll('.edge-line')
1277
- .data(edges, d => d.from + '→' + d.to);
1278
-
1279
- edgeSel.enter().append('line')
1280
- .attr('class', 'edge-line')
1281
- .attr('stroke-opacity', 0)
1282
- .attr('stroke-linecap', 'round')
1283
- .transition().duration(400)
1284
- .attr('stroke-opacity', 1);
1285
-
1286
- edgeSel.exit().remove();
1287
-
1288
- d3.selectAll('.edge-line')
1289
- .attr('stroke', edgeColor)
1290
- .attr('stroke-width', edgeWidth)
1291
- .attr('class', d => d.isPath && foundPath.length ? 'edge-line edge-path-found' : 'edge-line');
1292
-
1293
- const circSel = d3.select('#node-layer')
1294
- .selectAll('.node-circle')
1295
- .data(nodeArr, d => d.id);
1296
-
1297
- circSel.enter().append('circle')
1298
- .attr('class', 'node-circle')
1299
- .attr('r', 0)
1300
- .attr('fill', nodeColor)
1301
- .attr('stroke', '#0a0a0f')
1302
- .attr('stroke-width', 2.5)
1303
- .style('cursor', 'pointer')
1304
- .on('mouseover', onNodeHover)
1305
- .on('mouseout', onNodeOut)
1306
- .on('click', onNodeClick)
1307
- .call(d3.drag()
1308
- .on('start', dragStart)
1309
- .on('drag', dragged)
1310
- .on('end', dragEnd))
1311
- .transition().duration(350)
1312
- .attr('r', nodeRadius);
1313
-
1314
- d3.selectAll('.node-circle')
1315
- .attr('class', 'node-circle')
1316
- .attr('fill', nodeColor)
1317
- .style('filter', d => {
1318
- if (d.state === 'centre' || d.state === 'target' || pathNodes.has(d.id)) {
1319
- return 'url(#glow)';
1320
- }
1321
- return 'none';
1322
- })
1323
- .transition().duration(200)
1324
- .attr('r', nodeRadius);
1325
-
1326
- circSel.exit().transition().duration(200).attr('r', 0).remove();
1327
-
1328
- const lblSel = d3.select('#label-layer')
1329
- .selectAll('.node-label-el')
1330
- .data(nodeArr, d => d.id);
1331
-
1332
- lblSel.enter().append('text')
1333
- .attr('class', d => 'node-label-el ' + (d.state === 'centre' ? 'centre-label' : 'node-label'))
1334
- .attr('opacity', 0)
1335
- .transition().duration(400)
1336
- .attr('opacity', d => {
1337
- if (d.state === 'centre' || d.state === 'target') return 1;
1338
- if (pathNodes.has(d.id)) return 0.9;
1339
- return 0.45;
1340
- });
1341
-
1342
- d3.selectAll('.node-label-el')
1343
- .text(d => truncate(d.id, d.state === 'centre' ? 24 : 18))
1344
- .attr('class', d => 'node-label-el ' + (d.state === 'centre' ? 'centre-label' : 'node-label'))
1345
- .attr('fill', d =>
1346
- d.state === 'centre' ? 'var(--text)' :
1347
- d.state === 'target' ? 'var(--green)' : 'var(--text2)')
1348
- .transition().duration(300)
1349
- .attr('opacity', d => {
1350
- if (d.state === 'centre' || d.state === 'target') return 1;
1351
- if (pathNodes.has(d.id)) return 0.9;
1352
- return 0.42;
1353
- });
1354
 
1355
- lblSel.exit().remove();
 
 
 
 
1356
 
1357
- simulation.nodes(nodeArr);
1358
- simulation.force('link').links(edges.map(e => ({
1359
- source: e.from, target: e.to, isPath: e.isPath
1360
- })));
1361
- simulation.alpha(0.3).restart();
1362
  }
1363
 
1364
- // ── Pan/fly to node ─────────────────────────────────────────────────────────
 
1365
  function panToNode(nodeId) {
1366
- if (use3D) {
1367
- // While a search is streaming, the auto-tracker owns the camera (it
1368
- // keeps the whole growing graph framed and zooms out as it grows);
1369
- // flying to every expanded node would fight it. Explicit clicks after
1370
- // the search still fly to the node via GV.focus.
1371
- if (!running) window.GV.focus(nodeId);
1372
- return;
1373
- }
1374
- const n = nodes[nodeId];
1375
- if (!n || !n.x) return;
1376
- const cw = document.getElementById('canvas-wrap').clientWidth;
1377
- const ch = document.getElementById('canvas-wrap').clientHeight;
1378
- const t = d3.zoomTransform(svgEl.node());
1379
- const tx = cw / 2 - t.k * n.x;
1380
- const ty = ch / 2 - t.k * n.y;
1381
- svgEl.transition().duration(600).ease(d3.easeCubicInOut)
1382
- .call(d3.zoom().transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
1383
- }
1384
-
1385
- // ── Drag ──────────────────────────────────────────────────────────────────────
1386
- function dragStart(e, d) { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
1387
- function dragged(e, d) { d.fx = e.x; d.fy = e.y; }
1388
- function dragEnd(e, d) { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
1389
-
1390
- // ── Tooltip ───────────────────────────────────────────────────────────────────
1391
- function onNodeHover(e, d) {
1392
- const tip = document.getElementById('tooltip');
1393
- document.getElementById('tt-title').textContent = d.id;
1394
- const scores = [];
1395
- if (d.g != null) scores.push(`g(n) = ${d.g} hops from start`);
1396
- if (d.h != null) scores.push(`h(n) = ${d.h} heuristic`);
1397
- if (d.f != null) scores.push(`f(n) = ${d.f} total`);
1398
- document.getElementById('tt-scores').innerHTML = scores.join('<br>');
1399
- tip.style.left = (e.pageX + 14) + 'px';
1400
- tip.style.top = (e.pageY - 32) + 'px';
1401
- tip.classList.add('visible');
1402
- }
1403
- function onNodeOut() { document.getElementById('tooltip').classList.remove('visible'); }
1404
- function onNodeClick(e, d) { panToNode(d.id); }
1405
 
1406
  // ── Helpers ───────────────────────────────────────────────────────────────────
1407
  function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
@@ -1566,11 +1810,8 @@ function handleMessage(msg) {
1566
  addLog(`Expanding: ${msg.id} f=${msg.f}`, 'highlight');
1567
  if (msg.stats) _updateStats(msg.stats);
1568
  render();
1569
- // 2D only: follow the current centre node. In 3D the auto-tracker
1570
- // owns the camera and this delayed call must never reach GV.focus,
1571
- // or a `found` landing inside the 200ms window flips `running` false
1572
- // and the stray focus() disables auto-framing mid-glide.
1573
- if (!use3D) setTimeout(() => panToNode(msg.id), 200);
1574
  break;
1575
  }
1576
 
@@ -1658,10 +1899,13 @@ function handleMessage(msg) {
1658
  showPathBanner(path);
1659
  addLog(`✓ Found! ${path.join(' → ')} (${msg.total_hops} hops, ${msg.steps} steps)`, 'success');
1660
  setStatus(`Done! Path found in ${msg.total_hops} hops.`);
1661
- render();
1662
- document.getElementById('btn-search').disabled = false;
 
1663
  running = false;
1664
  foundPath = path;
 
 
1665
  _hidePauseBtn();
1666
  document.getElementById('btn-export').classList.add('visible');
1667
  if (msg.stats) _updateStats(msg.stats);
@@ -1774,14 +2018,13 @@ function logoClick() {
1774
 
1775
  // ── Boot ──────────────────────────────────────────────────────────────────────
1776
  window.addEventListener('load', () => {
1777
- initSVG();
1778
  // Focus first hero input
1779
  document.getElementById('hero-inp-start').focus();
1780
 
1781
  window.addEventListener('resize', () => {
1782
  width = document.getElementById('canvas-wrap').clientWidth;
1783
  height = document.getElementById('canvas-wrap').clientHeight;
1784
- if (simulation) simulation.force('center', d3.forceCenter(width / 2, height / 2));
1785
  });
1786
 
1787
  // NEW: wire vector-search autocomplete onto the two hero inputs. This is
@@ -1796,20 +2039,10 @@ window.addEventListener('load', () => {
1796
  });
1797
 
1798
  function animatePath(path, steps, hops, displayTexts) {
1799
- const delay = 160;
1800
- path.forEach((nodeId, i) => {
1801
- setTimeout(() => {
1802
- d3.selectAll('.node-circle')
1803
- .filter(d => d.id === nodeId)
1804
- .transition().duration(120)
1805
- .attr('r', d => nodeRadius(d) * 1.7)
1806
- .attr('fill', '#34d399')
1807
- .transition().duration(260)
1808
- .attr('r', d => nodeRadius(d))
1809
- .attr('fill', nodeColor);
1810
- }, i * delay);
1811
- });
1812
- setTimeout(() => openSuccessModal(path, steps, hops, displayTexts), path.length * delay + 480);
1813
  }
1814
 
1815
  function openSuccessModal(path, steps, hops, displayTexts) {
 
4
  let edges = [];
5
  let centreNode = null;
6
  let pathNodes = new Set();
 
 
7
  let width, height;
8
  let running = false;
9
  let _searchGen = 0;
 
136
  const cmpFab = document.getElementById('compare-fab');
137
  if (cmpFab) cmpFab.style.display =
138
  (currentSource === 'finance' || currentSource === 'biomed') ? '' : 'none';
139
+ // Company Profile is a finance-only surface.
140
+ const profFab = document.getElementById('profile-fab');
141
+ if (profFab) profFab.style.display = isFinance ? '' : 'none';
142
+ if (!isFinance) toggleProfile(false);
143
+ // Citation Explorer is the research-papers surface.
144
+ const isPapers = currentSource === 'openalex';
145
+ const paperFab = document.getElementById('paper-fab');
146
+ if (paperFab) paperFab.style.display = isPapers ? '' : 'none';
147
+ if (!isPapers) togglePaper(false);
148
  }
149
 
150
  function renderLensPicker() {
 
323
  const AC_BACKEND = _isLocalHost ? 'http://localhost:8000'
324
  : 'https://mvali77-aurelius.hf.space';
325
  const AC_BACKEND_WS = AC_BACKEND.replace(/^http/, 'ws');
 
326
 
327
  // ════════════════════════════════════════════════════════════════════════════
328
  // Multi-source: load the registered adapters and let the user pick one.
 
351
 
352
  function sourceLabel(name) {
353
  const map = {
354
+ wikipedia: 'Wikipedia', openalex: 'Research Papers',
355
  biomed: 'Biology', news: 'News', finance: 'Finance',
356
  };
357
  return map[name] || name;
 
362
  wikipedia: [['Nagpur', 'Mars'], ['Cleopatra', 'Bitcoin'], ['Jazz', 'Quantum mechanics']],
363
  finance: [['NVDA', 'Ford'], ['Tim Cook', 'Crude Oil'], ['Berkshire Hathaway', 'Apple']],
364
  biomed: [['Levodopa', "Alzheimer's disease"], ['BRCA1', 'Apoptosis'], ['Tamoxifen', 'DNA repair']],
365
+ // Research papers lead with the citation explorer (hero CTA), not
366
+ // connect-two-papers pathfinding — so no A→B example pairs here.
367
  };
368
 
369
  function renderExampleChips() {
370
  const box = document.getElementById('hero-examples');
371
  if (!box) return;
372
  const pairs = EXAMPLE_PAIRS[currentSource] || [];
373
+ // Finance and Research lead with a dedicated research surface, not
374
+ // pathfinding: a prominent CTA opens it straight from the landing.
375
+ let cta = '';
376
+ if (currentSource === 'finance') {
377
+ cta = `<button class="hero-research-cta" onclick="heroResearchCompany()">
378
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V11l5-3v12M13 20V6l5-3v17M3 20h18"/></svg>
379
+ Research a company</button>`;
380
+ } else if (currentSource === 'openalex') {
381
+ cta = `<button class="hero-research-cta" onclick="heroExplorePaper()">
382
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h9l4 4v14H6z"/><path d="M15 3v4h4"/><line x1="9" y1="12" x2="16" y2="12"/><line x1="9" y1="16" x2="14" y2="16"/></svg>
383
+ Explore a paper's citations</button>`;
384
+ }
385
+ if (!pairs.length && !cta) { box.innerHTML = ''; return; }
386
+ const tryLabel = cta ? 'or trace a link' : 'Try';
387
+ box.innerHTML = cta
388
+ + (pairs.length ? `<span class="hero-ex-label">${tryLabel}</span>`
389
+ + pairs.map(([a, b]) =>
390
+ `<button class="hero-ex-chip" onclick="runExample('${a.replace(/'/g, "\\'")}','${b.replace(/'/g, "\\'")}')">`
391
+ + `${escHtml(a)} <span>&rarr;</span> ${escHtml(b)}</button>`).join('') : '');
392
+ }
393
+
394
+ // Finance landing → jump straight to the company research surface.
395
+ function heroResearchCompany() {
396
+ const seed = (document.getElementById('hero-inp-start').value || '').trim();
397
+ dismissHero(seed, '');
398
+ setTimeout(() => { if (seed) openProfile(seed); else toggleProfile(true); }, 260);
399
+ }
400
+
401
+ // Research landing → jump straight to the citation explorer.
402
+ function heroExplorePaper() {
403
+ const seed = (document.getElementById('hero-inp-start').value || '').trim();
404
+ dismissHero(seed, '');
405
+ setTimeout(() => { if (seed) openPaper(seed); else togglePaper(true); }, 260);
406
  }
407
 
408
  function runExample(a, b) {
 
651
  startSearch();
652
  }
653
 
654
+ // ════════════════════════════════════════════════════════════════════════════
655
+ // Company Profile — the primary finance research surface (/api/company).
656
+ // A single company's dossier: price, key facts, peers, supply chain,
657
+ // correlations, ownership, and the news moving it — the graph is secondary.
658
+ // ════════════════════════════════════════════════════════════════════════════
659
+ const PROFILE_QUICK = ['NVDA', 'AAPL', 'JPM', 'XOM', 'TSLA', 'BRK-B'];
660
+ let _profileCurrent = null;
661
+
662
+ function toggleProfile(open) {
663
+ const panel = document.getElementById('profile-panel');
664
+ if (!panel) return;
665
+ const fab = document.getElementById('profile-fab');
666
+ const show = open === undefined ? !panel.classList.contains('open') : open;
667
+ panel.classList.toggle('open', show);
668
+ panel.setAttribute('aria-hidden', show ? 'false' : 'true');
669
+ if (fab) fab.classList.toggle('hidden', show);
670
+ if (show) {
671
+ renderProfileEmptyChips();
672
+ const inp = document.getElementById('profile-input');
673
+ // seed with the last-focused / centre company when the box is empty
674
+ if (inp && !inp.value && centreNode) inp.value = centreNode;
675
+ if (inp && !_profileCurrent) inp.focus();
676
+ }
677
+ }
678
+
679
+ function renderProfileEmptyChips() {
680
+ const box = document.getElementById('profile-empty-chips');
681
+ if (!box) return;
682
+ box.innerHTML = PROFILE_QUICK.map(t =>
683
+ `<button class="prof-chip" onclick="openProfile('${t}')">${escHtml(t)}</button>`).join('');
684
+ }
685
+
686
+ async function openProfile(query) {
687
+ query = (query || '').trim();
688
+ if (!query) return;
689
+ toggleProfile(true);
690
+ const body = document.getElementById('profile-body');
691
+ const inp = document.getElementById('profile-input');
692
+ if (inp) inp.value = query;
693
+ if (body) body.innerHTML = '<div class="prof-loading"><span class="prof-spinner"></span>Building the profile…</div>';
694
+ try {
695
+ const data = await fetch(`${AC_BACKEND}/api/company?q=${encodeURIComponent(query)}`).then(r => r.json());
696
+ if (data.error) {
697
+ if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`;
698
+ return;
699
+ }
700
+ _profileCurrent = data;
701
+ renderProfile(data);
702
+ } catch (e) {
703
+ if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>';
704
+ }
705
+ }
706
+
707
+ // single-series normalized price chart with an area fill
708
+ function _profileChartSVG(series, changePct) {
709
+ if (!Array.isArray(series) || series.length < 2) return '';
710
+ const W = 560, H = 150, pad = 8;
711
+ const lo = Math.min(...series), hi = Math.max(...series);
712
+ const span = hi - lo || 1;
713
+ const up = (changePct || 0) >= 0;
714
+ const stroke = up ? '#4aab79' : '#d66a6a';
715
+ const step = (W - 2 * pad) / (series.length - 1);
716
+ const xy = series.map((v, i) =>
717
+ [pad + i * step, H - pad - (v - lo) / span * (H - 2 * pad)]);
718
+ const line = xy.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
719
+ const area = `${pad},${H - pad} ${line} ${(W - pad).toFixed(1)},${H - pad}`;
720
+ return `<svg class="prof-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Price chart">
721
+ <defs><linearGradient id="prof-grad" x1="0" y1="0" x2="0" y2="1">
722
+ <stop offset="0%" stop-color="${stroke}" stop-opacity="0.28"/>
723
+ <stop offset="100%" stop-color="${stroke}" stop-opacity="0"/>
724
+ </linearGradient></defs>
725
+ <polygon fill="url(#prof-grad)" points="${area}"/>
726
+ <polyline fill="none" stroke="${stroke}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="${line}"/>
727
+ </svg>`;
728
+ }
729
+
730
+ function _fmtChange(pct) {
731
+ if (typeof pct !== 'number' || !isFinite(pct)) return '';
732
+ const cls = pct >= 0 ? 'up' : 'down';
733
+ return `<span class="prof-chg ${cls}">${pct >= 0 ? '▲' : '▼'} ${Math.abs(pct)}%</span>`;
734
+ }
735
+
736
+ // a horizontal list of related-company cards (peers/suppliers/…)
737
+ function _profileCards(items, opts = {}) {
738
+ if (!Array.isArray(items) || !items.length) return '';
739
+ return `<div class="prof-cards">` + items.map(c => {
740
+ const t = escHtml(c.title || c.id);
741
+ const meta = [];
742
+ if (opts.showCorr && typeof c.corr === 'number') meta.push(`corr ${c.corr}`);
743
+ else if (c.sector) meta.push(escHtml(c.sector));
744
+ const chg = (opts.showChange && typeof c.change_pct === 'number') ? _fmtChange(c.change_pct) : '';
745
+ return `<button class="prof-card" onclick="openProfile('${(c.id || '').replace(/'/g, "\\'")}')" title="Open ${t}">
746
+ <span class="prof-card-name">${t}</span>
747
+ <span class="prof-card-meta">${meta.join(' · ')} ${chg}</span>
748
+ </button>`;
749
+ }).join('') + `</div>`;
750
+ }
751
+
752
+ function _profileSection(title, hint, html) {
753
+ if (!html) return '';
754
+ return `<div class="prof-section">
755
+ <div class="prof-sec-head">${escHtml(title)}${hint ? `<span class="prof-sec-hint">${escHtml(hint)}</span>` : ''}</div>
756
+ ${html}
757
+ </div>`;
758
+ }
759
+
760
+ function renderProfile(d) {
761
+ const body = document.getElementById('profile-body');
762
+ if (!body) return;
763
+ const facts = [];
764
+ if (d.sector) facts.push(`<span class="prof-fact"><i>Sector</i>${escHtml(d.sector)}</span>`);
765
+ if (d.ceo && d.ceo.title) facts.push(`<span class="prof-fact"><i>CEO</i>${escHtml(d.ceo.title)}</span>`);
766
+ if (d.country) facts.push(`<span class="prof-fact"><i>HQ</i>${escHtml(d.country)}</span>`);
767
+ if (d.etfs && d.etfs.length) facts.push(`<span class="prof-fact"><i>In ETFs</i>${d.etfs.length}</span>`);
768
+
769
+ const price = (typeof d.last_price === 'number')
770
+ ? `<div class="prof-price">$${d.last_price} ${_fmtChange(d.change_pct)}
771
+ <span class="prof-price-note">last close · ${Array.isArray(d.series) ? d.series.length : 0}-day window</span></div>`
772
+ : '';
773
+
774
+ const kindBadge = d.kind ? `<span class="prof-kind">${escHtml(d.kind)}</span>` : '';
775
+
776
+ body.innerHTML = `
777
+ <div class="prof-title-row">
778
+ <h2 class="prof-title">${escHtml(d.title || d.id)}</h2>${kindBadge}
779
+ </div>
780
+ ${price}
781
+ ${facts.length ? `<div class="prof-facts">${facts.join('')}</div>` : ''}
782
+ ${d.summary ? `<p class="prof-summary">${escHtml(d.summary)}</p>` : ''}
783
+ ${Array.isArray(d.series) && d.series.length ? `<div class="prof-chart-wrap">${_profileChartSVG(d.series, d.change_pct)}</div>` : ''}
784
+
785
+ ${_profileSection('Peers', 'rivals & sector', _profileCards(d.peers, { showChange: true }))}
786
+ ${_profileSection('Suppliers', 'depends on', _profileCards(d.suppliers, { showChange: true }))}
787
+ ${_profileSection('Customers', 'sells to / powers', _profileCards(d.customers, { showChange: true }))}
788
+ ${_profileSection('Moves with', 'return correlation', _profileCards(d.correlated, { showCorr: true }))}
789
+ ${_profileSection('Macro exposure', 'correlated indicators', _profileCards(d.macro, { showCorr: true }))}
790
+ ${_profileSection('Ownership', 'ETFs & investors', _profileCards([...(d.etfs || []), ...(d.investors || [])]))}
791
+
792
+ <div class="prof-news" id="prof-news"><div class="prof-sec-head">In the news<span class="prof-sec-hint">latest coverage</span></div><div class="prof-news-body"><span class="prof-spinner"></span></div></div>
793
+
794
+ <div class="prof-actions">
795
+ <button class="prof-act primary" onclick="profileExploreGraph('${(d.id || '').replace(/'/g, "\\'")}')">Explore in graph</button>
796
+ <button class="prof-act" onclick="profileCompare('${(d.id || '').replace(/'/g, "\\'")}')">Compare…</button>
797
+ </div>`;
798
+
799
+ loadProfileNews(d.title || d.id);
800
+ }
801
+
802
+ // News for the profiled company (reuses the News Intelligence entity feed).
803
+ async function loadProfileNews(name) {
804
+ const box = document.querySelector('#prof-news .prof-news-body');
805
+ if (!box) return;
806
+ try {
807
+ const data = await fetch(`${AC_BACKEND}/api/news/entity?name=${encodeURIComponent(name)}&k=5`).then(r => r.json());
808
+ const items = (data && data.results) || [];
809
+ if (!items.length) { box.innerHTML = '<div class="prof-msg small">No recent coverage indexed. Open the graph view and toggle News to refresh the feed.</div>'; return; }
810
+ box.innerHTML = items.map(a => {
811
+ const s = a.sentiment_label || 'neutral';
812
+ const when = a.published ? String(a.published).slice(0, 10) : '';
813
+ return `<a class="prof-news-item" href="${escHtml(a.url || '#')}" target="_blank" rel="noopener">
814
+ <span class="prof-news-dot prof-${s}" title="${s} tone"></span>
815
+ <span class="prof-news-txt">
816
+ <span class="prof-news-title">${escHtml(a.title || 'Untitled')}</span>
817
+ <span class="prof-news-src">${escHtml(a.source || '')}${when ? ' · ' + escHtml(when) : ''}</span>
818
+ </span>
819
+ </a>`;
820
+ }).join('');
821
+ } catch (e) {
822
+ box.innerHTML = '<div class="prof-msg small">Couldn\'t load news.</div>';
823
+ }
824
+ }
825
+
826
+ function profileExploreGraph(id) {
827
+ if (!id) return;
828
+ toggleProfile(false);
829
+ // seed the graph with this company and expand it
830
+ if (!nodes[id]) {
831
+ nodes[id] = { id, g: 0, h: 0, f: 0, state: 'centre',
832
+ kind: 'company', expanded: false,
833
+ x: width / 2, y: height / 2, vx: 0, vy: 0 };
834
+ }
835
+ centreNode = id;
836
+ render();
837
+ expandNodeFromGraph(id);
838
+ }
839
+
840
+ function profileCompare(id) {
841
+ toggleProfile(false);
842
+ toggleCompare(true);
843
+ const a = document.getElementById('cmp-a');
844
+ if (a) a.value = id;
845
+ const b = document.getElementById('cmp-b');
846
+ if (b) b.focus();
847
+ }
848
+
849
+ // ════════════════════════════════════════════════════════════════════════════
850
+ // Citation Explorer — the primary research-papers surface (/api/paper).
851
+ // Give it a paper (title, DOI, arXiv id or URL) and it shows the works it
852
+ // cites and the works citing it, then builds an interactive citation graph
853
+ // you expand on demand. Replaces the old connect-two-papers pathfinding.
854
+ // ═══════════════════════════════════��════════════════════════════════════════
855
+ const PAPER_QUICK = ['Attention is all you need', 'AlphaFold', 'ImageNet',
856
+ 'BERT language model', 'CRISPR gene editing'];
857
+ let _paperCurrent = null;
858
+
859
+ function togglePaper(open) {
860
+ const panel = document.getElementById('paper-panel');
861
+ if (!panel) return;
862
+ const fab = document.getElementById('paper-fab');
863
+ const show = open === undefined ? !panel.classList.contains('open') : open;
864
+ panel.classList.toggle('open', show);
865
+ panel.setAttribute('aria-hidden', show ? 'false' : 'true');
866
+ if (fab) fab.classList.toggle('hidden', show);
867
+ if (show) {
868
+ renderPaperEmptyChips();
869
+ const inp = document.getElementById('paper-input');
870
+ if (inp && !_paperCurrent) inp.focus();
871
+ }
872
+ }
873
+
874
+ function renderPaperEmptyChips() {
875
+ const box = document.getElementById('paper-empty-chips');
876
+ if (!box) return;
877
+ box.innerHTML = PAPER_QUICK.map(t =>
878
+ `<button class="prof-chip" onclick="openPaper('${t.replace(/'/g, "\\'")}')">${escHtml(t)}</button>`).join('');
879
+ }
880
+
881
+ async function openPaper(query) {
882
+ query = (query || '').trim();
883
+ if (!query) return;
884
+ togglePaper(true);
885
+ const body = document.getElementById('paper-body');
886
+ const inp = document.getElementById('paper-input');
887
+ if (inp) inp.value = query;
888
+ if (body) body.innerHTML = '<div class="prof-loading"><span class="prof-spinner"></span>Fetching citations…</div>';
889
+ try {
890
+ const data = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(query)}`).then(r => r.json());
891
+ if (data.error) { if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; }
892
+ _paperCurrent = data;
893
+ renderPaper(data);
894
+ } catch (e) {
895
+ if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>';
896
+ }
897
+ }
898
+
899
+ // Upload a PDF → backend extracts its DOI/arXiv/title and returns the
900
+ // citation dossier, which renders exactly like a searched paper.
901
+ // Drag-and-drop a PDF anywhere on the citation panel.
902
+ (function wirePaperDrop() {
903
+ const setup = () => {
904
+ const panel = document.getElementById('paper-panel');
905
+ if (!panel) return;
906
+ const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
907
+ ['dragenter', 'dragover'].forEach(ev => panel.addEventListener(ev, (e) => {
908
+ stop(e); panel.classList.add('drag-over');
909
+ }));
910
+ ['dragleave', 'drop'].forEach(ev => panel.addEventListener(ev, (e) => {
911
+ stop(e); if (ev === 'dragleave' && panel.contains(e.relatedTarget)) return;
912
+ panel.classList.remove('drag-over');
913
+ }));
914
+ panel.addEventListener('drop', (e) => {
915
+ const f = e.dataTransfer && e.dataTransfer.files;
916
+ if (f && f.length) uploadPaperPdf(f);
917
+ });
918
+ };
919
+ if (document.readyState !== 'loading') setup();
920
+ else document.addEventListener('DOMContentLoaded', setup);
921
+ })();
922
+
923
+ async function uploadPaperPdf(files) {
924
+ const file = files && files[0];
925
+ if (!file) return;
926
+ togglePaper(true);
927
+ const body = document.getElementById('paper-body');
928
+ if (body) body.innerHTML = `<div class="prof-loading"><span class="prof-spinner"></span>Reading “${escHtml(file.name)}”…</div>`;
929
+ try {
930
+ const fd = new FormData();
931
+ fd.append('file', file);
932
+ const data = await fetch(`${AC_BACKEND}/api/paper/upload`, { method: 'POST', body: fd }).then(r => r.json());
933
+ if (data.error) { if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; }
934
+ _paperCurrent = data;
935
+ const inp = document.getElementById('paper-input');
936
+ if (inp) inp.value = data.title || '';
937
+ renderPaper(data);
938
+ } catch (e) {
939
+ if (body) body.innerHTML = '<div class="prof-msg">Could not upload the PDF.</div>';
940
+ }
941
+ }
942
+
943
+ function _paperMeta(c) {
944
+ const bits = [];
945
+ if (c.authors && c.authors.length) bits.push(escHtml(c.authors.join(', ') + (c.authors.length >= 3 ? ' et al.' : '')));
946
+ if (c.year) bits.push(c.year);
947
+ if (c.venue) bits.push(escHtml(c.venue));
948
+ return bits.join(' · ');
949
+ }
950
+
951
+ function _paperCards(items, badge) {
952
+ if (!Array.isArray(items) || !items.length)
953
+ return '<div class="prof-msg small">None found.</div>';
954
+ return `<div class="paper-list">` + items.map(c => {
955
+ const cb = c.cited_by_count ? `<span class="paper-cb" title="times cited">${_kfmt(c.cited_by_count)}×</span>` : '';
956
+ return `<button class="paper-item" onclick="openPaper('${(c.id || '').replace(/'/g, "\\'")}')" title="Open this paper">
957
+ <span class="paper-item-main">
958
+ <span class="paper-item-title">${escHtml(c.title || c.id)}</span>
959
+ <span class="paper-item-meta">${_paperMeta(c) || '&nbsp;'}</span>
960
+ </span>
961
+ ${cb}
962
+ </button>`;
963
+ }).join('') + `</div>`;
964
+ }
965
+
966
+ function _kfmt(n) {
967
+ if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
968
+ return String(n);
969
+ }
970
+
971
+ function renderPaper(d) {
972
+ const body = document.getElementById('paper-body');
973
+ if (!body) return;
974
+ const meta = [];
975
+ if (d.authors && d.authors.length) meta.push(escHtml(d.authors.join(', ') + (d.authors.length >= 3 ? ' et al.' : '')));
976
+ if (d.year) meta.push(d.year);
977
+ if (d.venue) meta.push(escHtml(d.venue));
978
+
979
+ body.innerHTML = `
980
+ <div class="prof-title-row"><h2 class="prof-title">${escHtml(d.title || d.id)}</h2></div>
981
+ ${meta.length ? `<div class="paper-byline">${meta.join(' · ')}</div>` : ''}
982
+ <div class="paper-stats">
983
+ <span class="paper-stat"><b>${_kfmt(d.cited_by_count || 0)}</b> citations</span>
984
+ <span class="paper-stat"><b>${d.n_references || 0}</b> references</span>
985
+ <span class="paper-stat"><b>${d.n_citations || 0}</b> citing here</span>
986
+ </div>
987
+ ${d.abstract ? `<p class="prof-summary paper-abstract">${escHtml(d.abstract)}</p>` : ''}
988
+
989
+ <div class="prof-actions" style="margin-top:16px">
990
+ <button class="prof-act primary" onclick="paperBuildGraph()">Build citation graph</button>
991
+ </div>
992
+
993
+ ${_profileSection('References', 'papers this cites', _paperCards(d.references))}
994
+ ${_profileSection('Cited by', 'influential papers citing this', _paperCards(d.citations))}`;
995
+ }
996
+
997
+ // Seed the 2D graph with the paper at the centre, its references (papers it
998
+ // cites) and citations (papers citing it), then hand off to the explorer.
999
+ function paperBuildGraph() {
1000
+ const d = _paperCurrent;
1001
+ if (!d) return;
1002
+ togglePaper(false);
1003
+ nodes = {}; edges = []; expandedNodes.clear();
1004
+ const cx = width / 2, cy = height / 2;
1005
+ nodes[d.id] = { id: d.id, title: d.title, g: 0, h: 0, f: 0,
1006
+ state: 'centre', kind: 'paper', expanded: true, x: cx, y: cy, vx: 0, vy: 0 };
1007
+ centreNode = d.id;
1008
+ const place = (arr, sign) => (arr || []).forEach((c, i) => {
1009
+ if (!c.id || nodes[c.id]) return;
1010
+ const ang = (i / Math.max(1, arr.length)) * Math.PI + (sign > 0 ? 0 : Math.PI);
1011
+ nodes[c.id] = { id: c.id, title: c.title, g: 1, h: 0, f: 1,
1012
+ state: 'open', kind: 'paper', expanded: true,
1013
+ x: cx + Math.cos(ang) * 160 + (Math.random() - .5) * 40,
1014
+ y: cy + sign * 120 + (Math.random() - .5) * 40, vx: 0, vy: 0 };
1015
+ });
1016
+ place(d.references, -1); // things it cites, above
1017
+ place(d.citations, 1); // things citing it, below
1018
+ // directed edges: paper → reference (cites); citation → paper (cites)
1019
+ (d.references || []).forEach(c => { if (nodes[c.id]) edges.push({ from: d.id, to: c.id, isPath: false, type: 'cites', display: 'cites' }); });
1020
+ (d.citations || []).forEach(c => { if (nodes[c.id]) edges.push({ from: c.id, to: d.id, isPath: false, type: 'cites', display: 'cited by' }); });
1021
+ expandedNodes.add(d.id);
1022
+ render();
1023
+ if (window.GV) window.GV.fit();
1024
+ setStatus(`Citation graph for "${d.title}" — click any paper to expand its citations.`);
1025
+ }
1026
+
1027
+ // Expand a paper node in the graph: pull both its references and citations.
1028
+ async function expandPaperNode(id) {
1029
+ if (running || !nodes[id] || expandedNodes.has(id)) return;
1030
+ setStatus(`Expanding citations for "${truncate(id, 40)}"…`);
1031
+ try {
1032
+ const d = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(id)}&refs=12&cites=12`).then(r => r.json());
1033
+ if (d.error) { setStatus(d.error); return; }
1034
+ expandedNodes.add(id);
1035
+ const base = nodes[id] || { x: width / 2, y: height / 2 };
1036
+ if (d.title && nodes[id]) nodes[id].title = d.title;
1037
+ let added = 0;
1038
+ const add = (c, sign, from, to, disp) => {
1039
+ if (!c.id) return;
1040
+ if (!nodes[c.id]) {
1041
+ nodes[c.id] = { id: c.id, title: c.title, g: null, h: null, f: null,
1042
+ state: 'open', kind: 'paper', expanded: true,
1043
+ x: (base.x || width / 2) + (Math.random() - .5) * 120,
1044
+ y: (base.y || height / 2) + sign * 80 + (Math.random() - .5) * 60,
1045
+ vx: 0, vy: 0 };
1046
+ added++;
1047
+ }
1048
+ if (!edges.find(e => e.from === from && e.to === to))
1049
+ edges.push({ from, to, isPath: false, type: 'cites', display: disp });
1050
+ };
1051
+ (d.references || []).forEach(c => add(c, -1, id, c.id, 'cites'));
1052
+ (d.citations || []).forEach(c => add(c, 1, c.id, id, 'cited by'));
1053
+ render();
1054
+ setStatus(`Expanded "${truncate(d.title || id, 40)}": +${added} papers. ${d.n_references} references, ${d.n_citations} citing.`);
1055
+ } catch (e) {
1056
+ setStatus('Could not expand — backend unreachable.');
1057
+ }
1058
+ }
1059
+
1060
  // ════════════════════════════════════════════════════════════════════════════
1061
  // News / Reddit overlays — fold live coverage onto the focused company.
1062
  // ════════════════════════════════════════════════════════════════════════════
 
1140
  }
1141
 
1142
  /**
1143
+ * Debounced, domain-aware autocomplete. Called on every keystroke in a
1144
+ * hero input, but the network request is delayed by AC_DEBOUNCE_MS so
1145
+ * rapid typing doesn't spam the backend. Suggestions come from the active
1146
+ * source's own vocabulary via /api/suggest (companies for finance, papers
1147
+ * for research, articles for Wikipedia, ).
1148
  */
1149
  function triggerAutocomplete(fieldName) {
1150
  const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
 
1159
 
1160
  acDebounceTimers[fieldName] = setTimeout(async () => {
1161
  try {
1162
+ // Domain-aware: suggestions come from whichever source is active
1163
+ // (companies/tickers for finance, papers for research, diseases for
1164
+ // biology, articles for Wikipedia) via the backend /api/suggest —
1165
+ // no longer hardcoded to Wikipedia's opensearch.
1166
  const params = new URLSearchParams({
1167
+ source: currentSource, q: query, limit: '7',
 
1168
  });
1169
+ const res = await fetch(`${AC_BACKEND}/api/suggest?${params}`);
1170
  if (!res.ok) { closeAcDropdown(fieldName); return; }
1171
  const data = await res.json();
1172
+ const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
1173
 
1174
  // The user may have kept typing while this request was in flight —
1175
  // if the field no longer matches what we searched for, drop the
 
1189
  }, AC_DEBOUNCE_MS);
1190
  }
1191
 
1192
+ // Per-kind glyph for the suggestion dropdown — a small visual cue that the
1193
+ // recommendation is domain-appropriate (a paper vs a company vs a gene).
1194
+ function _acKindIcon(kind) {
1195
+ const P = 'stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"';
1196
+ const wrap = (inner) => `<svg class="ac-icon" viewBox="0 0 16 16" width="14" height="14" ${P}>${inner}</svg>`;
1197
+ switch (kind) {
1198
+ case 'company': return wrap('<path d="M3 14V4l5-2v12M8 14V6l5 2v6M2 14h12"/><path d="M5 6h0M5 9h0M10 9h0M10 11h0"/>');
1199
+ case 'etf': return wrap('<circle cx="8" cy="8" r="6"/><path d="M8 2v6l4 2"/>');
1200
+ case 'sector': return wrap('<rect x="2" y="9" width="3" height="5"/><rect x="6.5" y="5" width="3" height="9"/><rect x="11" y="2" width="3" height="12"/>');
1201
+ case 'executive':
1202
+ case 'person': return wrap('<circle cx="8" cy="5" r="2.5"/><path d="M3.5 14a4.5 4.5 0 0 1 9 0"/>');
1203
+ case 'country': return wrap('<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2.5 2 9.5 0 12M8 2c-2 2.5-2 9.5 0 12"/>');
1204
+ case 'macro': return wrap('<path d="M2 12l4-4 3 3 5-6"/><path d="M14 5v3h-3"/>');
1205
+ case 'paper': return wrap('<path d="M4 2h5l3 3v9H4z"/><path d="M9 2v3h3"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="11" x2="9" y2="11"/>');
1206
+ default: return wrap('<path d="M4 2h6l3 3v9H4V2z"/><path d="M10 2v3h3"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="10" y2="10"/>');
1207
+ }
1208
+ }
1209
+
1210
  /**
1211
  * Escape a string for safe interpolation into innerHTML. Article titles come
1212
  * from the Wikipedia API and can't currently contain "<"/">" (MediaWiki
 
1234
  return;
1235
  }
1236
 
1237
+ dropdown.innerHTML = items.map((item, i) => {
1238
+ // Backend suggestions are objects {title, subtitle, kind}; keep a
1239
+ // string fallback so nothing breaks if an older payload appears.
1240
+ const title = typeof item === 'string' ? item : (item.title || '');
1241
+ const subtitle = typeof item === 'string' ? '' : (item.subtitle || '');
1242
+ const kind = typeof item === 'string' ? '' : (item.kind || '');
1243
+ return `
1244
  <div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}"
1245
  data-idx="${i}" data-title="${escHtml(title)}">
1246
+ ${_acKindIcon(kind)}
1247
+ <span class="ac-text">
1248
+ <span class="ac-title">${escHtml(title)}</span>
1249
+ ${subtitle ? `<span class="ac-sub">${escHtml(subtitle)}</span>` : ''}
1250
+ </span>
1251
+ </div>`;
1252
+ }).join('');
1253
  dropdown.classList.add('visible');
1254
+ // Event delegation with data-title avoids inline-onclick quote-collision.
 
 
 
1255
  dropdown.querySelectorAll('.ac-item').forEach(el => {
1256
  el.addEventListener('click', () => selectAcSuggestion(fieldName, el.dataset.title));
1257
  });
 
1267
  acActiveIndex[fieldName] = -1;
1268
  }
1269
 
1270
+ /** Called when the user clicks (or presses Enter on) a suggestion row.
1271
+ * Accepts either a plain title string (click path) or a suggestion object
1272
+ * (keyboard path). */
1273
+ function selectAcSuggestion(fieldName, item) {
1274
+ const title = typeof item === 'string' ? item : (item && item.title) || '';
1275
  const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
1276
  document.getElementById(inputId).value = title;
1277
  closeAcDropdown(fieldName);
 
1403
 
1404
  // Collapsing/expanding either bar resizes #canvas-wrap via CSS transition,
1405
  // but that's an internal layout change, not a viewport resize — it never
1406
+ // fires the window `resize` the canvas renderer listens for. Nudge one
1407
+ // once the 0.32s transition (styles.css) finishes so the graph reframes
1408
+ // on the new canvas size.
1409
  setTimeout(_resyncCanvasSize, 340);
1410
  }
1411
 
1412
  function _resyncCanvasSize() {
 
1413
  const cw = document.getElementById('canvas-wrap');
1414
  if (!cw) return;
1415
  width = cw.clientWidth;
1416
  height = cw.clientHeight;
1417
+ // graph2d.js listens for window resize; dispatch one so it re-measures.
1418
+ window.dispatchEvent(new Event('resize'));
1419
  }
1420
 
1421
  // ── Search ────────────────────────────────────────────────────────────────────
 
1538
  resetHeroDisplay();
1539
  }
1540
 
1541
+ initGraph();
1542
  }
1543
 
1544
+ // ── Renderer (2D canvas via graph2d.js) ──────────────────────────────────────
1545
+ // The WebSocket message handlers only ever touch the shared `nodes`/`edges`
1546
+ // state and call render(); render() hands that state to the canvas
1547
+ // controller (window.GV) in graph2d.js.
1548
+ let _gvReady = false;
 
 
1549
 
1550
  function _graphCtx() {
1551
  return { pathNodes, foundPath, centreNode, filter: graphFilter,
1552
  lens: _lensTypes(), running };
1553
  }
1554
 
1555
+ // ── Graph view filters + fit (explorer controls) ─────────────────────────────
1556
  function setGraphFilter(mode) {
1557
  graphFilter = mode;
1558
  document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
 
1561
  }
1562
 
1563
  function fitGraphView() {
1564
+ if (window.GV) window.GV.fit();
1565
  }
1566
 
1567
  // ── Progressive expansion: click a node → pull its real neighbors in ─────────
 
1569
  // the stream). Every edge carries its relationship type + evidence string,
1570
  // which the 3D view surfaces as an edge tooltip.
1571
  async function expandNodeFromGraph(id) {
1572
+ // Research papers expand in BOTH citation directions (references +
1573
+ // citing works) via the citation-explorer path.
1574
+ if (currentSource === 'openalex') return expandPaperNode(id);
1575
  if (running || !nodes[id] || expandedNodes.has(id)) return;
1576
  setStatus(`Expanding "${id}"…`);
1577
  try {
 
1623
  }
1624
 
1625
  // ── Init graph ──────────────────────────────────────────────────────────────
1626
+ function initGraph() {
1627
+ const wrap = document.getElementById('canvas-wrap');
1628
+ width = wrap.clientWidth;
1629
+ height = wrap.clientHeight;
1630
+ const el = document.getElementById('graph-canvas');
 
 
 
1631
  const gcBar = document.getElementById('graph-controls');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1632
 
1633
+ if (!_gvReady) _gvReady = window.GV.init(el, { onExpand: expandNodeFromGraph });
1634
+ if (_gvReady) window.GV.reset();
1635
+ if (gcBar) gcBar.style.display = 'flex';
1636
+ renderLensPicker();
1637
+ }
1638
 
1639
+ // ── Render: hand the shared state to the canvas controller ───────────────────
1640
+ function render() {
1641
+ if (window.GV) window.GV.sync(nodes, edges, _graphCtx());
 
 
1642
  }
1643
 
1644
+ // ── Pan/fly to a node (after a search; the auto-follow owns the camera
1645
+ // while a search is streaming, so we don't fight it mid-run) ─────────────────
1646
  function panToNode(nodeId) {
1647
+ if (!running && window.GV) window.GV.focus(nodeId);
1648
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1649
 
1650
  // ── Helpers ───────────────────────────────────────────────────────────────────
1651
  function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
 
1810
  addLog(`Expanding: ${msg.id} f=${msg.f}`, 'highlight');
1811
  if (msg.stats) _updateStats(msg.stats);
1812
  render();
1813
+ // The canvas auto-follow keeps the whole growing graph framed while
1814
+ // the search streams, so we don't fly to each centre node here.
 
 
 
1815
  break;
1816
  }
1817
 
 
1899
  showPathBanner(path);
1900
  addLog(`✓ Found! ${path.join(' → ')} (${msg.total_hops} hops, ${msg.steps} steps)`, 'success');
1901
  setStatus(`Done! Path found in ${msg.total_hops} hops.`);
1902
+ // Set the found/running state BEFORE render() so the canvas captures
1903
+ // the found context (green path, dimmed cloud) on this sync — the
1904
+ // renderer reads _graphCtx() once per sync, not per animation frame.
1905
  running = false;
1906
  foundPath = path;
1907
+ render();
1908
+ document.getElementById('btn-search').disabled = false;
1909
  _hidePauseBtn();
1910
  document.getElementById('btn-export').classList.add('visible');
1911
  if (msg.stats) _updateStats(msg.stats);
 
2018
 
2019
  // ── Boot ──────────────────────────────────────────────────────────────────────
2020
  window.addEventListener('load', () => {
2021
+ initGraph();
2022
  // Focus first hero input
2023
  document.getElementById('hero-inp-start').focus();
2024
 
2025
  window.addEventListener('resize', () => {
2026
  width = document.getElementById('canvas-wrap').clientWidth;
2027
  height = document.getElementById('canvas-wrap').clientHeight;
 
2028
  });
2029
 
2030
  // NEW: wire vector-search autocomplete onto the two hero inputs. This is
 
2039
  });
2040
 
2041
  function animatePath(path, steps, hops, displayTexts) {
2042
+ // The canvas renderer already recolours the found path and dims the
2043
+ // cloud; frame the whole route, then present the result.
2044
+ if (window.GV) window.GV.fit();
2045
+ setTimeout(() => openSuccessModal(path, steps, hops, displayTexts), 900);
 
 
 
 
 
 
 
 
 
 
2046
  }
2047
 
2048
  function openSuccessModal(path, steps, hops, displayTexts) {
config.py CHANGED
@@ -133,8 +133,6 @@ AURELIUS_DB = Path(os.getenv("AURELIUS_DB", str(_THIS_DIR / "data" / "au
133
  # pool" is explicitly faster with a mailto.
134
  CONTACT_EMAIL = os.getenv("AURELIUS_CONTACT", "murtaza.vali.ug25@plaksha.edu.in")
135
  OPENALEX_API = "https://api.openalex.org"
136
- GITHUB_API = "https://api.github.com"
137
- GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") # optional, raises rate limits
138
  NEWSAPI_KEY = os.getenv("NEWSAPI_KEY", "") # optional; GDELT fallback when absent
139
  # News Intelligence scheduled refresh: minutes between pipeline runs.
140
  # 0 (default) = off — refresh manually via /api/news/refresh or the CLI.
@@ -165,6 +163,6 @@ N2V_NEGATIVES = 5
165
  # to text, per source. Text dominates for encyclopedic sources; structure
166
  # carries sources whose node text is thin (tickers, genes, file paths).
167
  FUSION_ALPHA = {
168
- "wikipedia": 0.3, "openalex": 0.5, "github": 0.8,
169
  "biomed": 0.8, "news": 0.5, "finance": 0.8,
170
  }
 
133
  # pool" is explicitly faster with a mailto.
134
  CONTACT_EMAIL = os.getenv("AURELIUS_CONTACT", "murtaza.vali.ug25@plaksha.edu.in")
135
  OPENALEX_API = "https://api.openalex.org"
 
 
136
  NEWSAPI_KEY = os.getenv("NEWSAPI_KEY", "") # optional; GDELT fallback when absent
137
  # News Intelligence scheduled refresh: minutes between pipeline runs.
138
  # 0 (default) = off — refresh manually via /api/news/refresh or the CLI.
 
163
  # to text, per source. Text dominates for encyclopedic sources; structure
164
  # carries sources whose node text is thin (tickers, genes, file paths).
165
  FUSION_ALPHA = {
166
+ "wikipedia": 0.3, "openalex": 0.5,
167
  "biomed": 0.8, "news": 0.5, "finance": 0.8,
168
  }
core/source.py CHANGED
@@ -9,9 +9,9 @@ Two adapter modes:
9
  live — neighbors/backlinks fetched from an upstream API per call
10
  (Wikipedia's links/linkshere, OpenAlex's refs/cited-by).
11
  ingested — the graph was written into core.store by an ingest run;
12
- the adapter answers from the store (biomed, news, finance,
13
- github after crawl). StoreBackedSource below is the shared
14
- implementation for this mode.
15
  """
16
 
17
  from __future__ import annotations
@@ -70,6 +70,18 @@ class GraphSource(abc.ABC):
70
  with every candidate."""
71
  return [await self.node_info(n) for n in ns]
72
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  # ── niceties (optional) ──────────────────────────────────────────────
74
  async def edge_display(self, src: NodeRef, dst: NodeRef) -> Optional[str]:
75
  """Human-facing rendering of an edge (Wikipedia: the piped link
 
9
  live — neighbors/backlinks fetched from an upstream API per call
10
  (Wikipedia's links/linkshere, OpenAlex's refs/cited-by).
11
  ingested — the graph was written into core.store by an ingest run;
12
+ the adapter answers from the store (biomed, news, finance).
13
+ StoreBackedSource below is the shared implementation for
14
+ this mode.
15
  """
16
 
17
  from __future__ import annotations
 
70
  with every candidate."""
71
  return [await self.node_info(n) for n in ns]
72
 
73
+ # ── recommendations ──────────────────────────────────────────────────
74
+ async def suggest(self, query: str, limit: int = 8) -> list[dict]:
75
+ """Type-ahead suggestions for the search box, drawn from THIS
76
+ source's own vocabulary — the fix for the old Wikipedia-only
77
+ autocomplete. Each item is
78
+ {id, title, kind?, subtitle?}
79
+ where `kind` groups results (company/paper/disease/…) and
80
+ `subtitle` is a short human hint. Default: no suggestions (a live
81
+ source with nothing cheap to offer simply returns []); adapters
82
+ override with a domain-appropriate lookup."""
83
+ return []
84
+
85
  # ── niceties (optional) ──────────────────────────────────────────────
86
  async def edge_display(self, src: NodeRef, dst: NodeRef) -> Optional[str]:
87
  """Human-facing rendering of an edge (Wikipedia: the piped link
core/store.py CHANGED
@@ -14,7 +14,7 @@ Embeddings are stored as float32 BLOBs. Two vector columns per node:
14
  struct_emb — node2vec over the stored edge list (representation.py)
15
 
16
  StoreBackedSource at the bottom is the shared GraphSource implementation
17
- for every ingested-mode adapter (biomed, news, finance, github): subclass,
18
  set name/description/edge_types, done.
19
  """
20
 
@@ -158,6 +158,32 @@ class GraphStore:
158
  (source, f"%{q}%", limit)).fetchall()
159
  return [{"id": r[0], "title": r[1]} for r in rows]
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def neighbors(self, source: str, id: str) -> list[tuple[str, str, float]]:
162
  """→ [(dst_id, type, weight)]"""
163
  with self._lock:
@@ -358,6 +384,33 @@ class StoreBackedSource(GraphSource):
358
  async def node_infos(self, ns: list[NodeRef]) -> list[NodeInfo]:
359
  return [await self.node_info(n) for n in ns]
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  # ── edge evidence ────────────────────────────────────────────────────
362
  def format_edge(self, typ: str, weight: float) -> str:
363
  """Human phrase for a typed edge. Adapters override for domain
 
14
  struct_emb — node2vec over the stored edge list (representation.py)
15
 
16
  StoreBackedSource at the bottom is the shared GraphSource implementation
17
+ for every ingested-mode adapter (biomed, news, finance): subclass,
18
  set name/description/edge_types, done.
19
  """
20
 
 
158
  (source, f"%{q}%", limit)).fetchall()
159
  return [{"id": r[0], "title": r[1]} for r in rows]
160
 
161
+ def suggest_titles(self, source: str, query: str,
162
+ limit: int = 8) -> list[dict]:
163
+ """Type-ahead lookup: prefix matches (on title or id) rank ahead of
164
+ mid-string matches, shortest title first. Carries features so the
165
+ UI can label each hit by kind. The ingested-mode counterpart to
166
+ Wikipedia's opensearch."""
167
+ q = query.strip()
168
+ if not q:
169
+ return []
170
+ sub = f"%{q}%"
171
+ prefix = f"{q}%"
172
+ with self._lock:
173
+ rows = self._conn.execute(
174
+ """SELECT id, title, features FROM nodes
175
+ WHERE source=? AND (title LIKE ? COLLATE NOCASE
176
+ OR id LIKE ? COLLATE NOCASE)
177
+ ORDER BY
178
+ CASE WHEN title LIKE ? COLLATE NOCASE THEN 0
179
+ WHEN id LIKE ? COLLATE NOCASE THEN 1
180
+ ELSE 2 END,
181
+ LENGTH(title)
182
+ LIMIT ?""",
183
+ (source, sub, sub, prefix, prefix, limit)).fetchall()
184
+ return [{"id": r[0], "title": r[1],
185
+ "features": json.loads(r[2] or "{}")} for r in rows]
186
+
187
  def neighbors(self, source: str, id: str) -> list[tuple[str, str, float]]:
188
  """→ [(dst_id, type, weight)]"""
189
  with self._lock:
 
384
  async def node_infos(self, ns: list[NodeRef]) -> list[NodeInfo]:
385
  return [await self.node_info(n) for n in ns]
386
 
387
+ # ── recommendations ──────────────────────────────────────────────────
388
+ async def suggest(self, query: str, limit: int = 8) -> list[dict]:
389
+ if not self.ingested():
390
+ return []
391
+ out: list[dict] = []
392
+ for r in self.store.suggest_titles(self.name, query, limit):
393
+ feats = r.get("features") or {}
394
+ out.append({
395
+ "id": r["id"], "title": r["title"],
396
+ "kind": feats.get("kind"),
397
+ "subtitle": self.suggest_subtitle(r["id"], r["title"], feats),
398
+ })
399
+ return out
400
+
401
+ def suggest_subtitle(self, node_id: str, title: str,
402
+ features: dict) -> Optional[str]:
403
+ """Short hint shown under a suggestion. Default: the node kind
404
+ (and the id when it differs from the title, e.g. a ticker).
405
+ Adapters override for richer hints."""
406
+ kind = features.get("kind")
407
+ label = kind.replace("_", " ") if kind else None
408
+ show_id = (node_id and node_id != title and len(node_id) <= 8
409
+ and node_id.lower() not in title.lower())
410
+ if show_id:
411
+ return f"{node_id} · {label}" if label else node_id
412
+ return label
413
+
414
  # ── edge evidence ────────────────────────────────────────────────────
415
  def format_edge(self, typ: str, weight: float) -> str:
416
  """Human phrase for a typed edge. Adapters override for domain
core/types.py CHANGED
@@ -15,9 +15,9 @@ from dataclasses import dataclass, field
15
  class NodeRef:
16
  """A node in some source graph.
17
 
18
- source : adapter name ("wikipedia", "openalex", "github", ...)
19
  id : stable unique id within that source (Wikipedia: the canonical
20
- title; OpenAlex: the work id "W2100837269"; GitHub: "owner/repo";
21
  Hetionet: "Gene::5468"; finance: the ticker).
22
  title : human-readable label for display and text embedding.
23
  """
 
15
  class NodeRef:
16
  """A node in some source graph.
17
 
18
+ source : adapter name ("wikipedia", "openalex", "finance", ...)
19
  id : stable unique id within that source (Wikipedia: the canonical
20
+ title; OpenAlex: the work id "W2100837269";
21
  Hetionet: "Gene::5468"; finance: the ticker).
22
  title : human-readable label for display and text embedding.
23
  """
graph2d.js ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Aurelius — 2D graph explorer (HTML canvas + d3-force).
2
+ *
3
+ * Replaces the old WebGL/three.js renderer. A canvas draws hundreds of
4
+ * nodes smoothly; d3-force lays them out; d3-zoom drives pan/zoom. The
5
+ * design goals, borrowed from Obsidian / Kumu / Observable graph views:
6
+ *
7
+ * · minimalist ink-on-dark palette — the found path is the only thing
8
+ * that shouts; the exploration cloud recedes to faint context
9
+ * · curved edges (quadratic beziers) so parallel links don't overlap
10
+ * · gentle kind-based clustering (typed sources group by what nodes ARE)
11
+ * · progressive expansion — click a node to pull its real neighbours in
12
+ * · focus mode — click a node to spotlight its neighbourhood, dim the rest
13
+ * · auto-fit that follows the graph as it grows and backs off the instant
14
+ * the user grabs the camera (resume with GV.fit())
15
+ * · label level-of-detail — text only on important / hovered nodes
16
+ *
17
+ * It runs the simulation directly over the SAME node objects app.js keeps
18
+ * in its `nodes` map (positions live on those objects), so the WebSocket
19
+ * flow is unchanged. One global, GV, matching the old renderer's surface:
20
+ * GV.available() always true (canvas is universal)
21
+ * GV.init(el, opts) build the renderer; opts.onExpand(id)
22
+ * GV.sync(nodes, edges, ctx) reconcile with current state
23
+ * GV.focus(id) ease the camera to a node
24
+ * GV.fit() frame everything + resume auto-follow
25
+ * GV.reset() clear the graph
26
+ */
27
+
28
+ (function () {
29
+ 'use strict';
30
+
31
+ const PALETTE = {
32
+ centre: '#e2a45c', target: '#4aab79', start: '#e4b254',
33
+ open: '#c99a4e', closed: '#5f5a4a', gated: '#7a3b3b',
34
+ pathLive: '#e0864a', pathFound: '#5cd6a0',
35
+ dim: 'rgba(150,140,110,0.28)',
36
+ ring: '#0c0b08',
37
+ link: 'rgba(201,158,90,0.22)',
38
+ linkDim: 'rgba(140,124,84,0.09)',
39
+ linkPath: 'rgba(224,134,74,0.9)',
40
+ linkPathFound: 'rgba(92,214,160,0.95)',
41
+ label: '#c8bd98', labelDim: 'rgba(150,140,110,0.5)',
42
+ labelKey: '#efe9d6',
43
+ };
44
+
45
+ // Node kind → colour: typed graphs (finance/biology/news) cluster and
46
+ // colour by what a node IS, not just its search state.
47
+ const KIND_COLORS = {
48
+ company: '#c98a2e', etf: '#8b7cf7', sector: '#4aab79',
49
+ executive: '#e0995c', country: '#5b8dd6', macro: '#d65b8d',
50
+ person: '#e0995c', organization: '#8b7cf7', place: '#5b8dd6',
51
+ gene: '#4aab79', protein: '#8b7cf7', disease: '#d65b8d',
52
+ drug: '#c98a2e', pathway: '#5b8dd6',
53
+ article: '#8a86a8', paper: '#8a86a8', entity: '#c98a2e',
54
+ };
55
+
56
+ let canvas = null, g = null, container = null;
57
+ let sim = null, onExpand = null;
58
+ let dpr = 1, width = 0, height = 0;
59
+ let zoomBehavior = null, transform = null; // d3.zoomIdentity at init
60
+ let nodesArr = [], linksArr = [];
61
+ const nodeById = new Map();
62
+ let lastCtx = {};
63
+ let hoverNode = null, hoverEdge = null, focusId = null;
64
+ let userNav = false, lastCount = -1, fitCooldown = 0;
65
+ let raf = 0, fitTarget = null; // desired transform we ease toward
66
+
67
+ // ── helpers ──────────────────────────────────────────────────────────
68
+ // Display name: prefer a human title (papers carry an opaque id like
69
+ // "W2100837269"); Wikipedia/finance nodes fall back to the id, which IS
70
+ // their title.
71
+ const nameOf = (n) => n.title || n.id;
72
+ const found = (c) => !!(c && c.foundPath && c.foundPath.length);
73
+ const onPath = (id, c) => !!(c && c.pathNodes && c.pathNodes.has(id));
74
+ const isKey = (n, c) => n.state === 'centre' || n.state === 'target' ||
75
+ n.state === 'start' || onPath(n.id, c);
76
+
77
+ function nodeColor(n, c) {
78
+ if (n.state === 'centre') return PALETTE.centre;
79
+ if (n.state === 'target') return PALETTE.target;
80
+ if (n.state === 'start') return PALETTE.start;
81
+ if (onPath(n.id, c)) return found(c) ? PALETTE.pathFound : PALETTE.pathLive;
82
+ if ((n.state === 'open') && (n.kind || n.expanded)) {
83
+ return KIND_COLORS[n.kind] || PALETTE.open;
84
+ }
85
+ if (found(c)) return PALETTE.dim;
86
+ if (n.state === 'closed') return PALETTE.closed;
87
+ if (n.state === 'gated') return PALETTE.gated;
88
+ return PALETTE.open;
89
+ }
90
+
91
+ function nodeRadius(n, c) {
92
+ if (n.state === 'centre') return 11;
93
+ if (n.state === 'target' || n.state === 'start') return 9.5;
94
+ if (onPath(n.id, c)) return 7.5;
95
+ if (n.kind || n.expanded) return 5.5;
96
+ return 4.2;
97
+ }
98
+
99
+ function labelWorthy(n, c) {
100
+ return isKey(n, c) || n === hoverNode ||
101
+ (focusId && (n.id === focusId || isNeighborOfFocus(n.id)));
102
+ }
103
+
104
+ function passesFilter(n, c) {
105
+ const f = (c && c.filter) || 'all';
106
+ if (isKey(n, c)) return true;
107
+ if (f === 'all') return true;
108
+ if (f === 'path') return false;
109
+ if (f === 'explored') return n.state !== 'open';
110
+ return true;
111
+ }
112
+
113
+ // ── focus mode neighbourhood ───────────────────���─────────────────────
114
+ let focusNeighbors = new Set();
115
+ function recomputeFocusNeighbors() {
116
+ focusNeighbors = new Set();
117
+ if (!focusId) return;
118
+ for (const l of linksArr) {
119
+ const s = l.source.id || l.source, t = l.target.id || l.target;
120
+ if (s === focusId) focusNeighbors.add(t);
121
+ else if (t === focusId) focusNeighbors.add(s);
122
+ }
123
+ }
124
+ const isNeighborOfFocus = (id) => focusNeighbors.has(id);
125
+ function dimmedByFocus(id) {
126
+ return focusId && id !== focusId && !focusNeighbors.has(id);
127
+ }
128
+
129
+ // ── clustering anchors (typed sources group by kind) ─────────────────
130
+ const kindAnchors = new Map();
131
+ function anchorFor(kind) {
132
+ if (!kind) return null;
133
+ if (!kindAnchors.has(kind)) {
134
+ // deterministic angle from the kind string so a kind keeps its side
135
+ let h = 0;
136
+ for (let i = 0; i < kind.length; i++) h = (h * 31 + kind.charCodeAt(i)) | 0;
137
+ const ang = (Math.abs(h) % 360) * Math.PI / 180;
138
+ kindAnchors.set(kind, { x: Math.cos(ang), y: Math.sin(ang) });
139
+ }
140
+ return kindAnchors.get(kind);
141
+ }
142
+
143
+ // ── canvas plumbing ──────────────────────────────────────────────────
144
+ function resize() {
145
+ if (!container || !canvas) return;
146
+ width = container.clientWidth || 800;
147
+ height = container.clientHeight || 600;
148
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
149
+ canvas.width = Math.round(width * dpr);
150
+ canvas.height = Math.round(height * dpr);
151
+ canvas.style.width = width + 'px';
152
+ canvas.style.height = height + 'px';
153
+ draw();
154
+ }
155
+
156
+ function kick() {
157
+ // Single rAF loop drives everything: it eases the camera toward
158
+ // `fitTarget` with a cheap lerp (NO d3 transitions — those stacked up
159
+ // and pegged the main thread), re-frames on a throttle while the layout
160
+ // is warm, and self-stops once the sim is cool AND the camera arrived.
161
+ if (raf) return;
162
+ const step = () => {
163
+ // periodically recompute the follow target while warm (unless the
164
+ // user grabbed the camera)
165
+ if (!userNav && sim && sim.alpha() > 0.02 && Date.now() >= fitCooldown) {
166
+ fitCooldown = Date.now() + 450;
167
+ fitTarget = computeFitTarget();
168
+ }
169
+ let easing = false;
170
+ if (fitTarget) {
171
+ const cur = transform;
172
+ const k = cur.k + (fitTarget.k - cur.k) * 0.16;
173
+ const x = cur.x + (fitTarget.x - cur.x) * 0.16;
174
+ const y = cur.y + (fitTarget.y - cur.y) * 0.16;
175
+ if (Math.abs(k - fitTarget.k) < 1e-3 &&
176
+ Math.abs(x - fitTarget.x) < 0.5 && Math.abs(y - fitTarget.y) < 0.5) {
177
+ applyTransform(fitTarget); fitTarget = null;
178
+ } else {
179
+ applyTransform(d3.zoomIdentity.translate(x, y).scale(k));
180
+ easing = true;
181
+ }
182
+ }
183
+ draw();
184
+ const warm = sim && sim.alpha() > 0.004;
185
+ if (warm || easing) raf = requestAnimationFrame(step);
186
+ else raf = 0;
187
+ };
188
+ raf = requestAnimationFrame(step);
189
+ }
190
+
191
+ // set the zoom transform instantly, keeping d3-zoom's internal state in
192
+ // sync so the next user gesture doesn't jump. Programmatic (no sourceEvent)
193
+ // so the zoom handler won't flag it as user navigation.
194
+ function applyTransform(t) {
195
+ transform = t;
196
+ if (zoomBehavior) d3.select(canvas).property('__zoom', t);
197
+ }
198
+
199
+ // ── draw ─────────────────────────────────────────────────────────────
200
+ function draw() {
201
+ if (!g) return;
202
+ g.save();
203
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
204
+ g.clearRect(0, 0, width, height);
205
+ const t = transform;
206
+ g.translate(t.x, t.y);
207
+ g.scale(t.k, t.k);
208
+
209
+ const c = lastCtx;
210
+ const isFound = found(c);
211
+ const now = performance.now();
212
+
213
+ // edges first
214
+ g.lineCap = 'round';
215
+ for (const l of linksArr) {
216
+ const a = l.source, b = l.target;
217
+ if (a.x == null || b.x == null) continue;
218
+ const path = !!l.isPath;
219
+ const dim = dimmedByFocus(a.id) && dimmedByFocus(b.id);
220
+ let col;
221
+ if (path) col = (isFound ? PALETTE.linkPathFound : PALETTE.linkPath);
222
+ else col = (isFound || dim) ? PALETTE.linkDim : PALETTE.link;
223
+ g.strokeStyle = col;
224
+ g.lineWidth = (path ? 2.4 : 1) / t.k;
225
+ // curved edge: perpendicular offset at the midpoint
226
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
227
+ const dx = b.x - a.x, dy = b.y - a.y;
228
+ const curve = path ? 0 : 0.12;
229
+ const cx = mx - dy * curve, cy = my + dx * curve;
230
+ g.beginPath();
231
+ g.moveTo(a.x, a.y);
232
+ g.quadraticCurveTo(cx, cy, b.x, b.y);
233
+ g.stroke();
234
+ }
235
+
236
+ // nodes
237
+ for (const n of nodesArr) {
238
+ if (n.x == null) continue;
239
+ const r = nodeRadius(n, c);
240
+ const appear = n.__appear ? Math.min(1, (now - n.__appear) / 380) : 1;
241
+ const rr = r * (0.4 + 0.6 * appear);
242
+ let col = nodeColor(n, c);
243
+ let alpha = 1;
244
+ if (dimmedByFocus(n.id)) alpha = 0.22;
245
+ else if (isFound && !onPath(n.id, c) && !isKey(n, c)) alpha = 0.5;
246
+ g.globalAlpha = alpha * appear;
247
+ // subtle glow for key nodes
248
+ if (isKey(n, c) || n === hoverNode) {
249
+ g.shadowColor = col;
250
+ g.shadowBlur = 14 / Math.sqrt(t.k);
251
+ } else {
252
+ g.shadowBlur = 0;
253
+ }
254
+ g.beginPath();
255
+ g.arc(n.x, n.y, rr, 0, 2 * Math.PI);
256
+ g.fillStyle = col;
257
+ g.fill();
258
+ g.shadowBlur = 0;
259
+ // ring
260
+ g.lineWidth = 1.6 / t.k;
261
+ g.strokeStyle = PALETTE.ring;
262
+ g.stroke();
263
+ if (n === hoverNode) {
264
+ g.beginPath();
265
+ g.arc(n.x, n.y, rr + 3 / t.k, 0, 2 * Math.PI);
266
+ g.strokeStyle = 'rgba(240,232,210,0.7)';
267
+ g.lineWidth = 1.4 / t.k;
268
+ g.stroke();
269
+ }
270
+ }
271
+ g.globalAlpha = 1;
272
+
273
+ // labels (LOD) — only key / hovered / focused nodes, and only when
274
+ // zoomed in enough to be readable
275
+ const showAll = t.k > 1.35;
276
+ g.font = `500 ${12 / t.k}px Outfit, system-ui, sans-serif`;
277
+ g.textAlign = 'center';
278
+ g.textBaseline = 'top';
279
+ for (const n of nodesArr) {
280
+ if (n.x == null) continue;
281
+ const key = isKey(n, c);
282
+ if (!key && !showAll && n !== hoverNode &&
283
+ !(focusId && (n.id === focusId || focusNeighbors.has(n.id)))) continue;
284
+ if (dimmedByFocus(n.id)) continue;
285
+ const r = nodeRadius(n, c);
286
+ const label = truncate(nameOf(n), key ? 30 : 22);
287
+ g.globalAlpha = key || n === hoverNode ? 1 : 0.75;
288
+ g.fillStyle = n.state === 'target' ? PALETTE.target
289
+ : (n.state === 'centre' || n.state === 'start') ? PALETTE.labelKey
290
+ : PALETTE.label;
291
+ // legibility: dark pill behind key labels
292
+ if (key || n === hoverNode) {
293
+ const w = g.measureText(label).width;
294
+ g.globalAlpha = 0.55;
295
+ g.fillStyle = 'rgba(8,7,5,0.72)';
296
+ roundRect(g, n.x - w / 2 - 5 / t.k, n.y + r + 3 / t.k,
297
+ w + 10 / t.k, 16 / t.k, 4 / t.k);
298
+ g.fill();
299
+ g.globalAlpha = 1;
300
+ g.fillStyle = n.state === 'target' ? PALETTE.target : PALETTE.labelKey;
301
+ }
302
+ g.fillText(label, n.x, n.y + r + 5 / t.k);
303
+ }
304
+ g.globalAlpha = 1;
305
+
306
+ // edge tooltip label on hover
307
+ if (hoverEdge && hoverEdge.display) {
308
+ const a = hoverEdge.source, b = hoverEdge.target;
309
+ if (a.x != null && b.x != null) {
310
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
311
+ g.font = `500 ${11 / t.k}px Outfit, system-ui, sans-serif`;
312
+ const w = g.measureText(hoverEdge.display).width;
313
+ g.fillStyle = 'rgba(8,7,5,0.85)';
314
+ roundRect(g, mx - w / 2 - 6 / t.k, my - 9 / t.k,
315
+ w + 12 / t.k, 18 / t.k, 4 / t.k);
316
+ g.fill();
317
+ g.fillStyle = '#e8dcbc';
318
+ g.textBaseline = 'middle';
319
+ g.fillText(hoverEdge.display, mx, my);
320
+ g.textBaseline = 'top';
321
+ }
322
+ }
323
+
324
+ g.restore();
325
+ }
326
+
327
+ function roundRect(ctx, x, y, w, h, r) {
328
+ ctx.beginPath();
329
+ ctx.moveTo(x + r, y);
330
+ ctx.arcTo(x + w, y, x + w, y + h, r);
331
+ ctx.arcTo(x + w, y + h, x, y + h, r);
332
+ ctx.arcTo(x, y + h, x, y, r);
333
+ ctx.arcTo(x, y, x + w, y, r);
334
+ ctx.closePath();
335
+ }
336
+
337
+ // ── hit testing ──────────────────────────────────────────────────────
338
+ function nodeAt(sx, sy) {
339
+ const [wx, wy] = transform.invert([sx, sy]);
340
+ let best = null, bestD = Infinity;
341
+ for (const n of nodesArr) {
342
+ if (n.x == null) continue;
343
+ const r = nodeRadius(n, lastCtx) + 4;
344
+ const d = (n.x - wx) ** 2 + (n.y - wy) ** 2;
345
+ if (d < r * r && d < bestD) { best = n; bestD = d; }
346
+ }
347
+ return best;
348
+ }
349
+
350
+ function edgeAt(sx, sy) {
351
+ const [wx, wy] = transform.invert([sx, sy]);
352
+ const tol = 6 / transform.k;
353
+ let best = null, bestD = tol * tol;
354
+ for (const l of linksArr) {
355
+ const a = l.source, b = l.target;
356
+ if (a.x == null || b.x == null || !l.display) continue;
357
+ const d = distToSeg(wx, wy, a.x, a.y, b.x, b.y);
358
+ if (d < bestD) { best = l; bestD = d; }
359
+ }
360
+ return best;
361
+ }
362
+
363
+ function distToSeg(px, py, x1, y1, x2, y2) {
364
+ const dx = x2 - x1, dy = y2 - y1;
365
+ const l2 = dx * dx + dy * dy || 1;
366
+ let t = ((px - x1) * dx + (py - y1) * dy) / l2;
367
+ t = Math.max(0, Math.min(1, t));
368
+ const cx = x1 + t * dx, cy = y1 + t * dy;
369
+ return (px - cx) ** 2 + (py - cy) ** 2;
370
+ }
371
+
372
+ // ── auto-fit ─────────────────────────────────────────────────────────
373
+ function bbox() {
374
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, n = 0;
375
+ for (const nd of nodesArr) {
376
+ if (nd.x == null) continue;
377
+ minX = Math.min(minX, nd.x); maxX = Math.max(maxX, nd.x);
378
+ minY = Math.min(minY, nd.y); maxY = Math.max(maxY, nd.y); n++;
379
+ }
380
+ if (!n) return null;
381
+ return { minX, minY, maxX, maxY };
382
+ }
383
+
384
+ // desired transform that frames the whole graph (does NOT apply it — the
385
+ // rAF loop eases `transform` toward it)
386
+ function computeFitTarget() {
387
+ const bb = bbox();
388
+ if (!bb) return null;
389
+ const pad = 90;
390
+ const w = (bb.maxX - bb.minX) || 1, h = (bb.maxY - bb.minY) || 1;
391
+ const k = Math.max(0.15, Math.min(1.6,
392
+ Math.min((width - pad) / w, (height - pad) / h)));
393
+ const cx = (bb.minX + bb.maxX) / 2, cy = (bb.minY + bb.maxY) / 2;
394
+ return d3.zoomIdentity.translate(width / 2 - k * cx, height / 2 - k * cy).scale(k);
395
+ }
396
+
397
+ function scheduleFit() {
398
+ if (userNav) return;
399
+ fitCooldown = Date.now() + 450;
400
+ fitTarget = computeFitTarget();
401
+ kick();
402
+ }
403
+
404
+ // ── the GV surface ───────────────────────────────────────────────────
405
+ const GV = {
406
+ available() { return typeof d3 !== 'undefined'; },
407
+
408
+ init(el, opts) {
409
+ if (!this.available()) return false;
410
+ container = el;
411
+ onExpand = (opts && opts.onExpand) || null;
412
+ transform = d3.zoomIdentity;
413
+
414
+ canvas = document.createElement('canvas');
415
+ canvas.style.display = 'block';
416
+ canvas.style.width = '100%';
417
+ canvas.style.height = '100%';
418
+ canvas.style.cursor = 'grab';
419
+ el.innerHTML = '';
420
+ el.appendChild(canvas);
421
+ g = canvas.getContext('2d');
422
+
423
+ zoomBehavior = d3.zoom()
424
+ .scaleExtent([0.08, 5])
425
+ .on('start', (e) => {
426
+ if (e.sourceEvent) { userNav = true; fitTarget = null; }
427
+ canvas.style.cursor = 'grabbing';
428
+ })
429
+ .on('zoom', (e) => {
430
+ // a genuine user gesture (wheel/drag) pauses auto-follow
431
+ if (e.sourceEvent) { userNav = true; fitTarget = null; }
432
+ transform = e.transform;
433
+ draw();
434
+ })
435
+ .on('end', () => { canvas.style.cursor = 'grab'; });
436
+ d3.select(canvas).call(zoomBehavior)
437
+ .on('dblclick.zoom', null);
438
+
439
+ // pointer interactions
440
+ canvas.addEventListener('mousemove', onMove);
441
+ canvas.addEventListener('mouseleave', () => {
442
+ hoverNode = null; hoverEdge = null; hideTip(); draw();
443
+ });
444
+ canvas.addEventListener('click', onClick);
445
+
446
+ sim = d3.forceSimulation(nodesArr)
447
+ .force('link', d3.forceLink(linksArr).id(d => d.id)
448
+ .distance(l => l.isPath ? 64 : 92).strength(0.3))
449
+ .force('charge', d3.forceManyBody().strength(-240).distanceMax(520))
450
+ .force('collide', d3.forceCollide(d => nodeRadius(d, lastCtx) + 10))
451
+ .force('cluster', clusterForce(0.05))
452
+ .force('x', d3.forceX(() => centerWorld().x).strength(0.02))
453
+ .force('y', d3.forceY(() => centerWorld().y).strength(0.02))
454
+ .alphaDecay(0.025)
455
+ .velocityDecay(0.4)
456
+ .on('tick', () => { kick(); })
457
+ .stop();
458
+
459
+ window.addEventListener('resize', resize);
460
+ resize();
461
+ window.__aureliusGraph = { GV, sim };
462
+ return true;
463
+ },
464
+
465
+ sync(nodesObj, edgesArr, c) {
466
+ if (!sim) return;
467
+ lastCtx = c || {};
468
+ const lens = lastCtx.lens || null;
469
+
470
+ const visible = Object.values(nodesObj).filter(n => passesFilter(n, lastCtx));
471
+ const visSet = new Set(visible.map(n => n.id));
472
+
473
+ // links: honour the relationship lens; path edges always survive
474
+ const connected = new Set();
475
+ linksArr = [];
476
+ for (const e of edgesArr) {
477
+ if (!visSet.has(e.from) || !visSet.has(e.to)) continue;
478
+ if (lens && !e.isPath && (!e.type || !lens.has(e.type))) continue;
479
+ linksArr.push({ source: e.from, target: e.to, isPath: !!e.isPath,
480
+ type: e.type, display: e.display });
481
+ connected.add(e.from); connected.add(e.to);
482
+ }
483
+
484
+ // nodes: a lens hides the nodes it orphans, except anchors
485
+ const now = performance.now();
486
+ nodesArr.length = 0;
487
+ nodeById.clear();
488
+ for (const n of visible) {
489
+ if (lens && !isKey(n, lastCtx) && !connected.has(n.id)) continue;
490
+ if (n.__appear == null) n.__appear = now; // fade-in stamp
491
+ nodesArr.push(n);
492
+ nodeById.set(n.id, n);
493
+ }
494
+
495
+ sim.nodes(nodesArr);
496
+ sim.force('link').links(linksArr);
497
+ recomputeFocusNeighbors();
498
+
499
+ // reheat so new nodes settle; harder when the graph changed size
500
+ const changed = nodesArr.length !== lastCount;
501
+ sim.alpha(changed ? 0.6 : 0.3).restart();
502
+ kick();
503
+ if (changed) { lastCount = nodesArr.length; scheduleFit(); }
504
+ },
505
+
506
+ focus(id) {
507
+ const n = nodeById.get(id);
508
+ if (!n || n.x == null) return;
509
+ userNav = true; // taking the camera; auto-follow yields
510
+ const k = Math.max(transform.k, 1.1);
511
+ fitTarget = d3.zoomIdentity
512
+ .translate(width / 2 - k * n.x, height / 2 - k * n.y).scale(k);
513
+ kick();
514
+ },
515
+
516
+ fit() {
517
+ userNav = false;
518
+ focusId = null;
519
+ recomputeFocusNeighbors();
520
+ fitCooldown = 0;
521
+ fitTarget = computeFitTarget();
522
+ kick();
523
+ },
524
+
525
+ reset() {
526
+ userNav = false; lastCount = -1; focusId = null; hoverNode = null;
527
+ hoverEdge = null; focusNeighbors = new Set(); fitTarget = null;
528
+ nodesArr.length = 0; linksArr = []; nodeById.clear();
529
+ if (sim) { sim.nodes([]); sim.force('link').links([]); sim.stop(); }
530
+ if (raf) { cancelAnimationFrame(raf); raf = 0; }
531
+ if (g) { g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, width, height); }
532
+ },
533
+
534
+ graph() { return { sim, nodesArr, linksArr }; },
535
+ };
536
+
537
+ // gentle pull of each node toward its kind's anchor direction
538
+ function clusterForce(strength) {
539
+ let nodes = [];
540
+ function force(alpha) {
541
+ const cw = centerWorld();
542
+ const spread = Math.min(width, height) * 0.32;
543
+ for (const n of nodes) {
544
+ const a = anchorFor(n.kind);
545
+ if (!a || onPath(n.id, lastCtx) || n.state === 'centre') continue;
546
+ n.vx += (cw.x + a.x * spread - n.x) * strength * alpha;
547
+ n.vy += (cw.y + a.y * spread - n.y) * strength * alpha;
548
+ }
549
+ }
550
+ force.initialize = (n) => { nodes = n; };
551
+ return force;
552
+ }
553
+
554
+ // world-space centre = screen centre un-projected (keeps layout stable
555
+ // under zoom/pan instead of snapping to a fixed origin)
556
+ function centerWorld() {
557
+ if (!transform) return { x: width / 2, y: height / 2 };
558
+ const [x, y] = transform.invert([width / 2, height / 2]);
559
+ return { x, y };
560
+ }
561
+
562
+ // ── pointer handlers ─────────────────────────────────────────────────
563
+ function onMove(e) {
564
+ const rect = canvas.getBoundingClientRect();
565
+ const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
566
+ const n = nodeAt(sx, sy);
567
+ const prevN = hoverNode, prevE = hoverEdge;
568
+ hoverNode = n;
569
+ hoverEdge = n ? null : edgeAt(sx, sy);
570
+ canvas.style.cursor = n ? 'pointer' : 'grab';
571
+ if (n) showNodeTip(n, e.clientX, e.clientY); else hideTip();
572
+ if (n !== prevN || hoverEdge !== prevE) draw();
573
+ }
574
+
575
+ function onClick(e) {
576
+ const rect = canvas.getBoundingClientRect();
577
+ const n = nodeAt(e.clientX - rect.left, e.clientY - rect.top);
578
+ if (!n) { // click empty space clears focus
579
+ if (focusId) { focusId = null; recomputeFocusNeighbors(); draw(); }
580
+ return;
581
+ }
582
+ // toggle focus spotlight on the node's neighbourhood
583
+ focusId = (focusId === n.id) ? null : n.id;
584
+ recomputeFocusNeighbors();
585
+ GV.focus(n.id);
586
+ draw();
587
+ if (onExpand && !lastCtx.running) onExpand(n.id);
588
+ }
589
+
590
+ // reuse the app's existing tooltip element
591
+ function showNodeTip(n, px, py) {
592
+ const tip = document.getElementById('tooltip');
593
+ if (!tip) return;
594
+ const title = document.getElementById('tt-title');
595
+ const scores = document.getElementById('tt-scores');
596
+ if (title) title.textContent = nameOf(n);
597
+ if (scores) {
598
+ const rows = [];
599
+ if (n.kind) rows.push(cap(n.kind));
600
+ if (n.g != null) rows.push(`g = ${n.g} · hops from start`);
601
+ if (n.h != null) rows.push(`h = ${n.h} · heuristic`);
602
+ if (n.f != null) rows.push(`f = ${n.f} · total`);
603
+ if (onExpand && !lastCtx.running) rows.push('click to expand');
604
+ scores.innerHTML = rows.join('<br>');
605
+ }
606
+ tip.style.left = (px + 14) + 'px';
607
+ tip.style.top = (py - 32) + 'px';
608
+ tip.classList.add('visible');
609
+ }
610
+ function hideTip() {
611
+ const tip = document.getElementById('tooltip');
612
+ if (tip) tip.classList.remove('visible');
613
+ }
614
+
615
+ function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
616
+ function cap(s) { return String(s).replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()); }
617
+
618
+ window.GV = GV;
619
+ })();
graph3d.js DELETED
@@ -1,403 +0,0 @@
1
- /* Aurelius — 3D graph explorer (WebGL, three.js via 3d-force-graph).
2
- *
3
- * v2 redesign: from "giant static ball" to an exploration tool.
4
- * · smaller nodes/links with more inter-node air (weak charge, longer
5
- * link distance, small relSize)
6
- * · inertial orbit controls (damping) — zoom/pan/rotate glide
7
- * · auto-fit follows the graph while a search grows, but backs off the
8
- * moment the user grabs the camera (and resumes via GV.fit())
9
- * · label level-of-detail: floating text only on structurally important
10
- * nodes (endpoints, centre, path); everything else is a small sphere
11
- * with a hover tooltip
12
- * · view filters (all / explored / path) applied at sync time, so the
13
- * exploration cloud can be collapsed after a path is found
14
- * · post-found dimming: once a path exists, non-path nodes fade back
15
- * · click-to-expand: clicking a node asks the host app to fetch its
16
- * neighbors (/api/neighbors) and animate them in — progressive
17
- * expansion instead of one monolithic render
18
- * · edge tooltips carry the relationship evidence ("supplies",
19
- * "co-moves (corr 0.72)") when the source provides it
20
- *
21
- * It reads the same `nodes` (object keyed by id) and `edges` (array of
22
- * {from,to,isPath,type,display}) state the app.js message handlers keep,
23
- * so the WebSocket flow never changed. Exposes one global, GV:
24
- * GV.init(el, opts) build the scene; opts.onExpand(id) is the
25
- * click-to-expand callback
26
- * GV.sync(nodes, edges, ctx) reconcile with current state
27
- * (ctx: pathNodes, foundPath, centreNode,
28
- * filter, running)
29
- * GV.focus(id) fly the camera to a node
30
- * GV.fit() frame everything + resume auto-follow
31
- * GV.reset() clear the graph
32
- * GV.available() true if the 3D libs loaded
33
- *
34
- * Degrades gracefully: if the CDN libs are unavailable, available() is
35
- * false and app.js keeps the 2D SVG renderer.
36
- */
37
-
38
- (function () {
39
- // Minimal palette: one warm accent for the path, everything else recedes
40
- // into low-alpha neutrals so the found route is the only thing that
41
- // shouts. Cloud nodes/links are deliberately faint context, not content.
42
- const PALETTE = {
43
- centre: '#e2a45c', target: '#4aab79', start: '#e4b254',
44
- closed: 'rgba(150,138,104,0.45)', open: 'rgba(201,158,90,0.55)',
45
- pathLive: '#e0864a', pathFound: '#5cd6a0', gated: 'rgba(75,32,32,0.5)',
46
- dimNode: 'rgba(110,100,74,0.22)',
47
- link: 'rgba(201,158,90,0.18)',
48
- linkDim: 'rgba(120,104,64,0.07)',
49
- linkPath: 'rgba(224,134,74,0.85)',
50
- linkPathFound: 'rgba(92,214,160,0.9)',
51
- };
52
-
53
- // Node-kind palette — visual clustering for typed graphs: expanded
54
- // finance/news nodes are colored by what they ARE, not just their
55
- // search state.
56
- const KIND_COLORS = {
57
- company: '#c98a2e', etf: '#8b7cf7', sector: '#4aab79',
58
- executive: '#e0995c', country: '#5b8dd6', macro: '#d65b8d',
59
- person: '#e0995c', organization: '#8b7cf7', place: '#5b8dd6',
60
- article: '#8a86a8', entity: '#c98a2e',
61
- };
62
-
63
- let Graph = null;
64
- let container = null;
65
- let SpriteText = (typeof window !== 'undefined') ? window.SpriteText : null;
66
- let lastFocus = 0;
67
- let onExpand = null;
68
- let userNav = false; // user grabbed the camera → stop auto-fitting
69
- let fitTimer = null;
70
- let lastCount = 0;
71
-
72
- function found(ctx) { return !!(ctx.foundPath && ctx.foundPath.length); }
73
- function onPath(n, ctx) { return ctx.pathNodes && ctx.pathNodes.has(n.id); }
74
-
75
- function colorFor(n, ctx) {
76
- if (n.state === 'centre') return PALETTE.centre;
77
- if (n.state === 'target') return PALETTE.target;
78
- if (n.state === 'start') return PALETTE.start;
79
- if (onPath(n, ctx)) return found(ctx) ? PALETTE.pathFound : PALETTE.pathLive;
80
- // progressively-expanded nodes stay bright (colored by kind when the
81
- // source types its nodes) even after the search cloud dims
82
- if (n.state === 'open' && (n.kind || n.expanded)) {
83
- return KIND_COLORS[n.kind] || PALETTE.open;
84
- }
85
- // once the path is found, the exploration cloud recedes
86
- if (found(ctx)) return PALETTE.dimNode;
87
- if (n.state === 'closed') return PALETTE.closed;
88
- if (n.state === 'gated') return PALETTE.gated;
89
- return PALETTE.open;
90
- }
91
-
92
- function sizeFor(n, ctx) {
93
- if (n.state === 'centre') return 3.2;
94
- if (n.state === 'target' || n.state === 'start') return 2.8;
95
- if (onPath(n, ctx)) return 2.2;
96
- return 0.7;
97
- }
98
-
99
- function labelWorthy(n, ctx) {
100
- return n.state === 'centre' || n.state === 'target' ||
101
- n.state === 'start' || onPath(n, ctx);
102
- }
103
-
104
- function passesFilter(n, ctx) {
105
- const f = ctx.filter || 'all';
106
- if (f === 'all') return true;
107
- if (labelWorthy(n, ctx)) return true; // key nodes always visible
108
- if (f === 'path') return false;
109
- if (f === 'explored') return n.state !== 'open'; // hide the candidate cloud
110
- return true;
111
- }
112
-
113
- // Continuous auto-framing: while the force layout is moving (nodes
114
- // streaming in, simulation settling), keep re-framing the camera on a
115
- // throttle so the whole graph stays centered and in frame — it zooms out
116
- // automatically as the graph grows and re-centers as it drifts. The
117
- // moment the user grabs the camera, tracking stops (GV.fit() resumes it).
118
- //
119
- // Framing is computed manually from graphData() positions and applied
120
- // via cameraPosition(): the library's own zoomToFit/getGraphBbox is
121
- // unreliable here (it reported a ±3-unit bbox for a ±120-unit layout and
122
- // never moved the camera — the original "tiny graph lost in the corner"
123
- // bug). cameraPosition() also plays well with damped orbit controls
124
- // because it updates the controls' target.
125
- let lastFitAt = 0;
126
- const FIT_EVERY_MS = 900;
127
-
128
- // Own camera animation: a persistent damper loop that exponentially
129
- // eases the camera toward the current goal with instant ms=0
130
- // cameraPosition steps. The library's animated moves (zoomToFit /
131
- // cameraPosition with a duration) depend on its rAF render loop, which
132
- // browsers throttle away in background tabs — and its getGraphBbox is
133
- // also plain wrong here (reported a ±3-unit bbox for a ±120-unit
134
- // layout), which was the original "tiny graph lost in the corner" bug.
135
- // A setInterval damper is immune to rAF throttling; in hidden tabs it
136
- // snaps instead of gliding, which is what you want anyway.
137
- let camGoal = null; // { pos: {x,y,z}, look: {x,y,z} }
138
- let camDamper = null;
139
-
140
- function animateCameraTo(pos, lookAt) {
141
- camGoal = { pos, look: lookAt };
142
- }
143
-
144
- function startCamDamper() {
145
- if (camDamper) return;
146
- camDamper = setInterval(() => {
147
- if (!Graph || !camGoal) return;
148
- const cur = Graph.cameraPosition();
149
- const ctl = Graph.controls();
150
- const curLook = (ctl && ctl.target)
151
- ? { x: ctl.target.x, y: ctl.target.y, z: ctl.target.z }
152
- : { x: 0, y: 0, z: 0 };
153
- const k = document.hidden ? 1 : 0.16; // glide when visible, snap when not
154
- const g = camGoal;
155
- const nx = cur.x + (g.pos.x - cur.x) * k;
156
- const ny = cur.y + (g.pos.y - cur.y) * k;
157
- const nz = cur.z + (g.pos.z - cur.z) * k;
158
- const lx = curLook.x + (g.look.x - curLook.x) * k;
159
- const ly = curLook.y + (g.look.y - curLook.y) * k;
160
- const lz = curLook.z + (g.look.z - curLook.z) * k;
161
- Graph.cameraPosition({ x: nx, y: ny, z: nz }, { x: lx, y: ly, z: lz }, 0);
162
- if (Math.hypot(g.pos.x - nx, g.pos.y - ny, g.pos.z - nz) < 1 &&
163
- Math.hypot(g.look.x - lx, g.look.y - ly, g.look.z - lz) < 1) {
164
- camGoal = null; // arrived
165
- }
166
- }, 50);
167
- }
168
-
169
- function frameGraph(ms) {
170
- if (!Graph) return;
171
- const nodes = Graph.graphData().nodes.filter(n => n.x != null);
172
- if (nodes.length < 1) return;
173
- let cx = 0, cy = 0, cz = 0;
174
- nodes.forEach(n => { cx += n.x; cy += n.y; cz += n.z; });
175
- cx /= nodes.length; cy /= nodes.length; cz /= nodes.length;
176
- let r = 0;
177
- nodes.forEach(n => {
178
- r = Math.max(r, Math.hypot(n.x - cx, n.y - cy, n.z - cz));
179
- });
180
- const cam = Graph.camera();
181
- const fov = ((cam && cam.fov) || 50) * Math.PI / 180;
182
- // distance that fits radius r in the vertical fov, +18% breathing room,
183
- // clamped so tiny graphs don't fill the screen with two giant spheres
184
- const dist = Math.max(120, (r + 14) / Math.tan(fov / 2) * 1.18);
185
- const cur = Graph.cameraPosition();
186
- let dx = cur.x - cx, dy = cur.y - cy, dz = cur.z - cz;
187
- const len = Math.hypot(dx, dy, dz) || 1;
188
- dx /= len; dy /= len; dz /= len;
189
- animateCameraTo(
190
- { x: cx + dx * dist, y: cy + dy * dist, z: cz + dz * dist },
191
- { x: cx, y: cy, z: cz });
192
- }
193
-
194
- function tickFit() {
195
- if (userNav || !Graph) return;
196
- const now = Date.now();
197
- if (now - lastFitAt < FIT_EVERY_MS) return;
198
- lastFitAt = now;
199
- frameGraph(650);
200
- }
201
-
202
- function scheduleFit() {
203
- if (userNav || !Graph) return;
204
- clearTimeout(fitTimer);
205
- fitTimer = setTimeout(() => {
206
- if (!userNav && Graph) frameGraph(700);
207
- }, 450);
208
- }
209
-
210
- const GV = {
211
- available() { return typeof window.ForceGraph3D === 'function'; },
212
-
213
- init(el, opts) {
214
- if (!this.available()) return false;
215
- container = el;
216
- onExpand = (opts && opts.onExpand) || null;
217
-
218
- Graph = window.ForceGraph3D({ controlType: 'orbit' })(el)
219
- .backgroundColor('rgba(0,0,0,0)') // CSS cosmic backdrop shows through
220
- .showNavInfo(false)
221
- .nodeRelSize(2.0)
222
- .nodeVal(n => n.__size || 1.5)
223
- .nodeColor(n => n.__color || PALETTE.open)
224
- .nodeOpacity(0.9)
225
- .nodeResolution(32) // smooth spheres, no visible faceting
226
- .linkColor(l => l.__color || PALETTE.link)
227
- .linkWidth(l => l.__pathFound ? 1.1 : (l.isPath ? 0.9 : 0.14))
228
- .linkOpacity(0.45)
229
- .linkDirectionalParticles(l => l.isPath ? 1 : 0)
230
- .linkDirectionalParticleWidth(0.8)
231
- .linkDirectionalParticleSpeed(0.008)
232
- .linkLabel(l => l.display
233
- ? `<div class="gv-tip">${escapeHtml(srcId(l))} → ${escapeHtml(dstId(l))}`
234
- + `<span>${escapeHtml(l.display)}</span></div>` : '')
235
- .warmupTicks(24)
236
- .cooldownTime(8000)
237
- .d3VelocityDecay(0.32)
238
- // Camera tracks the layout: refit on a throttle while the engine
239
- // runs, plus one final settle-frame when it stops.
240
- .onEngineTick(tickFit)
241
- .onEngineStop(() => { if (!userNav) frameGraph(800); })
242
- .onNodeClick(n => {
243
- this.focus(n.id);
244
- if (onExpand) onExpand(n.id);
245
- })
246
- .nodeLabel(n => `<div class="gv-tip">${escapeHtml(n.id)}${n.kind ? `<span>${escapeHtml(n.kind)}</span>` : ''}${n.f != null ? `<span>f=${n.f}</span>` : ''}${n.__expandable ? '<span>click to expand</span>' : ''}</div>`);
247
-
248
- // Floating text sprites for the important nodes only (label LOD).
249
- if (SpriteText) {
250
- Graph.nodeThreeObjectExtend(true).nodeThreeObject(n => {
251
- if (!n.__label) return null;
252
- const s = new SpriteText(truncate(n.id, 24));
253
- s.color = n.__labelColor || '#d8d0b8';
254
- s.textHeight = n.state === 'centre' ? 2.4 : 2.1;
255
- s.fontFace = 'Outfit, sans-serif';
256
- s.backgroundColor = 'rgba(10,9,6,0.35)';
257
- s.padding = 0.9;
258
- s.borderRadius = 1.5;
259
- s.position.y = (n.__size || 1.5) + 3.4;
260
- return s;
261
- });
262
- }
263
-
264
- // Space the layout: small nodes with air between them instead of one
265
- // dense ball. A collision force sized to each node's radius stops
266
- // spheres from overlapping; longer links + bounded repulsion keep the
267
- // footprint stable as the graph grows.
268
- const charge = Graph.d3Force('charge');
269
- if (charge) charge.strength(-80).distanceMax(300);
270
- const linkF = Graph.d3Force('link');
271
- if (linkF) linkF.distance(l => (l.isPath ? 38 : 52));
272
- // NOTE: do NOT add window.d3.forceCollide here — that's the 2D D3
273
- // build (loaded for the SVG fallback); its collide only handles x/y
274
- // and biases/breaks the 3D simulation. Short-range charge repulsion
275
- // is what keeps the small spheres from overlapping.
276
-
277
- // Inertial camera: zoom/rotate/pan glide instead of snapping, and
278
- // any manual grab pauses the auto-follow until GV.fit().
279
- const controls = Graph.controls();
280
- if (controls) {
281
- controls.enableDamping = true;
282
- controls.dampingFactor = 0.08;
283
- controls.rotateSpeed = 0.75;
284
- controls.zoomSpeed = 0.9;
285
- controls.panSpeed = 0.8;
286
- if (controls.addEventListener) {
287
- controls.addEventListener('start', () => {
288
- userNav = true;
289
- camGoal = null; // user grabbed the camera — stop steering it
290
- });
291
- }
292
- }
293
-
294
- window.addEventListener('resize', () => {
295
- if (Graph && container) {
296
- Graph.width(container.clientWidth).height(container.clientHeight);
297
- }
298
- });
299
- Graph.width(el.clientWidth).height(el.clientHeight);
300
- startCamDamper();
301
- window.__aureliusGraph = Graph; // debug/inspection handle
302
- return true;
303
- },
304
-
305
- graph() { return Graph; },
306
-
307
- sync(nodesObj, edgesArr, ctx) {
308
- if (!Graph) return;
309
- ctx = ctx || {};
310
- const gd = Graph.graphData();
311
- const existing = new Map(gd.nodes.map(n => [n.id, n]));
312
- const lens = ctx.lens || null; // Set of edge types, or null = all
313
-
314
- const candidates = Object.values(nodesObj).filter(n => passesFilter(n, ctx));
315
- const candSet = new Set(candidates.map(n => n.id));
316
-
317
- // Links first: the relationship lens decides which edges survive
318
- // (path edges always do), and therefore which nodes stay connected.
319
- const links = [];
320
- const connected = new Set();
321
- edgesArr.forEach(e => {
322
- if (!candSet.has(e.from) || !candSet.has(e.to)) return;
323
- if (lens && !e.isPath && (!e.type || !lens.has(e.type))) return;
324
- const pathFound = e.isPath && found(ctx);
325
- links.push({
326
- source: e.from, target: e.to, isPath: e.isPath,
327
- type: e.type, display: e.display,
328
- __pathFound: pathFound,
329
- __color: e.isPath
330
- ? (pathFound ? PALETTE.linkPathFound : PALETTE.linkPath)
331
- : (found(ctx) ? PALETTE.linkDim : PALETTE.link),
332
- });
333
- connected.add(e.from); connected.add(e.to);
334
- });
335
-
336
- const nodes = [];
337
- for (const src of candidates) {
338
- // A lens hides nodes it orphans — except the key ones (endpoints,
339
- // path) that anchor the view.
340
- if (lens && !labelWorthy(src, ctx) && !connected.has(src.id)) continue;
341
- const n = existing.get(src.id) || { id: src.id };
342
- n.state = src.state; n.g = src.g; n.h = src.h; n.f = src.f;
343
- n.kind = src.kind || null; n.expanded = !!src.expanded;
344
- n.__color = colorFor(src, ctx);
345
- n.__size = sizeFor(src, ctx);
346
- n.__label = labelWorthy(src, ctx);
347
- n.__expandable = !!onExpand && !ctx.running;
348
- n.__labelColor = src.state === 'target' ? PALETTE.target :
349
- (src.state === 'centre' ? '#ede8d4' : '#b8ad8a');
350
- nodes.push(n);
351
- }
352
-
353
- Graph.graphData({ nodes, links });
354
- if (nodes.length !== lastCount) {
355
- lastCount = nodes.length;
356
- scheduleFit();
357
- }
358
- },
359
-
360
- focus(id) {
361
- if (!Graph) return;
362
- const now = Date.now();
363
- if (now - lastFocus < 300) return; // debounce rapid expands
364
- lastFocus = now;
365
- const n = Graph.graphData().nodes.find(x => x.id === id);
366
- if (!n || n.x == null) return;
367
- userNav = true; // focusing a node = taking the camera;
368
- // the auto-tracker yields until Fit view
369
- const dist = 70;
370
- const r = Math.hypot(n.x, n.y, n.z) || 1;
371
- const k = 1 + dist / r;
372
- animateCameraTo(
373
- { x: n.x * k, y: n.y * k, z: n.z * k },
374
- { x: n.x, y: n.y, z: n.z });
375
- },
376
-
377
- fit() {
378
- if (!Graph) return;
379
- userNav = false; // resume auto-follow
380
- lastFitAt = Date.now();
381
- frameGraph(700);
382
- },
383
-
384
- reset() {
385
- userNav = false;
386
- lastCount = 0;
387
- camGoal = null;
388
- clearTimeout(fitTimer);
389
- if (Graph) Graph.graphData({ nodes: [], links: [] });
390
- },
391
- };
392
-
393
- function srcId(l) { return typeof l.source === 'object' ? l.source.id : l.source; }
394
- function dstId(l) { return typeof l.target === 'object' ? l.target.id : l.target; }
395
- function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
396
- function escapeHtml(s) {
397
- return String(s).replace(/[&<>"']/g, c => ({
398
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
399
- }[c]));
400
- }
401
-
402
- window.GV = GV;
403
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
htmlbackup.txt DELETED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -7,14 +7,8 @@
7
  <title>Aurelius</title>
8
  <link rel="icon" type="image/png"
9
  href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAH0AfQDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBAgBAwUCCf/EAEsQAAIBAwIEAwQGBwQHBwUBAAABAgMEBQYRByExQRJRYQgTcYEUIjKRobEVI0JSksHTM1Vi8SRjcnWisvAXNEZTgsLRFjVDs9Lh/8QAGwEBAAIDAQEAAAAAAAAAAAAAAAEDAgQFBgf/xAAuEQEAAgIBAgMHBQEAAwAAAAAAAQIDEQQFMRMhQQYSUXGBobEyM1Jh0SIVkfD/2gAMAwEAAhEDEQA/ANMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALS4Q8HsrrSMMpkZzx2E35Vdv1tfb/AMtNbbf4ny9GVZs1MNJvknUQuwYMnIvGPHG5lVoN49L8N9FadtoUrHT1jUqRW30i5pRq1ZPz8Uk9vlsj2rrT+BvKLo3eExtxSa2cKtrCa2+aOHb2j48W1WszD0FPZfkzXc2iJaBA2q4i8BNPZehWu9MJYjIbNxpbv6NN+TXN…[truncated base64 icon]">
 
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
11
- <!-- Immersive 3D graph (WebGL via three.js). A standalone THREE UMD is
12
- loaded first so three-spritetext (floating node labels) can find its
13
- peer; 3d-force-graph bundles its own three for rendering. graph3d.js
14
- degrades to the 2D D3 renderer if any of these fail to load. -->
15
- <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
16
- <script src="https://cdn.jsdelivr.net/npm/three-spritetext@1.8.2/dist/three-spritetext.min.js"></script>
17
- <script src="https://cdn.jsdelivr.net/npm/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
18
  <link rel="preconnect" href="https://fonts.googleapis.com">
19
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
20
  <link
@@ -87,8 +81,9 @@
87
  <div id="hero-title" style="font-family: Cinzel">Aurelius</div>
88
  <div id="hero-tagline" class="hero-reveal">Navigate the Knowledge Graph</div>
89
  <div id="hero-rest" class="hero-reveal">
90
- <div id="hero-sub">Find the hidden path between any two things in a giant network of ideas.<br>Watch Aurelius
91
- navigate the graph Wikipedia, research papers, code, biology, news, or markets — in real time.</div>
 
92
 
93
  <div id="hero-source-row">
94
  <span class="hero-source-label">Explore</span>
@@ -201,11 +196,9 @@
201
  <!-- Graph -->
202
  <div id="canvas-wrap">
203
  <div id="cosmos"></div>
204
- <div id="graph3d"></div>
205
- <svg id="graph"></svg>
206
 
207
- <!-- 3D explorer controls: view filters + camera fit. Hidden when the
208
- 2D SVG fallback renderer is active (app.js initSVG). -->
209
  <div id="graph-controls">
210
  <button class="gc-btn active" data-filter="all" onclick="setGraphFilter('all')"
211
  title="Show every node the search touched">All</button>
@@ -301,6 +294,77 @@
301
  <circle cx="11" cy="11" r="7"/><line x1="16" y1="16" x2="21" y2="21"/><path d="M11 8v6M8 11h6"/>
302
  </svg>
303
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  </div>
305
 
306
  <!-- Mobile-only: toggles the panel below open/closed. Lives OUTSIDE
@@ -464,8 +528,8 @@
464
 
465
  <p class="about-prose">Named after the Stoic emperor who wrote that every idea connects to the next. Aurelius
466
  makes those connections visible in any network of knowledge: pick two things, and it walks the real links
467
- between them, ranking every step by meaning. It started on Wikipedia; today the same engine navigates
468
- research-paper citations, code repositories, biomedical networks, live news coverage, and financial markets,
469
  and can surface connections that no direct link records at all.</p>
470
 
471
  <hr class="about-divider" />
@@ -568,7 +632,7 @@
568
  <div class="about-compare-cell accent">Aims at the goal from both ends</div>
569
 
570
  <div class="about-compare-cell">Built for one dataset</div>
571
- <div class="about-compare-cell accent">Same engine on Wikipedia, papers, code, biology, news &amp; markets</div>
572
 
573
  <div class="about-compare-cell">Only finds what's directly linked</div>
574
  <div class="about-compare-cell accent">Discovers hidden connections with no direct link at all</div>
@@ -613,37 +677,38 @@
613
  <div class="onboarding-step">
614
  <div class="onboarding-icon">1</div>
615
  <div>
616
- <strong>Pick a universe, then two things in it</strong>
617
- <p>Wikipedia, research papers, GitHub, finance, or live news; choose a source, type a From and a
618
- To (or hit Random).</p>
619
  </div>
620
  </div>
621
  <div class="onboarding-step">
622
  <div class="onboarding-icon">2</div>
623
  <div>
624
- <strong>Watch it navigate in real time</strong>
625
- <p>Aurelius walks real connections toward your target from both ends, ranking every step by
 
626
  meaning. It never invents a link.</p>
627
  </div>
628
  </div>
629
  <div class="onboarding-step">
630
  <div class="onboarding-icon">3</div>
631
  <div>
632
- <strong>Then explore</strong>
633
- <p>Click any node to expand its real neighbours, filter the view down to the path, and open
634
- Discover to surface connections with no direct link at all.</p>
635
  </div>
636
  </div>
637
  </div>
638
  <p class="onboarding-tip">
639
- Tip: try the Finance source with two companies from different industries; the route between them is
640
- real supply chains, ownership and market co-movement.
641
  </p>
642
  <button class="modal-close-btn onboarding-go-btn" onclick="closeOnboarding()">Let's Go</button>
643
  </div>
644
  </div>
645
 
646
- <script src="graph3d.js"></script>
647
  <script src="app.js"></script>
648
  </body>
649
 
 
7
  <title>Aurelius</title>
8
  <link rel="icon" type="image/png"
9
  href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAH0AfQDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBAgBAwUCCf/EAEsQAAIBAwIEAwQGBwQHBwUBAAABAgMEBQYRByExQRJRYQgTcYEUIjKRobEVI0JSksHTM1Vi8SRjcnWisvAXNEZTgsLRFjVDs9Lh/8QAGwEBAAIDAQEAAAAAAAAAAAAAAAEDAgQFBgf/xAAuEQEAAgIBAgMHBQEAAwAAAAAAAQIDEQQFMRMhQQYSUXGBobEyM1Jh0SIVkfD/2gAMAwEAAhEDEQA/ANMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALS4Q8HsrrSMMpkZzx2E35Vdv1tfb/AMtNbbf4ny9GVZs1MNJvknUQuwYMnIvGPHG5lVoN49L8N9FadtoUrHT1jUqRW30i5pRq1ZPz8Uk9vlsj2rrT+BvKLo3eExtxSa2cKtrCa2+aOHb2j48W1WszD0FPZfkzXc2iJaBA2q4i8BNPZehWu9MJYjIbNxpbv6NN+TXN…[truncated base64 icon]">
10
+ <!-- d3 powers the 2D canvas graph (force layout + zoom) in graph2d.js -->
11
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
 
 
 
 
 
 
 
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14
  <link
 
81
  <div id="hero-title" style="font-family: Cinzel">Aurelius</div>
82
  <div id="hero-tagline" class="hero-reveal">Navigate the Knowledge Graph</div>
83
  <div id="hero-rest" class="hero-reveal">
84
+ <div id="hero-sub">A research engine for connected knowledge. Explore a company, a paper's citations,
85
+ or the hidden path between any two ideas<br>across Wikipedia, research papers, finance, and biology
86
+ — every connection backed by real evidence.</div>
87
 
88
  <div id="hero-source-row">
89
  <span class="hero-source-label">Explore</span>
 
196
  <!-- Graph -->
197
  <div id="canvas-wrap">
198
  <div id="cosmos"></div>
199
+ <div id="graph-canvas"></div>
 
200
 
201
+ <!-- Explorer controls: view filters + camera fit. -->
 
202
  <div id="graph-controls">
203
  <button class="gc-btn active" data-filter="all" onclick="setGraphFilter('all')"
204
  title="Show every node the search touched">All</button>
 
294
  <circle cx="11" cy="11" r="7"/><line x1="16" y1="16" x2="21" y2="21"/><path d="M11 8v6M8 11h6"/>
295
  </svg>
296
  </button>
297
+
298
+ <!-- Company Profile panel — the primary finance surface. A dossier
299
+ for one company: price, key facts, peers, supply chain,
300
+ correlations and ownership, with the graph as a secondary
301
+ explorer. Shown only on the finance source. -->
302
+ <div id="profile-panel" aria-hidden="true">
303
+ <div class="prof-head">
304
+ <div class="prof-input-row">
305
+ <svg class="prof-search-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="15" y2="15"/></svg>
306
+ <input id="profile-input" type="text" placeholder="Research a company — ticker or name" autocomplete="off"
307
+ onkeydown="if(event.key==='Enter')openProfile(this.value)" />
308
+ <button id="profile-run" onclick="openProfile(document.getElementById('profile-input').value)">Go</button>
309
+ </div>
310
+ <button class="prof-close" onclick="toggleProfile(false)" aria-label="Close">
311
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>
312
+ </button>
313
+ </div>
314
+ <div id="profile-body">
315
+ <div class="prof-empty">
316
+ <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
317
+ <path d="M6 40V22l8-4v22M18 40V14l8-4v30M30 40V20l8-4v24M4 40h40"/>
318
+ </svg>
319
+ <p>Search any company to see its price, peers, supply chain, correlations, ownership and the news moving it — all in one place.</p>
320
+ <div class="prof-empty-chips" id="profile-empty-chips"></div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ <button id="profile-fab" onclick="toggleProfile(true)" title="Research a company">
325
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
326
+ <path d="M4 20V11l5-3v12M13 20V6l5-3v17M3 20h18"/>
327
+ </svg>
328
+ <span>Research</span>
329
+ </button>
330
+
331
+ <!-- Citation Explorer panel — the primary research-papers surface.
332
+ A paper's dossier: metadata, the works it cites and the works
333
+ citing it, with a "build citation graph" hand-off. Shown only on
334
+ the research (OpenAlex) source. -->
335
+ <div id="paper-panel" aria-hidden="true">
336
+ <div class="prof-head">
337
+ <div class="prof-input-row">
338
+ <svg class="prof-search-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="15" y2="15"/></svg>
339
+ <input id="paper-input" type="text" placeholder="Paper title, DOI, or arXiv id" autocomplete="off"
340
+ onkeydown="if(event.key==='Enter')openPaper(this.value)" />
341
+ <button id="paper-run" onclick="openPaper(document.getElementById('paper-input').value)">Go</button>
342
+ </div>
343
+ <button class="prof-close" onclick="togglePaper(false)" aria-label="Close">
344
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>
345
+ </button>
346
+ </div>
347
+ <div id="paper-body">
348
+ <div class="prof-empty">
349
+ <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
350
+ <path d="M14 6h16l6 6v30H14z"/><path d="M30 6v6h6"/><line x1="19" y1="22" x2="31" y2="22"/><line x1="19" y1="28" x2="31" y2="28"/><line x1="19" y1="34" x2="26" y2="34"/>
351
+ </svg>
352
+ <p>Give it a paper — by title, DOI, or arXiv id — to see what it cites, what cites it, and build an interactive citation graph you can expand.</p>
353
+ <div class="prof-empty-chips" id="paper-empty-chips"></div>
354
+ <label class="paper-drop" id="paper-drop">
355
+ <input type="file" accept="application/pdf" onchange="uploadPaperPdf(this.files)" hidden />
356
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M12 4l-4 4M12 4l4 4"/><path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3"/></svg>
357
+ <span>or drop / choose a PDF</span>
358
+ </label>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ <button id="paper-fab" onclick="togglePaper(true)" title="Explore a paper's citations">
363
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
364
+ <path d="M6 3h9l4 4v14H6z"/><path d="M15 3v4h4"/><line x1="9" y1="12" x2="16" y2="12"/><line x1="9" y1="16" x2="14" y2="16"/>
365
+ </svg>
366
+ <span>Citations</span>
367
+ </button>
368
  </div>
369
 
370
  <!-- Mobile-only: toggles the panel below open/closed. Lives OUTSIDE
 
528
 
529
  <p class="about-prose">Named after the Stoic emperor who wrote that every idea connects to the next. Aurelius
530
  makes those connections visible in any network of knowledge: pick two things, and it walks the real links
531
+ between them, ranking every step by meaning. It started on Wikipedia; today the same engine powers a
532
+ company research workspace, a paper citation explorer, biomedical networks and live news coverage,
533
  and can surface connections that no direct link records at all.</p>
534
 
535
  <hr class="about-divider" />
 
632
  <div class="about-compare-cell accent">Aims at the goal from both ends</div>
633
 
634
  <div class="about-compare-cell">Built for one dataset</div>
635
+ <div class="about-compare-cell accent">Same engine on Wikipedia, papers, finance, biology &amp; news</div>
636
 
637
  <div class="about-compare-cell">Only finds what's directly linked</div>
638
  <div class="about-compare-cell accent">Discovers hidden connections with no direct link at all</div>
 
677
  <div class="onboarding-step">
678
  <div class="onboarding-icon">1</div>
679
  <div>
680
+ <strong>Pick a world to research</strong>
681
+ <p>Wikipedia, research papers, finance, or biology. Each has its own workspace a company
682
+ profile, a paper's citations — plus the pathfinder that ties any two things together.</p>
683
  </div>
684
  </div>
685
  <div class="onboarding-step">
686
  <div class="onboarding-icon">2</div>
687
  <div>
688
+ <strong>Dive into one thing, or connect two</strong>
689
+ <p>Research a company (price, peers, supply chain, news) or a paper's citation tree; or type
690
+ a From and a To and watch Aurelius walk the real links between them, ranking every step by
691
  meaning. It never invents a link.</p>
692
  </div>
693
  </div>
694
  <div class="onboarding-step">
695
  <div class="onboarding-icon">3</div>
696
  <div>
697
+ <strong>Explore the graph</strong>
698
+ <p>Click any node to expand its real neighbours, focus on its neighbourhood, filter the view,
699
+ and open Discover to surface connections with no direct link at all.</p>
700
  </div>
701
  </div>
702
  </div>
703
  <p class="onboarding-tip">
704
+ Tip: in Finance, hit <strong>Research a company</strong> and search a ticker like NVDA you'll see
705
+ its peers, suppliers, correlations and news before you ever touch the graph.
706
  </p>
707
  <button class="modal-close-btn onboarding-go-btn" onclick="closeOnboarding()">Let's Go</button>
708
  </div>
709
  </div>
710
 
711
+ <script src="graph2d.js"></script>
712
  <script src="app.js"></script>
713
  </body>
714
 
ingest/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """Aurelius ingestion — batch pipelines that fill core.store for
2
- ingested-mode adapters (biomed, news, finance, and optionally github).
3
 
4
  Each domain module exposes `ingest(...)` that writes nodes + edges into
5
  the store. The shared `embed_and_fuse(source_name)` step (pipeline.py)
 
1
  """Aurelius ingestion — batch pipelines that fill core.store for
2
+ ingested-mode adapters (biomed, news, finance).
3
 
4
  Each domain module exposes `ingest(...)` that writes nodes + edges into
5
  the store. The shared `embed_and_fuse(source_name)` step (pipeline.py)
ingest/cli.py CHANGED
@@ -24,7 +24,7 @@ from ingest.pipeline import embed_and_fuse
24
  async def _run(args):
25
  if args.source == "status":
26
  store = get_store()
27
- for name in ("wikipedia", "openalex", "github",
28
  "biomed", "news", "finance"):
29
  n, e = store.node_count(name), store.edge_count(name)
30
  state = "ingested" if n else "live/empty"
 
24
  async def _run(args):
25
  if args.source == "status":
26
  store = get_store()
27
+ for name in ("wikipedia", "openalex",
28
  "biomed", "news", "finance"):
29
  n, e = store.node_count(name), store.edge_count(name)
30
  state = "ingested" if n else "live/empty"
mainpybackup.txt DELETED
@@ -1,799 +0,0 @@
1
- """
2
- Aurelius — Wikipedia Rabbit-Hole Navigator Backend
3
- Architecture: embeddings + graph search only. No LLM, no external services.
4
-
5
- Per-expansion pipeline:
6
- 1. Fetch ALL links from article (paginated, up to MAX_FETCH)
7
- 2. Batch-embed ALL uncached links in one in-process sentence-transformers call
8
- 3. Score each candidate: cosine-to-target + frontier bonus + category bonus
9
- 4. Prune to top PRUNE_TOP_K, dropping anything below the raw-cosine floor
10
- 5. Push survivors to heap (greedy best-first: heap key = h only)
11
-
12
- The only external dependency at runtime is the Wikipedia API itself. The
13
- embedding model (sentence-transformers, all-MiniLM-L6-v2) is loaded once,
14
- in-process, at startup — no network calls, no LLM, no failure mode where
15
- the search degrades silently.
16
- """
17
-
18
- import asyncio
19
- import heapq
20
- import json
21
- import math
22
- import random
23
- import re
24
- import sys
25
- import time
26
- from collections import defaultdict
27
- from pathlib import Path
28
- from typing import Optional
29
- from urllib.parse import unquote
30
-
31
- import httpx
32
- import numpy as np
33
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
34
- from fastapi.middleware.cors import CORSMiddleware
35
- from sentence_transformers import SentenceTransformer
36
-
37
- # Windows redirects stdout to cp1252 when it's not an interactive console
38
- # (e.g. piped to a log file), which raises on the arrow/emoji characters
39
- # used in log lines below and would otherwise crash mid-search.
40
- try:
41
- sys.stdout.reconfigure(encoding="utf-8")
42
- sys.stderr.reconfigure(encoding="utf-8")
43
- except Exception:
44
- pass
45
-
46
- app = FastAPI()
47
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
48
-
49
- # ── Embedding model: loaded once at startup, in-process ─────────────────────
50
- EMBED_MODEL_NAME = "all-MiniLM-L6-v2"
51
- _EMBED_MODEL: Optional[SentenceTransformer] = None
52
-
53
- @app.on_event("startup")
54
- async def _startup_embed_model():
55
- global _EMBED_MODEL
56
- print(f"[Embed] Loading sentence-transformers model '{EMBED_MODEL_NAME}'...")
57
- _EMBED_MODEL = SentenceTransformer(EMBED_MODEL_NAME)
58
- print(f"[Embed] Ready (dim={_EMBED_MODEL.get_embedding_dimension()})")
59
-
60
- WIKI_API = "https://en.wikipedia.org/w/api.php"
61
-
62
- MAX_FETCH = 500 # max links fetched from Wikipedia (paginated)
63
- MAX_PAGES = 6 # safety cap on pagination pages when hunting for a specific goal link
64
- MAX_HOPS = 60 # search iteration limit
65
- TOP_DISPLAY = 25 # max neighbours sent to frontend per step
66
- FRONTIER_DEPTH_2_K = 16 # how many depth-1 target neighbours to expand for depth-2 frontier
67
-
68
- EMBED_BATCH_SIZE = 128 # in-process model batch size (no HTTP token limits to worry about)
69
-
70
- PRUNE_TOP_K = 15 # keep this many candidates per expansion after scoring
71
- PRUNE_FLOOR = 0.15 # hard floor on raw cosine-to-target; frontier members exempt
72
-
73
- FRONTIER_D1_BONUS = 0.30 # candidate is a direct link of the target
74
- FRONTIER_D2_BONUS = 0.10 # candidate is 2 hops from the target
75
- CAT_BONUS_WEIGHT = 0.10 # max bonus for category-embedding alignment
76
-
77
- _THIS_DIR = Path(__file__).resolve().parent
78
- SEED_FILE = _THIS_DIR / "common_wiki_searches.txt"
79
-
80
- WIKI_HEADERS = {
81
- "User-Agent": "Aurelius-WikiNavigator/6.0 (educational; contact@example.com)",
82
- "Accept": "application/json",
83
- }
84
-
85
- def make_wiki_client() -> httpx.AsyncClient:
86
- return httpx.AsyncClient(headers=WIKI_HEADERS, follow_redirects=True,
87
- timeout=httpx.Timeout(25.0))
88
-
89
-
90
- # ══════════════════════════════════════════════════════════════
91
- # SEARCH STATS
92
- # ══════════════════════════════════════════════════════════════
93
-
94
- class SearchStats:
95
- def __init__(self):
96
- self.t0 = time.time()
97
- self.nodes_visited = 0
98
- self.nodes_pruned = 0
99
- self.embed_calls = 0
100
-
101
- def elapsed(self) -> float:
102
- return round(time.time() - self.t0, 1)
103
-
104
- def to_dict(self) -> dict:
105
- return {
106
- "nodes_visited": self.nodes_visited,
107
- "nodes_pruned": self.nodes_pruned,
108
- "embed_calls": self.embed_calls,
109
- "elapsed_s": self.elapsed(),
110
- }
111
-
112
-
113
- # ══════════════════════════════════════════════════════════════
114
- # SESSION CACHES (reset each search)
115
- # ══════════════════════════════════════════════════════════════
116
-
117
- _emb_cache: dict[str, np.ndarray] = {} # title → embedding vector
118
- _dead_ends: set[str] = set() # articles with 0 links
119
-
120
- def reset_session_caches():
121
- global _emb_cache, _dead_ends
122
- _emb_cache = {}
123
- _dead_ends = set()
124
-
125
-
126
- # ══════════════════════════════════════════════════════════════
127
- # VECTOR MATH
128
- # ══════════════════════════════════════════════════════════════
129
-
130
- def cosine_similarity(a, b) -> float:
131
- if a is None or b is None:
132
- return 0.0
133
- a = np.asarray(a, dtype=np.float32)
134
- b = np.asarray(b, dtype=np.float32)
135
- if a.size == 0 or b.size == 0:
136
- return 0.0
137
- na = np.linalg.norm(a)
138
- nb = np.linalg.norm(b)
139
- if na == 0 or nb == 0:
140
- return 0.0
141
- return float(np.dot(a, b) / (na * nb))
142
-
143
-
144
- # ══════════════════════════════════════════════════════════════
145
- # EMBEDDING (in-process sentence-transformers, no network calls)
146
- # ══════════════════════════════════════════════════════════════
147
-
148
- async def batch_embed(texts: list[str], stats: SearchStats) -> list[np.ndarray]:
149
- """
150
- Embed texts via the in-process sentence-transformers model.
151
- Results are cached per title across the session. Runs the (CPU-bound,
152
- synchronous) encode call in a thread executor so it never blocks the
153
- event loop — keeps pause/resume and other WS traffic responsive.
154
- """
155
- if not texts or _EMBED_MODEL is None:
156
- return [np.array([]) for _ in texts]
157
-
158
- uncached_idx = [i for i, t in enumerate(texts) if t not in _emb_cache]
159
- uncached_text = [texts[i] for i in uncached_idx]
160
- if uncached_text:
161
- loop = asyncio.get_event_loop()
162
- embs = await loop.run_in_executor(
163
- None,
164
- lambda: _EMBED_MODEL.encode(
165
- uncached_text, convert_to_numpy=True,
166
- batch_size=EMBED_BATCH_SIZE, show_progress_bar=False,
167
- ),
168
- )
169
- stats.embed_calls += 1
170
- for orig_i, emb in zip(uncached_idx, embs):
171
- _emb_cache[texts[orig_i]] = emb
172
-
173
- return [_emb_cache.get(t, np.array([])) for t in texts]
174
-
175
-
176
- async def wiki_get(client: httpx.AsyncClient, params: dict, retries: int = 3) -> dict:
177
- for attempt in range(retries + 1):
178
- try:
179
- r = await client.get(WIKI_API, params=params)
180
- if r.status_code == 429:
181
- await asyncio.sleep(min(2 ** attempt, 8)); continue
182
- if r.status_code != 200:
183
- return {}
184
- return r.json()
185
- except httpx.TimeoutException:
186
- if attempt < retries: await asyncio.sleep(1)
187
- except Exception as e:
188
- print(f"[wiki] {e}"); return {}
189
- return {}
190
-
191
- def extract_title_from_url(query: str) -> Optional[str]:
192
- """If query is a Wikipedia URL, extract and decode the article title; else None."""
193
- m = re.search(r'wikipedia\.org/wiki/([^#?\s]+)', query.strip())
194
- if not m:
195
- return None
196
- return unquote(m.group(1)).replace('_', ' ').strip()
197
-
198
- async def resolve_article_title(client: httpx.AsyncClient, query: str) -> Optional[str]:
199
- query = query.strip()
200
- url_t = extract_title_from_url(query)
201
- if url_t: query = url_t
202
-
203
- data = await wiki_get(client, {"action":"query","titles":query,
204
- "redirects":1,"format":"json","utf8":1})
205
- for pid, page in data.get("query",{}).get("pages",{}).items():
206
- if pid != "-1":
207
- print(f"[resolve] Exact ✓ '{page['title']}'")
208
- return page["title"]
209
-
210
- data = await wiki_get(client, {"action":"opensearch","search":query,
211
- "limit":10,"namespace":0,"format":"json","utf8":1})
212
- titles = data[1] if isinstance(data, list) and len(data) > 1 else []
213
- if titles:
214
- for t in titles:
215
- if t.lower() == query.lower(): return t
216
- return titles[0]
217
-
218
- data = await wiki_get(client, {"action":"query","list":"search","srsearch":query,
219
- "srlimit":5,"srprop":"title","format":"json","utf8":1})
220
- results = data.get("query",{}).get("search",[])
221
- if results:
222
- return results[0]["title"]
223
- return None
224
-
225
- _DATE_RE = re.compile(
226
- r'^\d{1,2} \w+$|^\w+ \d{1,2}$|^\d{4}$|^\d{4} in '
227
- r'|^\d{1,2}\w* century'
228
- r'|^(January|February|March|April|May|June|July|August|'
229
- r'September|October|November|December)$'
230
- r'|^\d{4}[-–]\d{2,4}$', re.IGNORECASE
231
- )
232
- def _is_date(t: str) -> bool:
233
- return bool(_DATE_RE.search(t))
234
-
235
- _JUNK_TITLES = frozenset({
236
- "Wayback Machine", "OCLC", "Digital object identifier", "CrossRef",
237
- "JSTOR", "PubMed", "PubMed Central", "Semantic Scholar", "ArXiv",
238
- "Bibcode", "CiteSeerX", "S2CID", "Zbl", "MR", "PMC",
239
- })
240
- def _is_junk(t: str) -> bool:
241
- """Bibliographic citation artifacts: dead ends with 1-3 links each."""
242
- return t.endswith("(identifier)") or t in _JUNK_TITLES
243
-
244
- async def get_all_links(client: httpx.AsyncClient, title: str,
245
- end_title: str = "",
246
- target_neighbours: set | None = None) -> list[str]:
247
- """
248
- Fetch ALL links via pagination (up to MAX_FETCH).
249
- Goal link is always included first if found.
250
- Target neighbours always get priority slots.
251
- Date/year stubs filtered unless list would be empty.
252
-
253
- BUGFIX: when hunting for end_title, the old exit condition
254
- (len(all_links) >= MAX_FETCH) stopped pagination after roughly the
255
- first alphabetical page of a large article, so a goal link sorting
256
- late in the alphabet (e.g. "Pakistan" on India's 1000+-link page)
257
- was never found even though it's a direct 1-hop link. We now keep
258
- paginating past MAX_FETCH while specifically searching for a goal,
259
- bounded by MAX_PAGES as a safety cap. Calls with no end_title (e.g.
260
- pre-seeding target neighbours) are unaffected — same stop condition
261
- as before.
262
- """
263
- all_links: list[str] = []
264
- date_links: list[str] = []
265
- goal_link: str | None = None
266
- end_lower = end_title.lower() if end_title else ""
267
- params = {
268
- "action": "query", "titles": title, "prop": "links",
269
- "pllimit": 500, "plnamespace": 0, "format": "json", "utf8": 1,
270
- }
271
- pages_fetched = 0
272
- while True:
273
- data = await wiki_get(client, params)
274
- pages_fetched += 1
275
- for page in data.get("query", {}).get("pages", {}).values():
276
- for link in page.get("links", []):
277
- t = link["title"]
278
- if end_lower and t.lower() == end_lower:
279
- goal_link = t; continue
280
- if _is_junk(t):
281
- continue
282
- if _is_date(t):
283
- date_links.append(t)
284
- else:
285
- all_links.append(t)
286
- cont = data.get("continue", {})
287
- if "plcontinue" not in cont:
288
- break
289
- if goal_link:
290
- break # found it — no need to keep paginating
291
- if end_lower:
292
- # Actively hunting a specific goal: keep going past MAX_FETCH,
293
- # bounded only by MAX_PAGES (safety cap on API calls).
294
- if pages_fetched >= MAX_PAGES:
295
- break
296
- else:
297
- # No specific goal (e.g. pre-seeding neighbours): original behavior.
298
- if len(all_links) + len(date_links) >= MAX_FETCH:
299
- break
300
- params["plcontinue"] = cont["plcontinue"]
301
-
302
- result: list[str] = []
303
- if goal_link:
304
- result.append(goal_link)
305
-
306
- tn = target_neighbours or set()
307
- bridges = [l for l in all_links if l in tn]
308
- rest = [l for l in all_links if l not in tn]
309
- result += bridges
310
- result += rest
311
- if len(result) < 5:
312
- result += date_links[:max(0, 5 - len(result))]
313
-
314
- return result[:MAX_FETCH]
315
-
316
- async def get_backlinks(client: httpx.AsyncClient, title: str,
317
- limit: int = MAX_FETCH) -> list[str]:
318
- """
319
- Fetch articles that link TO `title` (Wikipedia prop=linkshere) — the
320
- correct direction for a forward-search bridge set.
321
-
322
- get_all_links() returns what `title` points to. That is NOT useful as
323
- a "one hop from target" bridge set, because Wikipedia links are not
324
- reciprocal: target -> A does not imply A -> target. A backlink (B ->
325
- target) is a guaranteed bridge — if the forward search ever reaches B,
326
- expanding it is certain to find `target` in B's own outbound links.
327
- Using outbound links here was sending the search on real-but-useless
328
- detours (e.g. the "Star Wars" article links to "Star Destroyer", which
329
- in turn links to real-world "Capital ship"/"Destroyer" articles for
330
- etymology — those score high on the old outbound-based frontier but
331
- don't link back to anything Star Wars-related themselves).
332
- """
333
- all_links: list[str] = []
334
- date_links: list[str] = []
335
- params = {
336
- "action": "query", "titles": title, "prop": "linkshere",
337
- "lhlimit": 500, "lhnamespace": 0, "format": "json", "utf8": 1,
338
- }
339
- pages_fetched = 0
340
- while True:
341
- data = await wiki_get(client, params)
342
- pages_fetched += 1
343
- for page in data.get("query", {}).get("pages", {}).values():
344
- for link in page.get("linkshere", []):
345
- t = link["title"]
346
- if _is_junk(t):
347
- continue
348
- if _is_date(t):
349
- date_links.append(t)
350
- else:
351
- all_links.append(t)
352
- cont = data.get("continue", {})
353
- if "lhcontinue" not in cont:
354
- break
355
- if len(all_links) + len(date_links) >= limit or pages_fetched >= MAX_PAGES:
356
- break
357
- params["lhcontinue"] = cont["lhcontinue"]
358
- return (all_links + date_links)[:limit]
359
-
360
- async def get_article_summary(client: httpx.AsyncClient, title: str) -> str:
361
- data = await wiki_get(client, {"action":"query","titles":title,"prop":"extracts",
362
- "exintro":True,"explaintext":True,
363
- "exsentences":2,"format":"json","utf8":1})
364
- for page in data.get("query",{}).get("pages",{}).values():
365
- return page.get("extract","")[:220]
366
- return ""
367
-
368
- async def get_target_categories(client: httpx.AsyncClient, title: str) -> list[str]:
369
- """Fetch human-readable categories for the target article."""
370
- data = await wiki_get(client, {
371
- "action": "query", "titles": title, "prop": "categories",
372
- "cllimit": 20, "clshow": "!hidden", "format": "json", "utf8": 1,
373
- })
374
- cats = []
375
- for page in data.get("query", {}).get("pages", {}).values():
376
- for c in page.get("categories", []):
377
- # Strip "Category:" prefix
378
- name = re.sub(r"^Category:", "", c["title"]).strip()
379
- cats.append(name)
380
- return cats
381
-
382
-
383
- def _load_seed_titles() -> list[str]:
384
- """Read the curated topic list used by the Random button, one title per line."""
385
- if not SEED_FILE.exists():
386
- return []
387
- with open(SEED_FILE, "r", encoding="utf-8") as f:
388
- return [line.strip() for line in f if line.strip()]
389
-
390
-
391
- # ══════════════════════════════════════════════════════════════
392
- # SEARCH ENGINE — greedy best-first, embeddings + graph only
393
- # ══════════════════════════════════════════════════════════════
394
-
395
- class AStarNavigator:
396
- def __init__(self, start: str, end: str, ws: WebSocket):
397
- self.start = start
398
- self.end = end
399
- self.ws = ws
400
- self.stats = SearchStats()
401
- self._paused = False
402
-
403
- self.g_score: dict[str, float] = defaultdict(lambda: math.inf)
404
- self.came_from: dict[str, Optional[str]] = {}
405
- self.h_cache: dict[str, float] = {}
406
- self.open_heap: list[tuple] = []
407
- self.open_set: set[str] = set()
408
- self.closed_set:set[str] = set()
409
-
410
- self.cats_target: list[str] = []
411
- self.cat_embeddings: list[np.ndarray] = []
412
- self._target_neighbours: set[str] = set()
413
- self._frontier: dict[str, int] = {} # title → distance from target (1 or 2)
414
- self.target_embedding: Optional[np.ndarray] = None
415
- self._target_cosine: dict[str, float] = {} # raw cosine-to-target, used by prune floor
416
-
417
-
418
- async def send(self, event: str, data: dict):
419
- try:
420
- await self.ws.send_text(json.dumps({"event": event, **data}))
421
- except Exception:
422
- pass
423
-
424
- def reconstruct_path(self, current: str) -> list[str]:
425
- path = [current]
426
- while current in self.came_from and self.came_from[current] is not None:
427
- current = self.came_from[current]
428
- path.append(current)
429
- return list(reversed(path))
430
-
431
- # ── CORE SCORING PIPELINE ─────────────────────────────────────────────────
432
- async def _rank_candidates(self, candidates: list[str]) -> dict[str, float]:
433
- """
434
- score(candidate) =
435
- cosine(candidate_emb, target_emb) # primary signal
436
- + 0.30 if candidate backlinks the target directly else 0
437
- + 0.10 if candidate is one hop further out (depth-2) else 0
438
- + 0.10 * max(cosine(candidate_emb, category_emb_i))
439
-
440
- Raw target cosine is stashed in self._target_cosine for the prune
441
- floor — bonuses should not be able to rescue a semantically
442
- unrelated candidate, only break ties among related ones.
443
- """
444
- if not candidates:
445
- return {}
446
-
447
- need_embed = [t for t in candidates if t not in _emb_cache]
448
- if need_embed:
449
- await batch_embed(need_embed, self.stats)
450
-
451
- combined: dict[str, float] = {}
452
- for t in candidates:
453
- emb = _emb_cache.get(t)
454
- s_target = cosine_similarity(emb, self.target_embedding)
455
- self._target_cosine[t] = s_target
456
-
457
- fd = self._frontier.get(t)
458
- frontier_bonus = FRONTIER_D1_BONUS if fd == 1 else FRONTIER_D2_BONUS if fd == 2 else 0.0
459
-
460
- cat_bonus = 0.0
461
- if self.cat_embeddings and emb is not None and emb.size:
462
- cat_sim = max(cosine_similarity(emb, ce) for ce in self.cat_embeddings)
463
- cat_bonus = cat_sim * CAT_BONUS_WEIGHT
464
-
465
- combined[t] = min(0.99, s_target + frontier_bonus + cat_bonus)
466
-
467
- return combined
468
-
469
- async def _prune(self, pool: list[str],
470
- scores: dict[str, float]) -> tuple[list[str], int]:
471
- """
472
- Sort by combined score, keep the top PRUNE_TOP_K, then drop any of
473
- those below the raw-cosine floor (frontier members exempt — being
474
- one or two links from the target is itself strong evidence).
475
- """
476
- if not scores:
477
- return pool, 0
478
-
479
- ranked = sorted(pool, key=lambda t: -scores.get(t, 0.0))
480
- top = ranked[:PRUNE_TOP_K]
481
-
482
- survivors: list[str] = []
483
- for t in top:
484
- raw = self._target_cosine.get(t, 0.0)
485
- if t in self._frontier or raw >= PRUNE_FLOOR:
486
- survivors.append(t)
487
- else:
488
- self.h_cache[t] = 0.95
489
-
490
- pruned = len(pool) - len(survivors)
491
- self.stats.nodes_pruned += pruned
492
- print(f"[Prune] {pruned}/{len(pool)} pruned, {len(survivors)} kept")
493
- return survivors, pruned
494
-
495
- # ── RUN ───────────────────────────────────────────────────────────────────
496
- async def run(self):
497
- reset_session_caches()
498
-
499
- async with make_wiki_client() as client:
500
-
501
- # ── 1. Resolve titles ─────────────────────────────────────────
502
- await self.send("status", {"message": f"Resolving '{self.start}'..."})
503
- start_title = await resolve_article_title(client, self.start)
504
- if not start_title:
505
- await self.send("error", {"message": f"Cannot find: '{self.start}'"}); return
506
-
507
- await self.send("status", {"message": f"Resolving '{self.end}'..."})
508
- end_title = await resolve_article_title(client, self.end)
509
- if not end_title:
510
- await self.send("error", {"message": f"Cannot find: '{self.end}'"}); return
511
-
512
- await self.send("resolved", {"start": start_title, "end": end_title})
513
- self.end = end_title
514
-
515
- # ── 2. Target embedding ──────────────────────────────────────
516
- await self.send("status", {"message": "Embedding target article..."})
517
- te = await batch_embed([end_title], self.stats)
518
- if te and te[0] is not None and te[0].size:
519
- self.target_embedding = te[0]
520
- _emb_cache[end_title] = te[0]
521
- print(f"[Embed] Target dim={te[0].shape[0]}")
522
-
523
- # ── 3. Target categories + their embeddings ───────────────────
524
- await self.send("status", {"message": "Fetching target article categories..."})
525
- self.cats_target = await get_target_categories(client, end_title)
526
- print(f"[init] Target categories: {self.cats_target[:6]}")
527
- if self.cats_target:
528
- cat_embs = await batch_embed(self.cats_target, self.stats)
529
- self.cat_embeddings = [e for e in cat_embs if e is not None and e.size]
530
-
531
- # ── 4. Pre-seed: target frontier depth-1 ───────────────────────
532
- # Backlinks (who links TO the target), not outbound links — see
533
- # get_backlinks() docstring for why this direction matters.
534
- await self.send("status", {"message": "Pre-loading target backlinks..."})
535
- target_nb = await get_backlinks(client, end_title)
536
- self._target_neighbours = set(target_nb)
537
- for tn in self._target_neighbours:
538
- self._frontier[tn] = 1
539
- print(f"[init] Target has {len(self._target_neighbours)} backlinks")
540
-
541
- # ── 4b. Bidirectional-style frontier: depth-2 expansion ────────
542
- # For a sample of depth-1 bridges, fetch THEIR backlinks too:
543
- # X -> A -> target, where A is a depth-1 bridge. Reaching X
544
- # guarantees a path through A to the target.
545
- await self.send("status", {"message": "Expanding target frontier (depth 2)..."})
546
- d2_sample = [t for t in target_nb if not _is_date(t)][:FRONTIER_DEPTH_2_K]
547
- d2_results = await asyncio.gather(
548
- *[get_backlinks(client, nb_title) for nb_title in d2_sample]
549
- )
550
- d2_count = 0
551
- for nb_links in d2_results:
552
- for t in nb_links:
553
- if t not in self._frontier and not _is_date(t):
554
- self._frontier[t] = 2
555
- d2_count += 1
556
- print(f"[Frontier] Total: {len(self._frontier)} "
557
- f"(depth-1: {len(self._target_neighbours)}, depth-2: {d2_count})")
558
-
559
- # ── 5. Init search ────────────────────────────────────────────
560
- self.g_score[start_title] = 0
561
- se = await batch_embed([start_title], self.stats)
562
- if se and se[0] is not None and se[0].size and self.target_embedding is not None:
563
- h0 = max(0.01, 1.0 - cosine_similarity(se[0], self.target_embedding))
564
- else:
565
- h0 = 0.9
566
- self.h_cache[start_title] = h0
567
- heapq.heappush(self.open_heap, (h0, 0, start_title))
568
- self.open_set.add(start_title)
569
-
570
- summary = await get_article_summary(client, start_title)
571
- await self.send("node_add", {
572
- "id": start_title, "g": 0, "h": round(h0,3), "f": round(h0,3),
573
- "summary": summary, "state": "open",
574
- "is_start": True, "is_end": False,
575
- })
576
- await self.send("node_add", {
577
- "id": end_title, "g": None, "h": 0.0, "f": None,
578
- "summary": "", "state": "target",
579
- "is_start": False, "is_end": True,
580
- })
581
-
582
- step = 0
583
-
584
- # ── 6. Greedy best-first search loop ─────────────────────────
585
- while self.open_heap and step < MAX_HOPS * 30:
586
-
587
- while self._paused:
588
- await asyncio.sleep(0.3)
589
-
590
- if not self.open_heap:
591
- break
592
- h_curr, g_curr, current = heapq.heappop(self.open_heap)
593
- f_curr = g_curr + h_curr # for display only
594
- self.open_set.discard(current)
595
-
596
- if current in self.closed_set:
597
- continue
598
-
599
- step += 1
600
- self.stats.nodes_visited += 1
601
-
602
- await self.send("expand_node", {
603
- "id": current,
604
- "g": g_curr,
605
- "h": round(h_curr, 3),
606
- "f": round(f_curr, 3),
607
- "path_so_far": self.reconstruct_path(current),
608
- "step": step,
609
- "stats": self.stats.to_dict(),
610
- })
611
-
612
- # Goal check
613
- if current.lower() == end_title.lower():
614
- path = self.reconstruct_path(current)
615
- await self.send("found", {
616
- "path": path, "steps": step,
617
- "total_hops": len(path)-1,
618
- "stats": self.stats.to_dict(),
619
- })
620
- return
621
-
622
- self.closed_set.add(current)
623
- await self.send("node_state", {"id": current, "state": "closed"})
624
-
625
- # ── Fetch ALL links ───────────────────────────────────────
626
- await self.send("status", {"message": f"Fetching links: '{current}'..."})
627
- neighbours = await get_all_links(
628
- client, current,
629
- end_title=end_title,
630
- target_neighbours=self._target_neighbours,
631
- )
632
- print(f"[Search] step {step}: '{current}' → {len(neighbours)} links")
633
-
634
- if not neighbours:
635
- _dead_ends.add(current)
636
- continue
637
-
638
- # Immediate win
639
- if end_title in neighbours:
640
- self.came_from[end_title] = current
641
- self.g_score[end_title] = g_curr + 1
642
- path = self.reconstruct_path(end_title)
643
- print(f"[Search] Direct hit: '{current}' → '{end_title}'")
644
- await self.send("found", {
645
- "path": path, "steps": step,
646
- "total_hops": len(path)-1,
647
- "stats": self.stats.to_dict(),
648
- })
649
- return
650
-
651
- # Filter closed + dead-ends
652
- candidates = [
653
- nb for nb in neighbours
654
- if nb not in self.closed_set and nb not in _dead_ends
655
- ]
656
-
657
- # ── Embed + score pipeline ────────────────────────────────
658
- await self.send("status", {"message": f"Ranking links for '{current}'..."})
659
- scores = await self._rank_candidates(candidates)
660
-
661
- # ── Prune ─────────────────────────────────────────────────
662
- pool = list(scores.keys())
663
- survivors, _ = await self._prune(pool, scores)
664
-
665
- # ── Push to heap ──────────────────────────────────────────
666
- new_nodes = []
667
- for nb in survivors:
668
- if nb in self.closed_set:
669
- continue
670
- sc = scores.get(nb, 0.05)
671
- h_nb = max(0.01, 1.0 - sc)
672
- self.h_cache[nb] = h_nb
673
- tent_g = g_curr + 1
674
- if tent_g < self.g_score[nb]:
675
- self.came_from[nb] = current
676
- self.g_score[nb] = tent_g
677
- # Greedy best-first: heap priority = h only (not g+h).
678
- # This commits to the most promising direction without
679
- # penalising depth — Wikipedia paths are short (3-7
680
- # hops) and depth is not a useful cost signal here.
681
- heapq.heappush(self.open_heap, (h_nb, tent_g, nb))
682
- self.open_set.add(nb)
683
- is_end = nb.lower() == end_title.lower()
684
- new_nodes.append({
685
- "id": nb, "g": tent_g,
686
- "h": round(h_nb, 3), "f": round(tent_g + h_nb, 3),
687
- "state": "target" if is_end else "open",
688
- "is_end": is_end, "parent": current,
689
- })
690
-
691
- display_nodes = new_nodes[:TOP_DISPLAY]
692
- display_edges = [{"from": current, "to": n["id"]} for n in display_nodes]
693
- if display_nodes:
694
- await self.send("neighbours", {
695
- "centre": current,
696
- "nodes": display_nodes,
697
- "edges": display_edges,
698
- })
699
- await asyncio.sleep(0.01)
700
-
701
- await self.send("not_found", {
702
- "message": f"No path found within {MAX_HOPS} hops.",
703
- "visited": list(self.closed_set),
704
- "stats": self.stats.to_dict(),
705
- })
706
-
707
-
708
- # ══════════════════════════════════════════════════════════════
709
- # REST endpoints (autocomplete lives entirely on the frontend now —
710
- # it calls Wikipedia's opensearch API directly)
711
- # ══════════════════════════════════════════════════════════════
712
-
713
- @app.get("/api/random")
714
- async def api_random():
715
- """
716
- Powers the "Random" button on the start screen. Picks two distinct
717
- topics from the curated seed list (common_wiki_searches.txt) — NOT a
718
- fully random Wikipedia page — so the search always gets a
719
- well-connected pair of articles to find a path between.
720
- """
721
- titles = _load_seed_titles()
722
- if len(titles) < 2:
723
- return {"start": "Google", "end": "Mohali"}
724
- start, end = random.sample(titles, 2)
725
- return {"start": start, "end": end}
726
-
727
-
728
- @app.get("/api/health")
729
- async def api_health():
730
- """
731
- Polled by the frontend's loading screen on first load. Since FastAPI
732
- does not accept connections until the startup handler (which loads the
733
- embedding model) finishes, a successful response here already implies
734
- the model is ready — there is nothing further to check.
735
- """
736
- return {"status": "ready"}
737
-
738
-
739
- # ══════════════════════════════════════════════════════════════
740
- # WebSocket endpoint
741
- # ══════════════════════════════════════════════════════════════
742
-
743
- @app.websocket("/ws")
744
- async def websocket_endpoint(ws: WebSocket):
745
- await ws.accept()
746
- navigator: Optional[AStarNavigator] = None
747
- try:
748
- raw = await ws.receive_text()
749
- data = json.loads(raw)
750
- if "control" in data:
751
- return
752
-
753
- start = data.get("start", "").strip()
754
- end = data.get("end", "").strip()
755
- print(f"\n{'='*60}\n[WS] '{start}' → '{end}'\n{'='*60}")
756
- if not start or not end:
757
- await ws.send_text(json.dumps({"event":"error","message":"Need start and end."}))
758
- return
759
-
760
- navigator = AStarNavigator(start, end, ws)
761
-
762
- async def listen_controls():
763
- while True:
764
- try:
765
- msg = await asyncio.wait_for(ws.receive_text(), timeout=0.5)
766
- ctrl = json.loads(msg)
767
- if navigator:
768
- if ctrl.get("control") == "pause":
769
- navigator._paused = True; print("[WS] Paused")
770
- elif ctrl.get("control") == "resume":
771
- navigator._paused = False; print("[WS] Resumed")
772
- except asyncio.TimeoutError:
773
- pass
774
- except Exception:
775
- break
776
-
777
- ctrl_task = asyncio.create_task(listen_controls())
778
- search_task = asyncio.create_task(navigator.run())
779
- done, pending = await asyncio.wait(
780
- [ctrl_task, search_task], return_when=asyncio.FIRST_COMPLETED
781
- )
782
- for t in pending:
783
- t.cancel()
784
-
785
- except WebSocketDisconnect:
786
- print("[WS] disconnected")
787
- except Exception as e:
788
- print(f"[WS] {e}")
789
- try:
790
- await ws.send_text(json.dumps({"event":"error","message":str(e)}))
791
- except Exception:
792
- pass
793
-
794
-
795
- if __name__ == "__main__":
796
- import uvicorn
797
- print("\n🌐 Aurelius backend → http://localhost:8000")
798
- print(" Open index.html in your browser\n")
799
- uvicorn.run(app, host="0.0.0.0", port=8000, log_level="warning")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -3,3 +3,5 @@ uvicorn[standard]>=0.29
3
  httpx>=0.27
4
  numpy>=1.26
5
  sentence-transformers>=3.0
 
 
 
3
  httpx>=0.27
4
  numpy>=1.26
5
  sentence-transformers>=3.0
6
+ python-multipart>=0.0.9 # FastAPI file uploads (/api/paper/upload)
7
+ pypdf>=4.0 # PDF text extraction for the citation explorer
server.py CHANGED
@@ -15,12 +15,15 @@ Endpoints
15
  """
16
 
17
  import asyncio
 
18
  import json
19
  import os
 
20
  import time
21
  from collections import defaultdict, deque
22
 
23
- from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, Query
 
24
  from fastapi.middleware.cors import CORSMiddleware
25
  from starlette.middleware.base import BaseHTTPMiddleware
26
 
@@ -43,7 +46,7 @@ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
43
  app.add_middleware(
44
  CORSMiddleware,
45
  allow_origins=ALLOWED_ORIGINS,
46
- allow_methods=["GET"],
47
  allow_headers=["*"],
48
  )
49
 
@@ -244,6 +247,27 @@ async def api_neighbors(request_source: str = Query("wikipedia", alias="source")
244
  "neighbors": out}
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  @app.get("/api/node")
248
  async def api_node(request_source: str = Query("wikipedia", alias="source"),
249
  q: str = Query(...)):
@@ -312,6 +336,111 @@ def _compute_exposure(src, ref, k: int) -> list[dict]:
312
  return exposed
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  @app.get("/api/exposure")
316
  async def api_exposure(request_source: str = Query("finance", alias="source"),
317
  q: str = Query(...), k: int = Query(10)):
@@ -333,6 +462,134 @@ async def api_exposure(request_source: str = Query("finance", alias="source"),
333
  "exposed": _compute_exposure(src, ref, k)}
334
 
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  @app.get("/api/relate")
337
  async def api_relate(request_source: str = Query("wikipedia", alias="source"),
338
  a: str = Query(...), b: str = Query(...)):
 
15
  """
16
 
17
  import asyncio
18
+ import io
19
  import json
20
  import os
21
+ import re
22
  import time
23
  from collections import defaultdict, deque
24
 
25
+ from fastapi import (FastAPI, Request, WebSocket, WebSocketDisconnect, Query,
26
+ UploadFile, File)
27
  from fastapi.middleware.cors import CORSMiddleware
28
  from starlette.middleware.base import BaseHTTPMiddleware
29
 
 
46
  app.add_middleware(
47
  CORSMiddleware,
48
  allow_origins=ALLOWED_ORIGINS,
49
+ allow_methods=["GET", "POST"], # POST for the PDF citation upload
50
  allow_headers=["*"],
51
  )
52
 
 
247
  "neighbors": out}
248
 
249
 
250
+ @app.get("/api/suggest")
251
+ async def api_suggest(request_source: str = Query("wikipedia", alias="source"),
252
+ q: str = Query(...), limit: int = Query(8)):
253
+ """Domain-aware type-ahead. Each source suggests from its OWN
254
+ vocabulary (companies/tickers for finance, papers for research,
255
+ diseases/genes for biology, articles for Wikipedia) instead of the
256
+ old hardcoded Wikipedia opensearch. Never errors the UI — returns an
257
+ empty list on any failure."""
258
+ src = _resolve_source(request_source)
259
+ if src is None:
260
+ return {"error": f"Unknown source '{request_source}'"}
261
+ if len(q) > MAX_QUERY_LEN:
262
+ return {"error": "Query too long."}
263
+ try:
264
+ items = await src.suggest(q.strip(), limit=max(1, min(limit, 15)))
265
+ except Exception as e:
266
+ print(f"[suggest] {src.name}: {e}")
267
+ items = []
268
+ return {"source": src.name, "suggestions": items}
269
+
270
+
271
  @app.get("/api/node")
272
  async def api_node(request_source: str = Query("wikipedia", alias="source"),
273
  q: str = Query(...)):
 
336
  return exposed
337
 
338
 
339
+ def _fin_card(store, node_id: str) -> dict:
340
+ """Compact facts for a related finance node — enough to render a row
341
+ (name, ticker, kind, sector, last price, window change)."""
342
+ node = store.get_node("finance", node_id) or {}
343
+ f = node.get("features") or {}
344
+ return {
345
+ "id": node_id,
346
+ "title": node.get("title", node_id),
347
+ "kind": f.get("kind"),
348
+ "sector": f.get("sector"),
349
+ "last_price": f.get("last_price"),
350
+ "change_pct": f.get("window_change_pct"),
351
+ }
352
+
353
+
354
+ @app.get("/api/company")
355
+ async def api_company(q: str = Query(...),
356
+ request_source: str = Query("finance", alias="source")):
357
+ """One company's full research profile, aggregated from the finance
358
+ graph in a single call: price series + key facts, and every typed
359
+ relationship grouped into the sections a researcher actually wants —
360
+ peers, supply chain, correlations, ownership, leadership. The graph
361
+ becomes a secondary explorer; THIS is the primary finance surface."""
362
+ src = _resolve_source(request_source)
363
+ if src is None or getattr(src, "ingested", None) is None:
364
+ return {"error": "Company profiles need an ingested finance source."}
365
+ if not src.ingested():
366
+ return {"error": "Finance data has not been ingested yet."}
367
+ if len(q) > MAX_QUERY_LEN:
368
+ return {"error": "Query too long."}
369
+ ref = await src.resolve(q)
370
+ if not ref:
371
+ return {"error": f"Cannot find: '{q}'"}
372
+ store = src.store
373
+ node = store.get_node("finance", ref.id) or {}
374
+ feats = node.get("features") or {}
375
+
376
+ # group the node's typed edges into researcher-facing buckets
377
+ buckets: dict[str, list[tuple[str, float]]] = {
378
+ "competes_with": [], "supplied_by": [], "supplies": [],
379
+ "co_moves": [], "macro_correlates": [], "held_by": [],
380
+ "stake_held_by": [],
381
+ }
382
+ ceo_id = country_id = sector_id = None
383
+ for dst, typ, w in store.neighbors("finance", ref.id):
384
+ if typ in buckets:
385
+ buckets[typ].append((dst, w))
386
+ elif typ == "led_by":
387
+ ceo_id = dst
388
+ elif typ == "based_in":
389
+ country_id = dst
390
+ elif typ == "sector_member":
391
+ sector_id = dst
392
+
393
+ def cards(items, with_corr=False, limit=12):
394
+ items = sorted(items, key=lambda kv: -kv[1])[:limit]
395
+ out = []
396
+ for did, w in items:
397
+ c = _fin_card(store, did)
398
+ if with_corr:
399
+ c["corr"] = round(w, 2)
400
+ out.append(c)
401
+ return out
402
+
403
+ # peers: direct rivals first, then same-sector members
404
+ peers = [d for d, _ in buckets["competes_with"]]
405
+ seen = set(peers) | {ref.id}
406
+ if sector_id:
407
+ for d, typ, _w in store.neighbors("finance", sector_id):
408
+ if typ == "has_member" and d not in seen:
409
+ peers.append(d); seen.add(d)
410
+ peer_cards = [_fin_card(store, p) for p in peers[:10]]
411
+
412
+ ceo = None
413
+ if ceo_id:
414
+ cnode = store.get_node("finance", ceo_id) or {}
415
+ ceo = {"id": ceo_id, "title": cnode.get("title", ceo_id),
416
+ "summary": cnode.get("summary", "")}
417
+ country = None
418
+ if country_id:
419
+ country = (store.get_node("finance", country_id) or {}).get(
420
+ "title", country_id).replace(" (Sector)", "")
421
+
422
+ return {
423
+ "id": ref.id,
424
+ "title": node.get("title", ref.id),
425
+ "kind": feats.get("kind"),
426
+ "sector": feats.get("sector"),
427
+ "summary": node.get("summary", ""),
428
+ "last_price": feats.get("last_price"),
429
+ "change_pct": feats.get("window_change_pct"),
430
+ "series": feats.get("series"),
431
+ "series_days": feats.get("series_days"),
432
+ "ceo": ceo,
433
+ "country": country,
434
+ "peers": peer_cards,
435
+ "suppliers": cards(buckets["supplied_by"]),
436
+ "customers": cards(buckets["supplies"]),
437
+ "correlated": cards(buckets["co_moves"], with_corr=True),
438
+ "macro": cards(buckets["macro_correlates"], with_corr=True),
439
+ "etfs": cards(buckets["held_by"]),
440
+ "investors": cards(buckets["stake_held_by"]),
441
+ }
442
+
443
+
444
  @app.get("/api/exposure")
445
  async def api_exposure(request_source: str = Query("finance", alias="source"),
446
  q: str = Query(...), k: int = Query(10)):
 
462
  "exposed": _compute_exposure(src, ref, k)}
463
 
464
 
465
+ @app.get("/api/paper")
466
+ async def api_paper(q: str = Query(...),
467
+ request_source: str = Query("openalex", alias="source"),
468
+ refs: int = Query(25), cites: int = Query(25)):
469
+ """A paper's citation dossier: its metadata plus the works it CITES
470
+ (references) and the works that CITE it (citations), each with authors,
471
+ year, venue and citation count — the seed for the citation explorer.
472
+ Replaces the old connect-two-papers pathfinding as the primary research
473
+ surface."""
474
+ src = _resolve_source(request_source)
475
+ if src is None:
476
+ return {"error": f"Unknown source '{request_source}'"}
477
+ if len(q) > MAX_QUERY_LEN:
478
+ return {"error": "Query too long."}
479
+ ref = await src.resolve(q)
480
+ if not ref:
481
+ return {"error": f"Couldn't find a paper matching '{q}'."}
482
+ return await _paper_payload(src, ref, refs, cites)
483
+
484
+
485
+ async def _paper_payload(src, ref, refs: int, cites: int) -> dict:
486
+ info = await src.node_info(ref, rich=True)
487
+
488
+ def _card(nref, feats):
489
+ f = feats or {}
490
+ return {"id": nref.id, "title": nref.title,
491
+ "year": f.get("year") or None,
492
+ "authors": f.get("authors") or [],
493
+ "venue": f.get("venue") or "",
494
+ "cited_by_count": f.get("cited_by_count") or 0}
495
+
496
+ try:
497
+ out_edges = await src.neighbors(ref)
498
+ except Exception:
499
+ out_edges = []
500
+ in_edges = []
501
+ if src.supports_backlinks:
502
+ try:
503
+ in_edges = await src.back_neighbors(ref, limit=cites * 2)
504
+ except Exception:
505
+ in_edges = []
506
+
507
+ ref_nodes = [e.dst for e in out_edges][:max(1, min(refs, 60))]
508
+ cite_nodes = [e.src for e in in_edges][:max(1, min(cites, 60))]
509
+ ref_infos = await src.node_infos(ref_nodes) if ref_nodes else []
510
+ cite_infos = await src.node_infos(cite_nodes) if cite_nodes else []
511
+
512
+ references = [_card(n, i.features) for n, i in zip(ref_nodes, ref_infos)]
513
+ citations = [_card(n, i.features) for n, i in zip(cite_nodes, cite_infos)]
514
+ citations.sort(key=lambda c: -c["cited_by_count"]) # influential first
515
+
516
+ f = info.features or {}
517
+ return {
518
+ "source": src.name,
519
+ "id": ref.id, "title": ref.title,
520
+ "year": f.get("year") or None,
521
+ "authors": f.get("authors") or [],
522
+ "venue": f.get("venue") or "",
523
+ "cited_by_count": f.get("cited_by_count") or 0,
524
+ "abstract": info.summary or "",
525
+ "references": references,
526
+ "citations": citations,
527
+ "n_references": len(references),
528
+ "n_citations": len(citations),
529
+ }
530
+
531
+
532
+ _PDF_DOI_RE = re.compile(r"\b(10\.\d{4,9}/[-._;()/:a-z0-9]+)", re.IGNORECASE)
533
+ _PDF_ARXIV_RE = re.compile(r"arXiv:\s*(\d{4}\.\d{4,5})", re.IGNORECASE)
534
+
535
+
536
+ def _pdf_locator(data: bytes) -> str | None:
537
+ """Pull a resolvable identifier out of an uploaded PDF: a DOI or arXiv
538
+ id from the first few pages (where they almost always sit), else the
539
+ title guessed from the largest first-page line. OpenAlex resolves any
540
+ of these."""
541
+ try:
542
+ from pypdf import PdfReader
543
+ reader = PdfReader(io.BytesIO(data))
544
+ text = ""
545
+ for page in reader.pages[:3]:
546
+ text += "\n" + (page.extract_text() or "")
547
+ except Exception as e:
548
+ print(f"[paper] pdf parse failed: {e}")
549
+ return None
550
+ m = _PDF_DOI_RE.search(text)
551
+ if m:
552
+ return m.group(1).rstrip(".,;)")
553
+ m = _PDF_ARXIV_RE.search(text)
554
+ if m:
555
+ return f"arXiv:{m.group(1)}"
556
+ # Fallback: the first substantial line is usually the title.
557
+ for line in text.splitlines():
558
+ s = line.strip()
559
+ if len(s) >= 20 and any(ch.isalpha() for ch in s):
560
+ return s[:MAX_QUERY_LEN]
561
+ return None
562
+
563
+
564
+ @app.post("/api/paper/upload")
565
+ async def api_paper_upload(request: Request,
566
+ file: UploadFile = File(...),
567
+ request_source: str = Query("openalex", alias="source")):
568
+ """Resolve a paper from an uploaded PDF: extract its DOI / arXiv id /
569
+ title, then return the same citation dossier as /api/paper. Rate-limited
570
+ and size-capped like the other write-ish endpoints."""
571
+ ip = request.client.host if request.client else "unknown"
572
+ if not _rate_ok(ip):
573
+ return {"error": "Rate limit reached — slow down a moment."}
574
+ src = _resolve_source(request_source)
575
+ if src is None:
576
+ return {"error": f"Unknown source '{request_source}'"}
577
+ data = await file.read()
578
+ if not data:
579
+ return {"error": "Empty file."}
580
+ if len(data) > 15 * 1024 * 1024:
581
+ return {"error": "PDF too large (15 MB max)."}
582
+ locator = _pdf_locator(data)
583
+ if not locator:
584
+ return {"error": "Couldn't find a DOI, arXiv id or title in that PDF."}
585
+ ref = await src.resolve(locator)
586
+ if not ref:
587
+ return {"error": f"Found “{locator[:60]}” in the PDF but couldn't match a paper."}
588
+ payload = await _paper_payload(src, ref, 25, 25)
589
+ payload["matched_via"] = locator[:80]
590
+ return payload
591
+
592
+
593
  @app.get("/api/relate")
594
  async def api_relate(request_source: str = Query("wikipedia", alias="source"),
595
  a: str = Query(...), b: str = Query(...)):
styles.css CHANGED
@@ -833,10 +833,14 @@ body::before {
833
  background: var(--bg);
834
  }
835
 
836
- svg#graph {
 
 
837
  width: 100%;
838
  height: 100%;
 
839
  }
 
840
 
841
  /* ─── Tooltip ─── */
842
  #tooltip {
@@ -1167,8 +1171,40 @@ svg#graph {
1167
  }
1168
 
1169
  .ac-item .ac-icon {
1170
- opacity: 0.38;
1171
  flex-shrink: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1172
  }
1173
 
1174
  .input-wrap {
@@ -2470,34 +2506,6 @@ svg#graph {
2470
  to { transform: translate3d(-40px, -30px, 0) rotate(6deg); }
2471
  }
2472
 
2473
- #graph3d {
2474
- position: absolute;
2475
- inset: 0;
2476
- z-index: 1;
2477
- display: none;
2478
- }
2479
- #graph3d canvas { display: block; }
2480
-
2481
- /* 3D node hover tooltip (injected by 3d-force-graph nodeLabel HTML) */
2482
- .gv-tip {
2483
- font-family: 'Outfit', sans-serif;
2484
- font-size: 12px;
2485
- font-weight: 600;
2486
- color: var(--text);
2487
- background: rgba(13, 12, 8, 0.9);
2488
- border: 1px solid rgba(201, 138, 46, 0.4);
2489
- border-radius: 6px;
2490
- padding: 5px 9px;
2491
- box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5);
2492
- }
2493
- .gv-tip span {
2494
- display: block;
2495
- font-family: 'DM Mono', monospace;
2496
- font-size: 10px;
2497
- color: var(--accent2);
2498
- margin-top: 2px;
2499
- }
2500
-
2501
  /* ─── Source picker ─── */
2502
  .source-picker {
2503
  font-family: 'Outfit', sans-serif;
@@ -2797,6 +2805,29 @@ svg#graph {
2797
  transform: translateY(-1px);
2798
  }
2799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2800
  /* ── Compare panel + FAB ───────────────────────────────────────────────── */
2801
  #compare-fab {
2802
  position: absolute;
@@ -2952,6 +2983,213 @@ svg#graph {
2952
  .ovl-item-title { font-size: 12.5px; color: var(--text); line-height: 1.4; }
2953
  .ovl-item-meta { font-size: 10.5px; color: var(--text2); }
2954
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2955
  @media (max-width: 560px) {
2956
  #discover-panel { width: calc(100% - 24px); top: 12px; right: 12px; }
2957
  #discover-fab { width: 44px; height: 44px; bottom: 14px; right: 14px; }
@@ -2959,6 +3197,8 @@ svg#graph {
2959
  .gc-btn { font-size: 11px; padding: 4px 8px; }
2960
  #compare-panel { width: calc(100% - 24px); left: 12px; bottom: 12px; }
2961
  #compare-fab { width: 44px; height: 44px; bottom: 14px; left: 14px; }
 
 
2962
  #overlay-drawer { width: 100%; max-height: 50%; border-top-left-radius: 14px; border-top-right-radius: 14px; }
2963
  #hero-examples { gap: 6px; }
2964
  .hero-ex-chip { font-size: 11.5px; padding: 4px 10px; }
 
833
  background: var(--bg);
834
  }
835
 
836
+ #graph-canvas {
837
+ position: absolute;
838
+ inset: 0;
839
  width: 100%;
840
  height: 100%;
841
+ z-index: 1;
842
  }
843
+ #graph-canvas canvas { display: block; }
844
 
845
  /* ─── Tooltip ─── */
846
  #tooltip {
 
1171
  }
1172
 
1173
  .ac-item .ac-icon {
1174
+ opacity: 0.42;
1175
  flex-shrink: 0;
1176
+ margin-top: 1px;
1177
+ align-self: flex-start;
1178
+ }
1179
+
1180
+ .ac-item .ac-text {
1181
+ display: flex;
1182
+ flex-direction: column;
1183
+ gap: 1px;
1184
+ min-width: 0;
1185
+ flex: 1;
1186
+ }
1187
+
1188
+ .ac-item .ac-title {
1189
+ white-space: nowrap;
1190
+ overflow: hidden;
1191
+ text-overflow: ellipsis;
1192
+ }
1193
+
1194
+ .ac-item .ac-sub {
1195
+ font-size: 11px;
1196
+ font-weight: 400;
1197
+ color: var(--text2);
1198
+ white-space: nowrap;
1199
+ overflow: hidden;
1200
+ text-overflow: ellipsis;
1201
+ text-transform: capitalize;
1202
+ }
1203
+
1204
+ .ac-item:hover .ac-sub,
1205
+ .ac-item.ac-active .ac-sub {
1206
+ color: var(--accent2);
1207
+ opacity: 0.75;
1208
  }
1209
 
1210
  .input-wrap {
 
2506
  to { transform: translate3d(-40px, -30px, 0) rotate(6deg); }
2507
  }
2508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2509
  /* ─── Source picker ─── */
2510
  .source-picker {
2511
  font-family: 'Outfit', sans-serif;
 
2805
  transform: translateY(-1px);
2806
  }
2807
 
2808
+ /* Finance landing CTA — the primary "research a company" action */
2809
+ .hero-research-cta {
2810
+ display: inline-flex;
2811
+ align-items: center;
2812
+ gap: 9px;
2813
+ font-family: 'Outfit', sans-serif;
2814
+ font-size: 14px;
2815
+ font-weight: 600;
2816
+ color: #1a1206;
2817
+ background: var(--accent);
2818
+ border: 1px solid var(--accent);
2819
+ border-radius: 999px;
2820
+ padding: 9px 20px;
2821
+ cursor: pointer;
2822
+ box-shadow: 0 6px 20px rgba(201, 138, 46, 0.25);
2823
+ transition: filter 0.15s, transform 0.15s, box-shadow 0.15s;
2824
+ flex-basis: 100%;
2825
+ justify-content: center;
2826
+ max-width: 260px;
2827
+ }
2828
+ .hero-research-cta svg { width: 18px; height: 18px; }
2829
+ .hero-research-cta:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 8px 26px rgba(201, 138, 46, 0.35); }
2830
+
2831
  /* ── Compare panel + FAB ───────────────────────────────────────────────── */
2832
  #compare-fab {
2833
  position: absolute;
 
2983
  .ovl-item-title { font-size: 12.5px; color: var(--text); line-height: 1.4; }
2984
  .ovl-item-meta { font-size: 10.5px; color: var(--text2); }
2985
 
2986
+ /* ══════════════════════════════════════════════════════════
2987
+ COMPANY PROFILE — primary finance research surface
2988
+ ══════════════════════════════════════════════════════════ */
2989
+ #profile-fab, #paper-fab {
2990
+ position: absolute;
2991
+ bottom: 20px;
2992
+ left: 82px;
2993
+ z-index: 13;
2994
+ height: 50px;
2995
+ padding: 0 18px 0 15px;
2996
+ border-radius: 25px;
2997
+ border: 1px solid rgba(201, 138, 46, 0.45);
2998
+ background: rgba(20, 18, 12, 0.85);
2999
+ color: var(--accent2);
3000
+ cursor: pointer;
3001
+ display: inline-flex;
3002
+ align-items: center;
3003
+ gap: 8px;
3004
+ font-family: 'Outfit', sans-serif;
3005
+ font-size: 13.5px;
3006
+ font-weight: 600;
3007
+ letter-spacing: 0.2px;
3008
+ backdrop-filter: blur(10px);
3009
+ -webkit-backdrop-filter: blur(10px);
3010
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
3011
+ transition: transform 0.18s, background 0.18s, box-shadow 0.18s;
3012
+ }
3013
+ #profile-fab svg, #paper-fab svg { width: 20px; height: 20px; }
3014
+ #profile-fab:hover, #paper-fab:hover { transform: translateY(-2px); background: rgba(28, 24, 15, 0.94); box-shadow: 0 8px 26px rgba(0, 0, 0, 0.5); }
3015
+ #profile-fab.hidden, #paper-fab.hidden { opacity: 0; pointer-events: none; transform: scale(0.8); }
3016
+
3017
+ #profile-panel, #paper-panel {
3018
+ position: absolute;
3019
+ top: 16px;
3020
+ left: 16px;
3021
+ bottom: 16px;
3022
+ z-index: 15;
3023
+ width: 420px;
3024
+ max-width: calc(100% - 32px);
3025
+ display: flex;
3026
+ flex-direction: column;
3027
+ background: rgba(15, 14, 10, 0.94);
3028
+ border: 1px solid var(--border);
3029
+ border-radius: 16px;
3030
+ backdrop-filter: blur(18px);
3031
+ -webkit-backdrop-filter: blur(18px);
3032
+ box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
3033
+ opacity: 0;
3034
+ transform: translateX(-16px) scale(0.99);
3035
+ pointer-events: none;
3036
+ transition: opacity 0.24s, transform 0.24s;
3037
+ overflow: hidden;
3038
+ }
3039
+ #profile-panel.open, #paper-panel.open { opacity: 1; transform: none; pointer-events: auto; }
3040
+ #paper-body { overflow-y: auto; padding: 16px 16px 22px; flex: 1; }
3041
+
3042
+ /* Citation explorer specifics */
3043
+ .paper-byline { font-size: 12.5px; color: var(--text2); margin-top: 8px; line-height: 1.5; }
3044
+ .paper-abstract { margin-top: 12px; max-height: 120px; overflow-y: auto; }
3045
+ .paper-stats { display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; }
3046
+ .paper-stat { font-size: 12px; color: var(--text2); }
3047
+ .paper-stat b { color: var(--text); font-size: 14px; font-weight: 700; }
3048
+ .paper-list { display: flex; flex-direction: column; gap: 4px; }
3049
+ .paper-item {
3050
+ display: flex; align-items: center; gap: 10px; text-align: left;
3051
+ background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border);
3052
+ border-radius: 9px; padding: 9px 11px; cursor: pointer;
3053
+ transition: background 0.14s, border-color 0.14s, transform 0.14s;
3054
+ }
3055
+ .paper-item:hover { background: rgba(201, 138, 46, 0.08); border-color: rgba(201, 138, 46, 0.35); transform: translateY(-1px); }
3056
+ .paper-item-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
3057
+ .paper-item-title { font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1.35;
3058
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3059
+ .paper-item-meta { font-size: 10.5px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3060
+ .paper-cb { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--accent2);
3061
+ background: rgba(201, 138, 46, 0.1); border-radius: 5px; padding: 3px 7px; flex-shrink: 0; }
3062
+ .paper-drop {
3063
+ display: inline-flex; align-items: center; gap: 8px; margin-top: 16px;
3064
+ padding: 10px 16px; border-radius: 10px; cursor: pointer;
3065
+ border: 1px dashed rgba(201, 138, 46, 0.4); color: var(--text2);
3066
+ font-size: 12.5px; transition: border-color 0.15s, background 0.15s, color 0.15s;
3067
+ }
3068
+ .paper-drop:hover { border-color: rgba(201, 138, 46, 0.7); color: var(--accent2); background: rgba(201, 138, 46, 0.06); }
3069
+ .paper-drop svg { width: 17px; height: 17px; }
3070
+ #paper-panel.drag-over { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(201, 138, 46, 0.4), 0 16px 50px rgba(0, 0, 0, 0.55); }
3071
+ #paper-panel.drag-over .paper-drop { border-color: var(--accent); color: var(--accent2); background: rgba(201, 138, 46, 0.1); }
3072
+
3073
+ .prof-head {
3074
+ display: flex; align-items: center; gap: 10px;
3075
+ padding: 12px 12px 12px 14px;
3076
+ border-bottom: 1px solid var(--border);
3077
+ flex-shrink: 0;
3078
+ }
3079
+ .prof-input-row { position: relative; flex: 1; display: flex; align-items: center; }
3080
+ .prof-search-ic { position: absolute; left: 11px; width: 15px; height: 15px; color: var(--text2); pointer-events: none; }
3081
+ #profile-input {
3082
+ flex: 1; width: 100%;
3083
+ background: rgba(255, 255, 255, 0.04);
3084
+ border: 1px solid var(--border-bright);
3085
+ border-radius: 9px;
3086
+ color: var(--text);
3087
+ font-family: 'Outfit', sans-serif;
3088
+ font-size: 13.5px;
3089
+ padding: 9px 62px 9px 34px;
3090
+ outline: none;
3091
+ transition: border-color 0.16s;
3092
+ }
3093
+ #profile-input:focus { border-color: rgba(201, 138, 46, 0.55); }
3094
+ #profile-run {
3095
+ position: absolute; right: 5px;
3096
+ background: var(--accent); color: #1a1206;
3097
+ border: none; border-radius: 7px;
3098
+ font-family: 'Outfit', sans-serif; font-weight: 700; font-size: 12px;
3099
+ padding: 6px 12px; cursor: pointer;
3100
+ transition: filter 0.16s;
3101
+ }
3102
+ #profile-run:hover { filter: brightness(1.08); }
3103
+ .prof-close { background: none; border: none; color: var(--text2); cursor: pointer; width: 20px; height: 20px; padding: 0; flex-shrink: 0; }
3104
+ .prof-close svg { width: 15px; height: 15px; }
3105
+
3106
+ #profile-body { overflow-y: auto; padding: 16px 16px 22px; flex: 1; }
3107
+
3108
+ .prof-empty { text-align: center; padding: 34px 20px; color: var(--text2); }
3109
+ .prof-empty svg { width: 46px; height: 46px; color: rgba(201, 138, 46, 0.5); margin-bottom: 14px; }
3110
+ .prof-empty p { font-size: 13px; line-height: 1.6; max-width: 300px; margin: 0 auto 16px; }
3111
+ .prof-empty-chips { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; }
3112
+ .prof-chip {
3113
+ background: rgba(201, 138, 46, 0.1); border: 1px solid rgba(201, 138, 46, 0.32);
3114
+ color: var(--accent2); border-radius: 20px; padding: 5px 13px;
3115
+ font-family: 'DM Mono', monospace; font-size: 12px; cursor: pointer;
3116
+ transition: background 0.15s, transform 0.15s;
3117
+ }
3118
+ .prof-chip:hover { background: rgba(201, 138, 46, 0.2); transform: translateY(-1px); }
3119
+
3120
+ .prof-loading, .prof-msg { color: var(--text2); font-size: 13px; padding: 24px 8px; text-align: center; line-height: 1.5; }
3121
+ .prof-msg.small { padding: 12px 4px; font-size: 12px; text-align: left; }
3122
+ .prof-loading { display: flex; align-items: center; justify-content: center; gap: 10px; }
3123
+ .prof-spinner {
3124
+ width: 15px; height: 15px; border-radius: 50%;
3125
+ border: 2px solid rgba(201, 138, 46, 0.25); border-top-color: var(--accent);
3126
+ display: inline-block; animation: prof-spin 0.8s linear infinite;
3127
+ }
3128
+ @keyframes prof-spin { to { transform: rotate(360deg); } }
3129
+
3130
+ .prof-title-row { display: flex; align-items: flex-start; gap: 10px; justify-content: space-between; }
3131
+ .prof-title { font-family: 'Outfit', sans-serif; font-size: 20px; font-weight: 700; color: var(--text); line-height: 1.2; margin: 0; }
3132
+ .prof-kind {
3133
+ font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;
3134
+ color: var(--accent2); background: rgba(201, 138, 46, 0.12);
3135
+ border: 1px solid rgba(201, 138, 46, 0.3); border-radius: 5px; padding: 3px 7px;
3136
+ flex-shrink: 0; margin-top: 3px;
3137
+ }
3138
+ .prof-price { font-family: 'Outfit', sans-serif; font-size: 24px; font-weight: 700; color: var(--text); margin-top: 10px; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
3139
+ .prof-price-note { font-size: 11px; font-weight: 400; color: var(--text2); }
3140
+ .prof-chg { font-size: 14px; font-weight: 700; }
3141
+ .prof-chg.up { color: #4aab79; }
3142
+ .prof-chg.down { color: #d66a6a; }
3143
+
3144
+ .prof-facts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
3145
+ .prof-fact {
3146
+ display: flex; flex-direction: column; gap: 2px;
3147
+ background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border);
3148
+ border-radius: 8px; padding: 7px 11px; font-size: 12.5px; color: var(--text);
3149
+ }
3150
+ .prof-fact i { font-style: normal; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text2); }
3151
+ .prof-summary { font-size: 12.5px; color: var(--text2); line-height: 1.6; margin-top: 13px; }
3152
+
3153
+ .prof-chart-wrap { margin-top: 16px; background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 10px; padding: 10px; }
3154
+ .prof-chart { width: 100%; height: 130px; display: block; }
3155
+
3156
+ .prof-section { margin-top: 20px; }
3157
+ .prof-sec-head { font-size: 13px; font-weight: 700; color: var(--text); display: flex; align-items: baseline; gap: 8px; margin-bottom: 9px; }
3158
+ .prof-sec-hint { font-size: 10.5px; font-weight: 400; color: var(--text2); text-transform: uppercase; letter-spacing: 0.4px; }
3159
+ .prof-cards { display: flex; flex-wrap: wrap; gap: 7px; }
3160
+ .prof-card {
3161
+ display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
3162
+ background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border);
3163
+ border-radius: 9px; padding: 8px 11px; cursor: pointer; text-align: left;
3164
+ transition: background 0.14s, border-color 0.14s, transform 0.14s;
3165
+ max-width: 100%;
3166
+ }
3167
+ .prof-card:hover { background: rgba(201, 138, 46, 0.08); border-color: rgba(201, 138, 46, 0.35); transform: translateY(-1px); }
3168
+ .prof-card-name { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
3169
+ .prof-card-meta { font-size: 10.5px; color: var(--text2); display: flex; gap: 6px; align-items: center; }
3170
+
3171
+ .prof-news { margin-top: 22px; }
3172
+ .prof-news-body { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
3173
+ .prof-news-item { display: flex; gap: 9px; align-items: flex-start; padding: 8px 9px; border-radius: 8px; text-decoration: none; transition: background 0.14s; }
3174
+ .prof-news-item:hover { background: rgba(255, 255, 255, 0.04); }
3175
+ .prof-news-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; background: #9a916f; }
3176
+ .prof-news-dot.prof-positive { background: #5cd6a0; }
3177
+ .prof-news-dot.prof-negative { background: #e0736a; }
3178
+ .prof-news-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
3179
+ .prof-news-title { font-size: 12.5px; color: var(--text); line-height: 1.4; }
3180
+ .prof-news-src { font-size: 10.5px; color: var(--text2); }
3181
+
3182
+ .prof-actions { display: flex; gap: 9px; margin-top: 22px; }
3183
+ .prof-act {
3184
+ flex: 1; padding: 10px; border-radius: 9px; cursor: pointer;
3185
+ font-family: 'Outfit', sans-serif; font-size: 12.5px; font-weight: 600;
3186
+ background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border-bright); color: var(--text);
3187
+ transition: background 0.15s, transform 0.15s;
3188
+ }
3189
+ .prof-act:hover { background: rgba(255, 255, 255, 0.08); transform: translateY(-1px); }
3190
+ .prof-act.primary { background: var(--accent); border-color: var(--accent); color: #1a1206; font-weight: 700; }
3191
+ .prof-act.primary:hover { filter: brightness(1.08); }
3192
+
3193
  @media (max-width: 560px) {
3194
  #discover-panel { width: calc(100% - 24px); top: 12px; right: 12px; }
3195
  #discover-fab { width: 44px; height: 44px; bottom: 14px; right: 14px; }
 
3197
  .gc-btn { font-size: 11px; padding: 4px 8px; }
3198
  #compare-panel { width: calc(100% - 24px); left: 12px; bottom: 12px; }
3199
  #compare-fab { width: 44px; height: 44px; bottom: 14px; left: 14px; }
3200
+ #profile-fab, #paper-fab { bottom: 14px; left: 68px; height: 44px; font-size: 12.5px; }
3201
+ #profile-panel, #paper-panel { width: calc(100% - 24px); top: 12px; left: 12px; bottom: 12px; }
3202
  #overlay-drawer { width: 100%; max-height: 50%; border-top-left-radius: 14px; border-top-right-radius: 14px; }
3203
  #hero-examples { gap: 6px; }
3204
  .hero-ex-chip { font-size: 11.5px; padding: 4px 10px; }
vercel.json CHANGED
@@ -6,7 +6,7 @@
6
  "headers": [
7
  {
8
  "key": "Content-Security-Policy",
9
- "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://mvali77-aurelius.hf.space wss://mvali77-aurelius.hf.space https://en.wikipedia.org; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
10
  },
11
  { "key": "X-Content-Type-Options", "value": "nosniff" },
12
  { "key": "X-Frame-Options", "value": "DENY" },
 
6
  "headers": [
7
  {
8
  "key": "Content-Security-Policy",
9
+ "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://mvali77-aurelius.hf.space wss://mvali77-aurelius.hf.space; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
10
  },
11
  { "key": "X-Content-Type-Options", "value": "nosniff" },
12
  { "key": "X-Frame-Options", "value": "DENY" },