Skip to content
5 changes: 5 additions & 0 deletions .changeset/crowded-pie-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/charts': minor
---

Add `radialLabels` to `@tanstack/charts/polar`. It draws outside pie and donut labels with elbow connectors, pushes crowded labels apart on each side, and hides the lowest-priority labels when they do not fit. Labels that would cross the chart width are hidden, and `maxShift` also hides labels that would move too far from their slice.
15 changes: 15 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ Each entry records:
| F-312 | Tick stubs could not be styled independently | API | resolved |
| F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved |
| F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved |
| F-315 | Crowded pie labels overlapped | API | resolved |

## Findings

Expand Down Expand Up @@ -9317,3 +9318,17 @@ Each entry records:
direction changes. A focus dot appears during entrance while the data path
and its running animation remain intact. Concurrent inline states remain a
separate feature; this addresses the documented alternatives requested.

### F-315 - Crowded pie labels overlapped

- Status: resolved
- Severity: medium
- Owner: API
- Observed in: issue #227, `radialRule` plus `radialText` labels of small
adjacent slices overlapped, so the application owned its own label layout
- Decision: add `radialLabels`, which stacks labels in one column per side,
moves overlapping labels apart, and hides lower-priority labels that do not
fit the chart height, width, or optional `maxShift`.
- Verification: layout and rendered tests cover spacing, bounds, priority
ties, connectors clear of other labels, stable keys, motion, and SVG,
Canvas, and React Native output.
48 changes: 48 additions & 0 deletions docs/reference/marks/polar.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
radialBarRadius,
radialDot,
radialGrid,
radialLabels,
radialLine,
radialRule,
radialText,
Expand Down Expand Up @@ -369,6 +370,53 @@ the polar coordinate system. Its interaction point follows the final radial
offset plus `dx`/`dy` while retaining the original semantic radius value. Its
key defaults to a unique top-level or nested `data.id`, then row index.

## `radialLabels`

```ts
function radialLabels<TDatum>(
source: Iterable<TDatum>,
options?: RadialLabelsOptions<TDatum>,
): PolarMark<TDatum>
```

`radialLabels` renders outside slice labels with elbow connectors and keeps
crowded labels readable.

| Option | Meaning |
| ------------------------------ | -------------------------------------------------------------- |
| `id`, `className` | Stable layer ID and optional class |
| `angle` | Angle channel; defaults to datum `angle` |
| `radius` | Connector start radius; defaults to `1` |
| `text` | Label text |
| `priority` | Lower priority hides first; defaults to datum `value` |
| `maxShift` | Hides a label that would move more pixels from its slice |
| `key` | Stable label identity; defaults to top/nested `id`, then index |
| `z` | Interaction group |
| `color` | Color-scale value; defaults to `z` |
| `fill` | Final constant or datum-derived text paint |
| `fontSize`, `fontWeight` | Text size, default 12, and weight |
| `opacity` | Label and connector opacity |
| `stroke` | Connector paint; defaults to the label fill |
| `strokeOpacity`, `strokeWidth` | Connector opacity and width |

Labels stack on each side of the center in angular order, `fontSize * 1.4`
pixels apart. Overlapping labels move apart as little as possible and stay
inside the chart bounds. Each side places its text in one column outside its
outermost visible connector elbow, so no connector crosses another label.

A label is hidden when its side runs out of height, when its text would cross
the left or right chart edge, or when it would move more than `maxShift`
pixels. Lower-priority labels hide first; on equal priority, the later row
hides first, except that the width check hides the outermost-elbow label
first because it sets the column. Hidden labels emit no nodes or interaction
points, so pair the mark with a tooltip or legend when every slice name must
stay available.

The mark emits label and polyline scene nodes, so SVG, Canvas, and React
Native paint it the same way. Connector keys add `:connector` to the label
key. With the motion renderer, kept labels and connectors move to their new
layout, and hidden labels fade out.

## `radialRule`

