murtaza-2007 commited on
Commit ·
658d200
1
Parent(s): bc32a44
Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph
Browse files- CLAUDE.md +65 -30
- README.md +6 -5
- adapters/__init__.py +3 -4
- adapters/github.py +0 -156
- adapters/openalex.py +63 -8
- adapters/wikipedia.py +17 -0
- app.js +546 -313
- config.py +1 -3
- core/source.py +15 -3
- core/store.py +54 -1
- core/types.py +2 -2
- graph2d.js +619 -0
- graph3d.js +0 -403
- htmlbackup.txt +0 -0
- index.html +92 -27
- ingest/__init__.py +1 -1
- ingest/cli.py +1 -1
- mainpybackup.txt +0 -799
- requirements.txt +2 -0
- server.py +259 -2
- styles.css +270 -30
- 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 /
|
| 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/
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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
|
| 181 |
kind-based coloring and progressive click-to-expand.
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 |
-
- `
|
| 242 |
-
(
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
(
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
| 251 |
- `app.js` — `AC_BACKEND` auto-detects localhost vs the HF Spaces URL;
|
| 252 |
-
multi-source picker from `/api/sources`;
|
| 253 |
-
`/api/
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 259 |
-
and `worker-src
|
| 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
|
| 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,
|
|
|
|
|
|
|
| 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` / `
|
| 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
|
| 6 |
-
|
| 7 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 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 |
-
|
| 360 |
-
|
| 361 |
};
|
| 362 |
|
| 363 |
function renderExampleChips() {
|
| 364 |
const box = document.getElementById('hero-examples');
|
| 365 |
if (!box) return;
|
| 366 |
const pairs = EXAMPLE_PAIRS[currentSource] || [];
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 704 |
-
*
|
| 705 |
-
*
|
| 706 |
-
*
|
| 707 |
-
*
|
| 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 |
-
|
| 724 |
-
namespace: '0', format: 'json', origin: '*',
|
| 725 |
});
|
| 726 |
-
const res = await fetch(`${
|
| 727 |
if (!res.ok) { closeAcDropdown(fieldName); return; }
|
| 728 |
const data = await res.json();
|
| 729 |
-
const suggestions = Array.isArray(data)
|
| 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((
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
<div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}"
|
| 778 |
data-idx="${i}" data-title="${escHtml(title)}">
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
dropdown.classList.add('visible');
|
| 783 |
-
//
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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`
|
| 936 |
-
//
|
| 937 |
-
//
|
| 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 |
-
|
| 948 |
-
|
| 949 |
}
|
| 950 |
|
| 951 |
// ── Search ────────────────────────────────────────────────────────────────────
|
|
@@ -1068,23 +1538,21 @@ function resetAll(showHero = true) {
|
|
| 1068 |
resetHeroDisplay();
|
| 1069 |
}
|
| 1070 |
|
| 1071 |
-
|
| 1072 |
}
|
| 1073 |
|
| 1074 |
-
// ── Renderer
|
| 1075 |
-
//
|
| 1076 |
-
//
|
| 1077 |
-
// controller in
|
| 1078 |
-
|
| 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 (
|
| 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 (
|
| 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
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
const
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
})));
|
| 1361 |
-
simulation.alpha(0.3).restart();
|
| 1362 |
}
|
| 1363 |
|
| 1364 |
-
// ── Pan/fly to node
|
|
|
|
| 1365 |
function panToNode(nodeId) {
|
| 1366 |
-
if (
|
| 1367 |
-
|
| 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 |
-
//
|
| 1570 |
-
//
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1800 |
-
|
| 1801 |
-
|
| 1802 |
-
|
| 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>→</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) || ' '}</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,
|
| 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 |
-
|
| 14 |
-
|
| 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
|
| 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", "
|
| 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 |
"""
|
|
|
|
| 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 |
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
| 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">
|
| 91 |
-
|
|
|
|
| 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="
|
| 205 |
-
<svg id="graph"></svg>
|
| 206 |
|
| 207 |
-
<!--
|
| 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
|
| 468 |
-
research
|
| 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,
|
| 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
|
| 617 |
-
<p>Wikipedia, research papers,
|
| 618 |
-
|
| 619 |
</div>
|
| 620 |
</div>
|
| 621 |
<div class="onboarding-step">
|
| 622 |
<div class="onboarding-icon">2</div>
|
| 623 |
<div>
|
| 624 |
-
<strong>
|
| 625 |
-
<p>
|
|
|
|
| 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>
|
| 633 |
-
<p>Click any node to expand its real neighbours,
|
| 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:
|
| 640 |
-
|
| 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="
|
| 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 & 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
|
| 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",
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 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" },
|