Maps

Maps place data on geography, shading regions by a value or marking points and routes on a projected world.

LLMs

Code

Maps are drawn with TanStack Charts, through the kit's Chart components. They paint the chart with the theme's colors, grid and type, and add three palette props. The chart itself is a TanStack definition, written the way TanStack documents it.

Install

npx antuikit@latest init
npx antuikit@latest add tanstack-chart
The first command installs AntuikitProvider and the theme tokens. The second adds the chart components and TanStack Charts itself.

Your first map

A map is a geoShape mark over GeoJSON features, with a projection from d3-geo. Load the features, put each region's value on its properties, and shade it with color.
import { defineChart } from "@tanstack/charts"
import { geoShape } from "@tanstack/charts/geo"
import {
type ExtendedFeature,
type ExtendedFeatureCollection,
type GeoGeometryObjects,
geoEqualEarth,
} from "d3-geo"
import { scaleThreshold } from "d3-scale"
import { useEffect, useMemo, useState } from "react"
import { Chart, chartColors } from "@/components/ui/tanstack-chart"
type Country = ExtendedFeature<GeoGeometryObjects, { name: string }>
const projection = { type: geoEqualEarth, fit: "sphere" as const }
export function LearningPovertyMap({ rates }: { rates: Map<string, number> }) {
const [countries, setCountries] =
useState<ExtendedFeatureCollection<Country> | null>(null)
useEffect(() => {
// Your own copy of the countries GeoJSON.
fetch("/maps/world-countries-110m.json")
.then((response) => response.json())
.then(setCountries)
}, [])
const definition = useMemo(() => {
if (!countries) return null
const measured = countries.features.flatMap((country) => {
const rate = rates.get(country.properties.name)
return rate === undefined
? []
: [{ ...country, properties: { ...country.properties, rate } }]
})
return defineChart({
marks: [
geoShape(measured, {
projection,
color: (country) => country.properties.rate,
stroke: chartColors.surface,
strokeWidth: 0.5,
}),
],
scales: { x: null, y: null },
color: {
scale: scaleThreshold<number, string>,
domain: [20, 40, 60, 80],
range: [
"var(--ant-blue-2)",
"var(--ant-blue-4)",
"var(--ant-blue-6)",
"var(--ant-blue-8)",
"var(--ant-blue-10)",
],
},
margin: 10,
})
}, [countries, rates])
return definition ? (
<Chart
definition={definition}
ariaLabel="World map of the share of children who cannot read by age 10"
height={360}
/>
) : null
}
The chart fills its container's width, so give it a height. ariaLabel is required. A map has no axes, so turn both off with scales: { x: null, y: null }.

Which component

All three take the same definition and the same palette props.
Chart components

Where the map data comes from

TanStack Charts ships no boundaries. You bring the shapes as GeoJSON: a FeatureCollection whose features each have a geometry and properties.
  • World countries and land come from world-atlas (Natural Earth), and US states and counties from us-atlas (US Census Bureau). Both publish TopoJSON.
  • Convert TopoJSON to GeoJSON once with topojson-client, then keep the geometry fixed while the values change.
  • Pick the coarsest detail that looks right at your size. As GeoJSON, world countries at 1:110m are about 190 KB, world land at 1:50m about 900 KB, and all US counties at 1:10m about 1.4 MB.
  • Fetch the file when the map mounts instead of bundling it, and show a Skeleton until it arrives.
  • Your code imports from d3-geo and d3-scale. Add both to your own dependencies.
import { feature } from "topojson-client"
import type { Topology } from "topojson-specification"
import worldAtlas from "world-atlas/countries-110m.json"
const topology = worldAtlas as unknown as Topology
const countries = feature(topology, topology.objects.countries)

Join values to regions

Your values and your shapes come from different sources, so join them before you draw. Copy each value onto its feature's properties, and read it back from color and the tooltip.
// County shapes use 5 digit FIPS codes as ids, the rates file uses numbers.
const rateById = new Map(rows.map((row) => [String(row.id).padStart(5, "0"), row]))
const counties = atlas.counties.features.flatMap((county) => {
const row = rateById.get(String(county.id))
return row ? [{ ...county, properties: { ...county.properties, ...row } }] : []
})
  • Join by a stable code, such as an ISO country code or a FIPS code, when both sources have one. Names differ between sources: "Russian Federation" in one is "Russia" in the other.
  • When you must join by name, keep a small table that maps one spelling to the other, and log the rows that found no shape.
  • Draw regions with no value in their own neutral color, under the measured ones, so missing data does not look like zero.

Projections

projection takes a projection factory in type and what to fit into the chart in fit. The chart fits the map again whenever its size changes.
import { geoAlbersUsa, geoEqualEarth, geoIdentity, geoOrthographic } from "d3-geo"
// The whole world, framed by the globe's outline.
{ type: geoEqualEarth, fit: "sphere" }
// A globe, rotated and tilted toward the north.
{ type: () => geoOrthographic().rotate([0, -30, 20]), fit: "sphere" }
// The United States, fitted to the counties you draw.
{ type: geoAlbersUsa, fit: { type: "FeatureCollection", features: counties } }
// A floor plan or any shape in plain x and y coordinates.
{ type: geoIdentity, fit: plan }
  • geoEqualEarth keeps areas true, which a world choropleth needs. geoNaturalEarth1 looks similar but does not keep areas. Avoid geoMercator for world data.
  • fit: "sphere" keeps the world frame the same whatever data you draw. fit: "data" zooms to the mark's own features.
  • Give every layer the same projection object, so the layers line up.
  • geoAlbersUsa has no place for the territories, such as Puerto Rico. Drop the features it cannot draw before you fit to them.