```ts
Expand Down
48 changes: 48 additions & 0 deletions packages/charts-core/docs/reference/marks/polar.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
radialBarRadius,
radialDot,
radialGrid,
radialLabels,
radialLine,
radialRule,
radialText,
Expand Down Expand Up @@ -369,6 +370,53 @@ the polar coordinate system. Its interaction point follows the final radial
offset plus `dx`/`dy` while retaining the original semantic radius value. Its
key defaults to a unique top-level or nested `data.id`, then row index.

## `radialLabels`

```ts
function radialLabels<TDatum>(
source: Iterable<TDatum>,
options?: RadialLabelsOptions<TDatum>,
): PolarMark<TDatum>
```

`radialLabels` renders outside slice labels with elbow connectors and keeps
crowded labels readable.

| Option | Meaning |
| ------------------------------ | -------------------------------------------------------------- |
| `id`, `className` | Stable layer ID and optional class |
| `angle` | Angle channel; defaults to datum `angle` |
| `radius` | Connector start radius; defaults to `1` |
| `text` | Label text |
| `priority` | Lower priority hides first; defaults to datum `value` |
| `maxShift` | Hides a label that would move more pixels from its slice |
| `key` | Stable label identity; defaults to top/nested `id`, then index |
| `z` | Interaction group |
| `color` | Color-scale value; defaults to `z` |
| `fill` | Final constant or datum-derived text paint |
| `fontSize`, `fontWeight` | Text size, default 12, and weight |
| `opacity` | Label and connector opacity |
| `stroke` | Connector paint; defaults to the label fill |
| `strokeOpacity`, `strokeWidth` | Connector opacity and width |

Labels stack on each side of the center in angular order, `fontSize * 1.4`
pixels apart. Overlapping labels move apart as little as possible and stay
inside the chart bounds. Each side places its text in one column outside its
outermost visible connector elbow, so no connector crosses another label.

A label is hidden when its side runs out of height, when its text would cross
the left or right chart edge, or when it would move more than `maxShift`
pixels. Lower-priority labels hide first; on equal priority, the later row
hides first, except that the width check hides the outermost-elbow label
first because it sets the column. Hidden labels emit no nodes or interaction
points, so pair the mark with a tooltip or legend when every slice name must
stay available.

The mark emits label and polyline scene nodes, so SVG, Canvas, and React
Native paint it the same way. Connector keys add `:connector` to the label
key. With the motion renderer, kept labels and connectors move to their new
layout, and hidden labels fade out.

## `radialRule`

```ts
Expand Down
59 changes: 58 additions & 1 deletion packages/charts-core/src/canvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ import { dot } from './dot'
import { facet } from './facet'
import { lineX, lineY } from './line'
import { mountChart } from './dom'
import { pie, polar, radialArc, radialDot, radialLine } from './polar'
import {
pie,
polar,
radialArc,
radialDot,
radialLabels,
radialLine,
} from './polar'
import { resolveCrosshairGuide } from './crosshair-resolver'
import { createChartScene, defineChart } from './scene'
import { createSvgChartRenderer } from './svg-surface'
Expand Down Expand Up @@ -3291,6 +3298,56 @@ function scene(
}
}

describe('Canvas radialLabels', () => {
it('paints each visible label and its connector', () => {
const slices = pie(
[
{ name: 'Large', value: 80 },
{ name: 'Small', value: 3 },
{ name: 'Tiny', value: 2 },
],
{ value: 'value' },
)
const scene = createChartScene(
defineChart({
marks: [
polar({
radiusRatio: 0.5,
scales: {
angle: { scale: scaleLinear().domain([0, Math.PI * 2]) },
radius: { scale: scaleLinear().domain([0, 1]) },
},
marks: [
radialArc(slices, { key: 'name' }),
radialLabels(slices, { text: 'name', key: 'name' }),
],
}),
],
scales: { x: null, y: null },
}),
{ width: 400, height: 300 },
)
const container = document.createElement('div')
const surface = createCanvasChartRenderer().mount(container, () => {})

surface.render(scene, renderOptions())

const canvas = container.querySelector<HTMLCanvasElement>(
'.ts-chart-canvas__scene',
)
const painted = canvas ? contexts.get(canvas) : undefined
if (!painted) throw new Error('Expected a painted scene canvas')
expect(painted.textPaints.map(({ text }) => text)).toEqual(
expect.arrayContaining(['Large', 'Small', 'Tiny']),
)
expect(
painted.operations.filter((operation) => operation === 'stroke:path'),
).toHaveLength(3)

surface.destroy()
})
})

