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 @@
-
+
+ - - + +
+ +
+ + + + + +
+
+ +
+ Competitor + Customer + Supplier + Investor +
-
-
-
-
-
-
- Tracked company - Untracked entity - - - - - 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) {