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})`);
});