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');