// 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; 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 = '
Click a node to see its connections.
'; } 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 = `No relationships recorded.
`; } 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 = '