Aurelius / app.js
murtaza-2007
Aurelius improvement pass: domain-aware recs, finance/research surfaces, 2D graph
658d200
Raw
History Blame Contribute Delete
101 kB
// ── State ────────────────────────────────────────────────────────────────────
let ws = null;
let nodes = {};
let edges = [];
let centreNode = null;
let pathNodes = new Set();
let width, height;
let running = false;
let _searchGen = 0;
let scoreCards = {};
let heroDismissed = false;
let paused = false;
let foundPath = [];
let _lastElapsedS = 0;
// ── Multi-source state (Aurelius as a general graph engine) ──────────────────
// The active data source the navigator runs on. Wikipedia is the default;
// /api/sources populates the picker with every registered adapter that is
// ready (live sources always; ingested sources once their data exists).
let currentSource = 'wikipedia';
let availableSources = [];
// ── Optional AI layer (Gemini) ───────────────────────────────────────────────
// llmAvailable is a soft hint from /api/llm/status polled at load: it only
// controls whether we bother RENDERING the AI affordances. The real
// gate is per-call β€” every AI endpoint returns {available, reason} and the
// aiBlock helper shows a small non-blocking notice on failure while the
// standard (non-LLM) result stays fully usable. The app runs with no key.
let llmAvailable = false;
async function loadLlmStatus() {
try {
const r = await fetch(`${AC_BACKEND}/api/llm/status`);
const d = await r.json();
llmAvailable = !!d.available;
} catch (e) { llmAvailable = false; }
}
function _aiReason(reason) {
switch (reason) {
case 'rate_limited':
case 'cooling_down':
return 'AI paused β€” rate limit reached. Showing standard results.';
case 'no_key':
return 'AI features are not configured on this server.';
case 'no_articles':
return 'Not enough coverage to summarize yet.';
default:
return 'AI summary unavailable right now. Showing standard results.';
}
}
// Lazily fill an AI block: shimmer β†’ narrative, or a non-blocking notice.
// `field` is which key holds the text ('text' or 'summary'). Never throws;
// the surrounding standard result is always rendered independently first.
async function aiBlock(el, url, opts = {}) {
if (!el) return;
const field = opts.field || 'text';
if (!llmAvailable) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = '<div class="ai-head"><span class="ai-badge">AI</span>'
+ `<span class="ai-label">${escHtml(opts.label || 'In plain English')}</span></div>`
+ '<div class="ai-shimmer"></div>';
try {
const r = await fetch(url);
const d = await r.json();
const head = '<div class="ai-head"><span class="ai-badge">AI</span>'
+ `<span class="ai-label">${escHtml(opts.label || 'In plain English')}</span></div>`;
if (d.available && d[field]) {
el.innerHTML = head + `<div class="ai-text">${escHtml(d[field])}</div>`
+ (opts.tone && d.sentiment ? _aiTone(d.sentiment) : '');
} else {
el.innerHTML = head + `<div class="ai-notice">${escHtml(_aiReason(d.reason))}</div>`;
}
} catch (e) {
el.innerHTML = '<div class="ai-head"><span class="ai-badge">AI</span></div>'
+ `<div class="ai-notice">${escHtml(_aiReason('error'))}</div>`;
}
}
function _aiTone(s) {
const lbl = s.label || 'neutral';
const sym = lbl === 'positive' ? 'β–²' : lbl === 'negative' ? 'β–Ό' : '●';
return `<div class="ai-tone ai-${lbl}">${sym} overall tone: ${escHtml(lbl)}</div>`;
}
// ── Graph explorer state ─────────────────────────────────────────────────────
// graphFilter: which slice of the graph the 3D view shows (all / explored /
// path). expandedNodes: nodes already progressively expanded via
// /api/neighbors, so a second click doesn't refetch.
let graphFilter = 'all';
const expandedNodes = new Set();
// Relationship "lenses" β€” dedicated explorer views over typed edges.
// Picking one keeps only that family of relationships visible (path edges
// always stay), turning the one explorer into an ownership explorer, a
// supply-chain explorer, a macro explorer, etc.
let graphLens = 'all';
const SOURCE_LENSES = {
finance: [
['all', 'All relationships', null],
['ownership', 'Ownership & holdings', ['owns_stake', 'stake_held_by', 'holds', 'held_by']],
['supply', 'Supply chain', ['supplies', 'supplied_by']],
['competition', 'Competition', ['competes_with']],
['correlation', 'Correlation & macro', ['co_moves', 'macro_correlates']],
['leadership', 'Leadership', ['led_by', 'leads']],
['sector', 'Sector & geography', ['sector_member', 'has_member', 'based_in', 'headquarters_of']],
],
news: [
['all', 'All relationships', null],
['comention', 'Entity co-mentions', ['co_mentioned']],
['articles', 'Article mentions', ['mentions', 'mentioned_in']],
],
};
function _lensTypes() {
const lenses = SOURCE_LENSES[currentSource];
if (!lenses || graphLens === 'all') return null;
const row = lenses.find(l => l[0] === graphLens);
return row && row[2] ? new Set(row[2]) : null;
}
function setGraphLens(value) {
graphLens = value;
render();
}
// Finance-only controls (overlays + lens live here) β€” shown by the source
// picker only when the active source has them.
function updateSourceUI() {
const isFinance = currentSource === 'finance';
['gc-overlay-news', 'gc-overlay-reddit'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = isFinance ? '' : 'none';
});
const cmpFab = document.getElementById('compare-fab');
if (cmpFab) cmpFab.style.display =
(currentSource === 'finance' || currentSource === 'biomed') ? '' : 'none';
// Company Profile is a finance-only surface.
const profFab = document.getElementById('profile-fab');
if (profFab) profFab.style.display = isFinance ? '' : 'none';
if (!isFinance) toggleProfile(false);
// Citation Explorer is the research-papers surface.
const isPapers = currentSource === 'openalex';
const paperFab = document.getElementById('paper-fab');
if (paperFab) paperFab.style.display = isPapers ? '' : 'none';
if (!isPapers) togglePaper(false);
}
function renderLensPicker() {
const sel = document.getElementById('graph-lens');
if (!sel) return;
const lenses = SOURCE_LENSES[currentSource];
if (!lenses) {
sel.style.display = 'none';
graphLens = 'all';
return;
}
sel.style.display = '';
sel.innerHTML = lenses.map(([v, label]) =>
`<option value="${escHtml(v)}"${v === graphLens ? ' selected' : ''}>${escHtml(label)}</option>`).join('');
}
// ── NEW: autocomplete state (vector search) ───────────────────────────────────
let acDebounceTimers = { start: null, end: null }; // per-field debounce handles
let acActiveIndex = { start: -1, end: -1 }; // keyboard-highlighted row
let acLastResults = { start: [], end: [] }; // last suggestions per field
const AC_DEBOUNCE_MS = 80; // tight debounce β€” feel instant, but avoid a request on every single keypress
// ── Rotating placeholder examples (hero inputs) ────────────────────────────────
// Nagpur β†’ Mars is first (shown on launch) and held for 2s; every other pair
// uses the default 1.3s. Per-pair `duration` overrides DEFAULT_PLACEHOLDER_MS;
// a setTimeout chain (not setInterval) is what makes a per-pair duration
// possible β€” a single fixed-rate interval couldn't give one entry a different
// dwell time than the rest.
const PLACEHOLDER_PAIRS = [
{ pair: ['Nagpur', 'Mars'], duration: 2000 },
{ pair: ['Alexander the Great', 'Jazz music'] },
{ pair: ['Pizza', 'Mount Everest'] },
{ pair: ['Albert Einstein', 'Coffee'] },
{ pair: ['Leonardo da Vinci', 'The Internet'] },
{ pair: ['Cleopatra', 'Bitcoin'] },
{ pair: ['Shakespeare', 'Video games'] },
{ pair: ['Mahatma Gandhi', 'Formula 1'] },
{ pair: ['Vincent van Gogh', 'Climate change'] },
{ pair: ['Genghis Khan', 'Sushi'] },
{ pair: ['The Moon', 'Chess'] },
];
const DEFAULT_PLACEHOLDER_MS = 1300;
let _placeholderIdx = 0;
let _placeholderTimer = null;
function _applyPlaceholder() {
const startEl = document.getElementById('hero-inp-start');
const endEl = document.getElementById('hero-inp-end');
if (!startEl || !endEl) return;
const [s, e] = PLACEHOLDER_PAIRS[_placeholderIdx].pair;
startEl.placeholder = `e.g. ${s}`;
endEl.placeholder = `e.g. ${e}`;
}
function _scheduleNextPlaceholder() {
const duration = PLACEHOLDER_PAIRS[_placeholderIdx].duration || DEFAULT_PLACEHOLDER_MS;
_placeholderTimer = setTimeout(() => {
const startEl = document.getElementById('hero-inp-start');
const endEl = document.getElementById('hero-inp-end');
// Don't fight the user β€” only advance while both fields are empty, so
// cycling text never replaces something they've actually typed. Focus
// alone does NOT block rotation: boot() auto-focuses hero-inp-start on
// every load, so gating on activeElement here would freeze rotation at
// the first pair for any visitor who hasn't yet clicked away β€” there's
// no real cursor/typed content to interrupt in an empty, placeholder-only
// field regardless of focus.
const canAdvance = startEl && endEl && !startEl.value && !endEl.value;
if (canAdvance) {
_placeholderIdx = (_placeholderIdx + 1) % PLACEHOLDER_PAIRS.length;
_applyPlaceholder();
}
_scheduleNextPlaceholder();
}, duration);
}
function startPlaceholderRotation() {
if (_placeholderTimer) return;
_placeholderIdx = 0; // Nagpur β†’ Mars first, every time rotation (re)starts
_applyPlaceholder();
_scheduleNextPlaceholder();
}
function stopPlaceholderRotation() {
clearTimeout(_placeholderTimer);
_placeholderTimer = null;
}
// ── Hero input logic ──────────────────────────────────────────────────────────
function heroInputChanged() {
const s = document.getElementById('hero-inp-start').value.trim();
const e = document.getElementById('hero-inp-end').value.trim();
// Labels light up when filled
document.getElementById('lbl-start').classList.toggle('filled', s.length > 0);
document.getElementById('lbl-end').classList.toggle('filled', e.length > 0);
// Inputs get filled style
document.getElementById('hero-inp-start').classList.toggle('filled', s.length > 0);
document.getElementById('hero-inp-end').classList.toggle('filled', e.length > 0);
// Arrow lights up when both filled
document.getElementById('hero-arrow').classList.toggle('lit', s.length > 0 && e.length > 0);
// Button and hint appear when both filled
const ready = s.length > 0 && e.length > 0;
document.getElementById('hero-btn').classList.toggle('ready', ready);
document.getElementById('hero-hint').classList.toggle('visible', ready);
}
// NOTE: autocomplete (vector search) is wired separately via addEventListener
// in the NEW block below β€” heroInputChanged() above is the original,
// untouched function and does not need to know about autocomplete at all.
function heroKeyDown(e) {
// NEW: if an autocomplete dropdown is open for this field, arrow keys /
// Enter operate on the dropdown first. This is purely additive β€” if no
// dropdown is open, every branch below falls through to the exact
// original behaviour at the bottom of this function, untouched.
const fieldName = e.target.id === 'hero-inp-start' ? 'start' : 'end';
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
const isOpen = dropdown && dropdown.classList.contains('visible');
if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const items = acLastResults[fieldName];
if (items.length === 0) return;
const delta = e.key === 'ArrowDown' ? 1 : -1;
acActiveIndex[fieldName] = (acActiveIndex[fieldName] + delta + items.length) % items.length;
renderAcDropdown(fieldName);
return;
}
if (isOpen && e.key === 'Enter' && acActiveIndex[fieldName] >= 0) {
e.preventDefault();
selectAcSuggestion(fieldName, acLastResults[fieldName][acActiveIndex[fieldName]]);
return;
}
if (isOpen && e.key === 'Escape') {
closeAcDropdown(fieldName);
return;
}
// ── Original behaviour (unchanged) ──────────────────────────────────────
if (e.key === 'Enter') {
const s = document.getElementById('hero-inp-start').value.trim();
const en = document.getElementById('hero-inp-end').value.trim();
if (s && en) startSearch();
else if (!s) document.getElementById('hero-inp-start').focus();
else document.getElementById('hero-inp-end').focus();
}
}
// ── Wikipedia URL extractor ───────────────────────────────────────────────────
function extractTitleFromWikiUrl(input) {
const match = input.match(/wikipedia\.org\/wiki\/([^#?]+)/);
if (match) return decodeURIComponent(match[1]).replace(/_/g, ' ');
return input;
}
// ════════════════════════════════════════════════════════════════════════════
// NEW: Vector-search autocomplete (hooks into the existing hero inputs via
// addEventListener below β€” does not modify heroInputChanged/heroKeyDown's
// original bodies, see the dedicated NOTE comments near those functions).
// ════════════════════════════════════════════════════════════════════════════
// Single source of truth for the backend origin. Frontend (Vercel) and
// backend (Hugging Face Spaces) are deployed on separate domains, so this
// must be a fixed URL β€” the dynamic window.location.hostname trick used in
// local/LAN dev only works when frontend and backend share a host. The
// WebSocket URL below is derived from it automatically (http→ws, https→wss),
// so nothing else in this file needs to change if the backend URL changes.
// Backend origin. In production the frontend (Vercel) and backend (Hugging
// Face Spaces) live on separate domains, so this is a fixed URL. For local
// development the page is served from localhost (e.g. python http.server on
// :8081) alongside a local uvicorn on :8000 β€” detect that and point there so
// `python main.py` + a static server just works without editing this file.
const _isLocalHost = ['localhost', '127.0.0.1'].includes(location.hostname);
const AC_BACKEND = _isLocalHost ? 'http://localhost:8000'
: 'https://mvali77-aurelius.hf.space';
const AC_BACKEND_WS = AC_BACKEND.replace(/^http/, 'ws');
// ════════════════════════════════════════════════════════════════════════════
// Multi-source: load the registered adapters and let the user pick one.
// ════════════════════════════════════════════════════════════════════════════
async function loadSources() {
try {
const r = await fetch(`${AC_BACKEND}/api/sources`);
const data = await r.json();
availableSources = (data.sources || []).filter(s => s.ready);
} catch (e) {
availableSources = [{ name: 'wikipedia', description: 'English Wikipedia',
supports_backlinks: true, mode: 'live', ready: true }];
}
renderSourcePickers();
renderExampleChips();
updateSourceUI();
}
function renderSourcePickers() {
document.querySelectorAll('.source-picker').forEach(sel => {
sel.innerHTML = availableSources.map(s =>
`<option value="${escHtml(s.name)}"${s.name === currentSource ? ' selected' : ''}>`
+ `${escHtml(sourceLabel(s.name))}</option>`).join('');
});
}
function sourceLabel(name) {
const map = {
wikipedia: 'Wikipedia', openalex: 'Research Papers',
biomed: 'Biology', news: 'News', finance: 'Finance',
};
return map[name] || name;
}
// Per-source example pairs β€” a first-time visitor never faces an empty box.
const EXAMPLE_PAIRS = {
wikipedia: [['Nagpur', 'Mars'], ['Cleopatra', 'Bitcoin'], ['Jazz', 'Quantum mechanics']],
finance: [['NVDA', 'Ford'], ['Tim Cook', 'Crude Oil'], ['Berkshire Hathaway', 'Apple']],
biomed: [['Levodopa', "Alzheimer's disease"], ['BRCA1', 'Apoptosis'], ['Tamoxifen', 'DNA repair']],
// Research papers lead with the citation explorer (hero CTA), not
// connect-two-papers pathfinding — so no A→B example pairs here.
};
function renderExampleChips() {
const box = document.getElementById('hero-examples');
if (!box) return;
const pairs = EXAMPLE_PAIRS[currentSource] || [];
// Finance and Research lead with a dedicated research surface, not
// pathfinding: a prominent CTA opens it straight from the landing.
let cta = '';
if (currentSource === 'finance') {
cta = `<button class="hero-research-cta" onclick="heroResearchCompany()">
<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>
Research a company</button>`;
} else if (currentSource === 'openalex') {
cta = `<button class="hero-research-cta" onclick="heroExplorePaper()">
<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>
Explore a paper's citations</button>`;
}
if (!pairs.length && !cta) { box.innerHTML = ''; return; }
const tryLabel = cta ? 'or trace a link' : 'Try';
box.innerHTML = cta
+ (pairs.length ? `<span class="hero-ex-label">${tryLabel}</span>`
+ pairs.map(([a, b]) =>
`<button class="hero-ex-chip" onclick="runExample('${a.replace(/'/g, "\\'")}','${b.replace(/'/g, "\\'")}')">`
+ `${escHtml(a)} <span>&rarr;</span> ${escHtml(b)}</button>`).join('') : '');
}
// Finance landing β†’ jump straight to the company research surface.
function heroResearchCompany() {
const seed = (document.getElementById('hero-inp-start').value || '').trim();
dismissHero(seed, '');
setTimeout(() => { if (seed) openProfile(seed); else toggleProfile(true); }, 260);
}
// Research landing β†’ jump straight to the citation explorer.
function heroExplorePaper() {
const seed = (document.getElementById('hero-inp-start').value || '').trim();
dismissHero(seed, '');
setTimeout(() => { if (seed) openPaper(seed); else togglePaper(true); }, 260);
}
function runExample(a, b) {
document.getElementById('hero-inp-start').value = a;
document.getElementById('hero-inp-end').value = b;
heroInputChanged();
startSearch();
}
function onSourceChange(value) {
currentSource = value;
renderSourcePickers(); // keep hero + topbar pickers in sync
graphLens = 'all';
renderLensPicker();
renderExampleChips();
updateSourceUI();
const s = availableSources.find(x => x.name === value);
if (s) setStatus(`Source: ${sourceLabel(value)} β€” ${s.description}`);
}
function toggleDiscover(open) {
const panel = document.getElementById('discover-panel');
const fab = document.getElementById('discover-fab');
if (!panel) return;
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
if (fab) fab.classList.toggle('hidden', show);
if (show) {
const inp = document.getElementById('discover-input');
if (inp && centreNode && !inp.value) inp.value = centreNode;
if (inp) inp.focus();
}
}
// ── Discover: hidden-connection candidates for one node ─────────────────────
async function runDiscover(query) {
const q = (query || '').trim();
if (!q) return;
const panel = document.getElementById('discover-results');
if (panel) panel.innerHTML = '<div class="discover-loading">Searching for hidden connections…</div>';
try {
const r = await fetch(`${AC_BACKEND}/api/discover?source=${encodeURIComponent(currentSource)}`
+ `&a=${encodeURIComponent(q)}&k=10`);
const data = await r.json();
renderDiscover(data);
} catch (e) {
if (panel) panel.innerHTML = '<div class="discover-loading">Could not reach the backend.</div>';
}
}
function renderDiscover(data) {
const panel = document.getElementById('discover-results');
if (!panel) return;
if (data.error) { panel.innerHTML = `<div class="discover-loading">${escHtml(data.error)}</div>`; return; }
const cands = data.candidates || [];
if (!cands.length) {
panel.innerHTML = '<div class="discover-loading">No hidden connections surfaced. Try a more connected node.</div>';
return;
}
panel.innerHTML = `<div class="discover-head">Hidden connections from `
+ `<strong>${escHtml(data.a.title)}</strong> β€” none of these link to it `
+ `directly; each is reached only through shared neighbours.</div>`
+ '<div class="ai-block" id="disc-ai"></div>'
+ cands.map(c => {
const simPct = Math.round(Math.max(0, c.similarity) * 100);
return `
<div class="discover-card">
<div class="discover-card-top">
<span class="discover-title">${escHtml(c.title)}</span>
<span class="discover-score">${c.score}</span>
</div>
<div class="discover-why">Why: no direct link exists, but both connect to
${c.n_bridges} of the same neighbour${c.n_bridges === 1 ? '' : 's'}
&mdash; indirect evidence${simPct > 0 ? `, plus ${simPct}% embedding similarity` : ''}.</div>
<div class="discover-bridges">via ${c.bridges.map(b =>
`<span>${escHtml(b.title)}</span>`).join(', ')}</div>
</div>`;
}).join('');
aiBlock(document.getElementById('disc-ai'),
`${AC_BACKEND}/api/explain/discover?source=${encodeURIComponent(currentSource)}`
+ `&a=${encodeURIComponent(data.a.id || data.a.title)}`,
{ label: 'AI: why these surfaced' });
}
// ════════════════════════════════════════════════════════════════════════════
// Compare panel β€” the relationship explorer. Given two entities: connection
// strength + the real intermediaries (via /api/relate), each side's facts
// and price series (via /api/node), an overlaid normalized price chart with
// the computed correlation, and a hand-off to the pathfinder.
// ════════════════════════════════════════════════════════════════════════════
function toggleCompare(open) {
const panel = document.getElementById('compare-panel');
const fab = document.getElementById('compare-fab');
if (!panel) return;
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
if (fab) fab.classList.toggle('hidden', show);
if (show) {
const a = document.getElementById('cmp-a');
if (a && centreNode && !a.value) a.value = centreNode;
if (a) a.focus();
}
}
function _num(x) { return typeof x === 'number' && isFinite(x); }
// Pearson correlation of two equal-length normalized series.
function _correlation(a, b) {
const n = Math.min(a.length, b.length);
if (n < 5) return null;
const xa = a.slice(-n), xb = b.slice(-n);
const ma = xa.reduce((s, v) => s + v, 0) / n;
const mb = xb.reduce((s, v) => s + v, 0) / n;
let num = 0, da = 0, db = 0;
for (let i = 0; i < n; i++) {
const va = xa[i] - ma, vb = xb[i] - mb;
num += va * vb; da += va * va; db += vb * vb;
}
if (da === 0 || db === 0) return null;
return num / Math.sqrt(da * db);
}
// Draw two normalized price series into an SVG (no chart library).
function _priceChartSVG(sa, sb, labelA, labelB) {
const W = 300, H = 96, pad = 6;
const all = [...(sa || []), ...(sb || [])].filter(_num);
if (all.length < 2) return '';
const lo = Math.min(...all), hi = Math.max(...all);
const span = hi - lo || 1;
const path = (s, color) => {
if (!s || s.length < 2) return '';
const step = (W - 2 * pad) / (s.length - 1);
const pts = s.map((v, i) =>
`${(pad + i * step).toFixed(1)},${(H - pad - (v - lo) / span * (H - 2 * pad)).toFixed(1)}`);
return `<polyline fill="none" stroke="${color}" stroke-width="1.6"
stroke-linejoin="round" points="${pts.join(' ')}"/>`;
};
return `<svg class="cmp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="rgba(255,255,255,0.08)"/>
${path(sa, '#e0995c')}${path(sb, '#5b8dd6')}
</svg>
<div class="cmp-legend">
<span><i style="background:#e0995c"></i>${escHtml(labelA)}</span>
<span><i style="background:#5b8dd6"></i>${escHtml(labelB)}</span>
</div>`;
}
async function runCompare() {
const a = document.getElementById('cmp-a').value.trim();
const b = document.getElementById('cmp-b').value.trim();
const out = document.getElementById('compare-results');
if (!a || !b) { if (out) out.innerHTML = '<div class="cmp-msg">Enter two things to compare.</div>'; return; }
if (out) out.innerHTML = '<div class="cmp-msg">Working out the connection…</div>';
const src = encodeURIComponent(currentSource);
try {
const [rel, na, nb] = await Promise.all([
fetch(`${AC_BACKEND}/api/relate?source=${src}&a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`).then(r => r.json()),
fetch(`${AC_BACKEND}/api/node?source=${src}&q=${encodeURIComponent(a)}`).then(r => r.json()),
fetch(`${AC_BACKEND}/api/node?source=${src}&q=${encodeURIComponent(b)}`).then(r => r.json()),
]);
renderCompare(rel, na, nb);
} catch (e) {
if (out) out.innerHTML = '<div class="cmp-msg">Could not reach the backend.</div>';
}
}
function renderCompare(rel, na, nb) {
const out = document.getElementById('compare-results');
if (!out) return;
if (rel.error) { out.innerHTML = `<div class="cmp-msg">${escHtml(rel.error)}</div>`; return; }
const titleA = rel.a.title, titleB = rel.b.title;
const strength = rel.strength;
const bar = `<div class="cmp-strength">
<div class="cmp-strength-label">Connection strength <b>${strength}/100</b></div>
<div class="cmp-strength-track"><div class="cmp-strength-fill" style="width:${strength}%"></div></div>
</div>`;
const why = [];
if (rel.direct && (rel.direct.a_to_b || rel.direct.b_to_a))
why.push('a <b>direct relationship</b> links them');
if (rel.n_paths > 0)
why.push(`<b>${rel.n_paths}</b> short path${rel.n_paths === 1 ? '' : 's'} connect them through one step`);
if (rel.n_co_targets > 0)
why.push(`they connect to <b>${rel.n_co_targets}</b> of the same things`);
if (rel.n_co_sources > 0)
why.push(`<b>${rel.n_co_sources}</b> of the same things connect to both`);
if (_num(rel.similarity) && rel.similarity > 0.2)
why.push(`they're <b>${Math.round(rel.similarity * 100)}%</b> similar in meaning`);
const inter = [...(rel.paths_a_to_b || []), ...(rel.paths_b_to_a || []),
...(rel.co_targets || [])].slice(0, 8);
const whyBlock = `<div class="cmp-why">
<div class="cmp-why-title">Why they're connected</div>
${why.length ? '<ul>' + why.map(w => `<li>${w}</li>`).join('') + '</ul>'
: '<p class="cmp-msg">No strong link surfaced; they may be only distantly related.</p>'}
${inter.length ? `<div class="cmp-inter">through ${inter.map(x =>
`<span>${escHtml(x.title)}</span>`).join(', ')}</div>` : ''}
</div>`;
const fa = (na && na.features) || {}, fb = (nb && nb.features) || {};
let chart = '';
if (Array.isArray(fa.series) && Array.isArray(fb.series)) {
const corr = _correlation(fa.series, fb.series);
const facts = [];
if (_num(fa.window_change_pct)) facts.push(
`<span class="cmp-fact"><i>${escHtml(titleA)}</i> ${fa.window_change_pct >= 0 ? '+' : ''}${fa.window_change_pct}%</span>`);
if (_num(fb.window_change_pct)) facts.push(
`<span class="cmp-fact"><i>${escHtml(titleB)}</i> ${fb.window_change_pct >= 0 ? '+' : ''}${fb.window_change_pct}%</span>`);
chart = `<div class="cmp-chart-block">
<div class="cmp-why-title">How their prices move</div>
${_priceChartSVG(fa.series, fb.series, titleA, titleB)}
<div class="cmp-facts">${facts.join('')}</div>
${corr !== null ? `<div class="cmp-corr">Price correlation over the window:
<b>${corr.toFixed(2)}</b> &mdash; ${corr > 0.6 ? 'they move strongly together'
: corr > 0.3 ? 'they move loosely together'
: corr < -0.3 ? 'they tend to move in opposite directions'
: 'little consistent relationship'}.</div>` : ''}
</div>`;
}
out.innerHTML = `<div class="cmp-pair">${escHtml(titleA)} <span class="cmp-vs">vs</span> ${escHtml(titleB)}</div>`
+ bar + whyBlock + chart
+ '<div class="ai-block" id="cmp-ai"></div>'
+ `<button class="cmp-path-btn" onclick="compareToPath('${escHtml(titleA).replace(/'/g, "\\'")}','${escHtml(titleB).replace(/'/g, "\\'")}')">Show the path between them &rarr;</button>`;
// AI analysis is additive: the strength/why/chart above are already shown;
// this fills in after, and degrades to a small notice on failure.
const src = encodeURIComponent(currentSource);
aiBlock(document.getElementById('cmp-ai'),
`${AC_BACKEND}/api/explain/compare?source=${src}`
+ `&a=${encodeURIComponent(rel.a.id)}&b=${encodeURIComponent(rel.b.id)}`,
{ label: 'AI analysis' });
}
function compareToPath(a, b) {
toggleCompare(false);
if (!heroDismissed) {
document.getElementById('hero-inp-start').value = a;
document.getElementById('hero-inp-end').value = b;
heroInputChanged();
} else {
document.getElementById('inp-start').value = a;
document.getElementById('inp-end').value = b;
}
startSearch();
}
// ════════════════════════════════════════════════════════════════════════════
// Company Profile β€” the primary finance research surface (/api/company).
// A single company's dossier: price, key facts, peers, supply chain,
// correlations, ownership, and the news moving it β€” the graph is secondary.
// ════════════════════════════════════════════════════════════════════════════
const PROFILE_QUICK = ['NVDA', 'AAPL', 'JPM', 'XOM', 'TSLA', 'BRK-B'];
let _profileCurrent = null;
function toggleProfile(open) {
const panel = document.getElementById('profile-panel');
if (!panel) return;
const fab = document.getElementById('profile-fab');
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
panel.setAttribute('aria-hidden', show ? 'false' : 'true');
if (fab) fab.classList.toggle('hidden', show);
if (show) {
renderProfileEmptyChips();
const inp = document.getElementById('profile-input');
// seed with the last-focused / centre company when the box is empty
if (inp && !inp.value && centreNode) inp.value = centreNode;
if (inp && !_profileCurrent) inp.focus();
}
}
function renderProfileEmptyChips() {
const box = document.getElementById('profile-empty-chips');
if (!box) return;
box.innerHTML = PROFILE_QUICK.map(t =>
`<button class="prof-chip" onclick="openProfile('${t}')">${escHtml(t)}</button>`).join('');
}
async function openProfile(query) {
query = (query || '').trim();
if (!query) return;
toggleProfile(true);
const body = document.getElementById('profile-body');
const inp = document.getElementById('profile-input');
if (inp) inp.value = query;
if (body) body.innerHTML = '<div class="prof-loading"><span class="prof-spinner"></span>Building the profile…</div>';
try {
const data = await fetch(`${AC_BACKEND}/api/company?q=${encodeURIComponent(query)}`).then(r => r.json());
if (data.error) {
if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`;
return;
}
_profileCurrent = data;
renderProfile(data);
} catch (e) {
if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>';
}
}
// single-series normalized price chart with an area fill
function _profileChartSVG(series, changePct) {
if (!Array.isArray(series) || series.length < 2) return '';
const W = 560, H = 150, pad = 8;
const lo = Math.min(...series), hi = Math.max(...series);
const span = hi - lo || 1;
const up = (changePct || 0) >= 0;
const stroke = up ? '#4aab79' : '#d66a6a';
const step = (W - 2 * pad) / (series.length - 1);
const xy = series.map((v, i) =>
[pad + i * step, H - pad - (v - lo) / span * (H - 2 * pad)]);
const line = xy.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const area = `${pad},${H - pad} ${line} ${(W - pad).toFixed(1)},${H - pad}`;
return `<svg class="prof-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Price chart">
<defs><linearGradient id="prof-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${stroke}" stop-opacity="0.28"/>
<stop offset="100%" stop-color="${stroke}" stop-opacity="0"/>
</linearGradient></defs>
<polygon fill="url(#prof-grad)" points="${area}"/>
<polyline fill="none" stroke="${stroke}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" points="${line}"/>
</svg>`;
}
function _fmtChange(pct) {
if (typeof pct !== 'number' || !isFinite(pct)) return '';
const cls = pct >= 0 ? 'up' : 'down';
return `<span class="prof-chg ${cls}">${pct >= 0 ? 'β–²' : 'β–Ό'} ${Math.abs(pct)}%</span>`;
}
// a horizontal list of related-company cards (peers/suppliers/…)
function _profileCards(items, opts = {}) {
if (!Array.isArray(items) || !items.length) return '';
return `<div class="prof-cards">` + items.map(c => {
const t = escHtml(c.title || c.id);
const meta = [];
if (opts.showCorr && typeof c.corr === 'number') meta.push(`corr ${c.corr}`);
else if (c.sector) meta.push(escHtml(c.sector));
const chg = (opts.showChange && typeof c.change_pct === 'number') ? _fmtChange(c.change_pct) : '';
return `<button class="prof-card" onclick="openProfile('${(c.id || '').replace(/'/g, "\\'")}')" title="Open ${t}">
<span class="prof-card-name">${t}</span>
<span class="prof-card-meta">${meta.join(' Β· ')} ${chg}</span>
</button>`;
}).join('') + `</div>`;
}
function _profileSection(title, hint, html) {
if (!html) return '';
return `<div class="prof-section">
<div class="prof-sec-head">${escHtml(title)}${hint ? `<span class="prof-sec-hint">${escHtml(hint)}</span>` : ''}</div>
${html}
</div>`;
}
function renderProfile(d) {
const body = document.getElementById('profile-body');
if (!body) return;
const facts = [];
if (d.sector) facts.push(`<span class="prof-fact"><i>Sector</i>${escHtml(d.sector)}</span>`);
if (d.ceo && d.ceo.title) facts.push(`<span class="prof-fact"><i>CEO</i>${escHtml(d.ceo.title)}</span>`);
if (d.country) facts.push(`<span class="prof-fact"><i>HQ</i>${escHtml(d.country)}</span>`);
if (d.etfs && d.etfs.length) facts.push(`<span class="prof-fact"><i>In ETFs</i>${d.etfs.length}</span>`);
const price = (typeof d.last_price === 'number')
? `<div class="prof-price">$${d.last_price} ${_fmtChange(d.change_pct)}
<span class="prof-price-note">last close Β· ${Array.isArray(d.series) ? d.series.length : 0}-day window</span></div>`
: '';
const kindBadge = d.kind ? `<span class="prof-kind">${escHtml(d.kind)}</span>` : '';
body.innerHTML = `
<div class="prof-title-row">
<h2 class="prof-title">${escHtml(d.title || d.id)}</h2>${kindBadge}
</div>
${price}
${facts.length ? `<div class="prof-facts">${facts.join('')}</div>` : ''}
${d.summary ? `<p class="prof-summary">${escHtml(d.summary)}</p>` : ''}
${Array.isArray(d.series) && d.series.length ? `<div class="prof-chart-wrap">${_profileChartSVG(d.series, d.change_pct)}</div>` : ''}
${_profileSection('Peers', 'rivals & sector', _profileCards(d.peers, { showChange: true }))}
${_profileSection('Suppliers', 'depends on', _profileCards(d.suppliers, { showChange: true }))}
${_profileSection('Customers', 'sells to / powers', _profileCards(d.customers, { showChange: true }))}
${_profileSection('Moves with', 'return correlation', _profileCards(d.correlated, { showCorr: true }))}
${_profileSection('Macro exposure', 'correlated indicators', _profileCards(d.macro, { showCorr: true }))}
${_profileSection('Ownership', 'ETFs & investors', _profileCards([...(d.etfs || []), ...(d.investors || [])]))}
<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>
<div class="prof-actions">
<button class="prof-act primary" onclick="profileExploreGraph('${(d.id || '').replace(/'/g, "\\'")}')">Explore in graph</button>
<button class="prof-act" onclick="profileCompare('${(d.id || '').replace(/'/g, "\\'")}')">Compare…</button>
</div>`;
loadProfileNews(d.title || d.id);
}
// News for the profiled company (reuses the News Intelligence entity feed).
async function loadProfileNews(name) {
const box = document.querySelector('#prof-news .prof-news-body');
if (!box) return;
try {
const data = await fetch(`${AC_BACKEND}/api/news/entity?name=${encodeURIComponent(name)}&k=5`).then(r => r.json());
const items = (data && data.results) || [];
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; }
box.innerHTML = items.map(a => {
const s = a.sentiment_label || 'neutral';
const when = a.published ? String(a.published).slice(0, 10) : '';
return `<a class="prof-news-item" href="${escHtml(a.url || '#')}" target="_blank" rel="noopener">
<span class="prof-news-dot prof-${s}" title="${s} tone"></span>
<span class="prof-news-txt">
<span class="prof-news-title">${escHtml(a.title || 'Untitled')}</span>
<span class="prof-news-src">${escHtml(a.source || '')}${when ? ' Β· ' + escHtml(when) : ''}</span>
</span>
</a>`;
}).join('');
} catch (e) {
box.innerHTML = '<div class="prof-msg small">Couldn\'t load news.</div>';
}
}
function profileExploreGraph(id) {
if (!id) return;
toggleProfile(false);
// seed the graph with this company and expand it
if (!nodes[id]) {
nodes[id] = { id, g: 0, h: 0, f: 0, state: 'centre',
kind: 'company', expanded: false,
x: width / 2, y: height / 2, vx: 0, vy: 0 };
}
centreNode = id;
render();
expandNodeFromGraph(id);
}
function profileCompare(id) {
toggleProfile(false);
toggleCompare(true);
const a = document.getElementById('cmp-a');
if (a) a.value = id;
const b = document.getElementById('cmp-b');
if (b) b.focus();
}
// ════════════════════════════════════════════════════════════════════════════
// Citation Explorer β€” the primary research-papers surface (/api/paper).
// Give it a paper (title, DOI, arXiv id or URL) and it shows the works it
// cites and the works citing it, then builds an interactive citation graph
// you expand on demand. Replaces the old connect-two-papers pathfinding.
// ════════════════════════════════════════════════════════════════════════════
const PAPER_QUICK = ['Attention is all you need', 'AlphaFold', 'ImageNet',
'BERT language model', 'CRISPR gene editing'];
let _paperCurrent = null;
function togglePaper(open) {
const panel = document.getElementById('paper-panel');
if (!panel) return;
const fab = document.getElementById('paper-fab');
const show = open === undefined ? !panel.classList.contains('open') : open;
panel.classList.toggle('open', show);
panel.setAttribute('aria-hidden', show ? 'false' : 'true');
if (fab) fab.classList.toggle('hidden', show);
if (show) {
renderPaperEmptyChips();
const inp = document.getElementById('paper-input');
if (inp && !_paperCurrent) inp.focus();
}
}
function renderPaperEmptyChips() {
const box = document.getElementById('paper-empty-chips');
if (!box) return;
box.innerHTML = PAPER_QUICK.map(t =>
`<button class="prof-chip" onclick="openPaper('${t.replace(/'/g, "\\'")}')">${escHtml(t)}</button>`).join('');
}
async function openPaper(query) {
query = (query || '').trim();
if (!query) return;
togglePaper(true);
const body = document.getElementById('paper-body');
const inp = document.getElementById('paper-input');
if (inp) inp.value = query;
if (body) body.innerHTML = '<div class="prof-loading"><span class="prof-spinner"></span>Fetching citations…</div>';
try {
const data = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(query)}`).then(r => r.json());
if (data.error) { if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; }
_paperCurrent = data;
renderPaper(data);
} catch (e) {
if (body) body.innerHTML = '<div class="prof-msg">Could not reach the backend.</div>';
}
}
// Upload a PDF β†’ backend extracts its DOI/arXiv/title and returns the
// citation dossier, which renders exactly like a searched paper.
// Drag-and-drop a PDF anywhere on the citation panel.
(function wirePaperDrop() {
const setup = () => {
const panel = document.getElementById('paper-panel');
if (!panel) return;
const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
['dragenter', 'dragover'].forEach(ev => panel.addEventListener(ev, (e) => {
stop(e); panel.classList.add('drag-over');
}));
['dragleave', 'drop'].forEach(ev => panel.addEventListener(ev, (e) => {
stop(e); if (ev === 'dragleave' && panel.contains(e.relatedTarget)) return;
panel.classList.remove('drag-over');
}));
panel.addEventListener('drop', (e) => {
const f = e.dataTransfer && e.dataTransfer.files;
if (f && f.length) uploadPaperPdf(f);
});
};
if (document.readyState !== 'loading') setup();
else document.addEventListener('DOMContentLoaded', setup);
})();
async function uploadPaperPdf(files) {
const file = files && files[0];
if (!file) return;
togglePaper(true);
const body = document.getElementById('paper-body');
if (body) body.innerHTML = `<div class="prof-loading"><span class="prof-spinner"></span>Reading β€œ${escHtml(file.name)}”…</div>`;
try {
const fd = new FormData();
fd.append('file', file);
const data = await fetch(`${AC_BACKEND}/api/paper/upload`, { method: 'POST', body: fd }).then(r => r.json());
if (data.error) { if (body) body.innerHTML = `<div class="prof-msg">${escHtml(data.error)}</div>`; return; }
_paperCurrent = data;
const inp = document.getElementById('paper-input');
if (inp) inp.value = data.title || '';
renderPaper(data);
} catch (e) {
if (body) body.innerHTML = '<div class="prof-msg">Could not upload the PDF.</div>';
}
}
function _paperMeta(c) {
const bits = [];
if (c.authors && c.authors.length) bits.push(escHtml(c.authors.join(', ') + (c.authors.length >= 3 ? ' et al.' : '')));
if (c.year) bits.push(c.year);
if (c.venue) bits.push(escHtml(c.venue));
return bits.join(' Β· ');
}
function _paperCards(items, badge) {
if (!Array.isArray(items) || !items.length)
return '<div class="prof-msg small">None found.</div>';
return `<div class="paper-list">` + items.map(c => {
const cb = c.cited_by_count ? `<span class="paper-cb" title="times cited">${_kfmt(c.cited_by_count)}Γ—</span>` : '';
return `<button class="paper-item" onclick="openPaper('${(c.id || '').replace(/'/g, "\\'")}')" title="Open this paper">
<span class="paper-item-main">
<span class="paper-item-title">${escHtml(c.title || c.id)}</span>
<span class="paper-item-meta">${_paperMeta(c) || '&nbsp;'}</span>
</span>
${cb}
</button>`;
}).join('') + `</div>`;
}
function _kfmt(n) {
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
return String(n);
}
function renderPaper(d) {
const body = document.getElementById('paper-body');
if (!body) return;
const meta = [];
if (d.authors && d.authors.length) meta.push(escHtml(d.authors.join(', ') + (d.authors.length >= 3 ? ' et al.' : '')));
if (d.year) meta.push(d.year);
if (d.venue) meta.push(escHtml(d.venue));
body.innerHTML = `
<div class="prof-title-row"><h2 class="prof-title">${escHtml(d.title || d.id)}</h2></div>
${meta.length ? `<div class="paper-byline">${meta.join(' Β· ')}</div>` : ''}
<div class="paper-stats">
<span class="paper-stat"><b>${_kfmt(d.cited_by_count || 0)}</b> citations</span>
<span class="paper-stat"><b>${d.n_references || 0}</b> references</span>
<span class="paper-stat"><b>${d.n_citations || 0}</b> citing here</span>
</div>
${d.abstract ? `<p class="prof-summary paper-abstract">${escHtml(d.abstract)}</p>` : ''}
<div class="prof-actions" style="margin-top:16px">
<button class="prof-act primary" onclick="paperBuildGraph()">Build citation graph</button>
</div>
${_profileSection('References', 'papers this cites', _paperCards(d.references))}
${_profileSection('Cited by', 'influential papers citing this', _paperCards(d.citations))}`;
}
// Seed the 2D graph with the paper at the centre, its references (papers it
// cites) and citations (papers citing it), then hand off to the explorer.
function paperBuildGraph() {
const d = _paperCurrent;
if (!d) return;
togglePaper(false);
nodes = {}; edges = []; expandedNodes.clear();
const cx = width / 2, cy = height / 2;
nodes[d.id] = { id: d.id, title: d.title, g: 0, h: 0, f: 0,
state: 'centre', kind: 'paper', expanded: true, x: cx, y: cy, vx: 0, vy: 0 };
centreNode = d.id;
const place = (arr, sign) => (arr || []).forEach((c, i) => {
if (!c.id || nodes[c.id]) return;
const ang = (i / Math.max(1, arr.length)) * Math.PI + (sign > 0 ? 0 : Math.PI);
nodes[c.id] = { id: c.id, title: c.title, g: 1, h: 0, f: 1,
state: 'open', kind: 'paper', expanded: true,
x: cx + Math.cos(ang) * 160 + (Math.random() - .5) * 40,
y: cy + sign * 120 + (Math.random() - .5) * 40, vx: 0, vy: 0 };
});
place(d.references, -1); // things it cites, above
place(d.citations, 1); // things citing it, below
// directed edges: paper β†’ reference (cites); citation β†’ paper (cites)
(d.references || []).forEach(c => { if (nodes[c.id]) edges.push({ from: d.id, to: c.id, isPath: false, type: 'cites', display: 'cites' }); });
(d.citations || []).forEach(c => { if (nodes[c.id]) edges.push({ from: c.id, to: d.id, isPath: false, type: 'cites', display: 'cited by' }); });
expandedNodes.add(d.id);
render();
if (window.GV) window.GV.fit();
setStatus(`Citation graph for "${d.title}" β€” click any paper to expand its citations.`);
}
// Expand a paper node in the graph: pull both its references and citations.
async function expandPaperNode(id) {
if (running || !nodes[id] || expandedNodes.has(id)) return;
setStatus(`Expanding citations for "${truncate(id, 40)}"…`);
try {
const d = await fetch(`${AC_BACKEND}/api/paper?q=${encodeURIComponent(id)}&refs=12&cites=12`).then(r => r.json());
if (d.error) { setStatus(d.error); return; }
expandedNodes.add(id);
const base = nodes[id] || { x: width / 2, y: height / 2 };
if (d.title && nodes[id]) nodes[id].title = d.title;
let added = 0;
const add = (c, sign, from, to, disp) => {
if (!c.id) return;
if (!nodes[c.id]) {
nodes[c.id] = { id: c.id, title: c.title, g: null, h: null, f: null,
state: 'open', kind: 'paper', expanded: true,
x: (base.x || width / 2) + (Math.random() - .5) * 120,
y: (base.y || height / 2) + sign * 80 + (Math.random() - .5) * 60,
vx: 0, vy: 0 };
added++;
}
if (!edges.find(e => e.from === from && e.to === to))
edges.push({ from, to, isPath: false, type: 'cites', display: disp });
};
(d.references || []).forEach(c => add(c, -1, id, c.id, 'cites'));
(d.citations || []).forEach(c => add(c, 1, c.id, id, 'cited by'));
render();
setStatus(`Expanded "${truncate(d.title || id, 40)}": +${added} papers. ${d.n_references} references, ${d.n_citations} citing.`);
} catch (e) {
setStatus('Could not expand β€” backend unreachable.');
}
}
// ════════════════════════════════════════════════════════════════════════════
// News / Reddit overlays β€” fold live coverage onto the focused company.
// ════════════════════════════════════════════════════════════════════════════
const overlayOn = { news: false, reddit: false };
let overlayNode = null;
function toggleOverlay(medium) {
overlayOn[medium] = !overlayOn[medium];
const btn = document.getElementById('gc-overlay-' + medium);
if (btn) btn.classList.toggle('active', overlayOn[medium]);
if (overlayOn.news || overlayOn.reddit) {
refreshOverlay(overlayNode || centreNode);
} else {
closeOverlay();
}
}
function closeOverlay() {
const d = document.getElementById('overlay-drawer');
if (d) d.classList.remove('open');
}
async function refreshOverlay(nodeTitle) {
if (!nodeTitle || (!overlayOn.news && !overlayOn.reddit)) return;
overlayNode = nodeTitle;
const drawer = document.getElementById('overlay-drawer');
const body = document.getElementById('overlay-body');
const titleEl = document.getElementById('ovl-title');
const sentEl = document.getElementById('ovl-sentiment');
if (!drawer || !body) return;
drawer.classList.add('open');
const query = nodeTitle.replace(/\s*\([^)]*\)\s*$/, '').trim() || nodeTitle;
if (titleEl) titleEl.textContent = `Coverage of ${query}`;
if (sentEl) sentEl.textContent = '';
body.innerHTML = '<div class="ovl-msg">Pulling coverage…</div>';
try {
const r = await fetch(`${AC_BACKEND}/api/news/entity?name=${encodeURIComponent(query)}&k=12`);
const data = await r.json();
renderOverlay(data, query);
} catch (e) {
body.innerHTML = '<div class="ovl-msg">Could not reach the news service.</div>';
}
}
function renderOverlay(data, query) {
const body = document.getElementById('overlay-body');
const sentEl = document.getElementById('ovl-sentiment');
if (!body) return;
const items = (data.results || []).filter(a => {
const m = a.medium || 'news';
return (m === 'discussion' && overlayOn.reddit) || (m === 'news' && overlayOn.news);
});
if (sentEl && data.sentiment) {
const lbl = data.sentiment.label;
sentEl.textContent = lbl === 'positive' ? 'β–² positive tone'
: lbl === 'negative' ? 'β–Ό negative tone' : '● neutral tone';
sentEl.className = 'ovl-sentiment ovl-' + lbl;
}
if (!items.length) {
body.innerHTML = `<div class="ovl-msg">No ${overlayOn.reddit && !overlayOn.news ? 'discussion' : 'coverage'} found for ${escHtml(query)} yet.</div>`;
return;
}
body.innerHTML = '<div class="ai-block ovl-ai" id="ovl-ai"></div>'
+ items.map(a => {
const s = a.sentiment_label || 'neutral';
const dot = `<span class="ovl-dot ovl-${s}" title="${s} tone"></span>`;
const medium = (a.medium === 'discussion') ? (a.source || 'Discussion') : (a.source || 'news');
return `<a class="ovl-item" href="${escHtml(a.url || '#')}" target="_blank" rel="noopener">
${dot}
<span class="ovl-item-body">
<span class="ovl-item-title">${escHtml(a.title || '')}</span>
<span class="ovl-item-meta">${escHtml(medium)}${a.published ? ' Β· ' + escHtml(String(a.published).slice(0, 10)) : ''}</span>
</span>
</a>`;
}).join('');
// AI coverage summary + tone, above the headline list. Additive.
aiBlock(document.getElementById('ovl-ai'),
`${AC_BACKEND}/api/news/summary?entity=${encodeURIComponent(query)}`,
{ label: 'Coverage summary', field: 'summary', tone: true });
}
/**
* Debounced, domain-aware autocomplete. Called on every keystroke in a
* hero input, but the network request is delayed by AC_DEBOUNCE_MS so
* rapid typing doesn't spam the backend. Suggestions come from the active
* source's own vocabulary via /api/suggest (companies for finance, papers
* for research, articles for Wikipedia, …).
*/
function triggerAutocomplete(fieldName) {
const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
const query = document.getElementById(inputId).value.trim();
if (acDebounceTimers[fieldName]) clearTimeout(acDebounceTimers[fieldName]);
if (query.length < 2) {
closeAcDropdown(fieldName);
return;
}
acDebounceTimers[fieldName] = setTimeout(async () => {
try {
// Domain-aware: suggestions come from whichever source is active
// (companies/tickers for finance, papers for research, diseases for
// biology, articles for Wikipedia) via the backend /api/suggest β€”
// no longer hardcoded to Wikipedia's opensearch.
const params = new URLSearchParams({
source: currentSource, q: query, limit: '7',
});
const res = await fetch(`${AC_BACKEND}/api/suggest?${params}`);
if (!res.ok) { closeAcDropdown(fieldName); return; }
const data = await res.json();
const suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
// The user may have kept typing while this request was in flight β€”
// if the field no longer matches what we searched for, drop the
// (now-stale) result instead of flashing an outdated dropdown.
const currentValue = document.getElementById(inputId).value.trim();
if (currentValue !== query) return;
acLastResults[fieldName] = suggestions;
acActiveIndex[fieldName] = -1;
renderAcDropdown(fieldName);
} catch (err) {
// Network hiccup β€” fail silently, autocomplete is a nice-to-have
// and must never block the user from just typing a title and
// pressing Enter as before.
closeAcDropdown(fieldName);
}
}, AC_DEBOUNCE_MS);
}
// Per-kind glyph for the suggestion dropdown β€” a small visual cue that the
// recommendation is domain-appropriate (a paper vs a company vs a gene).
function _acKindIcon(kind) {
const P = 'stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"';
const wrap = (inner) => `<svg class="ac-icon" viewBox="0 0 16 16" width="14" height="14" ${P}>${inner}</svg>`;
switch (kind) {
case 'company': return wrap('<path d="M3 14V4l5-2v12M8 14V6l5 2v6M2 14h12"/><path d="M5 6h0M5 9h0M10 9h0M10 11h0"/>');
case 'etf': return wrap('<circle cx="8" cy="8" r="6"/><path d="M8 2v6l4 2"/>');
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"/>');
case 'executive':
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"/>');
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"/>');
case 'macro': return wrap('<path d="M2 12l4-4 3 3 5-6"/><path d="M14 5v3h-3"/>');
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"/>');
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"/>');
}
}
/**
* Escape a string for safe interpolation into innerHTML. Article titles come
* from the Wikipedia API and can't currently contain "<"/">" (MediaWiki
* forbids them in titles), but they CAN contain "&", quotes, and arbitrary
* Unicode β€” and the backend data source could change. Routing every
* untrusted string through here makes the innerHTML sinks below safe by
* construction rather than by assumption.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Render the top-5 suggestions below the given hero field. */
function renderAcDropdown(fieldName) {
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
const items = acLastResults[fieldName];
if (!items || items.length === 0) {
closeAcDropdown(fieldName);
return;
}
dropdown.innerHTML = items.map((item, i) => {
// Backend suggestions are objects {title, subtitle, kind}; keep a
// string fallback so nothing breaks if an older payload appears.
const title = typeof item === 'string' ? item : (item.title || '');
const subtitle = typeof item === 'string' ? '' : (item.subtitle || '');
const kind = typeof item === 'string' ? '' : (item.kind || '');
return `
<div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}"
data-idx="${i}" data-title="${escHtml(title)}">
${_acKindIcon(kind)}
<span class="ac-text">
<span class="ac-title">${escHtml(title)}</span>
${subtitle ? `<span class="ac-sub">${escHtml(subtitle)}</span>` : ''}
</span>
</div>`;
}).join('');
dropdown.classList.add('visible');
// Event delegation with data-title avoids inline-onclick quote-collision.
dropdown.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('click', () => selectAcSuggestion(fieldName, el.dataset.title));
});
}
function closeAcDropdown(fieldName) {
const dropdown = document.getElementById('ac-dropdown-' + fieldName);
if (dropdown) {
dropdown.classList.remove('visible');
dropdown.innerHTML = '';
}
acLastResults[fieldName] = [];
acActiveIndex[fieldName] = -1;
}
/** Called when the user clicks (or presses Enter on) a suggestion row.
* Accepts either a plain title string (click path) or a suggestion object
* (keyboard path). */
function selectAcSuggestion(fieldName, item) {
const title = typeof item === 'string' ? item : (item && item.title) || '';
const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
document.getElementById(inputId).value = title;
closeAcDropdown(fieldName);
// Re-run the existing, untouched hero validation logic (lights up the
// label/arrow/button exactly as if the user had typed this themselves).
heroInputChanged();
}
// Close dropdowns when clicking anywhere outside them (standard
// autocomplete UX) β€” purely additive, does not interfere with any
// existing click handlers elsewhere in the app.
document.addEventListener('click', (e) => {
if (!e.target.closest('.hero-field')) {
closeAcDropdown('start');
closeAcDropdown('end');
}
});
// ════════════════════════════════════════════════════════════════════════════
// NEW: Random button β€” picks two curated topics via /api/random, then
// reuses the EXISTING startSearch() pipeline untouched (same WebSocket
// flow, same A* navigator on the backend, same rendering code).
// ════════════════════════════════════════════════════════════════════════════
async function useRandomPair() {
// Per the requirements: only auto-fill if the user hasn't already typed
// a query themselves β€” we don't want to clobber a half-typed search.
const sVal = document.getElementById('hero-inp-start').value.trim();
const eVal = document.getElementById('hero-inp-end').value.trim();
if (sVal || eVal) {
// BUGFIX: setStatus() alone is invisible here β€” #status-bar lives inside
// #topbar, which has opacity:0 until the hero is dismissed. Surface the
// message on the button itself too, since that's always visible.
setStatus('Clear both fields first to use Random.');
btn_guard_msg(document.getElementById('hero-random-btn'), 'Clear fields first');
return;
}
const btn = document.getElementById('hero-random-btn');
btn.classList.add('loading');
btn.textContent = 'Picking...';
try {
const res = await fetch(`${AC_BACKEND}/api/random?source=${encodeURIComponent(currentSource)}`);
if (!res.ok) throw new Error('Random pick failed');
const data = await res.json();
document.getElementById('hero-inp-start').value = data.start;
document.getElementById('hero-inp-end').value = data.end;
heroInputChanged(); // existing function β€” lights up labels/arrow/button
btn.classList.remove('loading');
btn.textContent = 'Random';
// Small delay so the user can actually see which two topics were
// picked before the hero screen animates away.
setTimeout(() => startSearch(), 600);
} catch (err) {
btn.classList.remove('loading');
setStatus('Could not reach backend for Random pick.');
// BUGFIX: status bar is invisible on hero screen β€” show error on button too.
btn_guard_msg(btn, '⚠️ Backend offline');
}
}
// Small helper for useRandomPair(): briefly shows an error message directly
// on the button (always visible on hero screen), then restores its label.
function btn_guard_msg(btn, msg) {
const original = 'Random';
btn.textContent = msg;
setTimeout(() => { btn.textContent = original; }, 1800);
}
// ── Dismiss hero, show app ────────────────────────────────────────────────────
function dismissHero(start, end) {
if (heroDismissed) return;
heroDismissed = true;
stopPlaceholderRotation();
// Copy values to topbar inputs
document.getElementById('inp-start').value = start;
document.getElementById('inp-end').value = end;
// Animate hero out
const hero = document.getElementById('hero');
hero.classList.add('hiding');
setTimeout(() => hero.classList.add('hidden'), 500);
// Animate topbar in
document.getElementById('topbar').classList.add('visible');
// On phones/narrow tablets, default the search-controls topbar and the
// path/scores/log panel to collapsed the moment the main view opens, so
// what you see first is the live graph forming the path, not a wall of
// chrome. Both are reachable again via their handle bars. No-op on
// desktop/tablet β€” the CSS that makes .mobile-collapsed do anything only
// exists under the same max-width:820px breakpoint this check mirrors.
if (isMobileLayout()) {
document.getElementById('topbar').classList.add('mobile-collapsed');
document.getElementById('panel').classList.add('mobile-collapsed');
_syncMobileHandles();
}
}
// ── Mobile topbar/panel collapse ──────────────────────────────────────────────
function isMobileLayout() {
return window.matchMedia('(max-width: 820px)').matches;
}
function toggleMobileTopbar() {
document.getElementById('topbar').classList.toggle('mobile-collapsed');
_syncMobileHandles();
}
function toggleMobilePanel() {
document.getElementById('panel').classList.toggle('mobile-collapsed');
_syncMobileHandles();
}
// Keeps each handle's chevron direction (and its own .is-collapsed state,
// used purely for the CSS rotate transform) matching what it actually
// controls, since the two can change independently of each other.
function _syncMobileHandles() {
const topbarCollapsed = document.getElementById('topbar').classList.contains('mobile-collapsed');
const panelCollapsed = document.getElementById('panel').classList.contains('mobile-collapsed');
const th = document.getElementById('mobile-topbar-handle');
const ph = document.getElementById('mobile-panel-handle');
if (th) th.classList.toggle('is-collapsed', topbarCollapsed);
if (ph) ph.classList.toggle('is-collapsed', panelCollapsed);
// Collapsing/expanding either bar resizes #canvas-wrap via CSS transition,
// but that's an internal layout change, not a viewport resize β€” it never
// fires the window `resize` the canvas renderer listens for. Nudge one
// once the 0.32s transition (styles.css) finishes so the graph reframes
// on the new canvas size.
setTimeout(_resyncCanvasSize, 340);
}
function _resyncCanvasSize() {
const cw = document.getElementById('canvas-wrap');
if (!cw) return;
width = cw.clientWidth;
height = cw.clientHeight;
// graph2d.js listens for window resize; dispatch one so it re-measures.
window.dispatchEvent(new Event('resize'));
}
// ── Search ────────────────────────────────────────────────────────────────────
function startSearch() {
// Pull from whichever screen is active
let rawStart, rawEnd;
if (!heroDismissed) {
rawStart = document.getElementById('hero-inp-start').value.trim();
rawEnd = document.getElementById('hero-inp-end').value.trim();
} else {
rawStart = document.getElementById('inp-start').value.trim();
rawEnd = document.getElementById('inp-end').value.trim();
}
if (!rawStart || !rawEnd) {
setStatus('Please enter both a start and end article.');
return;
}
if (running) return;
const start = extractTitleFromWikiUrl(rawStart);
const end = extractTitleFromWikiUrl(rawEnd);
dismissHero(rawStart, rawEnd);
resetAll(false);
running = true;
document.getElementById('btn-search').disabled = true;
switchTab('log');
document.getElementById('stats-bar').classList.add('visible');
document.getElementById('btn-pause-bar').classList.add('visible');
const stepsLive = document.getElementById('sv-steps-live');
if (stepsLive) { document.getElementById('sv-steps-count').textContent = '0'; stepsLive.classList.add('visible'); }
paused = false;
foundPath = [];
addLog(`Starting: "${start}" β†’ "${end}"`, 'highlight');
setStatus('Connecting...');
const gen = ++_searchGen;
ws = new WebSocket(`${AC_BACKEND_WS}/ws`);
ws.onopen = () => {
if (gen !== _searchGen) return;
ws.send(JSON.stringify({ start, end, source: currentSource }));
setStatus('Connected. Navigating the graph...');
};
ws.onmessage = e => {
if (gen !== _searchGen) return;
try { handleMessage(JSON.parse(e.data)); }
catch (err) { console.error('Parse error:', err); }
};
ws.onerror = () => {
if (gen !== _searchGen) return;
// Only a connection error mid-search is a real problem. When the search
// finishes, the backend returns from the WS handler and closes the
// socket β€” mobile browsers (Safari/Chrome on iOS especially) surface
// that normal teardown as an `error` event, which would otherwise fire
// this scary "is the backend running?" message even though the path was
// found seconds earlier. `running` is false once found/not_found/error
// has been handled, so bail out β€” there's nothing wrong to report.
if (!running) return;
setStatus('⚠️ Cannot connect to backend. Is main.py running?');
addLog('WebSocket error; is the backend running? (python main.py)', 'error');
document.getElementById('btn-search').disabled = false;
running = false;
};
ws.onclose = () => {
if (gen !== _searchGen) return;
if (running) {
setStatus('Connection closed.');
running = false;
document.getElementById('btn-search').disabled = false;
}
};
}
function resetAll(showHero = true) {
if (ws) { ws.close(); ws = null; }
nodes = {}; edges = []; centreNode = null;
pathNodes = new Set(); scoreCards = {};
running = false;
expandedNodes.clear();
graphFilter = 'all';
graphLens = 'all';
overlayNode = null;
closeOverlay();
renderLensPicker();
document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
b.classList.toggle('active', b.dataset.filter === 'all'));
// BUGFIX: "New Search" lives inside #success-modal itself and calls
// resetAll(true) directly β€” without this, the stale "Path Discovered"
// modal stays visible on top of the freshly-reset hero screen,
// permanently blocking it until the next search happens to find a path.
document.getElementById('success-modal').classList.remove('visible');
document.getElementById('btn-search').disabled = false;
document.getElementById('path-banner').classList.remove('visible');
document.getElementById('path-display').innerHTML = '<p style="font-size:13px;color:var(--text2)">Path will appear here as Aurelius explores.</p>';
document.getElementById('scores-list').innerHTML = '';
document.getElementById('log').innerHTML = '';
setStatus('Enter two topics and hit Find Path.');
document.getElementById('stats-bar').classList.remove('visible');
_hidePauseBtn();
document.getElementById('btn-export').classList.remove('visible');
paused = false;
foundPath = [];
if (showHero) {
heroDismissed = false;
document.getElementById('topbar').classList.remove('visible');
document.getElementById('topbar').classList.remove('mobile-collapsed');
document.getElementById('panel').classList.remove('mobile-collapsed');
const hero = document.getElementById('hero');
hero.classList.remove('hiding', 'hidden');
document.getElementById('hero-inp-start').value = '';
document.getElementById('hero-inp-end').value = '';
heroInputChanged();
resetHeroDisplay();
}
initGraph();
}
// ── Renderer (2D canvas via graph2d.js) ──────────────────────────────────────
// The WebSocket message handlers only ever touch the shared `nodes`/`edges`
// state and call render(); render() hands that state to the canvas
// controller (window.GV) in graph2d.js.
let _gvReady = false;
function _graphCtx() {
return { pathNodes, foundPath, centreNode, filter: graphFilter,
lens: _lensTypes(), running };
}
// ── Graph view filters + fit (explorer controls) ─────────────────────────────
function setGraphFilter(mode) {
graphFilter = mode;
document.querySelectorAll('#graph-controls .gc-btn[data-filter]').forEach(b =>
b.classList.toggle('active', b.dataset.filter === mode));
render();
}
function fitGraphView() {
if (window.GV) window.GV.fit();
}
// ── Progressive expansion: click a node β†’ pull its real neighbors in ─────────
// Only active once the search has settled (clicking mid-search would fight
// the stream). Every edge carries its relationship type + evidence string,
// which the 3D view surfaces as an edge tooltip.
async function expandNodeFromGraph(id) {
// Research papers expand in BOTH citation directions (references +
// citing works) via the citation-explorer path.
if (currentSource === 'openalex') return expandPaperNode(id);
if (running || !nodes[id] || expandedNodes.has(id)) return;
setStatus(`Expanding "${id}"…`);
try {
const r = await fetch(`${AC_BACKEND}/api/neighbors?source=${encodeURIComponent(currentSource)}`
+ `&q=${encodeURIComponent(id)}&limit=10`);
const data = await r.json();
if (data.error || !data.neighbors) {
setStatus(data.error || `Could not expand "${id}".`);
return;
}
expandedNodes.add(id);
const base = nodes[id];
if (data.node && data.node.kind) base.kind = data.node.kind;
let added = 0;
data.neighbors.forEach(nb => {
const title = nb.title;
if (!nodes[title]) {
nodes[title] = {
id: title, g: null, h: null, f: null, state: 'open',
kind: nb.kind || null, expanded: true,
x: (base.x || width / 2) + (Math.random() - .5) * 90,
y: (base.y || height / 2) + (Math.random() - .5) * 90,
vx: 0, vy: 0,
};
added++;
} else {
if (nb.kind && !nodes[title].kind) nodes[title].kind = nb.kind;
nodes[title].expanded = true;
}
const exists = edges.find(e =>
(e.from === id && e.to === title) || (e.from === title && e.to === id));
if (!exists) {
edges.push({ from: id, to: title, isPath: false,
type: nb.type, display: nb.display });
} else if (!exists.display) {
exists.type = nb.type; exists.display = nb.display;
}
});
render();
if (overlayOn.news || overlayOn.reddit) refreshOverlay(id);
setStatus(`Expanded "${id}": ${data.neighbors.length} connections`
+ (added ? ` (${added} new)` : '') + '. Hover an edge to see why it exists.');
addLog(`Expanded ${id} β†’ ` + data.neighbors.slice(0, 5)
.map(n => `${n.title} [${n.display}]`).join(', ')
+ (data.neighbors.length > 5 ? '…' : ''));
} catch (e) {
setStatus('Could not expand β€” backend unreachable.');
}
}
// ── Init graph ──────────────────────────────────────────────────────────────
function initGraph() {
const wrap = document.getElementById('canvas-wrap');
width = wrap.clientWidth;
height = wrap.clientHeight;
const el = document.getElementById('graph-canvas');
const gcBar = document.getElementById('graph-controls');
if (!_gvReady) _gvReady = window.GV.init(el, { onExpand: expandNodeFromGraph });
if (_gvReady) window.GV.reset();
if (gcBar) gcBar.style.display = 'flex';
renderLensPicker();
}
// ── Render: hand the shared state to the canvas controller ───────────────────
function render() {
if (window.GV) window.GV.sync(nodes, edges, _graphCtx());
}
// ── Pan/fly to a node (after a search; the auto-follow owns the camera
// while a search is streaming, so we don't fight it mid-run) ─────────────────
function panToNode(nodeId) {
if (!running && window.GV) window.GV.focus(nodeId);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function setStatus(msg) { document.getElementById('status-bar').textContent = msg; }
function addLog(msg, type = '') {
const log = document.getElementById('log');
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
const time = new Date().toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
entry.textContent = `[${time}] ${msg}`;
log.insertBefore(entry, log.firstChild);
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
const names = ['path', 'scores', 'log', 'legend'];
t.classList.toggle('active', names[i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'tab-' + name);
});
}
// External URL for a node title, or null when the active source has no
// canonical public page per node (finance tickers, news entities, …).
function nodeUrl(title) {
if (currentSource === 'wikipedia') {
return 'https://en.wikipedia.org/wiki/' + encodeURIComponent(title.replace(/ /g, '_'));
}
return null;
}
function updatePathPanel(pathArr) {
const div = document.getElementById('path-display');
if (!pathArr || pathArr.length === 0) return;
div.innerHTML = '';
pathArr.forEach((p, i) => {
const row = document.createElement('div');
row.className = 'path-step';
const dot = document.createElement('div');
dot.className = 'dot' + (i === 0 ? ' start' : i === pathArr.length - 1 ? ' end' : '');
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
if (url) { btn.href = url; btn.target = '_blank'; }
btn.textContent = p;
row.appendChild(dot);
row.appendChild(btn);
div.appendChild(row);
if (i < pathArr.length - 1) {
const arr = document.createElement('div');
arr.className = 'path-step';
arr.style.margin = '2px 0 2px 4px';
arr.innerHTML = '<span class="arrow">↓</span>';
div.appendChild(arr);
}
});
}
function updateScores(nodeData) {
scoreCards[nodeData.id] = nodeData;
const list = document.getElementById('scores-list');
list.innerHTML = '';
const sorted = Object.values(scoreCards)
.filter(n => n.f != null)
.sort((a, b) => a.f - b.f)
.slice(0, 30);
sorted.forEach(n => {
const card = document.createElement('div');
card.className = 'score-card';
card.innerHTML = `
<div class="title">${escHtml(n.id)}</div>
<div class="score-row"><span>f(n)</span><span>${n.f}</span></div>
<div class="score-row"><span>g(n)</span><span>${n.g ?? 'β€”'}</span></div>
<div class="score-row"><span>h(n)</span><span>${n.h ?? 'β€”'}</span></div>
`;
list.appendChild(card);
});
}
function showPathBanner(pathArr) {
const banner = document.getElementById('path-banner');
banner.innerHTML = '';
banner.classList.add('visible');
const lbl = document.createElement('span');
lbl.style.cssText = 'font-size:13px;color:var(--text2);margin-right:12px;white-space:nowrap;font-weight:600';
lbl.textContent = `βœ“ Path (${pathArr.length - 1} hops):`;
banner.appendChild(lbl);
pathArr.forEach((p, i) => {
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
btn.style.flex = 'none';
if (url) { btn.href = url; btn.target = '_blank'; }
btn.textContent = p;
banner.appendChild(btn);
if (i < pathArr.length - 1) {
const arr = document.createElement('span');
arr.className = 'banner-arrow';
arr.textContent = 'β†’';
banner.appendChild(arr);
}
});
}
// ── WebSocket message handler ─────────────────────────────────────────────────
function handleMessage(msg) {
switch (msg.event) {
case 'status':
setStatus(msg.message);
addLog(msg.message);
break;
case 'resolved':
addLog(`Resolved: "${msg.start}" β†’ "${msg.end}"`, 'highlight');
setStatus(`Navigating: ${msg.start} β†’ ${msg.end}`);
break;
case 'node_add': {
nodes[msg.id] = {
id: msg.id, g: msg.g, h: msg.h, f: msg.f,
state: msg.state,
x: width / 2 + (Math.random() - .5) * 120,
y: height / 2 + (Math.random() - .5) * 120,
vx: 0, vy: 0,
};
updateScores(msg);
render();
break;
}
case 'expand_node': {
if (centreNode && centreNode !== msg.id) {
if (nodes[centreNode]) nodes[centreNode].state = 'open';
}
centreNode = msg.id;
if (nodes[msg.id]) {
nodes[msg.id].state = 'centre';
nodes[msg.id].g = msg.g;
nodes[msg.id].h = msg.h;
nodes[msg.id].f = msg.f;
}
pathNodes = new Set(msg.path_so_far || []);
edges.forEach(e => {
const pArr = msg.path_so_far || [];
e.isPath = false;
for (let i = 0; i < pArr.length - 1; i++) {
if ((e.from === pArr[i] && e.to === pArr[i + 1]) ||
(e.to === pArr[i] && e.from === pArr[i + 1])) {
e.isPath = true;
}
}
});
updatePathPanel(msg.path_so_far);
updateScores(msg);
addLog(`Expanding: ${msg.id} f=${msg.f}`, 'highlight');
if (msg.stats) _updateStats(msg.stats);
render();
// The canvas auto-follow keeps the whole growing graph framed while
// the search streams, so we don't fly to each centre node here.
break;
}
case 'node_state': {
if (nodes[msg.id]) nodes[msg.id].state = msg.state;
render();
break;
}
case 'neighbours': {
msg.nodes.forEach(n => {
if (!nodes[n.id]) {
const cn = nodes[msg.centre] || { x: width / 2, y: height / 2 };
const angle = Math.random() * 2 * Math.PI;
const dist = 90 + Math.random() * 70;
nodes[n.id] = {
id: n.id, g: n.g, h: n.h, f: n.f,
state: n.state,
x: cn.x + Math.cos(angle) * dist,
y: cn.y + Math.sin(angle) * dist,
vx: 0, vy: 0,
};
} else {
nodes[n.id].g = n.g;
nodes[n.id].h = n.h;
nodes[n.id].f = n.f;
if (nodes[n.id].state !== 'target' && nodes[n.id].state !== 'centre') {
nodes[n.id].state = n.state;
}
}
updateScores(n);
});
msg.edges.forEach(e => {
const exists = edges.find(ex => ex.from === e.from && ex.to === e.to);
if (!exists) edges.push({ from: e.from, to: e.to, isPath: false });
});
render();
break;
}
case 'found': {
const path = msg.path;
pathNodes = new Set(path);
// Synthesize missing nodes: the meeting check (d1/d2) skips the
// neighbours event, so bridge nodes may not exist in the frontend.
path.forEach((id, i) => {
if (!nodes[id]) {
const prev = i > 0 ? nodes[path[i - 1]] : null;
nodes[id] = {
id, g: i, h: 0, f: i,
state: 'path',
x: (prev ? prev.x : width / 2) + (Math.random() - .5) * 100,
y: (prev ? prev.y : height / 2) + (Math.random() - .5) * 100,
vx: 0, vy: 0,
};
}
});
path.forEach(id => { if (nodes[id]) nodes[id].state = 'path'; });
if (nodes[path[0]]) nodes[path[0]].state = 'start';
if (nodes[path[path.length - 1]]) nodes[path[path.length - 1]].state = 'target';
edges.forEach(e => {
e.isPath = false;
for (let i = 0; i < path.length - 1; i++) {
if ((e.from === path[i] && e.to === path[i + 1]) ||
(e.to === path[i] && e.from === path[i + 1])) {
e.isPath = true;
}
}
});
for (let i = 0; i < path.length - 1; i++) {
const a = path[i], b = path[i + 1];
const exists = edges.find(e =>
(e.from === a && e.to === b) || (e.from === b && e.to === a));
if (exists) {
exists.isPath = true;
} else {
edges.push({ from: a, to: b, isPath: true });
}
}
updatePathPanel(path);
showPathBanner(path);
addLog(`βœ“ Found! ${path.join(' β†’ ')} (${msg.total_hops} hops, ${msg.steps} steps)`, 'success');
setStatus(`Done! Path found in ${msg.total_hops} hops.`);
// Set the found/running state BEFORE render() so the canvas captures
// the found context (green path, dimmed cloud) on this sync β€” the
// renderer reads _graphCtx() once per sync, not per animation frame.
running = false;
foundPath = path;
render();
document.getElementById('btn-search').disabled = false;
_hidePauseBtn();
document.getElementById('btn-export').classList.add('visible');
if (msg.stats) _updateStats(msg.stats);
animatePath(path, msg.steps, msg.total_hops, msg.display_texts);
break;
}
case 'not_found': {
addLog(`βœ— ${msg.message}`, 'error');
setStatus(msg.message);
document.getElementById('btn-search').disabled = false;
running = false;
_hidePauseBtn();
if (msg.stats) _updateStats(msg.stats);
break;
}
case 'stats': {
_updateStats(msg);
break;
}
case 'error': {
addLog(`Error: ${msg.message}`, 'error');
setStatus(`Error: ${msg.message}`);
document.getElementById('btn-search').disabled = false;
running = false;
_hidePauseBtn();
break;
}
}
}
// ── Stats update ─────────────────────────────────────────────────────────────
function _updateStats(s) {
if (!s) return;
_lastElapsedS = s.elapsed_s ?? 0;
const cnt = document.getElementById('sv-steps-count');
if (cnt) cnt.textContent = (s.nodes_visited ?? 0).toLocaleString();
}
// ── Pause / Resume ────────────────────────────────────────────────────────────
function togglePause() {
paused = !paused;
const btn = document.getElementById('btn-pause-bar');
if (paused) {
btn.textContent = 'β–Ά Resume';
btn.classList.add('paused');
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'pause' }));
} else {
btn.textContent = '⏸ Pause';
btn.classList.remove('paused');
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'resume' }));
}
}
function _hidePauseBtn() {
paused = false;
const btn = document.getElementById('btn-pause-bar');
btn.classList.remove('visible', 'paused');
btn.textContent = '⏸ Pause';
const sl = document.getElementById('sv-steps-live');
if (sl) sl.classList.remove('visible');
}
// ── Export path ───────────────────────────────────────────────────────────────
function exportPath() {
if (!foundPath.length) return;
const lines = [
'Aurelius - Path Export',
'='.repeat(50),
'',
`Path (${foundPath.length - 1} hops):`,
'',
...foundPath.map((p, i) => {
const url = nodeUrl(p);
return url ? `${i + 1}. ${p}\n ${url}` : `${i + 1}. ${p}`;
}),
'',
`Exported: ${new Date().toLocaleString()}`,
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aurelius-path.txt';
a.click();
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.code === 'Space') { e.preventDefault(); if (running) togglePause(); }
if (e.code === 'KeyR') { resetAll(); }
if (e.code === 'KeyE') { exportPath(); }
if (e.key === '?') { openAboutModal(); }
});
function swapInputs() {
const s = document.getElementById('inp-start');
const en = document.getElementById('inp-end');
if (!s || !en) return;
[s.value, en.value] = [en.value, s.value];
}
function logoClick() {
resetAll(true);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
initGraph();
// Focus first hero input
document.getElementById('hero-inp-start').focus();
window.addEventListener('resize', () => {
width = document.getElementById('canvas-wrap').clientWidth;
height = document.getElementById('canvas-wrap').clientHeight;
});
// NEW: wire vector-search autocomplete onto the two hero inputs. This is
// a separate addEventListener (not the existing oninput="heroInputChanged()"
// attribute already on these elements) so both the original validation
// logic and the new autocomplete logic run independently, side by side,
// every time the user types β€” neither one touches or depends on the other.
document.getElementById('hero-inp-start')
.addEventListener('input', () => triggerAutocomplete('start'));
document.getElementById('hero-inp-end')
.addEventListener('input', () => triggerAutocomplete('end'));
});
function animatePath(path, steps, hops, displayTexts) {
// The canvas renderer already recolours the found path and dims the
// cloud; frame the whole route, then present the result.
if (window.GV) window.GV.fit();
setTimeout(() => openSuccessModal(path, steps, hops, displayTexts), 900);
}
function openSuccessModal(path, steps, hops, displayTexts) {
document.getElementById('modal-hops').textContent = hops;
document.getElementById('modal-steps').textContent = steps;
const elapsed = _lastElapsedS;
document.getElementById('modal-elapsed').textContent = elapsed >= 60
? Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's'
: elapsed + 's';
// The piped-link disclaimer only makes sense for Wikipedia paths.
const disclaimer = document.getElementById('modal-disclaimer');
if (disclaimer) disclaimer.style.display =
currentSource === 'wikipedia' ? '' : 'none';
const container = document.getElementById('modal-path-container');
container.innerHTML = '';
path.forEach((p, i) => {
const url = nodeUrl(p);
const btn = document.createElement(url ? 'a' : 'span');
btn.className = 'hop-btn' + (i === 0 ? ' start-hop' : i === path.length - 1 ? ' end-hop' : '');
if (url) { btn.href = url; btn.target = '_blank'; }
btn.innerHTML = `
<span>${escHtml(p)}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;opacity:0.7">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>`;
container.appendChild(btn);
if (i < path.length - 1) {
const arr = document.createElement('span');
arr.className = 'modal-arrow';
arr.textContent = 'β†’';
const piped = displayTexts && displayTexts[i];
if (piped) {
arr.classList.add('piped-edge');
arr.title = `Shown as "${piped}" in the article text`;
const note = document.createElement('span');
note.className = 'modal-piped-note';
note.textContent = `"${piped}"`;
arr.appendChild(note);
}
container.appendChild(arr);
}
});
document.getElementById('modal-copy-btn').textContent = 'Copy Path';
document.getElementById('success-modal').classList.add('visible');
// Plain-English path narrative β€” additive, lazy, below the hop chain.
const aiEl = document.getElementById('modal-ai');
if (aiEl) {
const nodesParam = encodeURIComponent(path.join('|'));
const edgesParam = encodeURIComponent((displayTexts || []).join('|'));
aiBlock(aiEl,
`${AC_BACKEND}/api/explain/path?source=${encodeURIComponent(currentSource)}`
+ `&nodes=${nodesParam}&edges=${edgesParam}`,
{ label: 'In plain English' });
}
}
function closeSuccessModal(e) {
if (!e || e.target === document.getElementById('success-modal') || e.target.tagName === 'BUTTON') {
document.getElementById('success-modal').classList.remove('visible');
}
}
function copyPath() {
if (!foundPath.length) return;
const text = foundPath.join(' β†’ ');
const btn = document.getElementById('modal-copy-btn');
navigator.clipboard.writeText(text).then(() => {
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
});
}
function openAboutModal() {
document.getElementById('about-modal').classList.add('visible');
}
function closeAboutModal(e) {
if (!e || e.target === document.getElementById('about-modal') || e.target.tagName === 'BUTTON') {
document.getElementById('about-modal').classList.remove('visible');
}
}
// ════════════════════════════════════════════════════════════════════════════
// Loading screen + landing entrance sequence.
// Polls /api/health until the backend's embedding model is ready (the
// server doesn't accept connections until startup finishes loading it, so
// a single successful response already means "ready" β€” no extra check
// needed). Once ready: fade out the loading screen and trigger the title's
// 3-stage entrance animation β€” landing position -> grow to viewport center
// -> hold -> return to landing position. The rest of the hero
// (.hero-reveal elements) and the hero background (#hero-bg-mask) fade in
// together exactly when that animation finishes, driven by the
// animationend event rather than a magic setTimeout that would drift out
// of sync if the CSS timing is ever tuned.
// ════════════════════════════════════════════════════════════════════════════
const HEALTH_POLL_MS = 350;
async function initLoadingSequence() {
const loadingScreen = document.getElementById('loading-screen');
const loadingHint = document.getElementById('loading-hint');
const hintTimer = setTimeout(() => loadingHint.classList.add('show'), 10000);
while (true) {
try {
const res = await fetch(`${AC_BACKEND}/api/health`);
if (res.ok) break;
} catch (err) {
// Backend not reachable yet β€” keep polling silently.
}
await new Promise(r => setTimeout(r, HEALTH_POLL_MS));
}
clearTimeout(hintTimer);
loadingScreen.classList.add('fade-out');
setTimeout(() => loadingScreen.classList.add('hidden'), 550);
loadSources(); // populate the source picker once the backend is up
loadLlmStatus(); // decide whether to render the optional AI affordances
playHeroEntrance();
}
// Drives the hero title's 3-stage entrance animation. Called ONLY on first
// load (initLoadingSequence(), below) β€” this is a one-time first-impression
// flourish, not something to replay on every return to the hero screen.
//
// BUGFIX: this used to also run from resetAll() on every Reset/"New Search",
// per its own now-removed claim that doing so kept the experience
// "consistent." It didn't β€” the function re-opaques the full-screen
// #hero-bg-mask and re-hides every .hero-reveal element (inputs, tagline,
// random button) for the FULL ~4.3s animation duration before its
// `animationend` handler reveals them again. Replaying that on every reset
// meant the entire screen went solid-background with nothing visible or
// interactive for several seconds β€” reported as "the page going blank" when
// tapping New Search, especially noticeable on mobile. resetAll() now calls
// the lightweight resetHeroDisplay() below instead, which shows everything
// immediately with no replay.
// Each call:
// 1. Re-hides the rest of the hero (.hero-reveal) and re-opaques the
// background mask (#hero-bg-mask) β€” necessary since this is the very
// first paint and both start in their "revealed" CSS state otherwise.
// 2. Measures the title's actual landing position and computes the
// exact pixel offset needed to land it on the true viewport center
// (a fixed vh value over/undershoots depending on monitor height β€”
// the title's landing position is a fixed-px distance from center,
// not a viewport-relative one).
// 3. Forces a reflow and re-adds the `entrance` class so the CSS
// animation restarts cleanly even if it was already played once.
function playHeroEntrance() {
const heroTitle = document.getElementById('hero-title');
const mask = document.getElementById('hero-bg-mask');
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.remove('show'));
if (mask) mask.classList.remove('hide');
heroTitle.classList.remove('entrance');
const rect = heroTitle.getBoundingClientRect();
const dy = window.innerHeight / 2 - (rect.top + rect.height / 2);
// The grown stage's transform is `scale(ENTRANCE_SCALE) translateY(...)` β€”
// translateY is applied first and then amplified by the scale that wraps
// it, so the on-screen displacement ends up as dy * ENTRANCE_SCALE, not
// dy. Pre-dividing here is what makes the title land exactly on center
// instead of overshooting by 45%. Must match the scale() value in the
// title-entrance keyframes (styles.css).
const ENTRANCE_SCALE = 1.45;
heroTitle.style.setProperty('--center-dy', `${Math.round(dy / ENTRANCE_SCALE)}px`);
void heroTitle.offsetWidth; // force reflow so the animation restarts from 0%
heroTitle.classList.add('entrance');
heroTitle.addEventListener('animationend', () => {
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
if (mask) mask.classList.add('hide');
startPlaceholderRotation();
// Show onboarding on first visit only
if (!localStorage.getItem('aurelius_seen_onboarding')) {
showOnboarding();
}
}, { once: true });
}
// Lightweight hero reset for return visits (Reset / New Search) β€” shows
// everything immediately instead of replaying playHeroEntrance()'s ~4.3s
// title-grow-to-center-and-back sequence with its full-screen mask. See the
// BUGFIX note on playHeroEntrance() above for why that replay was wrong here.
function resetHeroDisplay() {
const heroTitle = document.getElementById('hero-title');
const mask = document.getElementById('hero-bg-mask');
heroTitle.classList.remove('entrance');
heroTitle.style.opacity = '1';
heroTitle.style.transform = 'scale(1)';
if (mask) mask.classList.add('hide');
document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
startPlaceholderRotation();
}
function showOnboarding() {
const modal = document.getElementById('onboarding-modal');
modal.classList.add('visible');
}
function closeOnboarding() {
const modal = document.getElementById('onboarding-modal');
modal.classList.remove('visible');
localStorage.setItem('aurelius_seen_onboarding', 'true');
}
initLoadingSequence();