| name | run2_d3-force-cluster |
| description | D3.js v6 force simulation for clustered bubbles by category using forceX/forceY anchors, forceCollide for no overlap, with truly deterministic initial positions. |
D3.js v6 Force Cluster Simulation (Improved)
Key Principles
- Never use Math.random() — use deterministic golden-angle spiral per sector
- Run fixed ticks, then stop — don't animate
- Clamp final positions within SVG bounds after simulation
- Use forceX + forceY strength 0.10–0.15 for cohesive clusters
Sector Center Layout
With 5 sectors, use a ring with one sector on top and four around it:
const sectors = Object.keys(SECTOR_COLORS);
const W = 800, H = 600;
const cx = W / 2, cy = H / 2;
const sectorCenters = {};
sectors.forEach((sector, i) => {
const angle = (i / sectors.length) * 2 * Math.PI - Math.PI / 2;
sectorCenters[sector] = {
x: cx + (W * 0.28) * Math.cos(angle),
y: cy + (H * 0.28) * Math.sin(angle)
};
});
Deterministic Initial Positions (Critical!)
D3's simulation starts from node x/y. Seed them deliberately:
const sectorCounters = {};
const nodes = [...data].sort((a, b) => a.ticker.localeCompare(b.ticker));
nodes.forEach(d => {
const center = sectorCenters[d.sector];
const i = sectorCounters[d.sector] = (sectorCounters[d.sector] || 0);
sectorCounters[d.sector]++;
const angle = i * 2.39996;
const r = 6 * Math.sqrt(i + 1);
d.x = center.x + r * Math.cos(angle);
d.y = center.y + r * Math.sin(angle);
});
Force Simulation Setup
const simulation = d3.forceSimulation(nodes)
.force('x', d3.forceX(d => sectorCenters[d.sector].x).strength(0.12))
.force('y', d3.forceY(d => sectorCenters[d.sector].y).strength(0.12))
.force('collide', d3.forceCollide(d => d.radius + 2).strength(1.0))
.force('charge', d3.forceManyBody().strength(-10))
.alphaDecay(0.02)
.stop();
for (let i = 0; i < 400; i++) simulation.tick();
nodes.forEach(d => {
d.x = Math.max(d.radius + , .(W - d. - , d.));
d. = .(d. + , .(H - d. - , d.));
});
Placing SVG Elements After Simulation
Since we stop before rendering, just set attributes directly (no ticking):
circles.attr('cx', d => d.x).attr('cy', d => d.y);
labels.attr('x', d => d.x).attr('y', d => d.y);
Responsive SVG
Use viewBox on the SVG and measure container at draw time:
const W = container.clientWidth || 700;
const H = container.clientHeight || 560;
svg.attr('viewBox', `0 0 ${W} ${H}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
Tuning Guidance
- More nodes in a sector → increase
W * 0.28 slightly so clusters have more room
- Large radius variance → reduce
forceX/Y strength to 0.08 so big bubbles don't push out
- Overlap still present → increase collide strength or reduce MAX_RADIUS