D3-based charting library for publication-quality, OWID-inspired interactive data visualizations.
npm install @devdatalab/ddl-chartsimport { TrajectoryChart } from '@devdatalab/ddl-charts';
// The stylesheet is not auto-imported — include it once in your app:
import '@devdatalab/ddl-charts/styles/ddl-theme.css';
const chart = createLineChart(d3.select('#chart'), data, {
initialHighlights: ['USA', 'CHN', 'IND'],
showLegend: true,
showLabels: true,
});The library ships three charts — see the Charts catalogue below: a multi-series LineChart, a single-track BeeswarmChart, and the TrajectoryChart.
src/
├── core/ # Foundation utilities
│ ├── scales.js # D3 scale factories (log, linear, time)
│ ├── axes.js # OWID-styled axis generators
│ ├── colors.js # Color palettes, country/region highlighting
│ ├── utils.js # Formatting, data processing, debounce
│ └── layout.js # Label de-collision, beeswarm packing, nearest-point
├── components/ # Reusable UI elements
│ ├── Tooltip.js # Smart-positioned hover tooltips
│ ├── Legend.js # Interactive legend with state
│ ├── SearchFilter.js # Search input with filtering
│ └── Container.js # Responsive SVG containers
├── charts/ # Chart implementations
│ ├── LineChart.js # Multi-series interactive line chart
│ ├── BeeswarmChart.js # Single-track 1D beeswarm (dot strip)
│ └── TrajectoryChart.js # GDP vs PM2.5 trajectory visualization
└── styles/
└── ddl-theme.css # DDL / OWID-inspired theming
createLogScale(domain, range, options)- Logarithmic scales for GDP, populationcreateLinearScale(domain, range, options)- Linear scales with paddingcreateTimeScale(domain, range, options)- Temporal scales
createXAxis(scale, options)- Horizontal axis with grid linescreateYAxis(scale, options)- Vertical axis with grid lines
createColorManager(highlights)- Dynamic country highlightingcreateRegionColorManager()- Region-based color assignment- Built-in palettes:
PRIMARY_COLORS,CONTINENT_COLORS,REGION_COLORS,HIGHLIGHT_COLORS(default categorical),OKABE_ITO(colorblind-safe categorical; opt in via a chart'spaletteoption)
formatNumber(n, precision?)- Compact SI notation (45K,2B); integer results omit decimals, others useprecision(default 2, so1500000→1.50M)formatPercentage(n, decimals?)- Percentage with trailing%(45%,4.5%)formatLocaleNumber(n)- Full value with thousands separators (1,234,567)formatCurrency(n)- Dollar formattingformatPM25(n)- µg/m³ formattingsmoothValues(arr, window)- Rolling average
Behavior change (unreleased):
formatNumbernow prints whole-number SI results without trailing zeros —formatNumber(45000)returns"45K"instead of"45.00K". Non-integer results are unchanged. Warrants a version bump before any release that consumers depend on.
resolveCollisions(positions, minGap)- Space out crowded label Y positionsbeeswarmLayout(values, xScale, dotRadius, centerY, rowSpacing?)- Deterministic 1D beeswarm packing (O(n²); fine for n ≤ 250)nearestPoint(points, mouseX, mouseY, snapRadius)- Nearest-point hit testing for hover (linear scan)
import { createTooltip, showTooltip, hideTooltip } from '@devdatalab/ddl-charts';
const tooltip = createTooltip(container);
showTooltip(tooltip, x, y, content);import { createLegend } from '@devdatalab/ddl-charts';
const legend = createLegend(container, items, {
onItemClick: (item) => console.log('clicked', item),
onItemHover: (item) => console.log('hovered', item)
});import { createSearchFilter } from '@devdatalab/ddl-charts';
const search = createSearchFilter(container, {
placeholder: 'Search cities...',
onFilter: (query) => filterData(query)
});Every chart is an imperative factory — create*(parent, data, options) — that
builds the DOM with D3 and returns an instance object with update methods.
parent is a d3.Selection (e.g. d3.select('#chart')).
Multi-series interactive line chart. Highlighted series are drawn in full
colour, raised, and labelled at the line end (de-collided via
resolveCollisions); the rest are muted. Hover snaps to the nearest series
point for a marker + tooltip. A single-series chart is just the N=1 case
(showLegend: false, showLabels: false).
import * as d3 from 'd3';
import { createLineChart } from '@devdatalab/ddl-charts';
const chart = createLineChart(d3.select('#chart'), data, {
height: 480,
initialHighlights: ['USA', 'CHN', 'IND'],
showLegend: true,
showLabels: true,
curve: 'linear', // 'linear' | 'cardinal'
yTickFormat: (d) => `${d}%`,
});
chart.toggleHighlight('BRA'); // programmatic highlight toggleData format
{
"metadata": { "title": "...", "xAxisLabel": "Year", "yAxisLabel": "Urban population (%)" },
"series": [
{
"id": "USA",
"name": "United States",
"points": [
{ "year": 1975, "value": 73.7 },
{ "year": 2020, "value": 82.7 }
]
}
]
}Options
| Option | Default | Description |
|---|---|---|
height |
500 |
Chart height in px. |
margin |
{top:30,right:140,bottom:50,left:70} |
Chart margins. |
xKey / yKey |
'year' / 'value' |
Accessor keys on each point. |
showLegend |
true |
Clickable legend (toggles highlights). |
showLabels |
true |
De-collided end-of-line labels for highlighted series. |
showGrid |
true |
Axis grid lines. |
initialHighlights |
first 3 series | Series ids highlighted on load. |
palette |
HIGHLIGHT_COLORS |
Categorical palette for highlighted series. |
yZeroBaseline |
false |
Force the y-domain to start at 0. |
curve |
'linear' |
Line interpolation ('linear' or 'cardinal'). |
hoverSnapRadius |
40 |
Max px distance for hover snapping. |
xTickFormat / yTickFormat |
— | Custom tick formatters. |
Returns { container, svg, colorManager, toggleHighlight, updateAll }.
Single horizontal track that packs one value per entity into stacked rows
(beeswarmLayout, O(n²), fine for n ≤ 250). A handful of entities can be
highlighted (coloured + raised); the rest render as muted background dots.
Optional median reference line; hover snaps to the nearest dot for a tooltip.
Compose multi-track figures by stacking several instances.
import * as d3 from 'd3';
import { createBeeswarmChart } from '@devdatalab/ddl-charts';
const chart = createBeeswarmChart(d3.select('#chart'), data, {
height: 290,
dotRadius: 3.5,
rowSpacing: 8,
highlights: ['USA', 'CHN', 'IND', 'BRA', 'NGA'],
showMedian: true,
});
chart.setHighlights(['USA', 'IND']); // replace the highlighted setData format
{
"metadata": { "title": "...", "axisLabel": "Night lights (nW/cm²/sr)" },
"items": [
{ "id": "USA", "name": "United States", "value": 41.2 }
]
}Options
| Option | Default | Description |
|---|---|---|
height |
240 |
Chart height in px. |
margin |
{top:30,right:30,bottom:56,left:30} |
Chart margins. |
valueKey / idKey / nameKey |
'value' / 'id' / 'name' |
Accessor keys on each item. |
label |
metadata.axisLabel |
Axis label. |
dotRadius |
3 |
Dot radius in px. |
rowSpacing |
dotRadius * 2.2 |
Vertical row spacing (passed to beeswarmLayout). |
highlights |
[] |
Ids to highlight. |
showMedian |
true |
Draw a median reference line. |
hoverSnapRadius |
13 |
Max px distance for hover snapping. |
palette |
HIGHLIGHT_COLORS |
Categorical palette for highlights. |
valueFormat / tickFormat |
— | Custom value / tick formatters. |
Non-finite values are filtered (with a console.warn) before layout. Returns
{ container, svg, setHighlights, update }.
The original chart implementation showing city GDP vs PM2.5 trajectories over time, with country/region highlighting, search, and an interactive legend.
import * as d3 from 'd3';
import { createTrajectoryChart } from '@devdatalab/ddl-charts';
const chart = createTrajectoryChart(d3.select('#chart'), data, {
initialHighlights: ['CHN', 'IND', 'USA'],
showSearch: true,
showLegend: true,
showLegendControls: true,
showGrid: true
});{
"metadata": {
"title": "City GDP vs PM2.5",
"xAxisLabel": "GDP per capita (USD)",
"yAxisLabel": "PM2.5 (µg/m³)",
"yearRange": [2013, 2023]
},
"bounds": {
"xMin": 100, "xMax": 100000,
"yMin": 1, "yMax": 200
},
"cities": [
{
"id": "IND_Mumbai",
"name": "Mumbai",
"country": "India",
"iso3": "IND",
"region": "South Asia",
"trajectory": [
{ "year": 2013, "x": 5000, "y": 45 },
{ "year": 2023, "x": 8000, "y": 35 }
]
}
]
}npm install
npm run dev # Start dev server
npm test # Run tests
npm run build # Build for productionThe chart screenshots in this README are rendered headlessly from the example HTML so they stay reproducible:
npm install --no-save playwright # heavy, build-time only — not a runtime dep
npx playwright install chromium # one-time browser download
npm run render:examples # writes docs/assets/*.pngThe example HTML files import the library source directly (an import map
resolves d3 to a CDN ESM build), so they run on any static server or via
npm run dev:
examples/line_chart.html— multi-series LineChart.examples/beeswarm.html— single-track BeeswarmChart.examples/city_gdp_pm25.html— TrajectoryChart with 205+ cities.
MIT

