Polar geometry is available only from @tanstack/charts/polar. The container owns responsive center, angle, and radius ranges; granular D3 modules still own pie layout, configured scales, and curve factories.
import {
angleGrid,
polar,
radialArc,
radialArea,
radialDot,
radialGrid,
radialLine,
radialRule,
radialText,
} from '@tanstack/charts/polar'The package root stays Cartesian-sized when this subpath is not imported.
Use d3-shape's pie transform to turn totals into angular intervals. radialArc renders the intervals. A zero inner radius is a pie; a responsive nonzero inner radius is a donut.
import { defineChart } from '@tanstack/charts'
import { polar, radialArc } from '@tanstack/charts/polar'
import { pie } from 'd3-shape'
interface AlphabetRow {
letter: string
frequency: number
}
const alphabet: readonly AlphabetRow[] = [
{ letter: 'E', frequency: 0.12702 },
{ letter: 'T', frequency: 0.09056 },
{ letter: 'A', frequency: 0.08167 },
{ letter: 'O', frequency: 0.07507 },
{ letter: 'I', frequency: 0.06966 },
]
const partColors = ['#0ea5e9', '#6366f1', '#a855f7', '#ec4899', '#f97316']
const letters = alphabet.slice(0, 5)
const pieLayout = pie<AlphabetRow>()
.sort(null)
.value((row) => row.frequency)
function ring(innerRatio: number) {
const slices = pieLayout(letters)
return polar({
inset: 8,
radiusRatio: 0.82,
marks: [
radialArc(slices, {
startAngle: 'startAngle',
endAngle: 'endAngle',
padAngle: 'padAngle',
innerRadius: ({ radius }) => radius * innerRatio,
cornerRadius: 4,
color: (slice) => slice.data.letter,
key: (slice) => slice.data.letter,
}),
],
})
}
const pieChart = defineChart({
marks: [ring(0)],
color: { domain: letters.map((row) => row.letter), range: partColors },
})
const donutChart = defineChart({
marks: [ring(0.58)],
color: { domain: letters.map((row) => row.letter), range: partColors },
})The same primitives cover labels, center content, padding, rounded corners, and concentric rings:
Keep pie().sort(null) when source order is semantic. Stable arc keys must come from the original row, not the generated slice index.
A gauge is the same composition over a restricted D3 pie interval. It is not a separate geometry implementation.
import { defineChart } from '@tanstack/charts'
import { polar, radialArc } from '@tanstack/charts/polar'
import { pie } from 'd3-shape'
interface SurveyRow {
Question: string
ID: number
Response: string
}
const survey: readonly SurveyRow[] = [
{ Question: 'Q1', ID: 1, Response: 'Strongly Agree' },
{ Question: 'Q1', ID: 2, Response: 'Agree' },
{ Question: 'Q1', ID: 3, Response: 'Agree' },
{ Question: 'Q1', ID: 4, Response: 'Neutral' },
{ Question: 'Q1', ID: 5, Response: 'Disagree' },
{ Question: 'Q2', ID: 1, Response: 'Neutral' },
]
interface GaugePart {
id: 'agreement' | 'other'
value: number
}
function agreementPercent(rows: readonly SurveyRow[], question: string) {
const responses = rows.filter((row) => row.Question === question)
const agreements = responses.filter(
(row) => row.Response === 'Agree' || row.Response === 'Strongly Agree',
)
return responses.length === 0
? 0
: Math.round((agreements.length / responses.length) * 100)
}
const agreement = agreementPercent(survey, 'Q1')
const gaugeParts: GaugePart[] = [
{ id: 'agreement', value: agreement },
{ id: 'other', value: 100 - agreement },
]
const gaugeSlices = pie<GaugePart>()
.sort(null)
.value((part) => part.value)
.startAngle(-Math.PI * 0.75)
.endAngle(Math.PI * 0.75)(gaugeParts)
const gauge = defineChart({
marks: [
polar({
radiusRatio: 0.84,
marks: [
radialArc(gaugeSlices, {
startAngle: 'startAngle',
endAngle: 'endAngle',
innerRadius: ({ radius }) => radius * 0.72,
cornerRadius: 999,
color: (slice) => slice.data.id,
}),
],
}),
],
color: {
domain: ['agreement', 'other'],
range: ['#ef4444', '#e2e8f0'],
},
})Bound the input before layout and expose the exact value outside the arc. Arc length is useful for a compact status summary, not fine comparison.
Radar combines an inferred angle factory and a fixed radius instance with polar guides and radial marks. TanStack supplies both responsive ranges.
import { defineChart } from '@tanstack/charts'
import {
angleGrid,
polar,
radialArea,
radialDot,
radialGrid,
radialLine,
} from '@tanstack/charts/polar'
import { extent } from 'd3-array'
import { scaleLinear, scalePoint } from 'd3-scale'
import { curveLinearClosed } from 'd3-shape'
interface DecathlonRow {
Country: string
'100 Meters': number
'Long Jump': number
'High Jump': number
'100 Meter Hurdles': number
}
const decathlon: readonly DecathlonRow[] = [
{
Country: 'United States',
'100 Meters': 10.35,
'Long Jump': 7.96,
'High Jump': 2.05,
'100 Meter Hurdles': 13.61,
},
{
Country: 'Great Britain',
'100 Meters': 10.44,
'Long Jump': 7.74,
'High Jump': 2.11,
'100 Meter Hurdles': 13.75,
},
{
Country: 'Germany',
'100 Meters': 10.67,
'Long Jump': 7.62,
'High Jump': 2.08,
'100 Meter Hurdles': 14.02,
},
{
Country: 'France',
'100 Meters': 10.58,
'Long Jump': 7.81,
'High Jump': 1.99,
'100 Meter Hurdles': 13.88,
},
]
const events = [
'100 Meters',
'Long Jump',
'High Jump',
'100 Meter Hurdles',
] as const
type RadarEvent = (typeof events)[number]
const timedEvents = new Set<RadarEvent>(['100 Meters', '100 Meter Hurdles'])
const eventExtents = new Map(
events.map((event) => [event, extent(decathlon, (row) => row[event])]),
)
function radarProfile(row: DecathlonRow) {
return events.map((event) => {
const [minimum = 0, maximum = 1] = eventExtents.get(event) ?? []
const proportion = (row[event] - minimum) / (maximum - minimum || 1)
return {
Country: row.Country,
event,
relativePerformance:
(timedEvents.has(event) ? 1 - proportion : proportion) * 100,
}
})
}
const athlete = decathlon[0]
if (!athlete) throw new Error('The decathlon data is empty')
const profile = radarProfile(athlete)
const radar = defineChart({
marks: [
polar({
radiusRatio: 0.72,
angle: { scale: scalePoint<string>().domain(events), wrap: true },
radius: { scale: scaleLinear().domain([0, 100]).nice(4) },
guides: [
radialGrid({ ticks: 4, shape: 'polygon', labels: false }),
angleGrid({
labels: true,
labelDx: ({ x }) => (x < -1 ? -3 : x > 1 ? 3 : 0),
labelDy: ({ y }) => (y < -1 ? -2 : y > 1 ? 2 : 0),
}),
],
marks: [
radialArea(profile, {
angle: 'event',
radius: 'relativePerformance',
curve: curveLinearClosed,
fill: '#7c3aed',
fillOpacity: 0.22,
}),
radialLine(profile, {
angle: 'event',
radius: 'relativePerformance',
curve: curveLinearClosed,
stroke: '#8b5cf6',
strokeWidth: 2,
}),
radialDot(profile, {
angle: 'event',
radius: 'relativePerformance',
key: 'event',
r: 3,
fill: '#8b5cf6',
}),
],
}),
],
})Use radar for a small, fixed set of compatible dimensions. Keep every domain and direction explicit, and do not rank profiles by apparent filled area.
Continuous D3 scales map numeric angle and radius values without changing the mark API. A visible transform can derive angle and radius from existing source measurements without renaming those measurements into chart fields.
import { defineChart } from '@tanstack/charts'
import {
angleGrid,
polar,
radialDot,
radialGrid,
radialLine,
} from '@tanstack/charts/polar'
import { scaleLinear } from 'd3-scale'
interface WeatherRow {
location: string
date: Date
temp_max: number
}
const weather: readonly WeatherRow[] = [
{
location: 'Seattle',
date: new Date('2012-01-15T00:00:00Z'),
temp_max: 8.3,
},
{
location: 'Seattle',
date: new Date('2012-03-15T00:00:00Z'),
temp_max: 12.2,
},
{
location: 'Seattle',
date: new Date('2012-05-15T00:00:00Z'),
temp_max: 18.9,
},
{
location: 'Seattle',
date: new Date('2012-07-15T00:00:00Z'),
temp_max: 25.6,
},
{
location: 'Seattle',
date: new Date('2012-09-15T00:00:00Z'),
temp_max: 21.1,
},
{
location: 'Seattle',
date: new Date('2012-11-15T00:00:00Z'),
temp_max: 11.7,
},
]
interface WindRow {
latitude: number
u: number
v: number
}
const wind: readonly WindRow[] = [
{ latitude: 48.125, u: 4.2, v: 1.6 },
{ latitude: 48.125, u: 2.1, v: 5.8 },
{ latitude: 48.125, u: -3.4, v: 6.2 },
{ latitude: 48.125, u: -5.1, v: -2.3 },
{ latitude: 48.125, u: 1.8, v: -4.7 },
]
const seattle2012 = weather.filter(
(row) => row.location === 'Seattle' && row.date.getUTCFullYear() === 2012,
)
const latitudeBand = wind.filter((row) => row.latitude === 48.125)
function dayOfYearAngle(row: WeatherRow) {
const year = row.date.getUTCFullYear()
const start = Date.UTC(year, 0, 1)
const end = Date.UTC(year + 1, 0, 1)
return ((row.date.getTime() - start) / (end - start)) * 360
}
function windDirection(row: WindRow) {
return (Math.atan2(row.v, row.u) * (180 / Math.PI) + 360) % 360
}
function windSpeed(row: WindRow) {
return Math.hypot(row.u, row.v)
}
const polarLineChart = defineChart({
marks: [
polar({
angle: { scale: scaleLinear().domain([0, 360]) },
radius: { scale: scaleLinear().domain([-10, 40]) },
guides: [
radialGrid({ values: [0, 10, 20, 30, 40] }),
angleGrid({ values: [0, 90, 180, 270], labels: false }),
],
marks: [
radialLine(seattle2012, {
angle: dayOfYearAngle,
radius: 'temp_max',
stroke: '#0f766e',
}),
],
}),
],
})
const polarScatterChart = defineChart({
marks: [
polar({
angle: { scale: scaleLinear().domain([0, 360]) },
radius: { scale: scaleLinear().domain([0, 13]) },
guides: [
radialGrid({ values: [3, 6, 9, 12] }),
angleGrid({ values: [0, 90, 180, 270], labels: false }),
],
marks: [
radialDot(latitudeBand, {
angle: windDirection,
radius: windSpeed,
r: 4.5,
fill: '#e11d48',
}),
],
}),
],
})radialArc.generator accepts responsive D3 arc accessors, so variable-radius sectors, concentric radial bars, and hierarchy rings remain one primitive.
polar() is a positionless container mark. It resolves one center and radius, copies configured angle/radius scales, paints guide backgrounds, child marks, then guide foreground labels, and emits ordinary scene nodes and focus points. The outer chart therefore omits both Cartesian axes.
The polar entry uses D3 arc and radial path generators internally. Application source imports pie, configured scales, and curve factories directly from d3-shape or d3-scale. See Polar Marks for the complete API and Bundle Size and Performance for the isolated consumer budgets.