merge parallel edges in graph visualization; update link representation to include combined types and max count

This commit is contained in:
ImBenji 2026-04-23 16:52:55 +01:00
parent 3c436b0992
commit 0bc62b0a9b

View file

@ -1378,17 +1378,6 @@ function renderIntelGraph() {
// svg defs — arrow marker + glow filter // svg defs — arrow marker + glow filter
const defs = svg.append('defs'); 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') const glow = defs.append('filter')
.attr('id', 'ig-glow') .attr('id', 'ig-glow')
@ -1417,54 +1406,51 @@ function renderIntelGraph() {
const src = typeof d.source === 'object' ? d.source.key : d.source; const src = typeof d.source === 'object' ? d.source.key : d.source;
const tgt = typeof d.target === 'object' ? d.target.key : d.target; const tgt = typeof d.target === 'object' ? d.target.key : d.target;
const deg = Math.max(degree[src] || 1, degree[tgt] || 1); const deg = Math.max(degree[src] || 1, degree[tgt] || 1);
// busier nodes push their neighbors further away return 80 + deg * 8;
return 180 + deg * 18;
})) }))
.force('charge', d3.forceManyBody().strength(d => { .force('charge', d3.forceManyBody().strength(d => {
const deg = degree[d.key] || 1; const deg = degree[d.key] || 1;
return -1400 - deg * 200; return -300 - deg * 40;
})) }))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
// collision radius covers circle + name text below it .force('collide', d3.forceCollide(d => d.tracked ? 50 : 36))
.force('collide', d3.forceCollide(d => d.tracked ? 72 : 52))
.alphaDecay(0.015); .alphaDecay(0.015);
// links // links — use <path> so textPath can align labels to the line
const linkG = g.append('g'); const linkG = g.append('g');
const linkLines = linkG.selectAll('line') const linkPaths = linkG.selectAll('path.ig-edge')
.data(linksCopy) .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', '#334155')
.attr('stroke-width', d => 1 + Math.pow(d.count / maxCount, 0.55) * 6) .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('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) { .on('mouseenter', function(ev, d) {
d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1); 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) { .on('mouseleave', function(ev, d) {
d3.select(this) d3.select(this).attr('stroke', '#334155')
.attr('stroke', '#334155')
.attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7); .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 // nodes
@ -1554,11 +1540,13 @@ function renderIntelGraph() {
simulation.on('tick', () => { simulation.on('tick', () => {
linkLines linkPaths.attr('d', d => {
.attr('x1', d => d.source.x) // always draw left-to-right so textPath reads correctly
.attr('y1', d => d.source.y) const [sx, sy, tx, ty] = d.source.x < d.target.x
.attr('x2', d => d.target.x) ? [d.source.x, d.source.y, d.target.x, d.target.y]
.attr('y2', d => 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})`); nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
}); });