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

9.4 KiB

Shapes & Layouts

Why Shape Generators Matter

The Problem: Creating SVG paths manually for lines, areas, and arcs requires complex math and string concatenation. <path d="M0,50 L10,40 L20,60..."> is tedious and error-prone.

D3's Solution: Shape generators convert data arrays into SVG path strings automatically. You configure the generator, pass data, get path string.

Key Principle: "Configure generator once, reuse for all data." Separation of shape logic from data.


Line Generator

Basic Usage

const data = [
  {x: 0, y: 50},
  {x: 100, y: 80},
  {x: 200, y: 40},
  {x: 300, y: 90}
];

const line = d3.line()
  .x(d => d.x)
  .y(d => d.y);

svg.append('path')
  .datum(data)              // Use .datum() for single item
  .attr('d', line)          // line(data) generates path string
  .attr('fill', 'none')
  .attr('stroke', 'steelblue')
  .attr('stroke-width', 2);

Key Methods:

  • .x(accessor): Function returning x coordinate
  • .y(accessor): Function returning y coordinate
  • Returns path string when called with data

With Scales

const xScale = d3.scaleLinear().domain([0, 300]).range([0, width]);
const yScale = d3.scaleLinear().domain([0, 100]).range([height, 0]);

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

Curves

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y))
  .curve(d3.curveMonotoneX);  // Smooth curve

Curve Types:

  • d3.curveLinear: Straight lines (default)
  • d3.curveMonotoneX: Smooth, preserves monotonicity
  • d3.curveCatmullRom: Smooth, passes through points
  • d3.curveStep: Step function
  • d3.curveBasis: B-spline

Missing Data

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y))
  .defined(d => d.y !== null);  // Skip null values

Area Generator

Basic Usage

const area = d3.area()
  .x(d => xScale(d.x))
  .y0(height)               // Baseline (bottom)
  .y1(d => yScale(d.y));    // Top line

svg.append('path')
  .datum(data)
  .attr('d', area)
  .attr('fill', 'steelblue')
  .attr('opacity', 0.5);

Key Methods:

  • .y0(): Baseline (constant or function)
  • .y1(): Top boundary (usually data-driven)

Stacked Area Chart

const stack = d3.stack()
  .keys(['series1', 'series2', 'series3']);

const series = stack(data);  // Returns array of series

const area = d3.area()
  .x(d => xScale(d.data.x))
  .y0(d => yScale(d[0]))    // Lower bound
  .y1(d => yScale(d[1]));   // Upper bound

svg.selectAll('path')
  .data(series)
  .join('path')
    .attr('d', area)
    .attr('fill', (d, i) => colorScale(i));

Arc Generator

Basic Usage

const arc = d3.arc()
  .innerRadius(0)           // 0 = pie, >0 = donut
  .outerRadius(100);

const arcData = {
  startAngle: 0,
  endAngle: Math.PI / 2    // 90 degrees
};

svg.append('path')
  .datum(arcData)
  .attr('d', arc)
  .attr('fill', 'steelblue');

Angle Units: Radians (0 to 2π)


Donut Chart

const arc = d3.arc()
  .innerRadius(50)
  .outerRadius(100);

const pie = d3.pie()
  .value(d => d.value);

const arcs = pie(data);    // Generates angles

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

Rounded Corners

const arc = d3.arc()
  .innerRadius(50)
  .outerRadius(100)
  .cornerRadius(5);        // Rounded edges

Labels

// Use centroid for label positioning
svg.selectAll('text')
  .data(arcs)
  .join('text')
    .attr('transform', d => `translate(${arc.centroid(d)})`)
    .attr('text-anchor', 'middle')
    .text(d => d.data.label);

Pie Generator

Basic Usage

const data = [30, 80, 45, 60, 20];

const pie = d3.pie();

const arcs = pie(data);
// Returns: [{data: 30, startAngle: 0, endAngle: 0.53, ...}, ...]

What Pie Does: Converts values → angles. Use with arc generator for rendering.


With Objects

const data = [
  {name: 'A', value: 30},
  {name: 'B', value: 80},
  {name: 'C', value: 45}
];

const pie = d3.pie()
  .value(d => d.value);

const arcs = pie(data);

Sorting

const pie = d3.pie()
  .value(d => d.value)
  .sort((a, b) => b.value - a.value);  // Descending

Padding

const pie = d3.pie()
  .value(d => d.value)
  .padAngle(0.02);         // Gap between slices

Stack Generator

Basic Usage

const data = [
  {month: 'Jan', apples: 30, oranges: 20, bananas: 40},
  {month: 'Feb', apples: 50, oranges: 30, bananas: 35},
  {month: 'Mar', apples: 40, oranges: 25, bananas: 45}
];

const stack = d3.stack()
  .keys(['apples', 'oranges', 'bananas']);

const series = stack(data);
// Returns: [[{0: 0, 1: 30, data: {month: 'Jan', ...}}, ...], [...], [...]]

