Files
gh-lyndonkl-claude/skills/d3-visualization/resources/advanced-layouts.md
2025-11-30 08:38:26 +08:00

9.4 KiB

Advanced Layouts

Why Layouts Matter

The Problem: Calculating positions for complex visualizations (network graphs, treemaps, maps) involves sophisticated algorithms—force-directed simulation, spatial partitioning, map projections—that are mathematically complex.

D3's Solution: Layout generators compute positions/sizes/angles automatically. You provide data, configure layout, receive computed coordinates.

Key Principle: "Layouts transform data, don't render." Layouts add properties (x, y, width, etc.) that you bind to visual elements.


Force Simulation

Why Force Layouts

Use Case: Network diagrams, organic clustering where fixed positions are unnatural.

How It Works: Physics simulation with forces (repulsion, attraction, gravity) that iteratively compute node positions.


Basic Setup

const nodes = [
  {id: 'A', group: 1},
  {id: 'B', group: 1},
  {id: 'C', group: 2}
];

const links = [
  {source: 'A', target: 'B'},
  {source: 'B', target: 'C'}
];

const simulation = d3.forceSimulation(nodes)
  .force('link', d3.forceLink(links).id(d => d.id))
  .force('charge', d3.forceManyBody())
  .force('center', d3.forceCenter(width / 2, height / 2));

Forces

forceLink: Maintains fixed distance between connected nodes

.force('link', d3.forceLink(links)
  .id(d => d.id)
  .distance(50))

forceManyBody: Repulsion (negative) or attraction (positive)

.force('charge', d3.forceManyBody().strength(-100))

forceCenter: Pulls nodes toward center point

.force('center', d3.forceCenter(width / 2, height / 2))

forceCollide: Prevents overlapping circles

.force('collide', d3.forceCollide().radius(20))

forceX / forceY: Attracts to specific coordinates

.force('x', d3.forceX(width / 2).strength(0.1))

Rendering

const link = svg.selectAll('line')
  .data(links)
  .join('line')
    .attr('stroke', '#999');

const node = svg.selectAll('circle')
  .data(nodes)
  .join('circle')
    .attr('r', 10)
    .attr('fill', d => colorScale(d.group));

simulation.on('tick', () => {
  link
    .attr('x1', d => d.source.x)
    .attr('y1', d => d.source.y)
    .attr('x2', d => d.target.x)
    .attr('y2', d => d.target.y);

  node
    .attr('cx', d => d.x)
    .attr('cy', d => d.y);
});

Key: Update positions in tick handler as simulation runs.


Drag Behavior

function drag(simulation) {
  function dragstarted(event) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  }

  function dragged(event) {
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  }

  function dragended(event) {
    if (!event.active) simulation.alphaTarget(0);
    event.subject.fx = null;
    event.subject.fy = null;
  }

  return d3.drag()
    .on('start', dragstarted)
    .on('drag', dragged)
    .on('end', dragended);
}

node.call(drag(simulation));

Hierarchies

Creating Hierarchies

const data = {
  name: 'root',
  children: [
    {name: 'child1', value: 10},
    {name: 'child2', value: 20, children: [{name: 'grandchild', value: 5}]}
  ]
};

const root = d3.hierarchy(data)
  .sum(d => d.value)           // Aggregate values up tree
  .sort((a, b) => b.value - a.value);

Key Methods:

  • hierarchy(data): Creates hierarchy from nested object
  • .sum(accessor): Computes values (leaf → root)
  • .sort(comparator): Orders siblings
  • .descendants(): All nodes (breadth-first)
  • .leaves(): Leaf nodes only

Tree Layout

Use: Node-link diagrams (org charts, file systems)

const tree = d3.tree().size([width, height]);
tree(root);

// Creates x, y properties on each node
svg.selectAll('circle')
  .data(root.descendants())
  .join('circle')
    .attr('cx', d => d.x)
    .attr('cy', d => d.y)
    .attr('r', 5);

// Links
svg.selectAll('line')
  .data(root.links())
  .join('line')
    .attr('x1', d => d.source.x)
    .attr('y1', d => d.source.y)
    .attr('x2', d => d.target.x)
    .attr('y2', d => d.target.y);

Treemap Layout

Use: Space-filling rectangles (disk usage, budget allocation)

const treemap = d3.treemap()
  .size([width, height])
  .padding(1);

treemap(root);

svg.selectAll('rect')
  .data(root.leaves())
  .join('rect')
    .attr('x', d => d.x0)
    .attr('y', d => d.y0)
    .attr('width', d => d.x1 - d.x0)
    .attr('height', d => d.y1 - d.y0)
    .attr('fill', d => colorScale(d.value));

Pack Layout

Use: Circle packing (bubble charts)

const pack = d3.pack().size([width, height]).padding(3);
pack(root);

svg.selectAll('circle')
  .data(root.descendants())
  .join('circle')
    .attr('cx', d => d.x)
    .attr('cy', d => d.y)
    .attr('r', d => d.r)
    .attr('fill', d => d.children ? '#ccc' : colorScale(d.value));

Partition Layout

Use: Sunburst, icicle charts

const partition = d3.partition().size([2 * Math.PI, radius]);
partition(root);

const arc = d3.arc()
  .startAngle(d => d.x0)
  .endAngle(d => d.x1)
  .innerRadius(d => d.y0)
  .outerRadius(d => d.y1);

svg.selectAll('path')
  .data(root.descendants())
  .join('path')
    .attr('d', arc)
    .attr('fill', d => colorScale(d.depth));

Geographic Maps

GeoJSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[-100, 40], [-100, 50], [-90, 50], [-90, 40], [-100, 40]]]
      },
      "properties": {"name": "Region A"}
    }
  ]
}

Projections

// Mercator (world maps)
const projection = d3.geoMercator()
  .center([0, 0])
  .scale(150)
  .translate([width / 2, height / 2]);

// Albers (US maps)
const projection = d3.geoAlbersUsa().scale(1000).translate([width / 2, height / 2]);

// Orthographic (globe)
const projection = d3.geoOrthographic().scale(250).translate([width / 2, height / 2]);

Common Projections: Mercator, Albers, Equirectangular, Orthographic, Azimuthal, Conic


Path Generator

const path = d3.geoPath().projection(projection);

d3.json('countries.geojson').then(geojson => {
  svg.selectAll('path')
    .data(geojson.features)
    .join('path')
      .attr('d', path)
      .attr('fill', '#ccc')
      .attr('stroke', '#fff');
});

Auto-Fit

const projection = d3.geoMercator()
  .fitExtent([[0, 0], [width, height]], geojson);

fitExtent: Automatically scales/centers projection to fit data in bounds.


Choropleth

const colorScale = d3.scaleSequential(d3.interpolateBlues)
  .domain([0, d3.max(data, d => d.value)]);

svg.selectAll('path')
  .data(geojson.features)
  .join('path')
    .attr('d', path)
    .attr('fill', d => {
      const value = data.find(v => v.id === d.id)?.value;
      return value ? colorScale(value) : '#ccc';
    });

Chord Diagrams

Use Case

Visualize flows/relationships between entities (migrations, trade, connections).


Data Format

const matrix = [
  [0, 10, 20],    // From A to: A, B, C
  [15, 0, 5],     // From B to: A, B, C
  [25, 30, 0]     // From C to: A, B, C
];

Matrix[i][j]: Flow from entity i to entity j.


Creating Chord

const chord = d3.chord()
  .padAngle(0.05)
  .sortSubgroups(d3.descending);

const chords = chord(matrix);

const arc = d3.arc()
  .innerRadius(200)
  .outerRadius(220);

const ribbon = d3.ribbon()
  .radius(200);

// Outer arcs (groups)
svg.selectAll('g')
  .data(chords.groups)
  .join('path')
    .attr('d', arc)
    .attr('fill', (d, i) => colorScale(i));

// Inner ribbons (connections)
svg.selectAll('path.ribbon')
  .data(chords)
  .join('path')
    .attr('class', 'ribbon')
    .attr('d', ribbon)
    .attr('fill', d => colorScale(d.source.index))
    .attr('opacity', 0.7);

Choosing Layouts

Visualization Layout
Network graph, organic clusters Force simulation
Org chart, file tree (node-link) Tree layout
Space-filling rectangles Treemap
Bubble chart, circle packing Pack layout
Sunburst, icicle chart Partition layout
World/regional maps Geographic projection
Flow diagram (migration, trade) Chord diagram

Common Pitfalls

Pitfall 1: Not Handling Tick Updates

// WRONG - positions never update
const node = svg.selectAll('circle').data(nodes).join('circle');
simulation.on('tick', () => {});  // Empty!

// CORRECT
simulation.on('tick', () => {
  node.attr('cx', d => d.x).attr('cy', d => d.y);
});

Pitfall 2: Wrong Hierarchy Accessor

// WRONG - d3.hierarchy expects nested structure
const root = d3.hierarchy(flatArray);

// CORRECT - nest data first or use stratify
const root = d3.hierarchy(nestedObject);

Pitfall 3: Forgetting to Apply Layout

// WRONG - root has no x, y properties
const root = d3.hierarchy(data);
svg.selectAll('circle').data(root.descendants()).join('circle').attr('cx', d => d.x);

// CORRECT - apply layout first
const tree = d3.tree().size([width, height]);
tree(root);  // Now root.descendants() have x, y

Next Steps