Duriin-API/public/admin/assets/js/intel-graph.js

592 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
});
const ok = await loadIntelStatsRow();
if (!ok) return;
loadIntelGraph();
});