function renderOptions(): ChartSurfaceRenderOptions {
return {
ariaLabel: 'Canvas chart',
Expand Down
87 changes: 86 additions & 1 deletion packages/charts-core/src/motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { lineY } from './line'
import { createMark } from './mark'
import { compositeMark } from './mark-composite'
import { motion, stagger } from './motion'
import { polar, radialArea, radialBarRadius } from './polar'
import { pie, polar, radialArea, radialBarRadius, radialLabels } from './polar'
import { mountChartRenderer } from './renderer'
import { createChartScene, defineChart } from './scene'
import { chartSceneSource } from './scene-source'
Expand Down Expand Up @@ -4643,6 +4643,91 @@ describe('SVG motion', () => {
})
})

describe('radialLabels motion', () => {
it('moves keyed labels and connectors and fades removed labels', () => {
const makeScene = (rows: readonly { name: string; value: number }[]) => {
const slices = pie(rows, { value: 'value' })
return createChartScene(
defineChart({
marks: [
polar({
radiusRatio: 0.5,
scales: {
angle: { scale: scaleLinear().domain([0, Math.PI * 2]) },
radius: { scale: scaleLinear().domain([0, 1]) },
},
marks: [
radialLabels(slices, {
id: 'labels',
text: 'name',
key: 'name',
}),
],
}),
],
scales: { x: null, y: null },
margin: 0,
}),
{ width: 400, height: 300 },
)
}
const first = makeScene([
{ name: 'Large', value: 70 },
{ name: 'Small', value: 20 },
{ name: 'Gone', value: 10 },
])
const next = makeScene([
{ name: 'Large', value: 40 },
{ name: 'Small', value: 60 },
])
const container = document.createElement('div')
const frames = installFrames()
const surface = motion({
initial: false,
transition: { type: 'tween', duration: 100, easing: 'linear' },
}).mount(container, () => {})
const label = (name: string) =>
container.querySelector<SVGTextElement>(`text[data-ts-key$=":${name}"]`)
const connector = (name: string) =>
container.querySelector<SVGPathElement>(
`path[data-ts-key$=":${name}:connector"]`,
)

surface.render(first, { ariaLabel: 'Labels' })
const smallLabel = label('Small')
const smallConnector = connector('Small')
const from = {
y: Number(smallLabel?.getAttribute('y')),
d: smallConnector?.getAttribute('d'),
}

surface.render(next, { ariaLabel: 'Labels' })
frames.run(0)
frames.run(50)
expect(label('Small')).toBe(smallLabel)
expect(connector('Small')).toBe(smallConnector)
const middleY = Number(smallLabel?.getAttribute('y'))
const middleD = smallConnector?.getAttribute('d')
const gone = label('Gone')
expect(gone).not.toBeNull()
expect(Number(gone?.getAttribute('opacity'))).toBeLessThan(1)

frames.run(100)
const toY = Number(smallLabel?.getAttribute('y'))
const toD = smallConnector?.getAttribute('d')
expect(toY).not.toBeCloseTo(from.y, 3)
expect(Math.min(from.y, toY)).toBeLessThan(middleY)
expect(middleY).toBeLessThan(Math.max(from.y, toY))
expect(middleD).not.toBe(from.d)
expect(middleD).not.toBe(toD)
expect(label('Gone')).toBeNull()
expect(connector('Gone')).toBeNull()

surface.destroy()
frames.restore()
})
})

function installFrames() {
const now = vi.spyOn(window.performance, 'now').mockReturnValue(0)
const callbacks: FrameRequestCallback[] = []
Expand Down
Loading