Polar geometry is available only from @tanstack/charts/polar. The container owns responsive center, angle, and radius ranges. Its eager pie transform owns value allocation; granular D3 modules still own configured scales, curve factories, and final arc/path geometry.
import {
angleGrid,
pie,
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 pie to turn totals into flat source-linked 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 {
pie,
polar,
radialArc,
radialRule,
radialText,
} from '@tanstack/charts/polar'
import { scaleLinear } from 'd3-scale'
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)
function ring(innerRatio: number) {
const slices = pie(letters, { value: 'frequency' })
return polar({
inset: 8,
radiusRatio: 0.82,
marks: [
radialArc(slices, {
innerRadius: ({ radius }) => radius * innerRatio,
cornerRadius: 4,
color: 'letter',
key: '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 },
})
const labeledSlices = pie(letters, { value: 'frequency' })
const labeledPie = defineChart({
marks: [
polar({
radiusRatio: 0.72,
angle: { scale: scaleLinear().domain([0, Math.PI * 2]) },
radius: { scale: scaleLinear().domain([0, 1]) },
marks: [
radialArc(labeledSlices, {
color: 'letter',
key: 'letter',
}),
radialRule(labeledSlices, {
angle: 'angle',
radius1: 1,
radius2: 1,
radius2Offset: 20,
key: 'letter',
}),
radialText(labeledSlices, {
angle: 'angle',
radius: 1,
radiusOffset: 20,
text: 'letter',
color: 'letter',
key: 'letter',
anchor: 'outside',
}),
],
}),
],
color: { domain: letters.map((row) => row.letter), range: partColors },
})The same primitives cover labels, center content, padding, rounded corners, and concentric rings. Radial offsets are signed pixels applied after scale mapping. They do not change the radius domain or reserve outer margin; leave space with radiusRatio, inset, or chart margins.
Source order is the default. Use orderBy and order only for an explicit angular sort. Stable arc keys must come from the original row, not the generated slice index.
Each allocated row keeps the original fields plus direct source and sourceIndexes lineage. Fixed allocation fields overwrite source fields with the same names. gapAngle materializes direct empty space; the returned padAngle: 0 prevents radialArc from padding that interval again.
A gauge is the same composition over a restricted pie interval. It is not a separate geometry implementation.
import { defineChart } from '@tanstack/charts'
import { pie, polar, radialArc } from '@tanstack/charts/polar'
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(gaugeParts, {
value: 'value',
startAngle: -Math.PI * 0.75,
endAngle: Math.PI * 0.75,
})
const gauge = defineChart({
marks: [
polar({
radiusRatio: 0.84,
marks: [
radialArc(gaugeSlices, {
innerRadius: ({ radius }) => radius * 0.72,
cornerRadius: 999,
color: 'id',
key: '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, normalize, select } from '@tanstack/charts'
import { fold } from '@tanstack/charts/transform/fold'
import {
angleGrid,
polar,
radialArea,
radialDot,
radialGrid,
radialLine,
} from '@tanstack/charts/polar'
import { scaleLinear } from '@tanstack/charts-scales/linear'
import { scalePoint } from '@tanstack/charts-scales/point'
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 folded = fold(decathlon, {
fields: events,
as: { key: 'event', value: 'result' },
})
const normalized = normalize(folded, {
by: 'event',
value: ({ datum }) =>
timedEvents.has(datum.event) ? -datum.result : datum.result,
basis: 'extent',
as: 'relativePerformance',
})
const profile = select(normalized, { by: 'event', select: 'first' })
const percent = new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 0,
})
const radar = defineChart({
marks: [
polar({
radiusRatio: 0.72,
angle: { scale: scalePoint<string>().domain(events), wrap: true },
radius: { scale: scaleLinear().domain([0, 1]) },
guides: [
radialGrid({
values: [0.25, 0.5, 0.75, 1],
shape: 'polygon',
labels: true,
format: (value) => percent.format(Number(value)),
}),
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.
Lightweight linear 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 '@tanstack/charts-scales/linear'
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',
}),
],
}),
],
})Choose the mark by the quantitative direction. A rose extends one bar through radius for each angle band. Concentric radial bars extend through angle for each radius band. D3 band padding controls the categorical occupancy.
import { defineChart } from '@tanstack/charts'
import { polar, radialBarAngle, radialBarRadius } from '@tanstack/charts/polar'
import { scaleBand, scaleLinear } from 'd3-scale'
interface FrequencyRow {
letter: string
frequency: number
}
const frequencies: readonly FrequencyRow[] = [
{ 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 letters = frequencies.map((row) => row.letter)
const maximum = Math.max(...frequencies.map((row) => row.frequency))
const colors = ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#16a34a']
const rose = defineChart({
marks: [
polar({
radiusRatio: 0.8,
angle: { scale: () => scaleBand<string>() },
radius: {
scale: scaleLinear().domain([0, maximum]),
range: [({ radius }) => radius * 0.3, ({ radius }) => radius],
},
marks: [
radialBarRadius(frequencies, {
angle: 'letter',
radius: 'frequency',
color: 'letter',
key: 'letter',
}),
],
}),
],
color: { domain: letters, range: colors },
})
const concentricBars = defineChart({
marks: [
polar({
radiusRatio: 0.84,
angle: { scale: scaleLinear().domain([0, maximum]) },
radius: {
scale: () => scaleBand<string>().paddingInner(0.38).paddingOuter(0.19),
range: [({ radius }) => radius * 0.2, ({ radius }) => radius],
},
marks: [
radialBarAngle(frequencies, {
angle: 'frequency',
radius: 'letter',
color: 'letter',
cornerRadius: 'full',
key: 'letter',
}),
],
}),
],
color: { domain: letters, range: colors },
})An omitted radius baseline in radialBarRadius starts at the physical center; the responsive radius range controls the quantitative endpoints. Supply radius1 when both endpoints are semantic values. Signed radius data should use radius1: 0 so semantic zero maps through the scale. radialBarAngle maps its default angle baseline from semantic zero.
The optional sunburst mark accepts flat hierarchy rows and owns value aggregation, partitioning, responsive rings, and sector geometry:
import { defineChart } from '@tanstack/charts'
import { sunburst } from '@tanstack/charts/hierarchy/sunburst'
import { polar } from '@tanstack/charts/polar'
const chart = defineChart({
marks: [
polar({
startAngle: Math.PI / 2,
endAngle: Math.PI / 2 - Math.PI * 2,
marks: [
sunburst(rows, {
path: 'name',
delimiter: '.',
value: 'size',
innerRadius: ({ radius }) => radius * 0.14,
ringPadding: 2,
color: 'branchId',
stroke: '#fff',
}),
],
}),
],
})Use nodeId and parentId for explicit parent-reference rows. Responsive innerRadius and outerRadius callbacks receive the final polar radius; ringPadding remains a fixed pixel gap. Every SunburstNode retains its direct row and source index, while branchId gives descendants the color of their first ancestor below the root. See the Sunburst Mark reference.
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 can use compact angle and radius scales or upgrade either one to d3-scale; curve factories and application-owned pie layout can come directly from d3-shape. See Polar Marks for the complete API and Bundle Size and Performance for the isolated consumer budgets.
radialArc also accepts existing D3 pie DTOs as interoperability input; native pie is preferred when flat fields, transform lineage, and direct gap semantics are wanted.