From 0bc62b0a9bb9e4ea7152f6e36bf34e256cf693b0 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Thu, 23 Apr 2026 16:52:55 +0100 Subject: [PATCH] merge parallel edges in graph visualization; update link representation to include combined types and max count --- admin.html | 74 +++++++++++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/admin.html b/admin.html index 7afbe81..d1c3a35 100644 --- a/admin.html +++ b/admin.html @@ -1378,17 +1378,6 @@ function renderIntelGraph() { // svg defs — arrow marker + glow filter const defs = svg.append('defs'); - defs.append('marker') - .attr('id', 'ig-arrow') - .attr('viewBox', '0 -5 10 10') - .attr('refX', 28) - .attr('refY', 0) - .attr('markerWidth', 5) - .attr('markerHeight', 5) - .attr('orient', 'auto') - .append('path') - .attr('d', 'M0,-5L10,0L0,5') - .attr('fill', '#475569'); const glow = defs.append('filter') .attr('id', 'ig-glow') @@ -1417,54 +1406,51 @@ function renderIntelGraph() { const src = typeof d.source === 'object' ? d.source.key : d.source; const tgt = typeof d.target === 'object' ? d.target.key : d.target; const deg = Math.max(degree[src] || 1, degree[tgt] || 1); - // busier nodes push their neighbors further away - return 180 + deg * 18; + return 80 + deg * 8; })) .force('charge', d3.forceManyBody().strength(d => { const deg = degree[d.key] || 1; - return -1400 - deg * 200; + return -300 - deg * 40; })) .force('center', d3.forceCenter(width / 2, height / 2)) - // collision radius covers circle + name text below it - .force('collide', d3.forceCollide(d => d.tracked ? 72 : 52)) + .force('collide', d3.forceCollide(d => d.tracked ? 50 : 36)) .alphaDecay(0.015); - // links + // links — use so textPath can align labels to the line const linkG = g.append('g'); - const linkLines = linkG.selectAll('line') + const linkPaths = linkG.selectAll('path.ig-edge') .data(linksCopy) - .join('line') + .join('path') + .attr('class', 'ig-edge') + .attr('id', (d, i) => `ig-ep-${i}`) + .attr('fill', 'none') .attr('stroke', '#334155') .attr('stroke-width', d => 1 + Math.pow(d.count / maxCount, 0.55) * 6) .attr('stroke-opacity', d => 0.2 + (d.count / maxCount) * 0.7) - .attr('marker-end', 'url(#ig-arrow)'); - - // hover label — one floating text element that follows mouse - const hoverLabel = svg.append('text') - .attr('font-size', 11) - .attr('fill', '#94a3b8') - .attr('pointer-events', 'none') - .attr('opacity', 0); - - linkLines .on('mouseenter', function(ev, d) { d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1); - hoverLabel.text(`${d.type} ×${d.count}`).attr('opacity', 1); - }) - .on('mousemove', function(ev) { - const [mx, my] = d3.pointer(ev, svgEl); - hoverLabel.attr('x', mx + 10).attr('y', my - 6); }) .on('mouseleave', function(ev, d) { - d3.select(this) - .attr('stroke', '#334155') + d3.select(this).attr('stroke', '#334155') .attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7); - hoverLabel.attr('opacity', 0); }); - const linkLabels = { attr: () => {} }; // no-op so tick handler doesnt break + // labels along each edge via textPath + const linkLabels = linkG.selectAll('text.ig-elabel') + .data(linksCopy) + .join('text') + .attr('class', 'ig-elabel') + .attr('font-size', 9) + .attr('fill', '#475569') + .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 @@ -1554,11 +1540,13 @@ function renderIntelGraph() { simulation.on('tick', () => { - linkLines - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); + 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})`); });