Skip to content

feat(polar): add radialLabels for crowded pie labels - #231

Draft
hshoja wants to merge 9 commits into
TanStack:mainfrom
hshoja:feat/pie-label-layout
Draft

hshoja wants to merge 9 commits into
TanStack:mainfrom
hshoja:feat/pie-label-layout

Conversation

@hshoja

@hshoja hshoja commented Oct 14, 2026 •

Copy link
Copy Markdown

Refs #227 - outside labels of small adjacent pie slices overlap, and there is no built-in way to spread or hide them.

  • New radialLabels mark in @tanstack/charts/polar: outside labels with elbow connectors, one text column per side
  • Overlapping labels move apart as little as possible and stay inside the chart; no connector crosses another label
  • Labels that do not fit the height or width hide by priority (default: datum value); maxShift also hides labels that move too far

Screenshot:
Before: labels of small slices overlap after-column

How to review: start at polar-label-layout-internal.ts (pure layout), then radialLabels in polar.ts; the rest is tests and docs
How to test: pnpm vitest run packages/charts-core/src/polar-label-layout-internal.test.ts packages/charts-core/src/polar-radial-labels.test.ts

More screenshots

maxShift: 24 hides labels that would move too far from their slice:

maxShift hides the smallest labels

Review fixture, before and after the text column (red = connector crosses a label):

640 x 320 before and after 220 x 320 before and after
Requested cases
Case Behavior
Narrow chart, long labels Labels that would cross the chart edge hide, lower priority first
Resize Layout runs on every render; a wider chart shows labels a narrow one hid, with the same keys
Equal priority The later row hides first; for the width check, the outermost-connector label hides first because it sets the column
maxShift The hidden label loses its text, connector, and interaction point
Stable keys Label keys come from key; connector keys add :connector
Motion With motion(), labels and connectors move to the new layout; hidden labels fade out
Renderers Only label and polyline nodes; tested in SVG, Canvas, and React Native
Bundle Under 0.01 kB gzip for charts without radialLabels; about 2.3 kB gzip when imported
Many labels 5,000 labels on one side lay out in about 1 ms
Separate mark or radialText option
  • Separate mark (this PR): one mark owns text, connectors, and hiding; the layout code is tree-shaken when unused.
  • radialText option: fewer exports, but radialText would need connector options, and hiding a label breaks its rule that every valid row renders.
Local checks

Type check, docs check, and new and changed tests pass. bundle:check fails the same way on clean main; package:check fails on a local Node version mismatch (24.7.0 vs .nvmrc 24.18.0).

Outside pie labels built from radialRule and radialText sit on their slice
angle, so labels of small adjacent slices overlap. radialLabels stacks labels
on each side, moves overlapping labels apart as little as possible, and hides
the lowest-priority labels when they do not fit. maxShift optionally hides
labels that would move too far from their slice.
Measure label text with the chart's text measurer and hide labels that would
cross the left or right edge, so narrow charts and long labels stay inside the
surface. Equal priorities now hide the later label first. Tests cover narrow
and resized charts, long labels, maxShift removal, and stable keys.
radialLabels emits only polyline and label scene nodes. These tests confirm each renderer paints every visible connector and label.
Keyed labels and connectors keep their elements and interpolate to the new layout, and labels of removed slices fade out before removal.
@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3797bfb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/charts Minor
@tanstack/alpine-charts Minor
@tanstack/angular-charts Minor
@tanstack/lit-charts Minor
@tanstack/octane-charts Minor
@tanstack/preact-charts Minor
@tanstack/react-charts Minor
@tanstack/react-native-charts Minor
@tanstack/solid-charts Minor
@tanstack/svelte-charts Minor
@tanstack/vue-charts Minor
@tanstack/charts-scales Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @hshoja, the requested cases are useful. I independently checked the layout helper at 9da9d8e3 in Chromium, Firefox and WebKit: 604 combinations per browser covered spacing, vertical bounds and finite positions, including empty inputs and several maxShift values. This was an isolated helper check, not the full PR test suite or a rendered visual review.

One performance improvement before this is ready: the capacity loop calls withoutLowest once per removed label, scanning and copying the remaining array each time. That is quadratic when many labels cannot fit. In a synthetic 5,000-label, one-side case with room for 25 labels, seven-run medians were about 35 ms in Chromium, 92 ms in Firefox and 52 ms in WebKit. This is a stress case, not a claim about typical small pies.

A private prototype selected the highest-priority capacity rows once, breaking ties by earlier input index, then filtered the original angular ordering through that selection. It produced identical placements across those 604 combinations and the 100/1,000/5,000-row cases in each browser. The 5,000-row Chromium median was about 0.2 ms; the other engines rounded below their timer resolution. Could you apply that capacity-selection approach and retain tests for equal-priority ties and angular order? The separate maxShift loop still needs its own behavior preserved.

I have not settled the separate-mark API choice yet, and the reported bundle costs and full validation still need independent verification.

@tannerlinsley

Copy link
Copy Markdown
Member

