diff --git a/admin.html b/admin.html
index ec66a9c..f8dcd73 100644
--- a/admin.html
+++ b/admin.html
@@ -1277,10 +1277,6 @@ function renderIntelGraph(nodes, links) {
const g = svg.append('g');
- svg.call(
- d3.zoom().scaleExtent([0.15, 5]).on('zoom', ev => g.attr('transform', ev.transform))
- );
-
// arrow marker
svg.append('defs').append('marker')
.attr('id', 'ig-arrow')
@@ -1296,32 +1292,27 @@ function renderIntelGraph(nodes, links) {
const maxCount = Math.max(...links.map(l => l.count), 1);
+ const zoomBehavior = d3.zoom().scaleExtent([0.1, 5]).on('zoom', ev => g.attr('transform', ev.transform));
+ svg.call(zoomBehavior);
+
const simulation = d3.forceSimulation(nodes)
- .force('link', d3.forceLink(links).id(d => d.key).distance(110))
- .force('charge', d3.forceManyBody().strength(-320))
+ .force('link', d3.forceLink(links).id(d => d.key).distance(150))
+ .force('charge', d3.forceManyBody().strength(-500))
.force('center', d3.forceCenter(width / 2, height / 2))
- .force('collide', d3.forceCollide(32));
+ .force('collide', d3.forceCollide(40));
const linkLines = g.append('g')
.selectAll('line')
.data(links)
.join('line')
- .attr('stroke', '#1e293b')
- .attr('stroke-width', d => 1 + (d.count / maxCount) * 3.5)
- .attr('stroke-opacity', d => 0.35 + (d.count / maxCount) * 0.55)
+ .attr('stroke', '#334155')
+ .attr('stroke-width', d => 1 + (d.count / maxCount) * 3)
+ .attr('stroke-opacity', d => 0.3 + (d.count / maxCount) * 0.6)
.attr('marker-end', 'url(#ig-arrow)');
-
- const linkLabels = g.append('g')
- .selectAll('text')
- .data(links)
- .join('text')
- .text(d => d.type)
- .attr('font-size', 9)
- .attr('fill', '#334155')
- .attr('text-anchor', 'middle')
- .attr('pointer-events', 'none');
+ // relationship type on hover only via title
+ linkLines.append('title').text(d => `${d.type} (×${d.count})`);
const nodeGroups = g.append('g')
@@ -1346,18 +1337,27 @@ function renderIntelGraph(nodes, links) {
});
nodeGroups.append('circle')
- .attr('r', d => d.tracked ? 14 : 9)
+ .attr('r', d => d.tracked ? 16 : 11)
.attr('fill', d => d.tracked ? '#1e3a5f' : '#0f172a')
.attr('stroke', d => d.tracked ? '#3b82f6' : '#475569')
.attr('stroke-width', d => d.tracked ? 2 : 1.5);
- nodeGroups.append('text')
- .text(d => d.ticker || d.name.slice(0, 7))
+ // ticker label inside tracked nodes; short name below untracked nodes
+ nodeGroups.filter(d => d.tracked).append('text')
+ .text(d => d.ticker || d.name.slice(0, 5))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
- .attr('font-size', d => d.tracked ? 9 : 8)
- .attr('font-weight', d => d.tracked ? '600' : '400')
- .attr('fill', d => d.tracked ? '#93c5fd' : '#64748b')
+ .attr('font-size', 9)
+ .attr('font-weight', '600')
+ .attr('fill', '#93c5fd')
+ .attr('pointer-events', 'none');
+
+ nodeGroups.filter(d => !d.tracked).append('text')
+ .text(d => d.name.length > 12 ? d.name.slice(0, 11) + '…' : d.name)
+ .attr('text-anchor', 'middle')
+ .attr('y', 20)
+ .attr('font-size', 9)
+ .attr('fill', '#475569')
.attr('pointer-events', 'none');
nodeGroups.append('title').text(d => d.name);
@@ -1370,12 +1370,25 @@ function renderIntelGraph(nodes, links) {
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
- linkLabels
- .attr('x', d => (d.source.x + d.target.x) / 2)
- .attr('y', d => (d.source.y + d.target.y) / 2 - 4);
-
nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
});
+
+ // fit everything into view once the simulation has cooled enough
+ simulation.on('end', () => {
+ const bounds = g.node().getBBox();
+ if (!bounds.width || !bounds.height) return;
+
+ const pad = 40;
+ const scaleX = width / (bounds.width + pad * 2);
+ const scaleY = height / (bounds.height + pad * 2);
+ const scale = Math.min(scaleX, scaleY, 1);
+
+ const tx = width / 2 - scale * (bounds.x + bounds.width / 2);
+ const ty = height / 2 - scale * (bounds.y + bounds.height / 2);
+
+ svg.transition().duration(600)
+ .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
+ });
}