593 lines
18 KiB
JavaScript
593 lines
18 KiB
JavaScript
// intelligence → graph (d3-force relationship network)
|
||
// depends on: app.js, intel-shared.js, d3.v7
|
||
|
||
const SECTOR_COLOR = {
|
||
AI: "#378ADD",
|
||
Tech: "#534AB7",
|
||
Semiconductor: "#BA7517",
|
||
Storage: "#888780",
|
||
Media: "#D4537E",
|
||
Auto: "#1D9E75",
|
||
Finance: "#639922",
|
||
Defense: "#6B7280",
|
||
Space: "#534AB7",
|
||
Telecom: "#1D9E75",
|
||
Cloud: "#D85A30",
|
||
};
|
||
|
||
|
||
const EDGE_COLOR = {
|
||
competitor: "#E24B4A",
|
||
customer: "#639922",
|
||
supplier: "#BA7517",
|
||
investor: "#378ADD",
|
||
};
|
||
|
||
|
||
// sector lookup for tickers we track — new tickers fall through to inferSector
|
||
const SECTOR_BY_TICKER = {
|
||
NVDA: "Semiconductor", AMD: "Semiconductor", INTC: "Semiconductor",
|
||
TSM: "Semiconductor", "2330.TW": "Semiconductor", ASML: "Semiconductor",
|
||
QCOM: "Semiconductor", AVGO: "Semiconductor", MRVL: "Semiconductor",
|
||
"005930.KS": "Semiconductor", "000660.KS": "Semiconductor",
|
||
KLAC: "Semiconductor", AMAT: "Semiconductor", LRCX: "Semiconductor",
|
||
TXN: "Semiconductor", ADI: "Semiconductor", ON: "Semiconductor",
|
||
|
||
AAPL: "Tech", GOOGL: "Tech", GOOG: "Tech", META: "Tech",
|
||
MSFT: "Tech", AMZN: "Tech", NFLX: "Tech",
|
||
|
||
OPENAI: "AI", ANTHROPIC: "AI", XAI: "AI",
|
||
|
||
MU: "Storage", WDC: "Storage", STX: "Storage",
|
||
|
||
DIS: "Media", CMCSA: "Media", WBD: "Media", SPOT: "Media", PARA: "Media",
|
||
|
||
TSLA: "Auto", F: "Auto", GM: "Auto", TM: "Auto", HMC: "Auto",
|
||
STLA: "Auto", RIVN: "Auto", LCID: "Auto",
|
||
|
||
JPM: "Finance", GS: "Finance", MS: "Finance", BAC: "Finance",
|
||
C: "Finance", BLK: "Finance", "BRK.B": "Finance", V: "Finance",
|
||
MA: "Finance", AXP: "Finance",
|
||
|
||
LMT: "Defense", RTX: "Defense", NOC: "Defense", GD: "Defense", HII: "Defense",
|
||
|
||
SPCX: "Space", RKLB: "Space",
|
||
|
||
VZ: "Telecom", T: "Telecom", TMUS: "Telecom",
|
||
|
||
ORCL: "Cloud", CRM: "Cloud", NOW: "Cloud", SNOW: "Cloud",
|
||
};
|
||
|
||
|
||
function inferSector(ticker, name) {
|
||
if (ticker && SECTOR_BY_TICKER[ticker.toUpperCase()]) {
|
||
return SECTOR_BY_TICKER[ticker.toUpperCase()];
|
||
}
|
||
if (name) {
|
||
const upper = name.toUpperCase();
|
||
if (SECTOR_BY_TICKER[upper]) return SECTOR_BY_TICKER[upper];
|
||
const low = name.toLowerCase();
|
||
if (/bank|capital|asset|fund|invest/.test(low)) return "Finance";
|
||
if (/semi|chip/.test(low)) return "Semiconductor";
|
||
if (/media|studio|entertain/.test(low)) return "Media";
|
||
if (/telecom|wireless|mobile/.test(low)) return "Telecom";
|
||
if (/cloud/.test(low)) return "Cloud";
|
||
if (/motor|automot/.test(low)) return "Auto";
|
||
}
|
||
return "Tech"; // sensible default
|
||
}
|
||
|
||
|
||
let graphNodes = [];
|
||
let graphEdges = [];
|
||
let graphDegree = new Map();
|
||
let graphFilterType = "all";
|
||
let graphSearchTerm = "";
|
||
let graphSelectedId = null;
|
||
|
||
|
||
function syncGraphUrl() {
|
||
queryWrite({
|
||
q: graphSearchTerm,
|
||
type: graphFilterType && graphFilterType !== "all" ? graphFilterType : "",
|
||
});
|
||
}
|
||
|
||
|
||
async function loadIntelGraph() {
|
||
const data = await api("/admin/api/intelligence/graph");
|
||
|
||
if (!data.nodes || data.nodes.length === 0) {
|
||
document.getElementById("graph-empty").style.display = "block";
|
||
graphNodes = []; graphEdges = [];
|
||
document.getElementById("intel-graph-svg").innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
document.getElementById("graph-empty").style.display = "none";
|
||
|
||
const idByCompanyId = new Map();
|
||
const untrackedIds = new Set();
|
||
graphNodes = [];
|
||
|
||
for (const n of data.nodes) {
|
||
if (n.tracked) {
|
||
const nodeId = n.ticker || `C${n.id}`;
|
||
idByCompanyId.set(n.id, nodeId);
|
||
graphNodes.push({
|
||
id: nodeId,
|
||
companyId: n.id,
|
||
label: n.name,
|
||
sector: inferSector(n.ticker, n.name),
|
||
tracked: true,
|
||
});
|
||
} else {
|
||
const nodeId = `U:${n.name}`;
|
||
if (untrackedIds.has(nodeId)) continue;
|
||
untrackedIds.add(nodeId);
|
||
graphNodes.push({
|
||
id: nodeId,
|
||
companyId: null,
|
||
label: n.name,
|
||
sector: null,
|
||
tracked: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
const seen = new Set();
|
||
graphEdges = [];
|
||
|
||
for (const e of data.edges) {
|
||
let type = (e.relationship_type || "").toLowerCase();
|
||
let src = idByCompanyId.get(e.from_company_id);
|
||
let tgt;
|
||
|
||
if (e.to_company_id) {
|
||
tgt = idByCompanyId.get(e.to_company_id);
|
||
} else if (e.to_entity && untrackedIds.has(`U:${e.to_entity}`)) {
|
||
tgt = `U:${e.to_entity}`;
|
||
}
|
||
|
||
if (!src || !tgt) continue;
|
||
|
||
// dependency is the reciprocal of investor — flip direction and normalize
|
||
if (type === "dependency" && e.to_company_id) {
|
||
type = "investor";
|
||
[src, tgt] = [tgt, src];
|
||
}
|
||
|
||
if (!EDGE_COLOR[type]) continue;
|
||
|
||
const key = `${src}|${tgt}|${type}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
graphEdges.push({ source: src, target: tgt, type });
|
||
}
|
||
|
||
graphDegree = new Map();
|
||
for (const n of graphNodes) graphDegree.set(n.id, 0);
|
||
for (const e of graphEdges) {
|
||
graphDegree.set(e.source, (graphDegree.get(e.source) || 0) + 1);
|
||
graphDegree.set(e.target, (graphDegree.get(e.target) || 0) + 1);
|
||
}
|
||
|
||
graphSelectedId = null;
|
||
clearGraphInfo();
|
||
|
||
renderIntelGraph();
|
||
}
|
||
|
||
|
||
function graphNodeRadius(d) {
|
||
return 5 + Math.min((graphDegree.get(d.id) || 0) * 0.8, 12);
|
||
}
|
||
|
||
|
||
function renderIntelGraph() {
|
||
const svgEl = document.getElementById("intel-graph-svg");
|
||
svgEl.innerHTML = "";
|
||
if (graphNodes.length === 0) return;
|
||
|
||
const width = svgEl.clientWidth || 900;
|
||
const height = svgEl.clientHeight || 600;
|
||
|
||
const svg = d3.select(svgEl).attr("viewBox", [0, 0, width, height]);
|
||
const root = svg.append("g");
|
||
|
||
const zoom = d3.zoom()
|
||
.scaleExtent([0.25, 4])
|
||
.on("zoom", ev => root.attr("transform", ev.transform));
|
||
svg.call(zoom);
|
||
|
||
svg.on("click", ev => {
|
||
if (ev.target === svg.node()) clearGraphSelection();
|
||
});
|
||
|
||
const linkLayer = root.append("g").attr("class", "ig-links");
|
||
const nodeLayer = root.append("g").attr("class", "ig-nodes");
|
||
|
||
const nodesCopy = graphNodes.map(n => ({ ...n }));
|
||
const edgesCopy = graphEdges.map(e => ({ ...e }));
|
||
|
||
|
||
const linkSel = linkLayer.selectAll("line")
|
||
.data(edgesCopy)
|
||
.join("line")
|
||
.attr("stroke", d => EDGE_COLOR[d.type] || "#888")
|
||
.attr("stroke-width", 1.5)
|
||
.attr("stroke-opacity", 0.55);
|
||
|
||
const nodeSel = nodeLayer.selectAll("g.ig-node")
|
||
.data(nodesCopy, d => d.id)
|
||
.join("g")
|
||
.attr("class", "ig-node")
|
||
.style("cursor", d => d.tracked ? "pointer" : "default")
|
||
.call(d3.drag()
|
||
.on("start", dragStart)
|
||
.on("drag", dragMove)
|
||
.on("end", dragEnd));
|
||
|
||
nodeSel.append("circle")
|
||
.attr("r", graphNodeRadius)
|
||
.attr("fill", d => d.tracked ? (SECTOR_COLOR[d.sector] || "#888") : "none")
|
||
.attr("stroke", d => d.tracked ? "transparent" : "#6b7280")
|
||
.attr("stroke-width", d => d.tracked ? 2 : 1.5)
|
||
.on("mouseenter", function (ev, d) {
|
||
if (!d.tracked) {
|
||
d3.select(this).attr("stroke", "#94a3b8");
|
||
return;
|
||
}
|
||
if (d.id !== graphSelectedId) {
|
||
d3.select(this).attr("stroke", "rgba(255,255,255,0.6)");
|
||
}
|
||
})
|
||
.on("mouseleave", function (ev, d) {
|
||
if (!d.tracked) {
|
||
d3.select(this).attr("stroke", "#6b7280");
|
||
return;
|
||
}
|
||
if (d.id !== graphSelectedId) {
|
||
d3.select(this).attr("stroke", "transparent");
|
||
}
|
||
})
|
||
.on("click", (ev, d) => {
|
||
ev.stopPropagation();
|
||
if (!d.tracked) return;
|
||
selectGraphNode(d);
|
||
});
|
||
|
||
nodeSel.append("text")
|
||
.attr("class", d => d.tracked ? "graph-node-label" : "graph-node-label graph-node-label-untracked")
|
||
.attr("text-anchor", "middle")
|
||
.attr("y", d => graphNodeRadius(d) + 13)
|
||
.text(d => d.label);
|
||
|
||
nodeSel.insert("rect", "text.graph-node-label")
|
||
.attr("class", "ig-label-bg")
|
||
.attr("rx", 2).attr("ry", 2);
|
||
|
||
nodeSel.each(function () {
|
||
const g = d3.select(this);
|
||
const t = g.select("text.graph-node-label").node();
|
||
if (!t) return;
|
||
const bb = t.getBBox();
|
||
g.select("rect.ig-label-bg")
|
||
.attr("x", bb.x - 3)
|
||
.attr("y", bb.y - 1)
|
||
.attr("width", bb.width + 6)
|
||
.attr("height", bb.height + 2);
|
||
});
|
||
|
||
|
||
const sim = d3.forceSimulation(nodesCopy)
|
||
.force("link", d3.forceLink(edgesCopy).id(d => d.id).distance(80))
|
||
.force("charge", d3.forceManyBody().strength(-300))
|
||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||
.force("x", d3.forceX(width / 2).strength(0.07))
|
||
.force("y", d3.forceY(height / 2).strength(0.07))
|
||
.force("collide", d3.forceCollide(d => graphNodeRadius(d) + 4));
|
||
|
||
sim.on("tick", () => {
|
||
linkSel
|
||
.attr("x1", d => d.source.x)
|
||
.attr("y1", d => d.source.y)
|
||
.attr("x2", d => d.target.x)
|
||
.attr("y2", d => d.target.y);
|
||
|
||
nodeSel.attr("transform", d => `translate(${d.x},${d.y})`);
|
||
});
|
||
|
||
|
||
window._igSim = sim;
|
||
window._igLinkSel = linkSel;
|
||
window._igNodeSel = nodeSel;
|
||
|
||
applyGraphFilters();
|
||
|
||
|
||
function dragStart(ev, d) {
|
||
if (!ev.active) sim.alphaTarget(0.3).restart();
|
||
d.fx = d.x; d.fy = d.y;
|
||
}
|
||
function dragMove(ev, d) {
|
||
d.fx = ev.x; d.fy = ev.y;
|
||
}
|
||
function dragEnd(ev, d) {
|
||
if (!ev.active) sim.alphaTarget(0);
|
||
d.fx = null; d.fy = null;
|
||
}
|
||
}
|
||
|
||
|
||
function applyGraphFilters() {
|
||
const linkSel = window._igLinkSel;
|
||
const nodeSel = window._igNodeSel;
|
||
if (!linkSel || !nodeSel) return;
|
||
|
||
const term = graphSearchTerm;
|
||
const filter = graphFilterType;
|
||
|
||
let visibleIds;
|
||
if (term) {
|
||
const direct = graphNodes.filter(n =>
|
||
n.label.toLowerCase().includes(term) ||
|
||
n.id.toLowerCase().includes(term)
|
||
).map(n => n.id);
|
||
visibleIds = new Set(direct);
|
||
|
||
for (const e of graphEdges) {
|
||
if (visibleIds.has(e.source)) visibleIds.add(e.target);
|
||
if (visibleIds.has(e.target)) visibleIds.add(e.source);
|
||
}
|
||
} else {
|
||
visibleIds = new Set(graphNodes.map(n => n.id));
|
||
}
|
||
|
||
const lit = graphSelectedId ? neighborsOfNode(graphSelectedId) : null;
|
||
|
||
nodeSel
|
||
.style("display", d => visibleIds.has(d.id) ? null : "none")
|
||
.style("opacity", d => (lit && !lit.has(d.id)) ? 0.15 : 1);
|
||
|
||
nodeSel.select("circle")
|
||
.attr("stroke", d => {
|
||
if (!d.tracked) return "#6b7280";
|
||
return d.id === graphSelectedId ? "#ffffff" : "transparent";
|
||
})
|
||
.attr("stroke-width", d => {
|
||
if (!d.tracked) return 1.5;
|
||
return d.id === graphSelectedId ? 2.5 : 2;
|
||
});
|
||
|
||
linkSel
|
||
.style("display", e => {
|
||
if (filter !== "all" && e.type !== filter) return "none";
|
||
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||
if (!visibleIds.has(s) || !visibleIds.has(t)) return "none";
|
||
return null;
|
||
})
|
||
.attr("stroke-opacity", e => {
|
||
if (!lit) return 0.55;
|
||
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||
return (s !== graphSelectedId && t !== graphSelectedId) ? 0.08 : 0.55;
|
||
});
|
||
}
|
||
|
||
|
||
function neighborsOfNode(id) {
|
||
const set = new Set([id]);
|
||
for (const e of graphEdges) {
|
||
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||
if (s === id) set.add(t);
|
||
if (t === id) set.add(s);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
|
||
function selectGraphNode(d) {
|
||
graphSelectedId = d.id;
|
||
applyGraphFilters();
|
||
renderGraphInfo(d);
|
||
}
|
||
|
||
|
||
function clearGraphSelection() {
|
||
graphSelectedId = null;
|
||
applyGraphFilters();
|
||
clearGraphInfo();
|
||
}
|
||
|
||
|
||
function clearGraphInfo() {
|
||
document.getElementById("graph-info").innerHTML =
|
||
'<p class="graph-empty-msg">Click a node to see its connections.</p>';
|
||
}
|
||
|
||
|
||
function renderGraphInfo(node) {
|
||
const groups = { competitor: [], customer: [], supplier: [], investor: [] };
|
||
const seenPer = { competitor: new Set(), customer: new Set(), supplier: new Set(), investor: new Set() };
|
||
|
||
for (const e of graphEdges) {
|
||
let otherId = null;
|
||
if (e.source === node.id) otherId = e.target;
|
||
else if (e.target === node.id) otherId = e.source;
|
||
if (!otherId) continue;
|
||
if (seenPer[e.type].has(otherId)) continue;
|
||
seenPer[e.type].add(otherId);
|
||
const other = graphNodes.find(n => n.id === otherId);
|
||
if (other) groups[e.type].push(other);
|
||
}
|
||
|
||
let html = `<div id="graph-info-title">${escapeHtml(node.label)}</div>`;
|
||
html += `<span id="graph-info-sector">${escapeHtml(node.sector)}</span>`;
|
||
|
||
let any = false;
|
||
for (const type of ["competitor", "customer", "supplier", "investor"]) {
|
||
const list = groups[type];
|
||
if (!list.length) continue;
|
||
any = true;
|
||
html += `<div class="graph-group-title">${type} (${list.length})</div>`;
|
||
for (const o of list) {
|
||
const canExpand = !!o.companyId;
|
||
if (canExpand) {
|
||
html += `
|
||
<div class="graph-conn-row" data-from="${node.companyId}" data-to="${o.companyId}" data-type="${type}">
|
||
<div class="graph-conn-head">
|
||
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
|
||
<span class="graph-conn-right">
|
||
<span class="graph-conn-sector">${escapeHtml(o.sector)}</span>
|
||
<span class="graph-conn-toggle">▸</span>
|
||
</span>
|
||
</div>
|
||
<div class="graph-conn-body"></div>
|
||
</div>`;
|
||
} else {
|
||
html += `
|
||
<div class="graph-conn-row untracked">
|
||
<div class="graph-conn-head">
|
||
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
|
||
<span class="graph-conn-right">
|
||
<span class="graph-conn-sector">untracked</span>
|
||
</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!any) {
|
||
html += `<p class="graph-empty-msg" style="margin-top:12px">No relationships recorded.</p>`;
|
||
}
|
||
|
||
document.getElementById("graph-info").innerHTML = html;
|
||
|
||
document.querySelectorAll("#graph-info .graph-conn-row").forEach(row => {
|
||
if (row.classList.contains("untracked")) return;
|
||
row.querySelector(".graph-conn-head").addEventListener("click", () => toggleEvidenceRow(row));
|
||
});
|
||
}
|
||
|
||
|
||
async function toggleEvidenceRow(row) {
|
||
const body = row.querySelector(".graph-conn-body");
|
||
const toggle = row.querySelector(".graph-conn-toggle");
|
||
|
||
const expanded = row.classList.toggle("expanded");
|
||
toggle.textContent = expanded ? "▾" : "▸";
|
||
|
||
if (!expanded) return;
|
||
if (row.dataset.loaded) return;
|
||
|
||
body.innerHTML = '<div class="graph-evidence-loading">Loading evidence…</div>';
|
||
|
||
const fromId = row.dataset.from;
|
||
const toId = row.dataset.to;
|
||
const type = row.dataset.type;
|
||
|
||
try {
|
||
const data = await api(`/admin/api/intelligence/edge-evidence?from_id=${fromId}&to_id=${toId}&type=${encodeURIComponent(type)}`);
|
||
|
||
let html = "";
|
||
|
||
if (data.edge) {
|
||
html += `<div class="graph-evidence-stats">
|
||
<span class="graph-evidence-badge">${escapeHtml(data.edge.confidence || "low")}</span>
|
||
<span>×${data.edge.confirmation_count || 1} confirmations</span>
|
||
</div>`;
|
||
}
|
||
|
||
if (data.facts && data.facts.length) {
|
||
html += '<div class="graph-evidence-section">Backing facts</div>';
|
||
for (const f of data.facts) {
|
||
html += `<div class="graph-evidence-fact">
|
||
<div class="graph-evidence-claim">${escapeHtml(f.claim)}</div>
|
||
<div class="graph-evidence-meta">${escapeHtml(f.confidence || "low")} · ×${f.confirmation_count || 1}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
if (data.events && data.events.length) {
|
||
html += `<div class="graph-evidence-section">Source events (${data.events.length})</div>`;
|
||
for (const ev of data.events) {
|
||
const title = ev.title || `Event #${ev.id}`;
|
||
html += `<details class="graph-evidence-event">
|
||
<summary>
|
||
<span class="graph-evidence-event-title">${escapeHtml(title)}</span>
|
||
<span class="graph-evidence-event-id">#${ev.id}</span>
|
||
</summary>`;
|
||
|
||
if (ev.articles && ev.articles.length) {
|
||
html += '<ul class="graph-evidence-articles">';
|
||
for (const a of ev.articles) {
|
||
const date = a.pub_date ? String(a.pub_date).slice(0, 10) : "";
|
||
html += `<li>
|
||
<a href="${escapeHtml(a.url || "#")}" target="_blank" rel="noopener">${escapeHtml(a.title || a.url || "untitled")}</a>
|
||
<div class="graph-evidence-article-meta">${escapeHtml(a.source || "")}${date ? " · " + date : ""}</div>
|
||
</li>`;
|
||
}
|
||
html += "</ul>";
|
||
} else {
|
||
html += '<div class="graph-evidence-empty">No articles.</div>';
|
||
}
|
||
|
||
html += "</details>";
|
||
}
|
||
}
|
||
|
||
if (!html) {
|
||
html = '<div class="graph-evidence-empty">No backing evidence recorded.</div>';
|
||
}
|
||
|
||
body.innerHTML = html;
|
||
row.dataset.loaded = "1";
|
||
|
||
} catch (err) {
|
||
body.innerHTML = '<div class="graph-evidence-error">Failed to load evidence.</div>';
|
||
}
|
||
}
|
||
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
// restore search/type from url
|
||
const urlSearch = queryGet("q");
|
||
const urlType = queryGet("type") || "all";
|
||
|
||
if (urlSearch) {
|
||
document.getElementById("graph-search").value = urlSearch;
|
||
graphSearchTerm = urlSearch.toLowerCase();
|
||
}
|
||
if (urlType && urlType !== "all") {
|
||
graphFilterType = urlType;
|
||
document.querySelectorAll(".graph-chip").forEach(c =>
|
||
c.classList.toggle("active", c.dataset.type === urlType)
|
||
);
|
||
}
|
||
|
||
document.getElementById("graph-search").addEventListener("input", ev => {
|
||
graphSearchTerm = ev.target.value.trim().toLowerCase();
|
||
syncGraphUrl();
|
||
applyGraphFilters();
|
||
});
|
||
|
||
document.getElementById("graph-chips").addEventListener("click", ev => {
|
||
const btn = ev.target.closest(".graph-chip");
|
||
if (!btn) return;
|
||
document.querySelectorAll(".graph-chip").forEach(c => c.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
graphFilterType = btn.dataset.type;
|
||
syncGraphUrl();
|
||
applyGraphFilters();
|
||
});
|
||
|
||
// both calls are independent — run them concurrently. the graph
|
||
// endpoint returns empty nodes/edges when intelligence db is missing
|
||
// so we dont need to gate on the stats check.
|
||
Promise.all([loadIntelStatsRow(), loadIntelGraph()]);
|
||
});
|