diff --git a/admin.html b/admin.html
index 3189f77..2673ae4 100644
--- a/admin.html
+++ b/admin.html
@@ -1390,15 +1390,30 @@ function renderIntelGraph() {
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);
+
const simulation = d3.forceSimulation(nodesCopy)
.force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
- // tracked-tracked links spread wider
- const bothTracked = typeof d.source === 'object' ? d.source.tracked && d.target.tracked : false;
- return bothTracked ? 220 : 160;
+ 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;
+ }))
+ .force('charge', d3.forceManyBody().strength(d => {
+ const deg = degree[d.key] || 1;
+ return -1400 - deg * 200;
}))
- .force('charge', d3.forceManyBody().strength(-900))
.force('center', d3.forceCenter(width / 2, height / 2))
- .force('collide', d3.forceCollide(d => d.tracked ? 52 : 38));
+ // collision radius covers circle + name text below it
+ .force('collide', d3.forceCollide(d => d.tracked ? 72 : 52))
+ .alphaDecay(0.015);
// links
@@ -1408,22 +1423,22 @@ function renderIntelGraph() {
.data(linksCopy)
.join('line')
.attr('stroke', '#334155')
- .attr('stroke-width', d => {
- // more dramatic: 1px at count=1, up to 7px at maxCount
- return 1 + Math.pow(d.count / maxCount, 0.6) * 6;
- })
- .attr('stroke-opacity', d => 0.25 + (d.count / maxCount) * 0.65)
+ .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)');
- // edge labels — size and opacity proportional to count
+ // edge labels — only on edges where count >= median, to avoid hub clutter
+ const sortedCounts = linksCopy.map(l => l.count).sort((a, b) => a - b);
+ const medianCount = sortedCounts[Math.floor(sortedCounts.length / 2)] || 1;
+
const linkLabels = linkG.selectAll('text')
- .data(linksCopy)
+ .data(linksCopy.filter(l => l.count >= medianCount))
.join('text')
.text(d => d.type)
.attr('text-anchor', 'middle')
- .attr('font-size', d => 7 + Math.round((d.count / maxCount) * 4))
+ .attr('font-size', d => 8 + Math.round((d.count / maxCount) * 3))
.attr('fill', '#475569')
- .attr('opacity', d => 0.4 + (d.count / maxCount) * 0.5)
+ .attr('opacity', d => 0.45 + (d.count / maxCount) * 0.45)
.attr('pointer-events', 'none');