import { geoAlbersUsa, geoPath } from "d3-geo"
const drawable = geoPath(geoAlbersUsa())
const counties = features.filter((county) => drawable(county) !== null)

Layers

A map is usually several geoShape marks on top of each other: land or regions with no data, the measured regions, then borders and the globe's outline. Wrap the layers that carry no data in decorative, so the keyboard and the tooltip skip them.
import { decorative } from "@tanstack/charts/mark/decorative"
import { geoGraticule10 } from "d3-geo"
marks: [
decorative(
geoShape(land.features, {
projection,
fill: "var(--ant-color-fill-secondary)",
stroke: chartColors.surface,
strokeWidth: 0.5,
}),
),
geoShape(measured, { projection, color: (country) => country.properties.rate }),
decorative(
geoShape([geoGraticule10()], {
projection,
fill: "none",
stroke: chartColors.grid,
}),
),
decorative(
geoShape([{ type: "Sphere" }], {
projection,
fill: "none",
stroke: chartColors.axis,
}),
),
]

Color scales and legends

Set the scale on the definition's color, and give it a range from light to dark. Pick the scale by how you want the values split into bands.
import { scaleQuantile, scaleQuantize, scaleThreshold } from "d3-scale"
// Bands you choose: under 20, 20 to 40, 40 to 60, 60 to 80, 80 and over.
color: { scale: scaleThreshold<number, string>, domain: [20, 40, 60, 80], range: ramp }
// Bands of equal width across the data's range.
color: { scale: scaleQuantize<string>, range: ramp }
// The same number of regions in each band.
color: { scale: scaleQuantile<number, string>, range: ramp }
  • A threshold scale needs its cut points in domain. The other two find their domain in the data.
  • Fix the cut points when the map is filtered or compared with another map, so a color keeps its meaning.
  • Always show a legend. Write it next to the chart with the kit's Badge, one per band from the same ramp array, or let the chart draw one with legend: colorLegend(...) from @tanstack/charts/legend.

Points and routes

geoShape also draws Point and LineString features. For a bubble map, make a point at each region's center, put the amount in r, and map it to a radius with a square root scale, so the circle's area follows the value.
import { geoCentroid } from "d3-geo"
import { scaleSqrt } from "d3-scale"
const bubbles = countries
.map((country) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: geoCentroid(country) },
properties: country.properties,
}))
// Largest first, so small circles stay on top and can be hovered.
.sort((a, b) => b.properties.population - a.properties.population)
geoShape(bubbles, {
projection,
r: (country) => country.properties.population,
rScale: { scale: () => scaleSqrt().range([2, 18]) },
fill: chartColors.series[0],
fillOpacity: 0.72,
stroke: chartColors.surface,
})
// A route is one LineString with longitude and latitude pairs.
geoShape([{ type: "Feature", properties: { name: "Route" }, geometry: { type: "LineString", coordinates: stops } }], {
projection,
fill: "none",
stroke: chartColors.series[0],
strokeWidth: 2,
})

Tooltip

The tooltip receives the GeoJSON feature, so read the name and the value from its properties. Include the unit.
import { tooltip } from "@tanstack/charts/tooltip"
defineChart(spec, {
tooltip: {
use: tooltip,
content: (points) => ({
rows: points.map(({ datum, color }) => ({
label: datum.properties.name,
value: `${datum.properties.rate}%`,
color,
})),
}),
},
})

Colors

A choropleth reads its colors from the scale's range, not from the palette. Use one hue from the theme, light to dark, such as var(--ant-blue-2) to var(--ant-blue-10), and paint the base layers with chartColors so they follow the theme in dark mode. The Color guideline covers which palette fits which data.
const ramp = [
"var(--ant-blue-2)",
"var(--ant-blue-4)",
"var(--ant-blue-6)",
"var(--ant-blue-8)",
"var(--ant-blue-10)",
]
color: { scale: scaleThreshold<number, string>, domain: [20, 40, 60, 80], range: ramp }
// Borders in the page color, the globe outline in the axis color.
geoShape(measured, { projection, color, stroke: chartColors.surface, strokeWidth: 0.5 })
Palette props
Taken by Chart, RendererChart and CanvasChart. Rows marked antuikit in the Version column are added by the kit.

Animation

Use RendererChart with TanStack's motion() renderer to animate marks in and between data updates. It skips the animation when the reader has asked for reduced motion.
import { motion } from "@tanstack/charts/motion"
import { RendererChart } from "@/components/ui/tanstack-chart"
const renderer = motion({ initial: "always" })
<RendererChart definition={definition} renderer={renderer} ariaLabel="..." />

API

The options a map uses most. TanStack Charts' documentation has the full reference.
geoShape
projection
color