diff --git a/admin.html b/admin.html
index 4203181..fe5db7f 100644
--- a/admin.html
+++ b/admin.html
@@ -468,35 +468,105 @@
display: none;
}
+ #intel-graph-layout {
+ display: flex;
+ gap: 14px;
+ }
+
#intel-graph-svg-wrap {
flex: 1;
+ position: relative;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
- position: relative;
height: 600px;
}
- #intel-graph-svg-wrap.graph-expanded {
- position: fixed;
- inset: 0;
- z-index: 500;
- border-radius: 0;
- border: none;
- height: 100dvh;
- }
-
#intel-graph-svg {
width: 100%;
height: 100%;
+ display: block;
+ cursor: grab;
+ }
+ #intel-graph-svg:active { cursor: grabbing; }
+
+
+ #graph-controls {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ z-index: 10;
}
- #graph-sidebar {
- display: none;
+ #graph-search {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ color: var(--foreground);
+ padding: 5px 9px;
+ border-radius: var(--radius);
+ font-size: 12px;
+ min-width: 170px;
+ outline: none;
+ font-family: inherit;
+ }
+ #graph-search:focus { border-color: var(--accent); }
+ #graph-search::placeholder { color: var(--muted-dark); }
+
+ #graph-chips {
+ display: flex;
+ gap: 4px;
+ }
+
+ .graph-chip {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ color: var(--muted);
+ padding: 4px 10px;
+ border-radius: var(--radius);
+ font-size: 11px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: color 120ms, background 120ms, border-color 120ms;
+ }
+ .graph-chip:hover { color: var(--foreground); }
+ .graph-chip.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+ }
+
+ #graph-legend {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ display: flex;
+ gap: 14px;
+ font-size: 11px;
+ color: var(--muted);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 6px 10px;
+ z-index: 10;
+ }
+
+ .graph-legend-dot {
+ display: inline-block;
+ width: 12px;
+ height: 3px;
+ border-radius: 2px;
+ margin-right: 5px;
+ vertical-align: middle;
+ }
+
+ #graph-info {
width: 270px;
flex-shrink: 0;
- margin-left: 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
@@ -505,51 +575,62 @@
height: 600px;
}
- #graph-sidebar-title {
- font-size: 13px;
+ #graph-info-title {
+ font-size: 14px;
font-weight: 600;
+ margin-bottom: 4px;
+ }
+
+ #graph-info-sector {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 11px;
+ font-size: 11px;
+ background: var(--border);
+ color: var(--foreground);
margin-bottom: 12px;
- padding-bottom: 10px;
+ }
+
+ .graph-group-title {
+ margin-top: 14px;
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--muted);
+ padding-bottom: 3px;
border-bottom: 1px solid var(--border);
}
- .graph-fact-row {
- padding: 7px 0;
- border-bottom: 1px solid var(--border-light);
- }
-
- .graph-fact-row:last-child {
- border-bottom: none;
- }
-
- .graph-fact-claim {
+ .graph-conn-row {
+ padding: 5px 0;
font-size: 12px;
- color: var(--foreground);
- line-height: 1.5;
- margin-bottom: 3px;
- }
-
- .graph-fact-meta {
- font-size: 11px;
- color: var(--muted-dark);
- }
-
- #graph-legend {
- margin-top: 10px;
display: flex;
- gap: 18px;
- font-size: 11px;
- color: var(--muted-dark);
- align-items: center;
+ justify-content: space-between;
+ gap: 8px;
}
- .graph-legend-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- display: inline-block;
- margin-right: 5px;
- vertical-align: middle;
+ .graph-conn-sector {
+ color: var(--muted-dark);
+ font-size: 11px;
+ }
+
+ .graph-empty-msg {
+ color: var(--muted-dark);
+ font-size: 13px;
+ margin: 0;
+ }
+
+ .graph-node-label {
+ font-size: 11px;
+ fill: var(--foreground);
+ pointer-events: none;
+ user-select: none;
+ font-family: inherit;
+ }
+
+ .ig-label-bg {
+ fill: var(--bg-subtle);
+ fill-opacity: 0.8;
}
@@ -694,30 +775,34 @@
-
+
+
No relationship data yet
-
⊡ Fit
-
⤢ Expand
+
+
+
+
+ All
+ Competitor
+ Customer
+ Supplier
+ Investor
+
+
+
+
+ Competitor
+ Customer
+ Supplier
+ Investor
+
-
-
-
-
Tracked company
-
Untracked entity
-
-
-
- Show untracked entities
-
-
-
-
Scroll to zoom · drag nodes · click for facts
+
@@ -1257,29 +1342,78 @@ document.getElementById('i-view').onchange = () => { intelOffset = 0; loadIntell
// ── intelligence graph ─────────────────────────────────────────────────────
-// tickers that dont make a readable label inside a node
-const TICKER_DISPLAY = {
- '005930.KS': 'SMSNG',
- '000660.KS': 'HYNIX',
- 'OPENAI': 'OAI',
- 'ANTHROPIC': 'ANTH',
+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",
};
-// split a name into up to two lines at a natural word boundary
-function splitLabel(name, maxLine) {
- if (name.length <= maxLine) return [name];
- const mid = Math.floor(name.length / 2);
- // find a space near the middle to break on
- let best = -1;
- let bestDist = Infinity;
- for (let i = 0; i < name.length; i++) {
- if (name[i] === ' ') {
- const dist = Math.abs(i - mid);
- if (dist < bestDist) { bestDist = dist; best = i; }
- }
+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 (best === -1) return [name]; // no spaces, just show as-is
- return [name.slice(0, best), name.slice(best + 1)];
+ 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
}
@@ -1290,8 +1424,13 @@ function showGraphView(visible) {
}
-let graphAllNodes = [];
-let graphAllLinks = [];
+let graphNodes = [];
+let graphEdges = [];
+let graphDegree = new Map();
+let graphFilterType = 'all';
+let graphSearchTerm = '';
+let graphSelectedId = null;
+
async function loadIntelGraph() {
showGraphView(true);
@@ -1303,373 +1442,336 @@ async function loadIntelGraph() {
if (!data.nodes || data.nodes.length === 0) {
document.getElementById('graph-empty').style.display = 'block';
+ graphNodes = []; graphEdges = [];
+ const svgEl = document.getElementById('intel-graph-svg');
+ svgEl.innerHTML = '';
return;
}
document.getElementById('graph-empty').style.display = 'none';
- const nodeMap = new Map();
+ // map company_id → node id (we use ticker; fall back to synthetic id)
+ const idByCompanyId = new Map();
+ graphNodes = [];
+
for (const n of data.nodes) {
- const key = n.tracked ? `c_${n.id}` : `u_${n.name}`;
- nodeMap.set(key, { ...n, key });
+ if (!n.tracked) continue;
+ const nodeId = n.ticker || `C${n.id}`;
+ idByCompanyId.set(n.id, nodeId);
+ graphNodes.push({
+ id: nodeId,
+ label: n.name,
+ sector: inferSector(n.ticker, n.name),
+ });
}
- graphAllNodes = Array.from(nodeMap.values());
+ // normalize edges: only tracked↔tracked, strict spec types, dedupe
+ const seen = new Set();
+ graphEdges = [];
- graphAllLinks = [];
for (const e of data.edges) {
- const src = `c_${e.from_company_id}`;
- const tgt = e.to_company_id ? `c_${e.to_company_id}` : `u_${e.to_entity}`;
- if (nodeMap.has(src) && nodeMap.has(tgt)) {
- graphAllLinks.push({
- source: src,
- target: tgt,
- type: e.relationship_type,
- count: e.confirmation_count || 1,
- confidence: e.confidence,
- });
+ let type = (e.relationship_type || '').toLowerCase();
+ let src = idByCompanyId.get(e.from_company_id);
+ let tgt = e.to_company_id ? idByCompanyId.get(e.to_company_id) : null;
+ if (!src || !tgt) continue;
+
+ // dependency is the reciprocal of investor — flip direction and normalize
+ if (type === 'dependency') {
+ type = 'investor';
+ [src, tgt] = [tgt, src];
}
+
+ if (!EDGE_COLOR[type]) continue; // drops partner and other non-spec types
+
+ const key = `${src}|${tgt}|${type}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ graphEdges.push({ source: src, target: tgt, type });
}
+ // degree counts (used for node radius)
+ 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();
}
-document.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- const wrap = document.getElementById('intel-graph-svg-wrap');
- if (wrap.classList.contains('graph-expanded')) toggleGraphExpand();
- }
-});
-document.getElementById('graph-show-untracked').addEventListener('change', () => {
- if (graphAllNodes.length) renderIntelGraph();
-});
-
-
-
-function toggleGraphExpand() {
- const wrap = document.getElementById('intel-graph-svg-wrap');
- const btn = document.getElementById('graph-expand-btn');
- const expanded = wrap.classList.toggle('graph-expanded');
- btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand';
- // re-render so the simulation uses the new dimensions
- if (graphAllNodes.length) renderIntelGraph();
+function graphNodeRadius(d) {
+ return 5 + Math.min((graphDegree.get(d.id) || 0) * 0.8, 12);
}
function renderIntelGraph() {
- const showUntracked = document.getElementById('graph-show-untracked').checked;
- const filteredLinks = graphAllLinks.slice();
-
- // only show nodes that still have at least one edge after the count filter
- const activeKeys = new Set();
- for (const l of filteredLinks) {
- const src = typeof l.source === 'object' ? l.source.key : l.source;
- const tgt = typeof l.target === 'object' ? l.target.key : l.target;
- activeKeys.add(src);
- activeKeys.add(tgt);
- }
-
- const nodes = graphAllNodes.filter(n =>
- activeKeys.has(n.key) && (showUntracked || n.tracked)
- );
-
- const visibleKeys = new Set(nodes.map(n => n.key));
- const links = filteredLinks.filter(l => visibleKeys.has(
- typeof l.source === 'object' ? l.source.key : l.source
- ) && visibleKeys.has(
- typeof l.target === 'object' ? l.target.key : l.target
- ));
-
- // deep-copy so d3 doesnt mutate our stored arrays on re-render
- const nodesCopy = nodes.map(n => ({ ...n }));
-
- // merge parallel + reciprocal edges into one — sort the pair so A→B and B→A share a key
- const edgeMap = new Map();
- for (const l of links) {
- const src = typeof l.source === 'object' ? l.source.key : l.source;
- const tgt = typeof l.target === 'object' ? l.target.key : l.target;
- const [a, b] = src < tgt ? [src, tgt] : [tgt, src];
- const key = `${a}||${b}`;
- if (edgeMap.has(key)) {
- const existing = edgeMap.get(key);
- if (!existing.types.includes(l.type)) existing.types.push(l.type);
- existing.count = Math.max(existing.count, l.count || 1);
- } else {
- edgeMap.set(key, { source: src, target: tgt, types: [l.type], count: l.count || 1 });
- }
- }
- const linksCopy = Array.from(edgeMap.values()).map(l => ({
- ...l,
- type: l.types.join(' · '),
- }));
-
const svgEl = document.getElementById('intel-graph-svg');
svgEl.innerHTML = '';
+ if (graphNodes.length === 0) return;
- const width = svgEl.clientWidth || 900;
+ const width = svgEl.clientWidth || 900;
const height = svgEl.clientHeight || 600;
- const svg = d3.select(svgEl);
- const g = svg.append('g');
+ const svg = d3.select(svgEl).attr('viewBox', [0, 0, width, height]);
+ const root = svg.append('g');
- // svg defs — arrow marker + glow filter
- const defs = svg.append('defs');
+ 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');
+
+ // deep-copy so d3 doesnt mutate our stored arrays
+ const nodesCopy = graphNodes.map(n => ({ ...n }));
+ const edgesCopy = graphEdges.map(e => ({ ...e }));
- const glow = defs.append('filter')
- .attr('id', 'ig-glow')
- .attr('x', '-50%').attr('y', '-50%')
- .attr('width', '200%').attr('height', '200%');
- glow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur');
- const merge = glow.append('feMerge');
- merge.append('feMergeNode').attr('in', 'blur');
- merge.append('feMergeNode').attr('in', 'SourceGraphic');
+ 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 maxCount = Math.max(...linksCopy.map(l => l.count), 1);
-
- const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', ev => g.attr('transform', ev.transform));
- svg.call(zoomBehavior);
-
- // degree map — nodes with many connections need stronger repulsion
- const degree = {};
- for (const l of linksCopy) {
- degree[l.source] = (degree[l.source] || 0) + 1;
- degree[l.target] = (degree[l.target] || 0) + 1;
- }
- const maxDegree = Math.max(...Object.values(degree), 1);
-
- // custom force: push nodes away from edges they are not endpoints of
- function forceNodeEdgeRepulsion() {
- const minDist = 55;
-
- return function() {
- for (const node of nodesCopy) {
- for (const link of linksCopy) {
- const s = link.source;
- const t = link.target;
- if (s === node || t === node) continue;
-
- const dx = t.x - s.x;
- const dy = t.y - s.y;
- const lenSq = dx * dx + dy * dy;
- if (lenSq === 0) continue;
-
- // parameter of closest point on segment
- let u = ((node.x - s.x) * dx + (node.y - s.y) * dy) / lenSq;
- u = Math.max(0, Math.min(1, u));
-
- const cx = s.x + u * dx;
- const cy = s.y + u * dy;
- const ex = node.x - cx;
- const ey = node.y - cy;
- const dist = Math.sqrt(ex * ex + ey * ey);
-
- if (dist < minDist && dist > 0) {
- const push = (minDist - dist) / dist * 0.6;
- node.vx += ex * push;
- node.vy += ey * push;
- }
- }
- }
- };
- }
-
- const simulation = d3.forceSimulation(nodesCopy)
- .force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
- return Math.max(130, d.type.length * 5 + 90);
- }))
- .force('charge', d3.forceManyBody().strength(-220))
- .force('center', d3.forceCenter(width / 2, height / 2))
- .force('x', d3.forceX(width / 2).strength(0.08))
- .force('y', d3.forceY(height / 2).strength(0.08))
- .force('collide', d3.forceCollide(d => d.tracked ? 56 : 40).strength(1))
- .force('nodeEdgeRepulsion', forceNodeEdgeRepulsion())
- .alphaDecay(0.015);
-
-
- // links — use
so textPath can align labels to the line
- const linkG = g.append('g');
- let linkLabels;
-
- const strokeWidth = d => 1.5 + Math.pow(d.count / maxCount, 0.55) * 3.5;
-
- const linkPaths = linkG.selectAll('path.ig-edge')
- .data(linksCopy)
- .join('path')
- .attr('class', 'ig-edge')
- .attr('id', (d, i) => `ig-ep-${i}`)
- .attr('fill', 'none')
- .attr('stroke', '#1e293b')
- .attr('stroke-width', strokeWidth)
- .attr('stroke-opacity', d => 0.55 + (d.count / maxCount) * 0.35)
- .on('mouseenter', function(ev, d) {
- d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1);
- linkLabels.filter(l => l === d).attr('fill', '#f8fafc');
- })
- .on('mouseleave', function(ev, d) {
- d3.select(this).attr('stroke', '#334155')
- .attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7);
- linkLabels.filter(l => l === d).attr('fill', '#94a3b8');
- });
-
- // labels along each edge via textPath — font-size slightly smaller than stroke-width
- linkLabels = linkG.selectAll('text.ig-elabel')
- .data(linksCopy)
- .join('text')
- .attr('class', 'ig-elabel')
- .attr('font-size', 7)
- .attr('fill', '#94a3b8')
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('pointer-events', 'none')
- .append('textPath')
- .attr('href', (d, i) => `#ig-ep-${i}`)
- .attr('startOffset', '50%')
- .text(d => d.type);
-
-
- // nodes
- const nodeGroups = g.append('g')
- .selectAll('g')
- .data(nodesCopy)
+ const nodeSel = nodeLayer.selectAll('g.ig-node')
+ .data(nodesCopy, d => d.id)
.join('g')
- .attr('cursor', d => d.tracked ? 'pointer' : 'default')
- .call(
- d3.drag()
- .on('start', (ev, d) => {
- if (!ev.active) simulation.alphaTarget(0.3).restart();
- d.fx = d.x; d.fy = d.y;
- })
- .on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
- .on('end', (ev, d) => {
- if (!ev.active) simulation.alphaTarget(0);
- d.fx = null; d.fy = null;
- })
- )
+ .attr('class', 'ig-node')
+ .style('cursor', 'pointer')
+ .call(d3.drag()
+ .on('start', dragStart)
+ .on('drag', dragMove)
+ .on('end', dragEnd));
+
+ nodeSel.append('circle')
+ .attr('r', graphNodeRadius)
+ .attr('fill', d => SECTOR_COLOR[d.sector] || '#888')
+ .attr('stroke', 'transparent')
+ .attr('stroke-width', 2)
+ .on('mouseenter', function () {
+ d3.select(this).attr('stroke', 'rgba(255,255,255,0.85)');
+ })
+ .on('mouseleave', function () {
+ d3.select(this).attr('stroke', 'transparent');
+ })
.on('click', (ev, d) => {
- if (d.tracked) openGraphSidebar(d);
+ ev.stopPropagation();
+ selectGraphNode(d);
});
- // tracked node: glow circle + main circle
- nodeGroups.filter(d => d.tracked).append('circle')
- .attr('r', 24)
- .attr('fill', 'rgba(59,130,246,0.08)')
- .attr('stroke', 'rgba(59,130,246,0.25)')
- .attr('stroke-width', 1)
- .attr('filter', 'url(#ig-glow)')
- .attr('pointer-events', 'none');
-
- nodeGroups.append('circle')
- .attr('r', d => d.tracked ? 20 : 12)
- .attr('fill', d => d.tracked ? '#1a3152' : '#0b1524')
- .attr('stroke', d => d.tracked ? '#3b82f6' : '#334155')
- .attr('stroke-width', d => d.tracked ? 2 : 1.5);
-
- // short ticker label inside circle for tracked nodes
- nodeGroups.filter(d => d.tracked).append('text')
- .text(d => TICKER_DISPLAY[d.ticker] || (d.ticker && d.ticker.length <= 5 ? d.ticker : d.name.slice(0, 4).toUpperCase()))
+ // label text first (to measure), then bg rect inserted before it
+ nodeSel.append('text')
+ .attr('class', 'graph-node-label')
.attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('font-size', 8)
- .attr('font-weight', '700')
- .attr('fill', '#93c5fd')
- .attr('letter-spacing', '0.04em')
- .attr('pointer-events', 'none');
+ .attr('y', d => graphNodeRadius(d) + 13)
+ .text(d => d.label);
- // full company name below tracked nodes — wrapped
- nodeGroups.filter(d => d.tracked).each(function(d) {
- const lines = splitLabel(d.name, 14);
- const el = d3.select(this).append('text')
- .attr('text-anchor', 'middle')
- .attr('font-size', 10)
- .attr('font-weight', '500')
- .attr('fill', '#cbd5e1')
- .attr('pointer-events', 'none');
+ nodeSel.insert('rect', 'text.graph-node-label')
+ .attr('class', 'ig-label-bg')
+ .attr('rx', 2).attr('ry', 2);
- lines.forEach((line, i) => {
- el.append('tspan')
- .attr('x', 0)
- .attr('dy', i === 0 ? 32 : 13)
- .text(line);
- });
+ 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);
});
- // full entity name below untracked nodes — wrapped
- nodeGroups.filter(d => !d.tracked).each(function(d) {
- const lines = splitLabel(d.name, 14);
- const el = d3.select(this).append('text')
- .attr('text-anchor', 'middle')
- .attr('font-size', 9)
- .attr('fill', '#475569')
- .attr('pointer-events', 'none');
- lines.forEach((line, i) => {
- el.append('tspan')
- .attr('x', 0)
- .attr('dy', i === 0 ? 20 : 12)
- .text(line);
- });
+ 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('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})`);
});
- nodeGroups.append('title').text(d => d.name);
+
+ // stash refs so filter/search handlers can update visibility without re-rendering
+ window._igSim = sim;
+ window._igLinkSel = linkSel;
+ window._igNodeSel = nodeSel;
+
+ applyGraphFilters();
- simulation.on('tick', () => {
- linkPaths.attr('d', d => {
- // always draw left-to-right so textPath reads correctly
- const [sx, sy, tx, ty] = d.source.x < d.target.x
- ? [d.source.x, d.source.y, d.target.x, d.target.y]
- : [d.target.x, d.target.y, d.source.x, d.source.y];
- return `M${sx},${sy} L${tx},${ty}`;
- });
-
- nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
- });
-
- // expose fit function for the button
- window._graphFit = () => {
- const bounds = g.node().getBBox();
- if (!bounds.width || !bounds.height) return;
- const pad = 60;
- const scale = Math.min(width / (bounds.width + pad * 2), height / (bounds.height + pad * 2), 1);
- const tx = width / 2 - scale * (bounds.x + bounds.width / 2);
- const ty = height / 2 - scale * (bounds.y + bounds.height / 2);
- svg.transition().duration(500)
- .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
- };
-}
-
-
-async function openGraphSidebar(node) {
- const sidebar = document.getElementById('graph-sidebar');
- const titleEl = document.getElementById('graph-sidebar-title');
- const bodyEl = document.getElementById('graph-sidebar-body');
-
- sidebar.style.display = '';
- titleEl.textContent = node.ticker ? `${node.name} (${node.ticker})` : node.name;
- bodyEl.innerHTML = 'Loading...
';
-
- try {
- const facts = await api(`/admin/api/intelligence/facts/${node.id}`);
-
- if (!facts.length) {
- bodyEl.innerHTML = 'No facts yet.
';
- return;
- }
-
- bodyEl.innerHTML = facts.map(f => `
-
-
${f.claim}
-
${f.type} · ${f.confidence} · ×${f.confirmation_count}
-
- `).join('');
-
- } catch (_) {
- bodyEl.innerHTML = 'Failed to load.
';
+ 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;
+
+ // search: keep matching nodes + their neighbors so subgraph stays readable
+ 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);
+
+ 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 = `${escapeHtmlG(node.label)}
`;
+ html += `${escapeHtmlG(node.sector)} `;
+
+ let any = false;
+ for (const type of ['competitor', 'customer', 'supplier', 'investor']) {
+ const list = groups[type];
+ if (!list.length) continue;
+ any = true;
+ html += `${type} (${list.length})
`;
+ for (const o of list) {
+ html += `${escapeHtmlG(o.label)} ${escapeHtmlG(o.sector)}
`;
+ }
+ }
+
+ if (!any) {
+ html += `No relationships recorded.
`;
+ }
+
+ document.getElementById('graph-info').innerHTML = html;
+}
+
+
+function escapeHtmlG(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+}
+
+
+// wire controls (once)
+
+document.getElementById('graph-search').addEventListener('input', ev => {
+ graphSearchTerm = ev.target.value.trim().toLowerCase();
+ 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;
+ applyGraphFilters();
+});
+
let intelRows = [];
function openIntelDetail(id, view) {