diff --git a/admin.html b/admin.html
index d1c3a35..7812d5e 100644
--- a/admin.html
+++ b/admin.html
@@ -1347,12 +1347,13 @@ function renderIntelGraph() {
// deep-copy so d3 doesnt mutate our stored arrays on re-render
const nodesCopy = nodes.map(n => ({ ...n }));
- // merge parallel edges (same src+tgt pair) into one — concat labels, take max count
+ // merge parallel + reciprocal edges into one — sort the pair so A→B and B→A share a key
const edgeMap = new Map();
for (const l of links) {
const src = typeof l.source === 'object' ? l.source.key : l.source;
const tgt = typeof l.target === 'object' ? l.target.key : l.target;
- const key = `${src}||${tgt}`;
+ const [a, b] = src < tgt ? [src, tgt] : [tgt, src];
+ const key = `${a}||${b}`;
if (edgeMap.has(key)) {
const existing = edgeMap.get(key);
if (!existing.types.includes(l.type)) existing.types.push(l.type);
@@ -1402,18 +1403,10 @@ function renderIntelGraph() {
const maxDegree = Math.max(...Object.values(degree), 1);
const simulation = d3.forceSimulation(nodesCopy)
- .force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
- 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);
- return 80 + deg * 8;
- }))
- .force('charge', d3.forceManyBody().strength(d => {
- const deg = degree[d.key] || 1;
- return -300 - deg * 40;
- }))
+ .force('link', d3.forceLink(linksCopy).id(d => d.key).distance(60))
+ .force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2))
- .force('collide', d3.forceCollide(d => d.tracked ? 50 : 36))
+ .force('collide', d3.forceCollide(d => d.tracked ? 48 : 34).strength(1))
.alphaDecay(0.015);