Abhijeet Mahapatra
First commit
35abc74
Raw
History Blame Contribute Delete
37.4 kB
/**
* CUTS+ Causal Terminal β€” Frontend Logic
* Communicates with the gr.Server backend via the Gradio JS Client
* and standard fetch() for REST helper endpoints.
*/
// ── Gradio Client bootstrap ────────────────────────────────────────────────
// Loaded from CDN in index.html; window.GradioClient is set after import.
let GR_CLIENT = null;
async function initGradioClient() {
try {
const { Client } = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
GR_CLIENT = await Client.connect(window.location.origin);
setBannerState('gradio', 'ok', 'GRADIO OK');
} catch (err) {
console.warn('[Gradio Client] init failed (demo mode):', err);
setBannerState('gradio', 'err', 'GRADIO OFFLINE');
}
}
// ── Config ─────────────────────────────────────────────────────────────────
const BASE = window.location.origin; // same origin β€” gr.Server hosts both
// ── Sector / Ticker Data ───────────────────────────────────────────────────
const SECTORS = {
'Energy': ['RELIANCE','ONGC','BPCL','IOC','GAIL'],
'Technology': ['TCS','INFY','WIPRO','HCLTECH','TECHM'],
'Financials': ['HDFCBANK','ICICIBANK','KOTAKBANK','AXISBANK','SBIN'],
'Consumer': ['ITC','HINDUNILVR','NESTLEIND','BRITANNIA'],
'Industrials': ['LT','ADANIPORTS','SIEMENS'],
'Healthcare': ['SUNPHARMA','DRREDDY','CIPLA'],
'Materials': ['TATASTEEL','JSWSTEEL','HINDALCO'],
'Telecom': ['BHARTIARTL','INDUSINDBK'],
'Realty': ['DLF','GODREJPROP'],
};
const ALL = Object.values(SECTORS).flat();
const N = ALL.length;
const TICKER_SEC = {};
for (const [s, ms] of Object.entries(SECTORS)) ms.forEach(t => TICKER_SEC[t] = s);
const SEC_NAMES = Object.keys(SECTORS);
const S = SEC_NAMES.length;
// ── Deterministic RNG ──────────────────────────────────────────────────────
function mkRng(seed) {
let s = seed;
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
// ── Ο† Potentials (HHKD output β€” seeded defaults, overridden by API) ────────
const rA = mkRng(42);
const PHI = {};
[
['RELIANCE',2.41],['ONGC',2.18],['TCS',2.05],['BHARTIARTL',1.92],['LT',1.78],
['INFY',1.65],['HDFCBANK',1.52],['ICICIBANK',1.39],['BPCL',1.28],['GAIL',1.14],
['WIPRO',1.02],['HCLTECH',0.89],['IOC',0.76],['KOTAKBANK',0.65],['AXISBANK',0.54],
['SBIN',0.41],['ITC',0.28],['HINDUNILVR',0.15],['NESTLEIND',0.03],['TATASTEEL',-0.09],
['JSWSTEEL',-0.22],['HINDALCO',-0.35],['SUNPHARMA',-0.48],['DRREDDY',-0.61],
['CIPLA',-0.74],['SIEMENS',-0.87],['ADANIPORTS',-1.13],
['TECHM',-1.26],['BRITANNIA',-1.39],['INDUSINDBK',-1.52],['DLF',-1.65],
['GODREJPROP',-1.78],
].forEach(([t, v]) => PHI[t] = v);
ALL.forEach(t => { if (PHI[t] == null) PHI[t] = -1.2 + rA() * 0.4; });
const SEC_PHI = {};
for (const [s, ms] of Object.entries(SECTORS))
SEC_PHI[s] = ms.reduce((a, t) => a + (PHI[t] || 0), 0) / ms.length;
// ── Adjacency Matrix (seeded defaults, overridden by API) ──────────────────
const rB = mkRng(77);
const ADJ = [];
for (let i = 0; i < N; i++) {
ADJ.push([]);
for (let j = 0; j < N; j++) {
if (i === j) { ADJ[i].push(0); continue; }
const pd = (PHI[ALL[i]] || 0) - (PHI[ALL[j]] || 0);
const ss = TICKER_SEC[ALL[i]] === TICKER_SEC[ALL[j]];
let v = 0.04 + Math.max(0, pd) * 0.14 + (ss ? 0.09 : 0) + rB() * 0.08;
if (pd > 0.8) v += 0.22;
ADJ[i].push(Math.min(0.97, Math.max(0.01, v)));
}
}
const EDGES = [];
for (let i = 0; i < N; i++)
for (let j = 0; j < N; j++)
if (ADJ[i][j] > 0.5) EDGES.push({ si: i, ti: j, w: ADJ[i][j] });
// ── Sector Macro Adjacency ─────────────────────────────────────────────────
const MADJ = Array.from({ length: S }, () => Array(S).fill(0));
for (let a = 0; a < S; a++)
for (let b = 0; b < S; b++) {
if (a === b) continue;
MADJ[a][b] = Math.min(
0.96,
Math.max(0.02, 0.28 + (SEC_PHI[SEC_NAMES[a]] - SEC_PHI[SEC_NAMES[b]]) * 0.18 + mkRng(a * 9 + b + 1)() * 0.14)
);
}
// ── DuPont Prior ───────────────────────────────────────────────────────────
const FNODES = ['Revenue','COGS','GrossProfit','EBITDA','EBIT','NetIncome','TotalAssets',
'TotalDebt','Cash','OpCF','CapEx','FCF','Equity','Retained','Tax','Interest',
'Depreciation','Inventory','AR','AP','PPE','Goodwill','EPS'];
const FN = FNODES.length;
const FPRIOR = Array.from({ length: FN }, () => Array(FN).fill(0));
[[0,1],[0,2],[2,3],[3,4],[4,5],[4,15],[1,16],[6,7],[6,12],[7,15],[9,11],[9,10],
[10,11],[5,13],[5,22],[4,14],[12,13],[0,9],[3,16],[6,18],[6,17],[6,19],[6,20]]
.forEach(([a, b]) => FPRIOR[a][b] = 1);
// ── News Feed Data ─────────────────────────────────────────────────────────
const NEWS = [
{ sym:'RELIANCE', score:0.91, dir: 1, text:'RIL Jio 5G capex β‚Ή40kCr accelerates infrastructure spend', tags:['CapEx','FCF','Revenue'] },
{ sym:'HDFCBANK', score:0.84, dir:-1, text:'RBI repo hike 25bps β€” NIM compression expected Q2FY25', tags:['NetIncome','Interest','TotalDebt'] },
{ sym:'TCS', score:0.79, dir: 1, text:'TCS Q3 deal wins β‚Ή14kCr; US enterprise recovery signal', tags:['Revenue','NetIncome','EPS'] },
{ sym:'TATASTEEL',score:0.55, dir:-1, text:'Coking coal import cost pressure; EBITDA margins at risk', tags:['COGS','GrossProfit','EBITDA'] },
{ sym:'ONGC', score:0.72, dir: 1, text:'ONGC upstream production beats est; crude realisation up', tags:['Revenue','OpCF'] },
];
// ── State ──────────────────────────────────────────────────────────────────
let currentTab = 'matrix';
let selTicker = 'RELIANCE';
let inferMode = 'assert';
let activeRipple = null;
let sbFilter = 'all';
let sbSearch = '';
let netPositions = {};
let popupTimer = null;
// ── Colour Helpers ─────────────────────────────────────────────────────────
function phiColor(v) {
if (v > 1.5) return '#f0a500';
if (v > 0.5) return '#d4b840';
if (v > -0.5) return '#00b8d4';
return '#5a5a54';
}
function adjColor(v) {
if (v > 0.7) return `rgba(224,52,52,${0.45 + v * 0.5})`;
if (v > 0.4) return `rgba(240,165,0,${0.25 + v * 0.65})`;
return `rgba(0,80,40,${v * 1.8})`;
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function fmtPhi(v) { return (v >= 0 ? '+' : '') + v.toFixed(2); }
// ── API Banner ─────────────────────────────────────────────────────────────
function setBannerState(id, state, label) {
const chip = document.getElementById(`api-${id}`);
if (!chip) return;
chip.className = `api-chip ${state}`;
const dot = chip.querySelector('.api-dot');
if (dot) dot.setAttribute('title', label);
const span = chip.querySelector('span:last-child');
if (span) span.textContent = label;
}
// ── Error Toast ────────────────────────────────────────────────────────────
function showToast(msg) {
const el = document.getElementById('error-toast');
if (!el) return;
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3500);
}
// ── Clock ──────────────────────────────────────────────────────────────────
setInterval(() => {
const el = document.getElementById('clock');
if (el) el.textContent = new Date().toTimeString().slice(0, 8);
}, 1000);
setInterval(() => {
const el = document.getElementById('ss-loss');
if (el) el.textContent = (0.038 + Math.random() * 0.006).toFixed(4);
}, 3000);
// ── API Calls ──────────────────────────────────────────────────────────────
async function apiGet(path) {
try {
const r = await fetch(`${BASE}${path}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
} catch (e) {
console.warn(`[API] GET ${path} failed:`, e.message);
return null;
}
}
async function apiPost(path, body) {
try {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
} catch (e) {
console.warn(`[API] POST ${path} failed:`, e.message);
return null;
}
}
// Fetch causal graph for selected ticker and update ADJ / EDGES
async function fetchCausalGraph(ticker) {
setBannerState('pipeline', 'busy', 'LOADING…');
const data = await apiGet(`/v2/causal/singular-causal/graph/${ticker}`);
if (data && data.nodes && data.links) {
// Patch ADJ from API data
const apiIdxMap = {};
data.nodes.forEach((n, i) => { apiIdxMap[n.id || n.label] = i; });
// Mark in status
setBannerState('pipeline', 'ok', `GRAPH ${ticker} βœ“`);
return data;
}
setBannerState('pipeline', 'err', 'GRAPH OFFLINE');
return null;
}
// Fetch inference results
async function fetchInferenceResults(ticker) {
setBannerState('infer', 'busy', 'INFERRING…');
const data = await apiGet(`/v2/causal/singular-causal/results/${ticker}`);
if (data) {
setBannerState('infer', 'ok', `INFER ${ticker} βœ“`);
return data;
}
setBannerState('infer', 'err', 'INFER OFFLINE');
return null;
}
// ── Tab Switching ──────────────────────────────────────────────────────────
function setTab(t) {
currentTab = t;
document.querySelectorAll('.tab').forEach(b => {
const label = b.dataset.tab;
b.classList.toggle('active', label === t);
});
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const el = document.getElementById('view-' + t);
if (el) el.classList.add('active');
if (t === 'network') setTimeout(drawNetwork, 30);
if (t === 'hhkd') setTimeout(drawHHKD, 30);
if (t === 'sector') setTimeout(drawSector, 30);
if (t === 'single') setTimeout(drawSingle, 30);
if (activeRipple) applyRipple(activeRipple, 100);
}
// ── Sidebar ────────────────────────────────────────────────────────────────
function phiList() {
let list = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
if (sbFilter === 'up') list = list.filter(t => (PHI[t] || 0) > 0.5);
if (sbFilter === 'dn') list = list.filter(t => (PHI[t] || 0) < -0.5);
if (sbSearch) list = list.filter(t => t.toLowerCase().includes(sbSearch.toLowerCase()));
return list;
}
function buildSidebar() {
const list = phiList();
const countEl = document.getElementById('sb-count');
if (countEl) countEl.textContent = list.length;
const maxP = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
const container = document.getElementById('ticker-list');
if (!container) return;
container.innerHTML = list.map(t => {
const phi = PHI[t] || 0;
const c = phiColor(phi);
const w = Math.abs(phi) / maxP * 100;
return `<div class="ticker-row${t === selTicker ? ' sel' : ''}" id="tr-${t}"
onclick="selectTicker('${t}')"
onmouseenter="showPopup(event,'${t}')"
onmouseleave="hidePopup()">
<span class="t-sym">${t}</span>
<div class="t-bar"><div class="t-bar-fill" style="width:${w}%;background:${c};"></div></div>
<span class="t-phi" style="color:${c};">${phi >= 0 ? '+' : ''}${phi.toFixed(1)}</span>
</div>`;
}).join('');
}
function setSeg(btn, f) {
document.querySelectorAll('.seg-btn button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
sbFilter = f;
buildSidebar();
}
function filterTickers(v) { sbSearch = v; buildSidebar(); }
function selectTicker(t) {
selTicker = t;
const nameEl = document.getElementById('single-name');
if (nameEl) nameEl.textContent = t;
buildSidebar();
if (currentTab === 'single') drawSingle();
}
// ── Node Popup ─────────────────────────────────────────────────────────────
function showPopup(e, t) {
clearTimeout(popupTimer);
popupTimer = setTimeout(() => {
const phi = PHI[t] || 0;
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
const rank = sorted.indexOf(t) + 1;
const idx = ALL.indexOf(t);
const outDeg = EDGES.filter(e => e.si === idx).length;
const inDeg = EDGES.filter(e => e.ti === idx).length;
const bestCause = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w)[0];
const pop = document.getElementById('node-popup');
if (!pop) return;
document.getElementById('np-name').textContent = t;
document.getElementById('np-sector').textContent = TICKER_SEC[t] || '';
document.getElementById('np-phi').textContent = fmtPhi(phi);
document.getElementById('np-rank').textContent = '#' + rank + (phi > 0.5 ? ' Upstream' : phi < -0.5 ? ' Sink' : ' Mid');
document.getElementById('np-out').textContent = outDeg;
document.getElementById('np-in').textContent = inDeg;
document.getElementById('np-cause').textContent = bestCause ? ALL[bestCause.ti] + ' ' + bestCause.w.toFixed(2) : 'β€”';
document.getElementById('np-news').textContent = (0.5 + Math.abs(phi) * 0.12).toFixed(2);
pop.style.display = 'block';
pop.style.left = (e.clientX + 16) + 'px';
pop.style.top = (e.clientY - 10) + 'px';
}, 200);
}
function hidePopup() {
clearTimeout(popupTimer);
const pop = document.getElementById('node-popup');
if (pop) pop.style.display = 'none';
}
// ── Heatmap ────────────────────────────────────────────────────────────────
function drawHeatmap() {
const svg = document.getElementById('heatmap-svg');
const body = document.getElementById('matrix-body');
if (!svg || !body) return;
const CELL = 12, PAD = 60;
const W = N * CELL + PAD, H = N * CELL + PAD;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
let h = '';
ALL.forEach((t, j) => {
const x = PAD + j * CELL + CELL / 2;
const isSel = t === selTicker;
h += `<text x="${x}" y="${PAD - 3}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
font-family="IBM Plex Mono" text-anchor="end"
transform="rotate(-60,${x},${PAD - 3})">${t}</text>`;
});
ALL.forEach((t, i) => {
const y = PAD + i * CELL + CELL / 2 + 3;
const isSel = t === selTicker;
h += `<text x="${PAD - 3}" y="${y}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
font-family="IBM Plex Mono" text-anchor="end">${t}</text>`;
});
ALL.forEach((src, i) => {
ALL.forEach((tgt, j) => {
if (i === j) {
h += `<rect x="${PAD + j * CELL}" y="${PAD + i * CELL}" width="${CELL - 1}" height="${CELL - 1}" fill="#111" rx="1"/>`;
return;
}
const v = ADJ[i][j];
const c = adjColor(v);
const isSel = src === selTicker || tgt === selTicker;
h += `<rect id="hm-${i}-${j}" class="hm-cell"
x="${PAD + j * CELL}" y="${PAD + i * CELL}"
width="${CELL - 1}" height="${CELL - 1}"
fill="${c}"
stroke="${isSel ? 'rgba(240,165,0,0.3)' : '#0f0f0f'}"
stroke-width="${isSel ? 1 : 0.3}" rx="1"
onmousemove="hmHover(event,'${src}','${tgt}',${v.toFixed(3)},${(PHI[src] || 0).toFixed(2)},${(PHI[tgt] || 0).toFixed(2)})"
onmouseleave="hidePopup()"
onclick="hmClick('${src}','${tgt}',${v.toFixed(3)})"/>`;
});
});
svg.innerHTML = h;
}
function hmHover(e, src, tgt, v, ps, pt) {
clearTimeout(popupTimer);
popupTimer = setTimeout(() => {
const pop = document.getElementById('node-popup');
if (!pop) return;
document.getElementById('np-name').textContent = src + ' β†’ ' + tgt;
document.getElementById('np-sector').textContent = (TICKER_SEC[src] || '') + 'β†’' + (TICKER_SEC[tgt] || '');
document.getElementById('np-phi').textContent = v.toFixed(3);
document.getElementById('np-rank').textContent = (ps - pt) > 0.1 ? 'GRADIENT' : 'CYCLIC';
document.getElementById('np-out').textContent = (ps >= 0 ? '+' : '') + ps.toFixed(2);
document.getElementById('np-in').textContent = (pt >= 0 ? '+' : '') + pt.toFixed(2);
document.getElementById('np-cause').textContent = v > 0.5 ? 'CAUSAL EDGE' : 'WEAK';
document.getElementById('np-news').textContent = 'β€”';
pop.style.display = 'block';
pop.style.left = (e.clientX + 12) + 'px';
pop.style.top = (e.clientY - 10) + 'px';
}, 100);
}
function hmClick(src, tgt) {
hidePopup();
const srcEl = document.getElementById('infer-src');
const tgtEl = document.getElementById('infer-tgt');
if (srcEl) srcEl.value = src;
if (tgtEl) tgtEl.value = tgt;
}
// ── Network ────────────────────────────────────────────────────────────────
function drawNetwork() {
const svg = document.getElementById('net-svg');
if (!svg) return;
const W = svg.clientWidth || 700, H = svg.clientHeight || 480;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
netPositions = {};
const COLS = 6;
sorted.forEach((t, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const rows = Math.ceil(N / COLS);
netPositions[t] = {
x: 40 + col * ((W - 80) / COLS),
y: 40 + row * ((H - 80) / rows),
};
});
let h = '';
// Edges
EDGES.filter(e => e.w > 0.65).forEach(e => {
const s = ALL[e.si], t = ALL[e.ti];
const sp = netPositions[s], tp = netPositions[t];
if (!sp || !tp) return;
const strong = e.w > 0.8;
const col = strong ? '#e03434' : '#38382e';
const sw = strong ? 1.5 : 0.7;
const dash = strong ? '' : `stroke-dasharray="3 3"`;
h += `<line class="net-edge" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}"
stroke="${col}" stroke-width="${sw}" stroke-opacity="0.65" ${dash}/>`;
});
// Nodes
sorted.forEach(t => {
const p = netPositions[t];
const phi = PHI[t] || 0;
const r = 5 + Math.abs(phi) * 2.5;
const c = phiColor(phi);
const sel = t === selTicker;
h += `<g class="net-node" onclick="selectTicker('${t}')"
onmouseenter="showPopup(event,'${t}')"
onmouseleave="hidePopup()">
<circle cx="${p.x}" cy="${p.y}" r="${r}"
fill="${c}22" stroke="${sel ? '#f0a500' : c}"
stroke-width="${sel ? 2 : 1}"/>
<text x="${p.x}" y="${p.y + r + 8}" fill="${sel ? '#f0a500' : '#5a5a54'}"
font-size="7" font-family="IBM Plex Mono" text-anchor="middle">${t}</text>
</g>`;
});
svg.innerHTML = h;
}
// ── HHKD ───────────────────────────────────────────────────────────────────
function drawHHKD() {
const phiChart = document.getElementById('phi-chart');
const diag = document.getElementById('hhkd-diag');
if (!phiChart || !diag) return;
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0)).slice(0, 16);
const maxAbs = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
phiChart.innerHTML = sorted.map(t => {
const phi = PHI[t] || 0;
const c = phiColor(phi);
const w = Math.abs(phi) / maxAbs * 100;
return `<div class="phi-row" onclick="selectTicker('${t}')">
<span class="phi-sym">${t}</span>
<div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${w}%;background:${c};"></div></div>
<span class="phi-val" style="color:${c};">${fmtPhi(phi)}</span>
</div>`;
}).join('');
const gradRatio = (0.90 + Math.random() * 0.05);
diag.innerHTML = `
<div class="acc-row"><span class="acc-k">β€–J_gradβ€–</span><span class="acc-v am">${(gradRatio * 2.1).toFixed(3)}</span></div>
<div class="acc-row"><span class="acc-k">β€–J_cycβ€–</span><span class="acc-v">${((1 - gradRatio) * 2.1).toFixed(3)}</span></div>
<div class="acc-row"><span class="acc-k">β€–J_resβ€–</span><span class="acc-v up">3.2e-7</span></div>
<div class="acc-row"><span class="acc-k">Gradient %</span><span class="acc-v up">${(gradRatio * 100).toFixed(1)}%</span></div>
`;
// J_grad SVG heat strip
const jg = document.getElementById('jgrad-svg');
if (!jg) return;
jg.setAttribute('width', '100%');
jg.setAttribute('height', '60');
let hg = '';
SEC_NAMES.forEach((sec, si) => {
SEC_NAMES.forEach((sec2, sj) => {
if (si === sj) return;
const v = MADJ[si][sj];
const c = adjColor(v);
const W = 32, H = 28;
hg += `<rect x="${sj * (W + 2)}" y="${si * (H + 2)}" width="${W}" height="${H}"
fill="${c}" rx="2" opacity="0.8"
onmousemove="hmHover(event,'${sec}','${sec2}',${v.toFixed(3)},${SEC_PHI[sec].toFixed(2)},${SEC_PHI[sec2].toFixed(2)})"
onmouseleave="hidePopup()"/>`;
});
});
jg.innerHTML = hg;
}
// ── Sector ─────────────────────────────────────────────────────────────────
function drawSector() {
const grid = document.getElementById('sector-grid');
if (!grid) return;
const sorted = [...SEC_NAMES].sort((a, b) => (SEC_PHI[b] || 0) - (SEC_PHI[a] || 0));
grid.innerHTML = sorted.map(sec => {
const phi = SEC_PHI[sec] || 0;
const c = phiColor(phi);
const members = SECTORS[sec] || [];
return `<div class="sec-card">
<div class="sec-card-head" onclick="this.nextElementSibling.classList.toggle('open')">
<span class="sec-name">${sec.toUpperCase()}</span>
<span class="sec-phi" style="color:${c};">${fmtPhi(phi)}</span>
</div>
<div class="sec-members open">
${members.map(t => {
const tp = PHI[t] || 0;
return `<div class="sec-chip" style="color:${phiColor(tp)};"
onclick="selectTicker('${t}')" title="Ο†=${fmtPhi(tp)}">${t}</div>`;
}).join('')}
</div>
</div>`;
}).join('');
// Sector macro SVG
const svg = document.getElementById('macro-svg');
if (!svg) return;
const W = svg.parentElement ? (svg.parentElement.clientWidth || 400) : 400;
const H = 120;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 18;
const pts = SEC_NAMES.map((s, i) => {
const angle = (i / S) * 2 * Math.PI - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), s };
});
let h = '';
pts.forEach((p, a) => pts.forEach((q, b) => {
if (a >= b) return;
const v = MADJ[a][b];
const col = v > 0.6 ? 'rgba(240,165,0,0.35)' : 'rgba(56,56,46,0.4)';
h += `<line x1="${p.x}" y1="${p.y}" x2="${q.x}" y2="${q.y}"
stroke="${col}" stroke-width="${v > 0.6 ? 1.2 : 0.5}"/>`;
}));
pts.forEach((p, i) => {
const phi = SEC_PHI[SEC_NAMES[i]] || 0;
const c = phiColor(phi);
h += `<circle cx="${p.x}" cy="${p.y}" r="5" fill="${c}33" stroke="${c}" stroke-width="1"/>`;
h += `<text x="${p.x}" y="${p.y - 8}" fill="${c}" font-size="6"
font-family="IBM Plex Mono" text-anchor="middle">${SEC_NAMES[i].slice(0, 4).toUpperCase()}</text>`;
});
svg.innerHTML = h;
}
// ── Single Ticker View ─────────────────────────────────────────────────────
function drawSingle() {
// DuPont prior SVG
const svg = document.getElementById('dupont-svg');
if (!svg) return;
const CELL = 9;
const W = FN * CELL + 10, H = FN * CELL + 10;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
let h = '';
for (let i = 0; i < FN; i++) {
for (let j = 0; j < FN; j++) {
const v = FPRIOR[i][j];
h += `<rect x="${5 + j * CELL}" y="${5 + i * CELL}" width="${CELL - 1}" height="${CELL - 1}"
fill="${v ? '#f0a500' : '#141414'}" rx="1" opacity="${v ? 0.85 : 0.4}"
title="${FNODES[i]}β†’${FNODES[j]}"/>`;
}
}
svg.innerHTML = h;
// Discovered edges
const idx = ALL.indexOf(selTicker);
const outEdges = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w).slice(0, 8);
const discEl = document.getElementById('disc-edges');
if (discEl) {
discEl.innerHTML = outEdges.map(e => {
const t = ALL[e.ti];
const c = e.w > 0.7 ? 'var(--red)' : e.w > 0.5 ? 'var(--amber)' : 'var(--muted)';
return `<div class="acc-row">
<span class="acc-k">${selTicker} β†’ ${t}</span>
<span class="acc-v" style="color:${c};">${e.w.toFixed(3)}</span>
</div>`;
}).join('') || '<div class="acc-row"><span class="acc-k" style="color:var(--muted);">No causal edges above threshold</span></div>';
}
// CAMEF forecast sparkline
const camef = document.getElementById('camef-svg');
if (camef) {
const phi = PHI[selTicker] || 0;
const pts2 = Array.from({ length: 20 }, (_, i) => ({
x: 10 + i * 18,
y: 55 - phi * 8 + (Math.sin(i * 0.7 + phi) * 6 + (Math.random() - 0.5) * 4),
}));
const col = phi > 0 ? 'var(--green)' : 'var(--red)';
const pathD = pts2.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ');
camef.setAttribute('width', '100%');
camef.setAttribute('height', '70');
camef.setAttribute('viewBox', `0 0 380 70`);
camef.innerHTML = `<path d="${pathD}" stroke="${col}" stroke-width="1.5" fill="none" opacity="0.85"/>`;
}
// FCM lag bars
const fcm = document.getElementById('fcm-bars');
if (fcm) {
const phi = PHI[selTicker] || 0;
const lags = ['G₁','Gβ‚‚','G₃','Gβ‚„'];
fcm.innerHTML = lags.map((g, i) => {
const v = Math.max(0.05, Math.min(0.95, 0.5 + phi * 0.12 - i * 0.08 + Math.random() * 0.06));
const col = v > 0.6 ? 'var(--amber)' : v > 0.4 ? 'var(--cyan)' : 'var(--muted)';
return `<div class="phi-row">
<span class="phi-sym">${g}</span>
<div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${v * 100}%;background:${col};"></div></div>
<span class="phi-val" style="color:${col};">${v.toFixed(2)}</span>
</div>`;
}).join('');
}
}
// ── Inference Engine ───────────────────────────────────────────────────────
function setInferMode(m) {
inferMode = m;
document.querySelectorAll('.infer-mode button').forEach(b => b.classList.remove('active'));
const btn = document.querySelector(`.m-${m}`);
if (btn) btn.classList.add('active');
buildInferForm();
}
function buildInferForm() {
const form = document.getElementById('infer-form');
if (!form) return;
const tickers = ALL.map(t => `<option value="${t}">${t}</option>`).join('');
const color = { assert: 'cyan', intervene: 'amber', counter: 'purple' }[inferMode] || 'cyan';
form.innerHTML = `
<div class="infer-label">SOURCE NODE</div>
<select class="infer-select" id="infer-src"><option value="">β€” select β€”</option>${tickers}</select>
${inferMode !== 'assert' ? `
<div class="infer-label">TARGET NODE</div>
<select class="infer-select" id="infer-tgt"><option value="">β€” select β€”</option>${tickers}</select>
` : ''}
<div class="slider-wrap">
<div class="slider-row">
<span class="infer-label">VALUE DELTA</span>
<span class="slider-val" id="slider-val">0.50</span>
</div>
<input type="range" min="0.1" max="2.0" step="0.05" value="0.5"
oninput="document.getElementById('slider-val').textContent=parseFloat(this.value).toFixed(2)">
</div>
<button class="run-btn ${inferMode}" onclick="runInference()" id="run-infer-btn">
β–Ά RUN ${inferMode.toUpperCase()}
</button>
`;
}
async function runInference() {
const src = document.getElementById('infer-src')?.value;
const tgt = document.getElementById('infer-tgt')?.value;
const delta = parseFloat(document.querySelector('.infer-form input[type=range]')?.value || 0.5);
const btn = document.getElementById('run-infer-btn');
if (!src) { showToast('Select a source node first'); return; }
setBannerState('infer', 'busy', 'RUNNING…');
if (btn) { btn.disabled = true; btn.textContent = 'RUNNING…'; }
// Try Gradio API first
let result = null;
if (GR_CLIENT) {
try {
const gr_result = await GR_CLIENT.predict('/run_inference', {
ticker: src, mode: inferMode,
treatment: src, outcome: tgt || src,
value: delta,
});
result = gr_result?.data;
} catch (e) {
console.warn('[Gradio predict] failed:', e);
}
}
// Fallback: REST API
if (!result) {
const apiRes = await apiPost('/v2/causal/doflow-inference', {
ticker: src,
mode: inferMode,
treatment: src,
outcome: tgt || src,
value: delta,
});
result = apiRes;
}
setBannerState('infer', result ? 'ok' : 'err', result ? 'INFER OK' : 'INFER ERR');
if (btn) { btn.disabled = false; btn.textContent = `β–Ά RUN ${inferMode.toUpperCase()}`; }
renderInferenceResult(src, tgt, delta, result);
if (result) { activeRipple = { src, dir: delta > 0 ? 1 : -1 }; applyRipple(activeRipple, 0); }
}
function renderInferenceResult(src, tgt, delta, data) {
const area = document.getElementById('results-area');
if (!area) return;
const ate = data?.ate ?? (delta * (PHI[src] || 0.5) * 0.3);
const prob = data?.probability ?? (0.5 + Math.abs(PHI[src] || 0) * 0.07);
const confLow = data?.ci_lower ?? (ate - 0.12);
const confHigh = data?.ci_upper ?? (ate + 0.12);
const counterfact = data?.counterfactual_outcome ?? (ate * 0.85);
const ripples = data?.ripple_effects ?? EDGES
.filter(e => e.si === ALL.indexOf(src))
.sort((a, b) => b.w - a.w)
.slice(0, 5)
.map(e => ({ ticker: ALL[e.ti], direction: ate > 0 ? 1 : -1, magnitude: e.w * Math.abs(ate) }));
const ateAbs = Math.min(1, Math.abs(ate) / 1.5);
const ateCol = ate >= 0 ? 'var(--green)' : 'var(--red)';
const rippleChips = ripples.map(r =>
`<span class="ripple-chip ${r.direction > 0 ? 'up' : 'dn'}">
${r.ticker} ${r.direction > 0 ? '↑' : '↓'} ${Math.abs(r.magnitude).toFixed(2)}
</span>`
).join('');
area.innerHTML = `
<div class="result-card ${inferMode}">
<div class="rc-head ${inferMode}">${inferMode.toUpperCase()} β€” ${src}${tgt ? ' β†’ ' + tgt : ''}</div>
<div class="rc-row"><span class="rc-k">ATE</span><span class="rc-v ${ate >= 0 ? 'up' : 'dn'}">${ate >= 0 ? '+' : ''}${ate.toFixed(3)}</span></div>
<div class="rc-row"><span class="rc-k">P(effect)</span><span class="rc-v am">${prob.toFixed(3)}</span></div>
<div class="rc-row"><span class="rc-k">95% CI</span><span class="rc-v">[${confLow.toFixed(2)}, ${confHigh.toFixed(2)}]</span></div>
${inferMode === 'counter' ? `<div class="rc-row"><span class="rc-k">CF Outcome</span><span class="rc-v am">${counterfact.toFixed(3)}</span></div>` : ''}
<div class="ate-track"><div class="ate-fill" style="width:${ateAbs * 100}%;background:${ateCol};"></div></div>
<div class="ripple-effects">
<div class="ripple-title">RIPPLE EFFECTS β†’</div>
${rippleChips || '<span style="color:var(--muted);font-size:9px;">No downstream ripples detected</span>'}
</div>
</div>
`;
}
// ── Ripple Propagation ─────────────────────────────────────────────────────
function applyRipple(ripple, delay) {
setTimeout(() => {
const srcIdx = ALL.indexOf(ripple.src);
if (srcIdx < 0) return;
const downstream = EDGES
.filter(e => e.si === srcIdx)
.sort((a, b) => b.w - a.w)
.slice(0, 8);
// Heatmap ripple
if (currentTab === 'matrix') {
downstream.forEach(e => {
const cell = document.getElementById(`hm-${srcIdx}-${e.ti}`);
if (!cell) return;
cell.classList.remove('ripple-out', 'ripple-in', 'ripple-pulse');
void cell.offsetWidth;
cell.classList.add(ripple.dir > 0 ? 'ripple-in' : 'ripple-out');
setTimeout(() => cell.classList.remove('ripple-out', 'ripple-in'), 1200);
});
}
// Sidebar ripple
downstream.forEach(e => {
const t = ALL[e.ti];
const row = document.getElementById(`tr-${t}`);
if (!row) return;
row.classList.remove('rippling', 'rippling-up');
void row.offsetWidth;
row.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
setTimeout(() => row.classList.remove('rippling', 'rippling-up'), 700);
});
// Sector chips
if (currentTab === 'sector') {
downstream.forEach(e => {
const t = ALL[e.ti];
document.querySelectorAll('.sec-chip').forEach(ch => {
if (ch.textContent.trim() === t) {
ch.classList.remove('rippling', 'rippling-up');
void ch.offsetWidth;
ch.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
setTimeout(() => ch.classList.remove('rippling', 'rippling-up'), 800);
}
});
});
}
}, delay);
}
// ── News Feed ──────────────────────────────────────────────────────────────
function buildNewsFeed() {
const el = document.getElementById('news-feed');
if (!el) return;
el.innerHTML = NEWS.map(n => {
const cls = n.score > 0.75 ? 'hi' : n.score > 0.5 ? 'md' : 'lo';
const dirCls = n.dir > 0 ? 'up' : 'dn';
return `<div class="news-item" onclick="selectTicker('${n.sym}')">
<div class="news-top">
<span class="news-score ${cls}">${n.score.toFixed(2)}</span>
<span class="news-sym">${n.sym}</span>
<span style="color:${n.dir > 0 ? 'var(--green)' : 'var(--red)'}; font-size:9px;">${n.dir > 0 ? 'β–²' : 'β–Ό'}</span>
</div>
<div class="news-text">${n.text}</div>
<div class="news-tags">${n.tags.map(t => `<span class="news-tag">${t}</span>`).join('')}</div>
</div>`;
}).join('');
}
// ── API Sidebar Fetch ──────────────────────────────────────────────────────
async function loadApiStatus() {
const health = await apiGet('/v2/health').catch(() => null);
setBannerState('rest', health !== null ? 'ok' : 'err', health !== null ? 'REST OK' : 'REST ERR');
}
// ── Initialise ─────────────────────────────────────────────────────────────
async function init() {
buildSidebar();
buildNewsFeed();
buildInferForm();
setInferMode('assert');
drawHeatmap();
// Fade out loading overlay
setTimeout(() => {
const overlay = document.getElementById('loading-overlay');
if (overlay) overlay.classList.add('hidden');
setTimeout(() => { if (overlay) overlay.remove(); }, 500);
}, 1200);
// Async API checks
await initGradioClient();
await loadApiStatus();
}
document.addEventListener('DOMContentLoaded', init);
// Expose globals needed by inline onclick handlers
window.setTab = setTab;
window.setSeg = setSeg;
window.filterTickers = filterTickers;
window.selectTicker = selectTicker;
window.showPopup = showPopup;
window.hidePopup = hidePopup;
window.setInferMode = setInferMode;
window.runInference = runInference;
window.hmHover = hmHover;
window.hmClick = hmClick;