Output: Array of series, each with [lower, upper] bounds for stacking.


Stacked Bar Chart

const xScale = d3.scaleBand()
  .domain(data.map(d => d.month))
  .range([0, width])
  .padding(0.1);

const yScale = d3.scaleLinear()
  .domain([0, d3.max(series, s => d3.max(s, d => d[1]))])
  .range([height, 0]);

svg.selectAll('g')
  .data(series)
  .join('g')
    .attr('fill', (d, i) => colorScale(i))
  .selectAll('rect')
  .data(d => d)
  .join('rect')
    .attr('x', d => xScale(d.data.month))
    .attr('y', d => yScale(d[1]))
    .attr('height', d => yScale(d[0]) - yScale(d[1]))
    .attr('width', xScale.bandwidth());

Offsets

// Default: stacked on top of each other
const stack = d3.stack().keys(keys);

// Normalized (0-1)
const stack = d3.stack().keys(keys).offset(d3.stackOffsetExpand);

// Centered (streamgraph)
const stack = d3.stack().keys(keys).offset(d3.stackOffsetWiggle);

Symbol Generator

Basic Usage

const symbol = d3.symbol()
  .type(d3.symbolCircle)
  .size(100);              // Area in square pixels

svg.append('path')
  .attr('d', symbol())
  .attr('fill', 'steelblue');

Symbol Types:

  • symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye

In Scatter Plots

const symbol = d3.symbol()
  .type(d => d3.symbolCircle)
  .size(d => sizeScale(d.value));

svg.selectAll('path')
  .data(data)
  .join('path')
    .attr('d', symbol)
    .attr('transform', d => `translate(${xScale(d.x)}, ${yScale(d.y)})`);

Complete Examples

Line Chart

const xScale = d3.scaleTime().domain(d3.extent(data, d => d.date)).range([0, width]);
const yScale = d3.scaleLinear().domain([0, d3.max(data, d => d.value)]).range([height, 0]);

const line = d3.line()
  .x(d => xScale(d.date))
  .y(d => yScale(d.value))
  .curve(d3.curveMonotoneX);

svg.append('path')
  .datum(data)
  .attr('d', line)
  .attr('fill', 'none')
  .attr('stroke', 'steelblue')
  .attr('stroke-width', 2);

svg.append('g').attr('transform', `translate(0, ${height})`).call(d3.axisBottom(xScale));
svg.append('g').call(d3.axisLeft(yScale));

Area Chart

const area = d3.area()
  .x(d => xScale(d.date))
  .y0(height)
  .y1(d => yScale(d.value))
  .curve(d3.curveMonotoneX);

svg.append('path')
  .datum(data)
  .attr('d', area)
  .attr('fill', 'steelblue')
  .attr('opacity', 0.5);

Donut Chart

const pie = d3.pie().value(d => d.value);
const arc = d3.arc().innerRadius(50).outerRadius(100);

const arcs = pie(data);

svg.selectAll('path')
  .data(arcs)
  .join('path')
    .attr('d', arc)
    .attr('fill', (d, i) => colorScale(i))
    .attr('stroke', 'white')
    .attr('stroke-width', 2);

svg.selectAll('text')
  .data(arcs)
  .join('text')
    .attr('transform', d => `translate(${arc.centroid(d)})`)
    .attr('text-anchor', 'middle')
    .text(d => d.data.label);

Stacked Bar Chart

const stack = d3.stack().keys(['series1', 'series2', 'series3']);
const series = stack(data);

const xScale = d3.scaleBand().domain(data.map(d => d.category)).range([0, width]).padding(0.1);
const yScale = d3.scaleLinear().domain([0, d3.max(series, s => d3.max(s, d => d[1]))]).range([height, 0]);

svg.selectAll('g')
  .data(series)
  .join('g')
    .attr('fill', (d, i) => colorScale(i))
  .selectAll('rect')
  .data(d => d)
  .join('rect')
    .attr('x', d => xScale(d.data.category))
    .attr('y', d => yScale(d[1]))
    .attr('height', d => yScale(d[0]) - yScale(d[1]))
    .attr('width', xScale.bandwidth());

Common Pitfalls

Pitfall 1: Using .data() Instead of .datum()

// WRONG - creates path per data point
svg.selectAll('path').data(data).join('path').attr('d', line);

// CORRECT - single path for entire dataset
svg.append('path').datum(data).attr('d', line);

Pitfall 2: Forgetting fill="none" for Lines

// WRONG - area filled by default
svg.append('path').datum(data).attr('d', line);

// CORRECT
svg.append('path').datum(data).attr('d', line).attr('fill', 'none').attr('stroke', 'steelblue');

Pitfall 3: Wrong Angle Units

// WRONG - degrees
arc.startAngle(90).endAngle(180);

// CORRECT - radians
arc.startAngle(Math.PI / 2).endAngle(Math.PI);

Next Steps