Loading...
Loading...
D3.js data visualization — selections, data joins, scales, axes, shapes, transitions, force layouts, geo projections, framework integration
npx skill4agent add agents-inc/skills web-dataviz-d3Quick Guide: D3 v7 is fully modular ES modules. Usefor the data join (replaces manual enter/update/exit). Prefer modular imports (selection.join(),d3-selection, etc.) to reduce bundle size. Scales map data domains to visual ranges; axes render tick marks from scales. Shape generators (d3-scale,d3.line,d3.arc) produce SVG path strings from data arrays. Transitions animate attribute/style changes with automatic interpolation. For framework integration, let D3 handle data computation (scales, layouts, shapes) and let your framework own the DOM.d3.area
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,, named constants)import type
selection.join()d3-selectiond3-scaled3-shapeimport * as d3 from "d3"Selection<SVGGElement, Datum, ...>ScaleLinear<number, number>selection.data().join().data().join()selection.join()@types/d3.join()import { select } from "d3-selection";
const BAR_HEIGHT = 30;
const BAR_GAP = 5;
// Select, bind data, join
select(svgElement)
.selectAll<SVGRectElement, number>("rect")
.data(values, (d) => String(d)) // key function for identity
.join("rect") // enter + update merged
.attr("y", (_, i) => i * (BAR_HEIGHT + BAR_GAP))
.attr("width", (d) => xScale(d))
.attr("height", BAR_HEIGHT);join("rect")import { scaleLinear, scaleBand, scaleTime, scaleOrdinal } from "d3-scale";
const CHART_WIDTH = 600;
const CHART_HEIGHT = 400;
// Continuous: numbers -> pixels
const x = scaleLinear<number>()
.domain([0, max(data, (d) => d.value)!])
.range([0, CHART_WIDTH]);
// Categorical: strings -> pixel bands (bar charts)
const y = scaleBand<string>()
.domain(data.map((d) => d.label))
.range([0, CHART_HEIGHT])
.padding(0.1);
// Time: Date -> pixels
const timeScale = scaleTime<number>()
.domain([startDate, endDate])
.range([0, CHART_WIDTH]);max().padding()scaleLogscaleSqrtselection.call(axis)import { axisBottom, axisLeft } from "d3-axis";
import { format } from "d3-format";
const TICK_COUNT = 5;
// Create axes from scales
const xAxis = axisBottom(xScale).ticks(TICK_COUNT).tickFormat(format(",.0f"));
const yAxis = axisLeft(yScale);
// Render into <g> containers
svg
.append("g")
.attr("transform", `translate(0,${CHART_HEIGHT - MARGIN_BOTTOM})`)
.call(xAxis);
svg.append("g").attr("transform", `translate(${MARGIN_LEFT},0)`).call(yAxis);selection.call()d3-formatdimport { line, area, arc, pie, curveMonotoneX } from "d3-shape";
import type { PieArcDatum } from "d3-shape";
// Line generator
const lineGen = line<DataPoint>()
.x((d) => xScale(d.date))
.y((d) => yScale(d.value))
.curve(curveMonotoneX);
svg
.append("path")
.datum(data)
.attr("d", lineGen)
.attr("fill", "none")
.attr("stroke", "steelblue");
// Pie + arc generators
const INNER_RADIUS = 0;
const OUTER_RADIUS = 150;
const pieGen = pie<SliceData>().value((d) => d.value);
const arcGen = arc<PieArcDatum<SliceData>>()
.innerRadius(INNER_RADIUS)
.outerRadius(OUTER_RADIUS);
svg
.selectAll("path")
.data(pieGen(sliceData))
.join("path")
.attr("d", arcGen)
.attr("fill", (d) => colorScale(d.data.label));.curve()PieArcDatumviewBoxconst VIEWBOX_WIDTH = 960;
const VIEWBOX_HEIGHT = 500;
const svg = select(container)
.append("svg")
.attr("viewBox", `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`)
.attr("preserveAspectRatio", "xMidYMid meet")
.style("width", "100%")
.style("height", "auto");ResizeObserverimport { transition } from "d3-transition";
import { easeCubicOut } from "d3-ease";
const TRANSITION_DURATION_MS = 750;
const STAGGER_DELAY_MS = 50;
select(svgElement)
.selectAll<SVGRectElement, DataPoint>("rect")
.data(newData, (d) => d.id)
.join(
(enter) =>
enter
.append("rect")
.attr("width", 0)
.call((s) =>
s
.transition()
.duration(TRANSITION_DURATION_MS)
.attr("width", (d) => xScale(d.value)),
),
(update) =>
update.call((s) =>
s
.transition()
.duration(TRANSITION_DURATION_MS)
.attr("width", (d) => xScale(d.value)),
),
(exit) =>
exit.call((s) =>
s
.transition()
.duration(TRANSITION_DURATION_MS)
.attr("width", 0)
.remove(),
),
);d3.zoom()import { zoom, zoomIdentity } from "d3-zoom";
import type { D3ZoomEvent } from "d3-zoom";
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 32;
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([MIN_ZOOM, MAX_ZOOM])
.on("zoom", (event: D3ZoomEvent<SVGSVGElement, unknown>) => {
chartGroup.attr("transform", event.transform.toString());
});
svg.call(zoomBehavior);import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
} from "d3-force";
const CHARGE_STRENGTH = -300;
const COLLISION_RADIUS = 5;
const simulation = forceSimulation(nodes)
.force(
"link",
forceLink(links)
.id((d: NodeDatum) => d.id)
.distance(100),
)
.force("charge", forceManyBody().strength(CHARGE_STRENGTH))
.force("center", forceCenter(width / 2, height / 2))
.force("collide", forceCollide(COLLISION_RADIUS))
.on("tick", () => {
// Update node and link positions from simulation
});.id().distance()import { geoMercator, geoPath, geoNaturalEarth1 } from "d3-geo";
import type { GeoPermissibleObjects } from "d3-geo";
const projection = geoNaturalEarth1().fitSize(
[CHART_WIDTH, CHART_HEIGHT],
geoJsonData,
);
const pathGenerator = geoPath().projection(projection);
svg
.selectAll("path")
.data(geoJsonData.features)
.join("path")
.attr("d", pathGenerator)
.attr("fill", (d) => colorScale(dataByRegion.get(d.properties.id) ?? 0));fitSized// Pattern: D3 for computation, framework for rendering
// In your component:
const xScale = scaleLinear().domain([0, maxValue]).range([0, width]);
const yScale = scaleBand().domain(labels).range([0, height]).padding(0.1);
const linePath = line<DataPoint>()
.x((d) => xScale(d.x))
.y((d) => yScale(d.y)!)(data);
// Your framework renders SVG with computed values
// <svg><path d={linePath} /><rect width={xScale(d.value)} /></svg>What are you building?
|
+-> Bar/line/area chart?
| -> d3-selection, d3-scale, d3-axis, d3-shape, d3-array
|
+-> Pie/donut chart?
| -> d3-shape (pie + arc generators), d3-scale (color)
|
+-> Force-directed graph?
| -> d3-force, d3-selection, d3-drag
|
+-> Geographic map?
| -> d3-geo, d3-selection, d3-scale (color)
|
+-> Animated transitions?
| -> d3-transition, d3-ease, d3-interpolate
|
+-> Interactive (zoom/brush)?
-> d3-zoom or d3-brush, d3-selectionDoes the visualization need zoom, brush, drag, or force tick?
|
+-> NO -> D3 for computation only (scales, shapes, layouts)
| Your framework renders SVG/HTML directly
| Cleanest integration, fully declarative
|
+-> YES -> D3 owns the SVG via a ref element
Call D3 in a lifecycle hook (mount/update)
Clean up behaviors on unmount| Data Type | Scale | Example |
|---|---|---|
| Continuous | scaleLinear | Revenue, temperature |
| Categorical | scaleBand | Categories on bar chart |
| Categorical dots | scalePoint | Categories without width |
| Time series | scaleTime | Dates on x-axis |
| Logarithmic | scaleLog | Exponential data ranges |
| Square root | scaleSqrt | Bubble chart radius |
| Color categories | scaleOrdinal | Category -> color |
import * as d3 from "d3"import { select } from "d3-selection"selection.join().data(array, key)<g>.call(axis).join()transition.remove().nice()[0, 473]selection.join()scaleBand().bandwidth()d3.max()undefined?? 0transition.duration()forceSimulationrequestAnimationFramegeoPath<g>d3-transitionselection.transition().datum().data().datum()All code must follow project conventions in CLAUDE.md
selection.join()d3-selectiond3-scaled3-shapeimport * as d3 from "d3"Selection<SVGGElement, Datum, ...>ScaleLinear<number, number>