One more case from a rendered check of 9da9d8e3: please test connectors against neighboring text bounds, not only label-to-label spacing.

My fixture uses 20 rows in input order, Slice 0 has value 50 and the other rows have value 1. Labels are Slice ${index}, except row 1 has a long name. The chart has zero margins, radiusRatio: 0.5, linear angle domain [0, 2 * Math.PI], radius domain [0, 1], and default label styling. At 640 × 320, Firefox reports connectors for rows 3 through 6 intersecting the bounding box of Slice 7, even with the box inset by 1 px. At 220 × 320, row 17's connector intersects Slice 18's box. Chromium and WebKit do not report those intersections for this fixture. This is a text-box intersection check, not a claim that every crossed pixel contains glyph ink.

All three engines kept labels inside the chart, with no label-to-label overlaps, at 640 × 320, 220 × 320 and 640 × 100. Visible label counts matched interaction-point counts. The connector routes still need a clear corridor beside the text when labels are spread vertically.

I also measured the existing 114 consumer entries independently: 108 are byte-identical, six polar entries change by +0.026 kB minified, at most +0.009 kB gzip and +0.068 kB Brotli. No budget changes are approved by this review.

Removing the lowest-priority label one at a time rescanned and copied the side for every removal, which is quadratic when many labels cannot fit. Sort once by priority, keep the labels that fit, and filter the angular order through that selection. The maxShift loop is unchanged.
Labels followed a curve, so a slanted connector to an outer label could pass
through a neighbor's text when labels were spread vertically. Each side now
places its text in one column outside the outermost visible elbow, so every
connector stays on the inner side of all text. When the column does not fit,
the lower-priority one of the outermost-elbow label and the widest label is
hidden, preferring the outermost-elbow label on ties because it sets the
column.
@hshoja

hshoja commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

Thanks for both reviews. Both points are fixed.

1. Faster selection when labels do not fit (8c41042)

Before, the code removed the weakest label one at a time and scanned the whole list each time. Now it sorts by priority once, keeps the top labels that fit, and keeps them in slice order. Ties still keep the earlier row, and the maxShift step is unchanged.

Example: 5,000 labels on one side with room for 25 took 152.5 ms, and now take about 1 ms.

New tests check that the result is the same as the old one-at-a-time removal (50 inputs), that slice order is kept, and the 5,000-label case.

2. Connectors no longer cross other labels (7d57e93)

Before, labels followed a curve, so a slanted connector could pass through a neighbor's text. Now all labels on one side start at the same x, just outside the outermost connector. Every connector ends before the text starts, so it cannot cross another label.

If the text does not fit the chart width, the mark hides either the label with the outermost connector or the widest label, whichever has the lower priority. On a tie it hides the outermost one, because that label pushes the column out.

Your fixture, with text boxes grown by 2 px (red = connector crosses a label):

image image
Size Before After
640 × 320 20 labels, 5 crossings 20 labels, 0 crossings
220 × 320 4 labels, 1 crossing 10 labels, 0 crossings
640 × 100 6 labels, 0 crossings 6 labels, 0 crossings

Your fixture is now a test. It checks every connector against every other label at all three sizes. On the old code it fails with Slice 3 crosses Slice 7 at 640x320.

Ideas for later

Not in this PR, until the API is decided:

  • wrap long labels instead of hiding them, for example maxLines: 3 with an ellipsis;
  • let polar shrink the pie to make room for outside labels.

The API shape is not settled, so expose only options that change behavior: priority, maxShift, and the text and style options. Connector offset, elbow length, text padding, and line height become internal defaults; line height follows fontSize.
numericField duplicated the existing numberProperty helper that radialArc uses for its datum defaults. Document the mark with the option table used by the other polar marks, and shorten the friction entry to the format of recent entries.
@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @hshoja, I rechecked the rendered fixture at 3797bfbc in Chromium, Firefox and WebKit, with DOM text measurement, at 640 × 320, 220 × 320 and 640 × 100. All three engines have zero connector-to-neighbor text-box crossings and zero label overlaps. Visible labels and interaction points match at 20, 10 and 6.

Firefox reports a slightly wider SVG text bounding box than the text advance. I compared screenshots with SVG overflow visible versus clipped, and there were zero changed pixels in all three engines for these fixtures. I do not see painted clipping here.

The revised layout helper also passed 1,812 bounds, spacing and connector-column cases per engine, plus the 5,000-label case with capacity 25. That helper was checked at 6ac6a145 and is unchanged in 3797bfbc. This is targeted verification, not the full PR suite or motion validation.

At the current head, 108 of the 114 existing consumer bundles remain byte-identical. The six changed polar bundles add 0.026 kB minified, at most 0.009 kB gzip and 0.073 kB Brotli. These measurements compare the reviewed source overlay against the current checkout, not a newly approved budget baseline.

The two specific review concerns, repeated capacity scans and connector crossings in this fixture, are addressed. The separate-mark API choice, bundle acceptance and full CI remain open, so this is not merge approval.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants