agentsclimarketplace

D3 force bubble chart

Skill cxcscmu/SkillLearnBench/skills/b1-one-shot-claude-sonnet-4-6/stock-data-visualization/d3-force-bubble-chart

Build a force-simulation bubble chart in D3.js v6 where bubbles are sized by a numeric value, colored by category, and clustered by category using forceX/forceY. Covers deterministic layout, collision, and tick-based settling.From its SKILL.md

Install
npx -y skills add cxcscmu/SkillLearnBench --skill d3-force-bubble-chart

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

SKILL.md

4.0 KB, ~1.1k tokens by cl100k_base, as published. Nobody here has run it

D3 v6 Force Bubble Chart

Overview

A force-simulated bubble chart positions nodes using D3's forceSimulation. Each bubble is a circle sized by a data value (e.g. market cap) and colored by a categorical variable (e.g. sector). Nodes in the same category are attracted to a common cluster center via forceX/forceY.

Core Pattern

// 1. Compute cluster centers per category
const categories = [...new Set(data.map(d => d.category))].sort();
const clusterCenters = {};
categories.forEach((cat, i) => {
    const angle = (2 * Math.PI * i) / categories.length;
    clusterCenters[cat] = {
        x: width / 2 + clusterRadius * Math.cos(angle),
        y: height / 2 + clusterRadius * Math.sin(angle)
    };
});

// 2. Size scale (sqrt for area perception)
const rScale = d3.scaleSqrt()
    .domain([0, d3.max(data, d => d.value)])
    .range([MIN_R, MAX_R]);

// 3. Assign initial positions deterministically (sort by category then name)
const sorted = [...data].sort((a, b) =>
    a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
);
sorted.forEach((d, i) => {
    const center = clusterCenters[d.category];
    d.x = center.x + (i % 5 - 2) * 20;  // grid offset
    d.y = center.y + (Math.floor(i / 5) - 2) * 20;
});

// 4. Force simulation
const simulation = d3.forceSimulation(sorted)
    .force('x', d3.forceX(d => clusterCenters[d.category].x).strength(0.15))
    .force('y', d3.forceY(d => clusterCenters[d.category].y).strength(0.15))
    .force('collide', d3.forceCollide(d => rScale(d.value) + PADDING).strength(0.8))
    .force('charge', d3.forceManyBody().strength(-5))
    .stop();  // Stop auto-ticking

// 5. Run ticks deterministically
simulation.tick(300);

// 6. Draw circles at settled positions
const node = svg.selectAll('circle')
    .data(sorted)
    .join('circle')
    .attr('cx', d => d.x)
    .attr('cy', d => d.y)
    .attr('r', d => rScale(d.value))
    .attr('fill', d => colorScale(d.category));

ETF / Missing-Value Handling

ETFs have no market cap. Use a uniform fallback radius:

const r = d.marketCap ? rScale(d.marketCap) : ETF_RADIUS;

Cluster Centering Strategy

For 5 sectors use a pentagon layout:

const N = categories.length;
categories.forEach((cat, i) => {
    const angle = (2 * Math.PI * i / N) - Math.PI / 2; // start at top
    clusterCenters[cat] = {
        x: cx + clusterRadius * Math.cos(angle),
        y: cy + clusterRadius * Math.sin(angle)
    };
});

Adjust clusterRadius (e.g. 160–200px) and forceX/forceY strength (0.1–0.2) to keep clusters tightly grouped.

Labels Inside Bubbles

svg.selectAll('text.label')
    .data(sorted)
    .join('text')
    .attr('class', 'label')
    .attr('x', d => d.x)
    .attr('y', d => d.y)
    .attr('dy', '0.35em')
    .attr('text-anchor', 'middle')
    .style('font-size', d => Math.min(12, rScale(d.value) * 0.45) + 'px')
    .style('pointer-events', 'none')
    .text(d => d.ticker);

Key Parameters

ParameterTypical ValueEffect
forceX/Y strength0.1–0.2Higher = tighter clusters
forceCollide strength0.7–1.0Higher = less overlap
forceManyBody strength-5 to -20Repulsion between nodes
simulation.tick(N)200–400More ticks = more settled
clusterRadius150–220pxDistance between cluster centers
PADDING1.5–3pxGap between bubbles

Gotchas

  • Always call .stop() before .tick(N) for deterministic layout.
  • Use d3.scaleSqrt (not linear) for bubble radius so area is proportional.
  • After ticking, positions are in d.x, d.y — bind them with .attr('cx', d => d.x).
  • Keep initial positions near the cluster center to aid convergence.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.