From 738da7f77b781e0258281ab931d7d507e7fb8f67 Mon Sep 17 00:00:00 2001 From: hshoja Date: Wed, 14 Oct 2026 12:07:15 +0200 Subject: [PATCH 01/10] feat(polar): add radialLabels for crowded pie labels 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. --- .changeset/crowded-pie-labels.md | 5 + API-FRICTION.md | 19 ++ docs/reference/marks/polar.md | 45 +++ .../charts-core/docs/reference/marks/polar.md | 45 +++ .../src/polar-label-layout-internal.test.ts | 148 ++++++++++ .../src/polar-label-layout-internal.ts | 181 ++++++++++++ .../src/polar-radial-labels.test.ts | 118 ++++++++ packages/charts-core/src/polar.ts | 279 ++++++++++++++++++ scripts/public-callback-contract.mjs | 4 + 9 files changed, 844 insertions(+) create mode 100644 .changeset/crowded-pie-labels.md create mode 100644 packages/charts-core/src/polar-label-layout-internal.test.ts create mode 100644 packages/charts-core/src/polar-label-layout-internal.ts create mode 100644 packages/charts-core/src/polar-radial-labels.test.ts diff --git a/.changeset/crowded-pie-labels.md b/.changeset/crowded-pie-labels.md new file mode 100644 index 00000000..94457b13 --- /dev/null +++ b/.changeset/crowded-pie-labels.md @@ -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. `maxShift` also hides labels that would move too far from their slice. diff --git a/API-FRICTION.md b/API-FRICTION.md index 09c20aac..0e84b32f 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -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 @@ -9339,3 +9340,21 @@ 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: a production pie with many small adjacent slices. The + `radialRule` plus `radialText` composition placed every label on its slice + angle, so neighboring labels overlapped. The application needed its own + pixel layout, connector paths, and hiding rules outside the polar mark. +- Decision: add `radialLabels`, which stacks labels on each side of the + center, 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. +- Verification: unit tests cover ideal placement, minimal spreading, capacity + hiding by priority, `maxShift`, vertical bounds, and connector geometry. A + rendered chart test covers non-overlapping labels per side and hiding the + smallest slices on a short chart. diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 4ac35fda..a9f7a2b2 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -17,6 +17,7 @@ import { radialBarRadius, radialDot, radialGrid, + radialLabels, radialLine, radialRule, radialText, @@ -369,6 +370,50 @@ 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( + source: Iterable, + options?: RadialLabelsOptions, +): PolarMark +``` + +`radialLabels` draws outside labels with connectors for pie and donut slices, +and keeps crowded labels readable. `angle` defaults to the numeric `angle` +field of each row, so `pie()` output needs no angle channel. `radius` defaults +to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the +largest mapped radius, bend at an elbow `offset` pixels outside it (default +16), and run `elbow` pixels horizontally (default 12). The text sits `padding` +pixels after the connector (default 4). + +Labels form one vertical stack on each side of the center, in angular order. +Each label gets `lineHeight` pixels (default `fontSize * 1.4`). When labels +overlap, the mark moves them apart as little as possible, and keeps every +label inside the polar container's chart bounds. When a side has more labels +than fit, the labels with the lowest `priority` are hidden first. `priority` +defaults to the numeric `value` field, so the smallest `pie()` slices are +hidden first. Set `maxShift` to also hide labels that would move more than +that many pixels from their slice; the mark hides the lowest-priority label +in the crowded group and lays out again. + +The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, +`color`, fill, font size and weight, opacity, and connector `stroke`, +`strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each +visible label emits one interaction point at its final text position. Hidden +labels emit no nodes and no points, so pair the mark with a tooltip or legend +when every slice name must stay available. + +```tsx +radialLabels(slices, { + text: (row) => `${row.name} ${row.share}%`, + key: 'name', + color: 'name', + fontSize: 12, + maxShift: 24, +}) +``` + ## `radialRule` ```ts diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 4ac35fda..a9f7a2b2 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -17,6 +17,7 @@ import { radialBarRadius, radialDot, radialGrid, + radialLabels, radialLine, radialRule, radialText, @@ -369,6 +370,50 @@ 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( + source: Iterable, + options?: RadialLabelsOptions, +): PolarMark +``` + +`radialLabels` draws outside labels with connectors for pie and donut slices, +and keeps crowded labels readable. `angle` defaults to the numeric `angle` +field of each row, so `pie()` output needs no angle channel. `radius` defaults +to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the +largest mapped radius, bend at an elbow `offset` pixels outside it (default +16), and run `elbow` pixels horizontally (default 12). The text sits `padding` +pixels after the connector (default 4). + +Labels form one vertical stack on each side of the center, in angular order. +Each label gets `lineHeight` pixels (default `fontSize * 1.4`). When labels +overlap, the mark moves them apart as little as possible, and keeps every +label inside the polar container's chart bounds. When a side has more labels +than fit, the labels with the lowest `priority` are hidden first. `priority` +defaults to the numeric `value` field, so the smallest `pie()` slices are +hidden first. Set `maxShift` to also hide labels that would move more than +that many pixels from their slice; the mark hides the lowest-priority label +in the crowded group and lays out again. + +The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, +`color`, fill, font size and weight, opacity, and connector `stroke`, +`strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each +visible label emits one interaction point at its final text position. Hidden +labels emit no nodes and no points, so pair the mark with a tooltip or legend +when every slice name must stay available. + +```tsx +radialLabels(slices, { + text: (row) => `${row.name} ${row.share}%`, + key: 'name', + color: 'name', + fontSize: 12, + maxShift: 24, +}) +``` + ## `radialRule` ```ts diff --git a/packages/charts-core/src/polar-label-layout-internal.test.ts b/packages/charts-core/src/polar-label-layout-internal.test.ts new file mode 100644 index 00000000..d7b4e547 --- /dev/null +++ b/packages/charts-core/src/polar-label-layout-internal.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { layoutRadialLabels } from './polar-label-layout-internal' +import type { + RadialLabelLayoutOptions, + RadialLabelPlacement, +} from './polar-label-layout-internal' + +const options: RadialLabelLayoutOptions = { + radius: 100, + offset: 20, + elbow: 10, + padding: 4, + lineHeight: 16, + top: -200, + bottom: 200, + maxShift: Infinity, +} + +const degrees = (value: number) => (value * Math.PI) / 180 + +function visible(placements: readonly RadialLabelPlacement[]) { + return placements.flatMap((placement, index) => + placement.visible ? [{ index, ...placement }] : [], + ) +} + +describe('layoutRadialLabels', () => { + it('keeps separated labels at their ideal height', () => { + const placements = layoutRadialLabels( + [ + { angle: degrees(60), priority: 1 }, + { angle: degrees(240), priority: 1 }, + ], + options, + ) + + expect(placements[0]).toMatchObject({ + visible: true, + side: 'right', + y: expect.closeTo(-Math.cos(degrees(60)) * 120, 8), + }) + expect(placements[1]).toMatchObject({ + visible: true, + side: 'left', + y: expect.closeTo(-Math.cos(degrees(240)) * 120, 8), + }) + }) + + it('pushes crowded labels apart by one line height in angular order', () => { + const angles = [88, 90, 92, 94].map(degrees) + const placements = visible( + layoutRadialLabels( + angles.map((angle) => ({ angle, priority: 1 })), + options, + ), + ) + + expect(placements.map(({ index }) => index)).toEqual([0, 1, 2, 3]) + placements.slice(1).forEach((placement, k) => { + expect(placement.y - placements[k]!.y).toBeCloseTo(16, 8) + }) + const idealMean = + angles.reduce((sum, angle) => sum - Math.cos(angle) * 120, 0) / 4 + const placedMean = + placements.reduce((sum, placement) => sum + placement.y, 0) / 4 + expect(placedMean).toBeCloseTo(idealMean, 8) + }) + + it('hides the lowest-priority labels when a side runs out of space', () => { + const placements = layoutRadialLabels( + [ + { angle: degrees(80), priority: 5 }, + { angle: degrees(85), priority: 1 }, + { angle: degrees(90), priority: 4 }, + { angle: degrees(95), priority: 2 }, + { angle: degrees(100), priority: 3 }, + ], + { ...options, top: -24, bottom: 24 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + true, + false, + true, + false, + true, + ]) + }) + + it('hides labels that would move farther than maxShift', () => { + const angles = [86, 88, 90, 92, 94].map(degrees) + const placements = layoutRadialLabels( + angles.map((angle, index) => ({ angle, priority: index === 2 ? 9 : 1 })), + { ...options, maxShift: 20 }, + ) + const shown = visible(placements) + + expect(shown.map(({ index }) => index)).toContain(2) + shown.forEach((placement) => { + const ideal = -Math.cos(angles[placement.index]!) * 120 + expect(Math.abs(placement.y - ideal)).toBeLessThanOrEqual(20) + }) + expect(shown.length).toBeLessThan(5) + }) + + it('keeps every label box inside the vertical limits', () => { + const placements = visible( + layoutRadialLabels( + [2, 4, 6, 8].map((value) => ({ angle: degrees(value), priority: 1 })), + { ...options, top: -130, bottom: 130 }, + ), + ) + + expect(placements).toHaveLength(4) + placements.forEach(({ y }) => { + expect(y - 8).toBeGreaterThanOrEqual(-130 - 1e-9) + expect(y + 8).toBeLessThanOrEqual(130 + 1e-9) + }) + }) + + it('draws connectors from the slice edge through the elbow to the text', () => { + const [placement] = layoutRadialLabels( + [{ angle: degrees(90), priority: 1 }], + options, + ) + + expect(placement).toMatchObject({ visible: true, side: 'right' }) + if (!placement?.visible) return + const [edge, elbow, end] = placement.connector + expect(edge![0]).toBeCloseTo(100, 8) + expect(elbow![0]).toBeCloseTo(120, 8) + expect(end![0]).toBeCloseTo(130, 8) + expect(placement.x).toBeCloseTo(134, 8) + }) + + it('places labels on the left side with mirrored x positions', () => { + const [placement] = layoutRadialLabels( + [{ angle: degrees(270), priority: 1 }], + options, + ) + + expect(placement).toMatchObject({ + visible: true, + side: 'left', + x: expect.closeTo(-134, 8), + }) + }) +}) diff --git a/packages/charts-core/src/polar-label-layout-internal.ts b/packages/charts-core/src/polar-label-layout-internal.ts new file mode 100644 index 00000000..bddf5eef --- /dev/null +++ b/packages/charts-core/src/polar-label-layout-internal.ts @@ -0,0 +1,181 @@ +export interface RadialLabelInput { + /** Polar angle in radians, clockwise from 12 o'clock. */ + readonly angle: number + /** Labels with lower priority are hidden first when space runs out. */ + readonly priority: number +} + +export interface RadialLabelLayoutOptions { + /** Pixel radius where connectors start. */ + readonly radius: number + /** Pixel gap between `radius` and the connector elbow. */ + readonly offset: number + /** Horizontal connector length after the elbow. */ + readonly elbow: number + /** Pixel gap between the connector end and the label text. */ + readonly padding: number + /** Vertical pixel space reserved for one label. */ + readonly lineHeight: number + /** Center-relative top and bottom edges of the space for label boxes. */ + readonly top: number + readonly bottom: number + /** Largest vertical move from the ideal position before a label is hidden. */ + readonly maxShift: number +} + +export type RadialLabelPlacement = + | { readonly visible: false } + | { + readonly visible: true + readonly x: number + readonly y: number + /** Physical side of the pie: text grows away from the center. */ + readonly side: 'left' | 'right' + readonly connector: readonly (readonly [number, number])[] + } + +interface Block { + readonly members: readonly number[] + /** Sum of `ideal[k] - k * lineHeight` over members. */ + readonly sum: number + readonly start: number +} + +/** + * Places labels in two vertical stacks beside a pie. Each side keeps labels in + * angular order, pushes overlapping labels apart as little as possible, and + * hides the lowest-priority labels until the rest fit. + */ +export function layoutRadialLabels( + labels: readonly RadialLabelInput[], + options: RadialLabelLayoutOptions, +): RadialLabelPlacement[] { + const elbowRadius = options.radius + options.offset + const placements: RadialLabelPlacement[] = labels.map(() => ({ + visible: false, + })) + const sides = { left: [] as number[], right: [] as number[] } + labels.forEach((label, index) => { + if (!Number.isFinite(label.angle)) return + sides[Math.sin(label.angle) < -1e-9 ? 'left' : 'right'].push(index) + }) + + for (const side of ['left', 'right'] as const) { + const ideal = (index: number) => + -Math.cos(labels[index]!.angle) * elbowRadius + const ordered = [...sides[side]].sort((a, b) => ideal(a) - ideal(b)) + const positions = placeSide(ordered, ideal, labels, options) + const direction = side === 'left' ? -1 : 1 + positions.forEach((y, index) => { + const angle = labels[index]!.angle + const edge = point(angle, options.radius) + const elbow = point(angle, elbowRadius) + const circleX = Math.sqrt(Math.max(elbowRadius ** 2 - y ** 2, 0)) + const end = Math.max(circleX, Math.abs(elbow[0])) + options.elbow + placements[index] = { + visible: true, + x: direction * (end + options.padding), + y, + side, + connector: [edge, elbow, [direction * end, y]], + } + }) + } + + return placements +} + +function placeSide( + ordered: readonly number[], + ideal: (index: number) => number, + labels: readonly RadialLabelInput[], + options: RadialLabelLayoutOptions, +): Map { + const capacity = Math.max( + 0, + Math.floor((options.bottom - options.top) / options.lineHeight), + ) + let kept = ordered + while (kept.length > capacity) kept = withoutLowest(kept, kept, labels) + + for (;;) { + const blocks = spread(kept, ideal, options) + const positions = new Map() + let worst: { shift: number; block: Block } | undefined + for (const block of blocks) { + block.members.forEach((index, k) => { + const y = block.start + k * options.lineHeight + positions.set(index, y) + const shift = Math.abs(y - ideal(index)) + if (!worst || shift > worst.shift) worst = { shift, block } + }) + } + if (!worst || worst.shift <= options.maxShift) return positions + kept = withoutLowest(kept, worst.block.members, labels) + } +} + +/** + * Pushes overlapping labels apart. Touching labels form a block whose start + * minimizes the squared distance of each member from its ideal position. + */ +function spread( + ordered: readonly number[], + ideal: (index: number) => number, + { lineHeight, top, bottom }: RadialLabelLayoutOptions, +): Block[] { + const first = top + lineHeight / 2 + const last = bottom - lineHeight / 2 + const blocks: Block[] = [] + for (const index of ordered) { + let block = createBlock([index], ideal(index), first, last, lineHeight) + for (;;) { + const previous = blocks.at(-1) + if ( + !previous || + previous.start + previous.members.length * lineHeight <= block.start + ) { + break + } + blocks.pop() + block = createBlock( + [...previous.members, ...block.members], + previous.sum + + block.sum - + block.members.length * previous.members.length * lineHeight, + first, + last, + lineHeight, + ) + } + blocks.push(block) + } + return blocks +} + +function createBlock( + members: readonly number[], + sum: number, + first: number, + last: number, + lineHeight: number, +): Block { + const latestStart = last - (members.length - 1) * lineHeight + const start = Math.min(Math.max(sum / members.length, first), latestStart) + return { members, sum, start } +} + +function withoutLowest( + kept: readonly number[], + candidates: readonly number[], + labels: readonly RadialLabelInput[], +): number[] { + const lowest = candidates.reduce((current, index) => + labels[index]!.priority < labels[current]!.priority ? index : current, + ) + return kept.filter((index) => index !== lowest) +} + +function point(angle: number, radius: number): [number, number] { + return [Math.sin(angle) * radius, -Math.cos(angle) * radius] +} diff --git a/packages/charts-core/src/polar-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts new file mode 100644 index 00000000..b5237d5f --- /dev/null +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -0,0 +1,118 @@ +import { scaleLinear } from 'd3-scale' +import { describe, expect, it } from 'vitest' +import { defineChart } from './index' +import { createChartRuntime } from './runtime' +import { pie, polar, radialArc, radialLabels } from './polar' +import type { SceneLabel, SceneNode, ScenePolyline } from './types' + +const browsers = [ + { name: 'Chrome', share: 64.2 }, + { name: 'Safari', share: 18.9 }, + { name: 'Edge', share: 5.4 }, + { name: 'Firefox', share: 3.1 }, + { name: 'Samsung', share: 2.4 }, + { name: 'Opera', share: 2.0 }, + { name: 'UC', share: 1.2 }, + { name: 'Yandex', share: 0.9 }, + { name: 'Vivaldi', share: 0.7 }, + { name: 'Brave', share: 0.6 }, + { name: 'Other', share: 0.6 }, +] + +const lineHeight = 17 + +function render( + size: { width: number; height: number }, + options: { maxShift?: number } = {}, +) { + const slices = pie(browsers, { value: 'share' }) + const definition = 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', color: 'name' }), + radialLabels(slices, { + id: 'labels', + text: 'name', + key: 'name', + color: 'name', + lineHeight, + ...options, + }), + ], + }), + ], + scales: { x: null, y: null }, + margin: 0, + }) + const scene = createChartRuntime().render(definition, size) + const nodes = flatten(scene.nodes) + return { + points: scene.points.filter((point) => point.markId === 'labels'), + labels: nodes.filter( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('labels:'), + ), + connectors: nodes.filter( + (node): node is ScenePolyline => + node.kind === 'polyline' && node.key.startsWith('labels:'), + ), + } +} + +describe('radialLabels', () => { + it('renders one connector and one label per visible slice', () => { + const { labels, connectors, points } = render({ width: 640, height: 480 }) + + expect(labels).toHaveLength(browsers.length) + expect(connectors).toHaveLength(browsers.length) + expect(points).toHaveLength(browsers.length) + connectors.forEach((connector) => { + expect(connector.path).toMatch(/^M[^L]+L[^L]+L[^L]+$/) + expect(connector.style?.fill).toBe('none') + }) + }) + + it('keeps labels on the same side from overlapping', () => { + const { labels } = render({ width: 640, height: 480 }) + + for (const anchor of ['start', 'end'] as const) { + const ys = labels + .filter((label) => label.anchor === anchor) + .map((label) => label.y) + .sort((a, b) => a - b) + ys.slice(1).forEach((y, index) => { + expect(y - ys[index]!).toBeGreaterThanOrEqual(lineHeight - 1e-9) + }) + } + }) + + it('hides the smallest slices first when the chart is too short', () => { + const { labels } = render({ width: 640, height: 120 }) + const shown = labels.map((label) => label.text) + + expect(shown.length).toBeLessThan(browsers.length) + expect(shown).toContain('Chrome') + expect(shown).toContain('Safari') + expect(shown).not.toContain('Other') + }) + + it('hides labels that would move too far from their slice', () => { + const all = render({ width: 640, height: 480 }).labels + const limited = render({ width: 640, height: 480 }, { maxShift: 8 }).labels + + expect(limited.length).toBeLessThan(all.length) + expect(limited.map((label) => label.text)).toContain('Chrome') + }) +}) + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + return nodes.flatMap((node) => + node.kind === 'group' ? [node, ...flatten(node.children)] : [node], + ) +} diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index ac7c836e..2d66c9b7 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -19,6 +19,7 @@ import { resolveCompositeChildMotion } from './composite-motion-internal' import { createMarkWithScaleValues } from './mark-with-scale-values' import { createPolarMark as createInternalPolarMark } from './polar-mark-internal' import { focusGroupAngle, withPolarFocusGeometry } from './polar-focus-internal' +import { layoutRadialLabels } from './polar-label-layout-internal' import { resolvePolarSector, tracePolarArcBoundary, @@ -1819,6 +1820,284 @@ export function radialText( ) } +export interface RadialLabelsOptions extends RadialPathOptions { + text?: Channel + /** + * Labels with lower priority hide first when labels do not fit. Defaults to + * the numeric `value` field, so `pie()` rows keep their largest slices. + */ + priority?: Channel + fill?: VisualChannel + fontSize?: number + fontWeight?: number + opacity?: number + /** Vertical pixel space for one label. Defaults to `fontSize * 1.4`. */ + lineHeight?: number + /** Pixel gap between the mapped radius and the connector elbow. Defaults to 16. */ + offset?: number + /** Horizontal connector length after the elbow. Defaults to 12. */ + elbow?: number + /** Pixel gap between the connector and the text. Defaults to 4. */ + padding?: number + /** Hides a label that would move more pixels than this from its slice. */ + maxShift?: number + /** Connector color. Defaults to the label fill. */ + stroke?: VisualChannel + strokeOpacity?: number + strokeWidth?: number +} + +type RadialLabelsCallOptions< + TDatum, + TAngle extends RadialLabelsOptions['angle'], + TRadius extends RadialLabelsOptions['radius'], + TAngleScaleId extends string | undefined, + TRadiusScaleId extends string | undefined, +> = Omit< + RadialLabelsOptions, + 'angle' | 'radius' | 'angleScale' | 'radiusScale' +> & { + angle?: TAngle | NoInfer['angle']> + radius?: TRadius | NoInfer['radius']> + angleScale?: TAngleScaleId | NoInfer + radiusScale?: TRadiusScaleId | NoInfer +} + +/** + * Outside labels with connectors. Crowded labels are pushed apart vertically, + * and the lowest-priority labels are hidden when they still do not fit. + */ +export function radialLabels( + source: Iterable, +): PolarMark +export function radialLabels< + TDatum, + const TAngle extends RadialLabelsOptions>['angle'] = never, + const TRadius extends RadialLabelsOptions>['radius'] = never, + const TAngleScaleId extends string | undefined = undefined, + const TRadiusScaleId extends string | undefined = undefined, +>( + source: Iterable, + options: + | RadialLabelsCallOptions< + NoInfer, + TAngle, + TRadius, + TAngleScaleId, + TRadiusScaleId + > + | undefined, +): PolarMark< + TDatum, + InferredPolarChannelOutput, + InferredPolarChannelOutput, + InferredPolarScaleId, + InferredPolarScaleId +> +export function radialLabels( + source: Iterable, + options: RadialLabelsOptions> = {}, +): PolarMark { + const data = asArray(source) + const lineHeight = options.lineHeight ?? (options.fontSize ?? 12) * 1.4 + if (!isFiniteNumber(lineHeight) || lineHeight <= 0) { + throw new TypeError('radialLabels: lineHeight must be positive and finite') + } + return createPolarMark( + ({ markIndex, parentId, states: defaultStates }) => { + const id = options.id ?? `${parentId}:radial-labels-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (datum) => + numericField(datum, 'angle'), + ) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, () => 1) + const textValues = channelValues(data, options.text, (datum) => + datum == null ? '' : String(datum), + ) + const priorityValues = channelValues(data, options.priority, (datum) => + numericField(datum, 'value'), + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups }) + + return { + id, + states: markStates(data, defaultStates), + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor, theme }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, + ) + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, + ) + const candidates = data.flatMap((_datum, datumIndex) => { + const anglePosition = mapPolarScale(angle, angleValues[datumIndex]) + const radiusPosition = mapPolarScale( + radius, + radiusValues[datumIndex], + ) + const text = textValues[datumIndex] + if ( + text == null || + text === '' || + !isFiniteNumber(anglePosition) || + !isFiniteNumber(radiusPosition) + ) { + return [] + } + const priority = priorityValues[datumIndex] + return [ + { + datumIndex, + angle: anglePosition, + radius: radiusPosition, + priority: isFiniteNumber(priority) ? priority : 0, + }, + ] + }) + const placements = layoutRadialLabels(candidates, { + radius: Math.max(0, ...candidates.map(({ radius }) => radius)), + offset: options.offset ?? 16, + elbow: options.elbow ?? 12, + padding: options.padding ?? 4, + lineHeight, + top: layout.chart.y - layout.centerY, + bottom: layout.chart.y + layout.chart.height - layout.centerY, + maxShift: options.maxShift ?? Infinity, + }) + const connectors: SceneNode[] = [] + const labels: SceneNode[] = [] + const points: ChartPoint[] = [] + candidates.forEach((candidate, candidateIndex) => { + const placement = placements[candidateIndex] + if (!placement?.visible) return + const { datumIndex } = candidate + const datum = data[datumIndex] as TDatum + const group = groups[datumIndex] ?? null + const colorValue = colorValues[datumIndex] ?? null + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + colorValue == null ? theme.foreground : resolveColor(colorValue), + ) + const stroke = visualValue( + options.stroke, + datum, + datumIndex, + data, + fill, + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const [start, ...rest] = placement.connector + connectors.push({ + kind: 'polyline', + key: `${key}:connector`, + points: [], + path: `M${start!.join(',')}${rest.map((point) => `L${point.join(',')}`).join('')}`, + style: { + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1, + opacity: options.opacity, + lineCap: 'round', + lineJoin: 'round', + }, + }) + labels.push({ + kind: 'label', + key, + x: placement.x, + y: placement.y, + text: String(textValues[datumIndex]), + anchor: physicalTextAnchor( + placement.side === 'left' ? 'right' : 'left', + layout.direction, + ), + baseline: 'middle', + fontSize: options.fontSize, + fontWeight: options.fontWeight, + style: { fill, opacity: options.opacity }, + }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValues[datumIndex] as ChartValue, + yValue: radiusValues[datumIndex] as ChartValue, + x: layout.centerX + placement.x, + y: layout.centerY + placement.y, + color: fill, + }, + layout, + candidate.angle, + candidate.radius, + placement.x, + placement.y, + ), + ) + }) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__radial-labels ts-chart__text', + options.className, + ), + ariaHidden: true, + children: [...connectors, ...labels], + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) +} + +function numericField(datum: unknown, field: string): number | undefined { + if (datum == null || typeof datum !== 'object') return undefined + const value = (datum as Record)[field] + return typeof value === 'number' ? value : undefined +} + export interface RadialRuleOptions< TDatum, > extends ChartMarkMotionOptions { diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 048a75fa..c1628b06 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -248,6 +248,10 @@ const callbackInventory = { '@tanstack/charts:src/polar.ts:RadialBarRadiusOptions', 'angle radius radius1 radius2', ], + [ + '@tanstack/charts:src/polar.ts:RadialLabelsOptions', + 'fill priority stroke text', + ], [ '@tanstack/charts:src/polar.ts:RadialPathOptions', 'angle color key radius z', From f9f75035249f362b46e6ec6f0284d428e42e269e Mon Sep 17 00:00:00 2001 From: hshoja Date: Wed, 14 Oct 2026 12:37:41 +0200 Subject: [PATCH 02/10] feat(polar): hide radial labels that cross the chart width 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. --- .../src/polar-label-layout-internal.test.ts | 108 +++++++++++++----- .../src/polar-label-layout-internal.ts | 57 ++++++--- .../charts-core/src/polar-mark-internal.ts | 3 + .../src/polar-radial-labels.test.ts | 101 ++++++++++++++-- packages/charts-core/src/polar.ts | 41 ++++++- scripts/public-callback-contract.mjs | 4 + 6 files changed, 260 insertions(+), 54 deletions(-) diff --git a/packages/charts-core/src/polar-label-layout-internal.test.ts b/packages/charts-core/src/polar-label-layout-internal.test.ts index d7b4e547..c93569f9 100644 --- a/packages/charts-core/src/polar-label-layout-internal.test.ts +++ b/packages/charts-core/src/polar-label-layout-internal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { layoutRadialLabels } from './polar-label-layout-internal' import type { + RadialLabelInput, RadialLabelLayoutOptions, RadialLabelPlacement, } from './polar-label-layout-internal' @@ -13,11 +14,20 @@ const options: RadialLabelLayoutOptions = { lineHeight: 16, top: -200, bottom: 200, + left: -400, + right: 400, maxShift: Infinity, } const degrees = (value: number) => (value * Math.PI) / 180 +function label( + angle: number, + { priority = 1, width = 40 }: { priority?: number; width?: number } = {}, +): RadialLabelInput { + return { angle: degrees(angle), priority, width } +} + function visible(placements: readonly RadialLabelPlacement[]) { return placements.flatMap((placement, index) => placement.visible ? [{ index, ...placement }] : [], @@ -26,13 +36,7 @@ function visible(placements: readonly RadialLabelPlacement[]) { describe('layoutRadialLabels', () => { it('keeps separated labels at their ideal height', () => { - const placements = layoutRadialLabels( - [ - { angle: degrees(60), priority: 1 }, - { angle: degrees(240), priority: 1 }, - ], - options, - ) + const placements = layoutRadialLabels([label(60), label(240)], options) expect(placements[0]).toMatchObject({ visible: true, @@ -47,10 +51,10 @@ describe('layoutRadialLabels', () => { }) it('pushes crowded labels apart by one line height in angular order', () => { - const angles = [88, 90, 92, 94].map(degrees) + const angles = [88, 90, 92, 94] const placements = visible( layoutRadialLabels( - angles.map((angle) => ({ angle, priority: 1 })), + angles.map((angle) => label(angle)), options, ), ) @@ -60,7 +64,7 @@ describe('layoutRadialLabels', () => { expect(placement.y - placements[k]!.y).toBeCloseTo(16, 8) }) const idealMean = - angles.reduce((sum, angle) => sum - Math.cos(angle) * 120, 0) / 4 + angles.reduce((sum, angle) => sum - Math.cos(degrees(angle)) * 120, 0) / 4 const placedMean = placements.reduce((sum, placement) => sum + placement.y, 0) / 4 expect(placedMean).toBeCloseTo(idealMean, 8) @@ -69,11 +73,11 @@ describe('layoutRadialLabels', () => { it('hides the lowest-priority labels when a side runs out of space', () => { const placements = layoutRadialLabels( [ - { angle: degrees(80), priority: 5 }, - { angle: degrees(85), priority: 1 }, - { angle: degrees(90), priority: 4 }, - { angle: degrees(95), priority: 2 }, - { angle: degrees(100), priority: 3 }, + label(80, { priority: 5 }), + label(85, { priority: 1 }), + label(90, { priority: 4 }), + label(95, { priority: 2 }), + label(100, { priority: 3 }), ], { ...options, top: -24, bottom: 24 }, ) @@ -87,17 +91,34 @@ describe('layoutRadialLabels', () => { ]) }) + it('hides the later label first when priorities are equal', () => { + const placements = layoutRadialLabels( + [label(80), label(85), label(90), label(95), label(100)], + { ...options, top: -24, bottom: 24 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + true, + true, + true, + false, + false, + ]) + }) + it('hides labels that would move farther than maxShift', () => { - const angles = [86, 88, 90, 92, 94].map(degrees) + const angles = [86, 88, 90, 92, 94] const placements = layoutRadialLabels( - angles.map((angle, index) => ({ angle, priority: index === 2 ? 9 : 1 })), + angles.map((angle, index) => + label(angle, { priority: index === 2 ? 9 : 1 }), + ), { ...options, maxShift: 20 }, ) const shown = visible(placements) expect(shown.map(({ index }) => index)).toContain(2) shown.forEach((placement) => { - const ideal = -Math.cos(angles[placement.index]!) * 120 + const ideal = -Math.cos(degrees(angles[placement.index]!)) * 120 expect(Math.abs(placement.y - ideal)).toBeLessThanOrEqual(20) }) expect(shown.length).toBeLessThan(5) @@ -106,7 +127,7 @@ describe('layoutRadialLabels', () => { it('keeps every label box inside the vertical limits', () => { const placements = visible( layoutRadialLabels( - [2, 4, 6, 8].map((value) => ({ angle: degrees(value), priority: 1 })), + [2, 4, 6, 8].map((angle) => label(angle)), { ...options, top: -130, bottom: 130 }, ), ) @@ -118,11 +139,49 @@ describe('layoutRadialLabels', () => { }) }) - it('draws connectors from the slice edge through the elbow to the text', () => { - const [placement] = layoutRadialLabels( - [{ angle: degrees(90), priority: 1 }], + it('hides labels whose text crosses the left or right edge', () => { + const placements = layoutRadialLabels( + [ + label(90, { width: 30 }), + label(120, { width: 200 }), + label(270, { width: 30 }), + label(240, { width: 200 }), + ], + { ...options, left: -180, right: 180 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + true, + false, + true, + false, + ]) + visible(placements).forEach(({ x, side, index }) => { + const width = [30, 200, 30, 200][index]! + if (side === 'right') expect(x + width).toBeLessThanOrEqual(180) + else expect(x - width).toBeGreaterThanOrEqual(-180) + }) + }) + + it('lets remaining labels use the space of a hidden long label', () => { + const crowded = [88, 90, 92] + const placements = layoutRadialLabels( + crowded.map((angle, index) => + label(angle, { width: index === 1 ? 500 : 30 }), + ), options, ) + const [first, , last] = placements + + expect(placements[1]).toEqual({ visible: false }) + expect(first?.visible && last?.visible).toBe(true) + if (first?.visible && last?.visible) { + expect(last.y - first.y).toBeCloseTo(16, 8) + } + }) + + it('draws connectors from the slice edge through the elbow to the text', () => { + const [placement] = layoutRadialLabels([label(90)], options) expect(placement).toMatchObject({ visible: true, side: 'right' }) if (!placement?.visible) return @@ -134,10 +193,7 @@ describe('layoutRadialLabels', () => { }) it('places labels on the left side with mirrored x positions', () => { - const [placement] = layoutRadialLabels( - [{ angle: degrees(270), priority: 1 }], - options, - ) + const [placement] = layoutRadialLabels([label(270)], options) expect(placement).toMatchObject({ visible: true, diff --git a/packages/charts-core/src/polar-label-layout-internal.ts b/packages/charts-core/src/polar-label-layout-internal.ts index bddf5eef..aa0f7f89 100644 --- a/packages/charts-core/src/polar-label-layout-internal.ts +++ b/packages/charts-core/src/polar-label-layout-internal.ts @@ -1,8 +1,13 @@ export interface RadialLabelInput { /** Polar angle in radians, clockwise from 12 o'clock. */ readonly angle: number - /** Labels with lower priority are hidden first when space runs out. */ + /** + * Labels with lower priority are hidden first when space runs out. On equal + * priority, the later input label is hidden first. + */ readonly priority: number + /** Measured text width in pixels. */ + readonly width: number } export interface RadialLabelLayoutOptions { @@ -16,9 +21,11 @@ export interface RadialLabelLayoutOptions { readonly padding: number /** Vertical pixel space reserved for one label. */ readonly lineHeight: number - /** Center-relative top and bottom edges of the space for label boxes. */ + /** Center-relative edges of the space for label boxes. */ readonly top: number readonly bottom: number + readonly left: number + readonly right: number /** Largest vertical move from the ideal position before a label is hidden. */ readonly maxShift: number } @@ -44,7 +51,8 @@ interface Block { /** * Places labels in two vertical stacks beside a pie. Each side keeps labels in * angular order, pushes overlapping labels apart as little as possible, and - * hides the lowest-priority labels until the rest fit. + * hides the lowest-priority labels until the rest fit. Labels whose text would + * cross the left or right edge are hidden too. */ export function layoutRadialLabels( labels: readonly RadialLabelInput[], @@ -63,23 +71,41 @@ export function layoutRadialLabels( for (const side of ['left', 'right'] as const) { const ideal = (index: number) => -Math.cos(labels[index]!.angle) * elbowRadius - const ordered = [...sides[side]].sort((a, b) => ideal(a) - ideal(b)) - const positions = placeSide(ordered, ideal, labels, options) const direction = side === 'left' ? -1 : 1 - positions.forEach((y, index) => { + const place = (index: number, y: number) => { const angle = labels[index]!.angle - const edge = point(angle, options.radius) const elbow = point(angle, elbowRadius) const circleX = Math.sqrt(Math.max(elbowRadius ** 2 - y ** 2, 0)) const end = Math.max(circleX, Math.abs(elbow[0])) + options.elbow - placements[index] = { - visible: true, + return { + visible: true as const, x: direction * (end + options.padding), y, side, - connector: [edge, elbow, [direction * end, y]], + connector: [point(angle, options.radius), elbow, [direction * end, y]], + } satisfies RadialLabelPlacement + } + const fits = (index: number, x: number) => + side === 'left' + ? x - labels[index]!.width >= options.left + : x + labels[index]!.width <= options.right + + let kept = [...sides[side]].sort((a, b) => ideal(a) - ideal(b) || a - b) + for (;;) { + const placed = [...placeSide(kept, ideal, labels, options)].map( + ([index, y]) => [index, place(index, y)] as const, + ) + const overflow = new Set( + placed.flatMap(([index, label]) => + fits(index, label.x) ? [] : [index], + ), + ) + if (overflow.size === 0) { + for (const [index, label] of placed) placements[index] = label + break } - }) + kept = kept.filter((index) => !overflow.has(index)) + } } return placements @@ -170,9 +196,12 @@ function withoutLowest( candidates: readonly number[], labels: readonly RadialLabelInput[], ): number[] { - const lowest = candidates.reduce((current, index) => - labels[index]!.priority < labels[current]!.priority ? index : current, - ) + const lowest = candidates.reduce((current, index) => { + const difference = labels[index]!.priority - labels[current]!.priority + return difference < 0 || (difference === 0 && index > current) + ? index + : current + }) return kept.filter((index) => index !== lowest) } diff --git a/packages/charts-core/src/polar-mark-internal.ts b/packages/charts-core/src/polar-mark-internal.ts index 1507d7db..1cf94176 100644 --- a/packages/charts-core/src/polar-mark-internal.ts +++ b/packages/charts-core/src/polar-mark-internal.ts @@ -6,6 +6,7 @@ import type { ChartMarkState, InitializedMark, ChartTheme, + ChartTextMeasurer, ChartTextTypography, ChartValue, MarkScene, @@ -62,6 +63,8 @@ export interface PolarMarkRenderContext { layout: PolarLayoutContext color: (value: ChartKey | null | undefined) => string theme: ChartTheme + /** Typography-aware text measurer from the chart layout. */ + measureText?: ChartTextMeasurer } export interface PolarMark< diff --git a/packages/charts-core/src/polar-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts index b5237d5f..70d7b73e 100644 --- a/packages/charts-core/src/polar-radial-labels.test.ts +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -2,10 +2,16 @@ import { scaleLinear } from 'd3-scale' import { describe, expect, it } from 'vitest' import { defineChart } from './index' import { createChartRuntime } from './runtime' +import { estimateSceneText, measureSceneLabelBounds } from './guide-layout' import { pie, polar, radialArc, radialLabels } from './polar' import type { SceneLabel, SceneNode, ScenePolyline } from './types' -const browsers = [ +interface Row { + name: string + share: number +} + +const browsers: readonly Row[] = [ { name: 'Chrome', share: 64.2 }, { name: 'Safari', share: 18.9 }, { name: 'Edge', share: 5.4 }, @@ -23,9 +29,10 @@ const lineHeight = 17 function render( size: { width: number; height: number }, - options: { maxShift?: number } = {}, + options: { maxShift?: number; rows?: readonly Row[] } = {}, ) { - const slices = pie(browsers, { value: 'share' }) + const { rows = browsers, ...labelOptions } = options + const slices = pie(rows, { value: 'share' }) const definition = defineChart({ marks: [ polar({ @@ -42,7 +49,7 @@ function render( key: 'name', color: 'name', lineHeight, - ...options, + ...labelOptions, }), ], }), @@ -53,6 +60,7 @@ function render( const scene = createChartRuntime().render(definition, size) const nodes = flatten(scene.nodes) return { + scene, points: scene.points.filter((point) => point.markId === 'labels'), labels: nodes.filter( (node): node is SceneLabel => @@ -102,12 +110,87 @@ describe('radialLabels', () => { expect(shown).not.toContain('Other') }) - it('hides labels that would move too far from their slice', () => { - const all = render({ width: 640, height: 480 }).labels - const limited = render({ width: 640, height: 480 }, { maxShift: 8 }).labels + it('keeps every visible label inside a narrow chart', () => { + const size = { width: 220, height: 480 } + const { scene, labels } = render(size) + const centerX = scene.chart.x + scene.chart.width / 2 + + expect(labels.length).toBeLessThan(browsers.length) + labels.forEach((label) => { + const bounds = measureSceneLabelBounds(label, estimateSceneText) + expect(centerX + bounds.x).toBeGreaterThanOrEqual(-1e-9) + expect(centerX + bounds.x + bounds.width).toBeLessThanOrEqual( + size.width + 1e-9, + ) + }) + }) + + it('shows more labels again when a narrow chart grows', () => { + const narrow = render({ width: 220, height: 480 }).labels + const wide = render({ width: 640, height: 480 }).labels + + expect(wide.length).toBeGreaterThan(narrow.length) + expect(narrow.map((label) => label.key)).toEqual( + wide + .filter((label) => narrow.some(({ key }) => key === label.key)) + .map((label) => label.key), + ) + }) + + it('hides a long label but keeps short labels on the same chart', () => { + const rows = browsers.map((row) => + row.name === 'Safari' + ? { ...row, name: 'Safari (all Apple devices, desktop and mobile)' } + : row, + ) + const shown = render({ width: 360, height: 480 }, { rows }).labels.map( + (label) => label.text, + ) - expect(limited.length).toBeLessThan(all.length) - expect(limited.map((label) => label.text)).toContain('Chrome') + expect(shown).not.toContain(rows[1]!.name) + expect(shown).toContain('Chrome') + expect(shown).toContain('Edge') + }) + + it('removes the label, connector, and point of a label hidden by maxShift', () => { + const all = render({ width: 640, height: 480 }) + const limited = render({ width: 640, height: 480 }, { maxShift: 8 }) + const hidden = all.labels + .map((label) => label.key) + .filter((key) => !limited.labels.some((label) => label.key === key)) + + expect(hidden.length).toBeGreaterThan(0) + expect(limited.labels.map((label) => label.text)).toContain('Chrome') + for (const key of hidden) { + expect(limited.connectors.some((node) => node.key.startsWith(key))).toBe( + false, + ) + expect(limited.points.some((point) => point.key === key)).toBe(false) + } + expect(limited.connectors).toHaveLength(limited.labels.length) + expect(limited.points).toHaveLength(limited.labels.length) + }) + + it('keys labels and connectors by datum key across renders and data changes', () => { + const first = render({ width: 640, height: 480 }) + const changed = render( + { width: 640, height: 480 }, + { + rows: browsers.map((row) => + row.name === 'Edge' ? { ...row, share: 9 } : row, + ), + }, + ) + const keys = (labels: readonly SceneLabel[]) => + labels.map((label) => label.key).sort() + + expect(keys(changed.labels)).toEqual(keys(first.labels)) + first.labels.forEach((label) => { + expect(label.key).toMatch(/^labels:.+:.+$/) + expect( + first.connectors.some((node) => node.key === `${label.key}:connector`), + ).toBe(true) + }) }) }) diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index 2d66c9b7..fab33a5e 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -26,7 +26,11 @@ import { } from './polar-sector-internal' import { resolveNumericScale, resolveScaleInput } from './scale-input' import { valueKey } from './scales' -import { physicalTextAnchor } from './guide-layout' +import { + estimateSceneText, + measureSceneLabelBounds, + physicalTextAnchor, +} from './guide-layout' import type { Arc, CurveFactory, CurveFactoryLineOnly } from 'd3-shape' import type { Channel, @@ -333,7 +337,12 @@ export function polar( } } for (const mark of marks) { - const rendered = mark.render({ layout, color, theme }) + const rendered = mark.render({ + layout, + color, + theme, + measureText: chartLayout.measureText, + }) if (mark.states) { nodes.push({ kind: 'group', @@ -1899,7 +1908,8 @@ export function radialLabels( options: RadialLabelsOptions> = {}, ): PolarMark { const data = asArray(source) - const lineHeight = options.lineHeight ?? (options.fontSize ?? 12) * 1.4 + const fontSize = options.fontSize ?? 12 + const lineHeight = options.lineHeight ?? fontSize * 1.4 if (!isFiniteNumber(lineHeight) || lineHeight <= 0) { throw new TypeError('radialLabels: lineHeight must be positive and finite') } @@ -1942,7 +1952,12 @@ export function radialLabels( includeZeroRadius: false, requiresAngleScale: true, requiresRadiusScale: true, - render: ({ layout, color: resolveColor, theme }) => { + render: ({ + layout, + color: resolveColor, + theme, + measureText = estimateSceneText, + }) => { const angle = requiredPolarScale( layout, angleScale, @@ -1977,6 +1992,20 @@ export function radialLabels( angle: anglePosition, radius: radiusPosition, priority: isFiniteNumber(priority) ? priority : 0, + width: measureSceneLabelBounds( + { + kind: 'label', + key: '', + x: 0, + y: 0, + text: String(text), + anchor: 'start', + baseline: 'middle', + fontSize, + fontWeight: options.fontWeight, + }, + measureText, + ).width, }, ] }) @@ -1988,6 +2017,8 @@ export function radialLabels( lineHeight, top: layout.chart.y - layout.centerY, bottom: layout.chart.y + layout.chart.height - layout.centerY, + left: layout.chart.x - layout.centerX, + right: layout.chart.x + layout.chart.width - layout.centerX, maxShift: options.maxShift ?? Infinity, }) const connectors: SceneNode[] = [] @@ -2042,7 +2073,7 @@ export function radialLabels( layout.direction, ), baseline: 'middle', - fontSize: options.fontSize, + fontSize, fontWeight: options.fontWeight, style: { fill, opacity: options.opacity }, }) diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index c1628b06..df5f165f 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -224,6 +224,10 @@ const callbackInventory = { '@tanstack/charts:src/polar-mark-internal.ts:InitializedPolarMark', 'motion', ], + [ + '@tanstack/charts:src/polar-mark-internal.ts:PolarMarkRenderContext', + 'measureText', + ], ['@tanstack/charts:src/polar-pie.ts:PieOptions', 'value'], [ '@tanstack/charts:src/polar.ts:RadialArcOptions', From ee8b1bd50d0db7a7d722ffb903e0e74a1dd4f0f6 Mon Sep 17 00:00:00 2001 From: hshoja Date: Wed, 14 Oct 2026 12:41:41 +0200 Subject: [PATCH 03/10] test(polar): cover radialLabels in SVG, Canvas, and React Native radialLabels emits only polyline and label scene nodes. These tests confirm each renderer paints every visible connector and label. --- packages/charts-core/src/canvas.test.ts | 59 ++++++++++++++++++- .../src/polar-radial-labels.test.ts | 15 +++++ .../react-native-charts/src/SvgScene.test.tsx | 44 ++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index edb81d10..d7444a05 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -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' @@ -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( + '.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', diff --git a/packages/charts-core/src/polar-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts index 70d7b73e..fb5ecd2e 100644 --- a/packages/charts-core/src/polar-radial-labels.test.ts +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -4,6 +4,7 @@ import { defineChart } from './index' import { createChartRuntime } from './runtime' import { estimateSceneText, measureSceneLabelBounds } from './guide-layout' import { pie, polar, radialArc, radialLabels } from './polar' +import { renderChartSvg } from './svg' import type { SceneLabel, SceneNode, ScenePolyline } from './types' interface Row { @@ -194,6 +195,20 @@ describe('radialLabels', () => { }) }) +describe('radialLabels SVG output', () => { + it('serializes connectors as paths and labels as text', () => { + const { scene, labels, connectors } = render({ width: 640, height: 480 }) + const svg = renderChartSvg(scene, { ariaLabel: 'Browser share' }) + + connectors.forEach((connector) => { + expect(svg).toContain(`d="${connector.path}"`) + }) + labels.forEach((label) => { + expect(svg).toContain(`>${label.text}`) + }) + }) +}) + function flatten(nodes: readonly SceneNode[]): SceneNode[] { return nodes.flatMap((node) => node.kind === 'group' ? [node, ...flatten(node.children)] : [node], diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 5755e158..06ce9279 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -1,7 +1,9 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, describe, expect, it, vi } from 'vitest' +import { scaleLinear as d3ScaleLinear } from 'd3-scale' import { createChartScene, defineChart, lineY } from '@tanstack/charts' +import { pie, polar, radialLabels } from '@tanstack/charts/polar' import { scaleLinear } from '@tanstack/charts/scales/linear' import type { ChartScene, SceneNode } from '@tanstack/charts/types' import { @@ -661,6 +663,48 @@ describe('React Native SVG scene renderer', () => { }) }) +describe('React Native radialLabels', () => { + it('renders connectors as paths and labels as native text', () => { + const slices = pie( + [ + { name: 'Large', value: 80 }, + { name: 'Small', value: 3 }, + { name: 'Tiny', value: 2 }, + ], + { value: 'value' }, + ) + const labelScene = createChartScene( + defineChart({ + marks: [ + polar({ + radiusRatio: 0.5, + scales: { + angle: { scale: d3ScaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: d3ScaleLinear().domain([0, 1]) }, + }, + marks: [radialLabels(slices, { text: 'name', key: 'name' })], + }), + ], + scales: { x: null, y: null }, + }), + { width: 400, height: 300 }, + ) + const markup = renderToStaticMarkup( + , + ) + + for (const name of ['Large', 'Small', 'Tiny']) { + expect(markup).toContain(`>${name}`) + } + expect(markup.match(/]*d="M[^"]+L[^"]+L[^"]+"/g)).toHaveLength(3) + }) +}) + function scene(): ChartScene { const nodes: SceneNode[] = [ { From b6670aa8bfcef3c91f7b64e12844d3cb60626343 Mon Sep 17 00:00:00 2001 From: hshoja Date: Wed, 14 Oct 2026 12:44:03 +0200 Subject: [PATCH 04/10] test(polar): cover radialLabels motion Keyed labels and connectors keep their elements and interpolate to the new layout, and labels of removed slices fade out before removal. --- packages/charts-core/src/motion.test.ts | 87 ++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index ef4049b0..cacc4698 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -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' @@ -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(`text[data-ts-key$=":${name}"]`) + const connector = (name: string) => + container.querySelector( + `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[] = [] From 29326b94d0ab97b5798f2b0ed73cf7d0da1f884d Mon Sep 17 00:00:00 2001 From: hshoja Date: Wed, 14 Oct 2026 12:47:43 +0200 Subject: [PATCH 05/10] docs(polar): document radialLabels fitting, ties, and motion --- .changeset/crowded-pie-labels.md | 2 +- API-FRICTION.md | 9 ++++--- docs/reference/marks/polar.md | 24 +++++++++++++++---- .../charts-core/docs/reference/marks/polar.md | 24 +++++++++++++++---- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.changeset/crowded-pie-labels.md b/.changeset/crowded-pie-labels.md index 94457b13..67ac7061 100644 --- a/.changeset/crowded-pie-labels.md +++ b/.changeset/crowded-pie-labels.md @@ -2,4 +2,4 @@ '@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. `maxShift` also hides labels that would move too far from their slice. +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. diff --git a/API-FRICTION.md b/API-FRICTION.md index 0e84b32f..1e5cf23c 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9355,6 +9355,9 @@ Each entry records: lowest-priority labels when they do not fit. `maxShift` optionally hides labels that would move too far from their slice. - Verification: unit tests cover ideal placement, minimal spreading, capacity - hiding by priority, `maxShift`, vertical bounds, and connector geometry. A - rendered chart test covers non-overlapping labels per side and hiding the - smallest slices on a short chart. + hiding by priority, equal-priority ties, `maxShift`, vertical and horizontal + bounds, long labels, and connector geometry. Rendered chart tests cover + non-overlapping labels per side, short, narrow, and resized charts, stable + keys, and SVG, Canvas, and React Native output. A motion test covers moving + kept labels and connectors and fading removed labels. Charts that do not + import `radialLabels` keep the same polar bundle size within 0.01 kB gzip. diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index a9f7a2b2..487a7168 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -388,15 +388,23 @@ largest mapped radius, bend at an elbow `offset` pixels outside it (default pixels after the connector (default 4). Labels form one vertical stack on each side of the center, in angular order. -Each label gets `lineHeight` pixels (default `fontSize * 1.4`). When labels -overlap, the mark moves them apart as little as possible, and keeps every -label inside the polar container's chart bounds. When a side has more labels -than fit, the labels with the lowest `priority` are hidden first. `priority` -defaults to the numeric `value` field, so the smallest `pie()` slices are +Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` +defaults to 12). When labels overlap, the mark moves them apart as little as +possible, and keeps every label inside the polar container's chart bounds. +When a side has more labels than fit, the labels with the lowest `priority` +are hidden first. `priority` defaults to the numeric `value` field, so the +smallest `pie()` slices are hidden first. On equal priority, the later row is hidden first. Set `maxShift` to also hide labels that would move more than that many pixels from their slice; the mark hides the lowest-priority label in the crowded group and lays out again. +The mark measures each label with the chart's text measurer. A label whose +text would cross the left or right chart edge is hidden, and the remaining +labels on that side are laid out again. Long labels and narrow charts +therefore lose labels instead of overflowing; widen the chart, lower +`radiusRatio`, or shorten `text` to keep more labels. Layout runs on every +render, so a resized chart can show labels that a smaller size hid. + The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, `color`, fill, font size and weight, opacity, and connector `stroke`, `strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each @@ -404,6 +412,12 @@ visible label emits one interaction point at its final text position. Hidden labels emit no nodes and no points, so pair the mark with a tooltip or legend when every slice name must stay available. +The mark emits only label and polyline scene nodes, so SVG, Canvas, and React +Native paint it the same way. Label keys come from `key`; each connector uses +the label key with a `:connector` suffix. With the motion renderer, kept labels +and connectors move to their new layout, and hidden or removed labels fade +out. + ```tsx radialLabels(slices, { text: (row) => `${row.name} ${row.share}%`, diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index a9f7a2b2..487a7168 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -388,15 +388,23 @@ largest mapped radius, bend at an elbow `offset` pixels outside it (default pixels after the connector (default 4). Labels form one vertical stack on each side of the center, in angular order. -Each label gets `lineHeight` pixels (default `fontSize * 1.4`). When labels -overlap, the mark moves them apart as little as possible, and keeps every -label inside the polar container's chart bounds. When a side has more labels -than fit, the labels with the lowest `priority` are hidden first. `priority` -defaults to the numeric `value` field, so the smallest `pie()` slices are +Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` +defaults to 12). When labels overlap, the mark moves them apart as little as +possible, and keeps every label inside the polar container's chart bounds. +When a side has more labels than fit, the labels with the lowest `priority` +are hidden first. `priority` defaults to the numeric `value` field, so the +smallest `pie()` slices are hidden first. On equal priority, the later row is hidden first. Set `maxShift` to also hide labels that would move more than that many pixels from their slice; the mark hides the lowest-priority label in the crowded group and lays out again. +The mark measures each label with the chart's text measurer. A label whose +text would cross the left or right chart edge is hidden, and the remaining +labels on that side are laid out again. Long labels and narrow charts +therefore lose labels instead of overflowing; widen the chart, lower +`radiusRatio`, or shorten `text` to keep more labels. Layout runs on every +render, so a resized chart can show labels that a smaller size hid. + The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, `color`, fill, font size and weight, opacity, and connector `stroke`, `strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each @@ -404,6 +412,12 @@ visible label emits one interaction point at its final text position. Hidden labels emit no nodes and no points, so pair the mark with a tooltip or legend when every slice name must stay available. +The mark emits only label and polyline scene nodes, so SVG, Canvas, and React +Native paint it the same way. Label keys come from `key`; each connector uses +the label key with a `:connector` suffix. With the motion renderer, kept labels +and connectors move to their new layout, and hidden or removed labels fade +out. + ```tsx radialLabels(slices, { text: (row) => `${row.name} ${row.share}%`, From 5a651c2caeb1f03cbaf0abb3f5cef0c907a6b13b Mon Sep 17 00:00:00 2001 From: hshoja Date: Thu, 15 Oct 2026 17:17:19 +0200 Subject: [PATCH 06/10] perf(polar): select radial label capacity in one pass 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. --- .../src/polar-label-layout-internal.test.ts | 57 +++++++++++++++++++ .../src/polar-label-layout-internal.ts | 20 ++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/charts-core/src/polar-label-layout-internal.test.ts b/packages/charts-core/src/polar-label-layout-internal.test.ts index c93569f9..9bbb3410 100644 --- a/packages/charts-core/src/polar-label-layout-internal.test.ts +++ b/packages/charts-core/src/polar-label-layout-internal.test.ts @@ -201,4 +201,61 @@ describe('layoutRadialLabels', () => { x: expect.closeTo(-134, 8), }) }) + + it('keeps the same labels as removing the lowest priority one at a time', () => { + const tight = { ...options, top: -40, bottom: 40 } + const capacity = Math.floor((tight.bottom - tight.top) / tight.lineHeight) + for (let seed = 1; seed <= 50; seed += 1) { + const labels = Array.from({ length: 30 }, (_, index) => + label(10 + ((index * 37 + seed * 11) % 160), { + priority: (index * seed) % 4, + }), + ) + let expected = labels.map((_, index) => index) + while (expected.length > capacity) { + const lowest = expected.reduce((current, index) => { + const difference = labels[index]!.priority - labels[current]!.priority + return difference < 0 || (difference === 0 && index > current) + ? index + : current + }) + expected = expected.filter((index) => index !== lowest) + } + + const shown = visible(layoutRadialLabels(labels, tight)) + expect(shown.map(({ index }) => index).sort((a, b) => a - b)).toEqual( + expected, + ) + } + }) + + it('keeps visible labels in angular order after capacity selection', () => { + const labels = Array.from({ length: 40 }, (_, index) => + label(5 + index * 4, { priority: (index * 7) % 5 }), + ) + const shown = visible( + layoutRadialLabels(labels, { ...options, top: -60, bottom: 60 }), + ) + const byHeight = [...shown].sort((a, b) => a.y - b.y) + + expect(byHeight.map(({ index }) => index)).toEqual( + [...shown] + .sort((a, b) => labels[a.index]!.angle - labels[b.index]!.angle) + .map(({ index }) => index), + ) + }) + + it('selects capacity labels from thousands of candidates', () => { + const labels = Array.from({ length: 5000 }, (_, index) => + label(1 + (index / 5000) * 170, { priority: index % 97 }), + ) + const shown = visible( + layoutRadialLabels(labels, { ...options, top: -200, bottom: 200 }), + ) + + expect(shown).toHaveLength(25) + expect( + Math.min(...shown.map(({ index }) => labels[index]!.priority)), + ).toBeGreaterThanOrEqual(96) + }) }) diff --git a/packages/charts-core/src/polar-label-layout-internal.ts b/packages/charts-core/src/polar-label-layout-internal.ts index aa0f7f89..460c46b1 100644 --- a/packages/charts-core/src/polar-label-layout-internal.ts +++ b/packages/charts-core/src/polar-label-layout-internal.ts @@ -121,8 +121,10 @@ function placeSide( 0, Math.floor((options.bottom - options.top) / options.lineHeight), ) - let kept = ordered - while (kept.length > capacity) kept = withoutLowest(kept, kept, labels) + let kept = + ordered.length > capacity + ? withHighestPriority(ordered, capacity, labels) + : ordered for (;;) { const blocks = spread(kept, ideal, options) @@ -191,6 +193,20 @@ function createBlock( return { members, sum, start } } +/** Keeps the `count` highest-priority labels in angular order; ties keep the earlier input. */ +function withHighestPriority( + ordered: readonly number[], + count: number, + labels: readonly RadialLabelInput[], +): number[] { + const selected = new Set( + [...ordered] + .sort((a, b) => labels[b]!.priority - labels[a]!.priority || a - b) + .slice(0, count), + ) + return ordered.filter((index) => selected.has(index)) +} + function withoutLowest( kept: readonly number[], candidates: readonly number[], From 9c12674e463901a07ff1cdc73c5030c3457977e8 Mon Sep 17 00:00:00 2001 From: hshoja Date: Thu, 15 Oct 2026 17:27:48 +0200 Subject: [PATCH 07/10] fix(polar): keep radial label connectors clear of other labels 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. --- API-FRICTION.md | 5 +- docs/reference/marks/polar.md | 20 +++-- .../charts-core/docs/reference/marks/polar.md | 20 +++-- .../src/polar-label-layout-internal.test.ts | 49 ++++++++++ .../src/polar-label-layout-internal.ts | 89 ++++++++++++------- .../src/polar-radial-labels.test.ts | 74 ++++++++++++++- 6 files changed, 208 insertions(+), 49 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 1e5cf23c..990a266a 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9356,8 +9356,9 @@ Each entry records: labels that would move too far from their slice. - Verification: unit tests cover ideal placement, minimal spreading, capacity hiding by priority, equal-priority ties, `maxShift`, vertical and horizontal - bounds, long labels, and connector geometry. Rendered chart tests cover - non-overlapping labels per side, short, narrow, and resized charts, stable + bounds, long labels, connector geometry, and connectors on the inner side + of each text column. Rendered chart tests cover connectors clear of other + labels' text boxes, non-overlapping labels per side, short, narrow, and resized charts, stable keys, and SVG, Canvas, and React Native output. A motion test covers moving kept labels and connectors and fading removed labels. Charts that do not import `radialLabels` keep the same polar bundle size within 0.01 kB gzip. diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 487a7168..7293d691 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -383,9 +383,10 @@ function radialLabels( and keeps crowded labels readable. `angle` defaults to the numeric `angle` field of each row, so `pie()` output needs no angle channel. `radius` defaults to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow `offset` pixels outside it (default -16), and run `elbow` pixels horizontally (default 12). The text sits `padding` -pixels after the connector (default 4). +largest mapped radius, bend at an elbow `offset` pixels outside it (default 16) on the slice angle, and run horizontally to a text column `elbow` pixels +beyond the outermost visible elbow on that side (default 12). The text sits +`padding` pixels after the column (default 4). Every connector stays on the +inner side of the column, so no connector crosses another label's text. Labels form one vertical stack on each side of the center, in angular order. Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` @@ -398,11 +399,14 @@ hidden first. Set `maxShift` to also hide labels that would move more than that many pixels from their slice; the mark hides the lowest-priority label in the crowded group and lays out again. -The mark measures each label with the chart's text measurer. A label whose -text would cross the left or right chart edge is hidden, and the remaining -labels on that side are laid out again. Long labels and narrow charts -therefore lose labels instead of overflowing; widen the chart, lower -`radiusRatio`, or shorten `text` to keep more labels. Layout runs on every +The mark measures each label with the chart's text measurer and keeps every +label inside the left and right chart edges. A label that does not fit even +beside its own elbow is hidden first. While the widest remaining label still +does not fit beside the column, the mark hides the lower-priority one of that +label and the label with the outermost elbow; on equal priority it hides the +outermost-elbow label, because that label sets the column. Long labels and +narrow charts therefore lose labels instead of overflowing; widen the chart, +lower `radiusRatio`, or shorten `text` to keep more labels. Layout runs on every render, so a resized chart can show labels that a smaller size hid. The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 487a7168..7293d691 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -383,9 +383,10 @@ function radialLabels( and keeps crowded labels readable. `angle` defaults to the numeric `angle` field of each row, so `pie()` output needs no angle channel. `radius` defaults to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow `offset` pixels outside it (default -16), and run `elbow` pixels horizontally (default 12). The text sits `padding` -pixels after the connector (default 4). +largest mapped radius, bend at an elbow `offset` pixels outside it (default 16) on the slice angle, and run horizontally to a text column `elbow` pixels +beyond the outermost visible elbow on that side (default 12). The text sits +`padding` pixels after the column (default 4). Every connector stays on the +inner side of the column, so no connector crosses another label's text. Labels form one vertical stack on each side of the center, in angular order. Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` @@ -398,11 +399,14 @@ hidden first. Set `maxShift` to also hide labels that would move more than that many pixels from their slice; the mark hides the lowest-priority label in the crowded group and lays out again. -The mark measures each label with the chart's text measurer. A label whose -text would cross the left or right chart edge is hidden, and the remaining -labels on that side are laid out again. Long labels and narrow charts -therefore lose labels instead of overflowing; widen the chart, lower -`radiusRatio`, or shorten `text` to keep more labels. Layout runs on every +The mark measures each label with the chart's text measurer and keeps every +label inside the left and right chart edges. A label that does not fit even +beside its own elbow is hidden first. While the widest remaining label still +does not fit beside the column, the mark hides the lower-priority one of that +label and the label with the outermost elbow; on equal priority it hides the +outermost-elbow label, because that label sets the column. Long labels and +narrow charts therefore lose labels instead of overflowing; widen the chart, +lower `radiusRatio`, or shorten `text` to keep more labels. Layout runs on every render, so a resized chart can show labels that a smaller size hid. The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, diff --git a/packages/charts-core/src/polar-label-layout-internal.test.ts b/packages/charts-core/src/polar-label-layout-internal.test.ts index 9bbb3410..28a41367 100644 --- a/packages/charts-core/src/polar-label-layout-internal.test.ts +++ b/packages/charts-core/src/polar-label-layout-internal.test.ts @@ -258,4 +258,53 @@ describe('layoutRadialLabels', () => { Math.min(...shown.map(({ index }) => labels[index]!.priority)), ).toBeGreaterThanOrEqual(96) }) + + it('keeps every connector on the inner side of its text column', () => { + const labels = Array.from({ length: 24 }, (_, index) => + label(3 + index * 15, { width: 20 + (index % 5) * 10 }), + ) + const shown = visible( + layoutRadialLabels(labels, { ...options, top: -150, bottom: 150 }), + ) + + expect(shown.length).toBeGreaterThan(0) + for (const side of ['left', 'right'] as const) { + const column = shown.filter((placement) => placement.side === side) + const textStart = Math.min(...column.map(({ x }) => Math.abs(x))) + expect(new Set(column.map(({ x }) => x)).size).toBeLessThanOrEqual(1) + column.forEach(({ connector }) => { + connector.forEach(([x]) => { + expect(Math.abs(x)).toBeLessThanOrEqual( + textStart - options.padding + 1e-9, + ) + }) + }) + } + }) + + it('hides the outermost-elbow label first when the column does not fit', () => { + const placements = layoutRadialLabels( + [label(10, { width: 60 }), label(20), label(90)], + { ...options, right: 180 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + true, + true, + false, + ]) + }) + + it('hides the widest label instead when the outermost label has higher priority', () => { + const placements = layoutRadialLabels( + [label(10, { width: 60 }), label(20), label(90, { priority: 5 })], + { ...options, right: 180 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + false, + true, + true, + ]) + }) }) diff --git a/packages/charts-core/src/polar-label-layout-internal.ts b/packages/charts-core/src/polar-label-layout-internal.ts index 460c46b1..c488b851 100644 --- a/packages/charts-core/src/polar-label-layout-internal.ts +++ b/packages/charts-core/src/polar-label-layout-internal.ts @@ -52,7 +52,7 @@ interface Block { * Places labels in two vertical stacks beside a pie. Each side keeps labels in * angular order, pushes overlapping labels apart as little as possible, and * hides the lowest-priority labels until the rest fit. Labels whose text would - * cross the left or right edge are hidden too. + * cross the left or right edge are hidden first. */ export function layoutRadialLabels( labels: readonly RadialLabelInput[], @@ -71,46 +71,75 @@ export function layoutRadialLabels( for (const side of ['left', 'right'] as const) { const ideal = (index: number) => -Math.cos(labels[index]!.angle) * elbowRadius + const elbowX = (index: number) => + Math.abs(Math.sin(labels[index]!.angle)) * elbowRadius + // Text starts outside the outermost elbow, so no connector crosses other labels' text. + const columnFor = (indexes: readonly number[]) => + Math.max(0, ...indexes.map(elbowX)) + options.elbow + const room = + (side === 'left' ? -options.left : options.right) - + options.elbow - + options.padding + const candidates = fitColumn(sides[side], elbowX, room, labels) + const ordered = [...candidates].sort((a, b) => ideal(a) - ideal(b) || a - b) + const positions = placeSide(ordered, ideal, labels, options) + const column = columnFor([...positions.keys()]) const direction = side === 'left' ? -1 : 1 - const place = (index: number, y: number) => { + for (const [index, y] of positions) { const angle = labels[index]!.angle - const elbow = point(angle, elbowRadius) - const circleX = Math.sqrt(Math.max(elbowRadius ** 2 - y ** 2, 0)) - const end = Math.max(circleX, Math.abs(elbow[0])) + options.elbow - return { - visible: true as const, - x: direction * (end + options.padding), + placements[index] = { + visible: true, + x: direction * (column + options.padding), y, side, - connector: [point(angle, options.radius), elbow, [direction * end, y]], - } satisfies RadialLabelPlacement - } - const fits = (index: number, x: number) => - side === 'left' - ? x - labels[index]!.width >= options.left - : x + labels[index]!.width <= options.right - - let kept = [...sides[side]].sort((a, b) => ideal(a) - ideal(b) || a - b) - for (;;) { - const placed = [...placeSide(kept, ideal, labels, options)].map( - ([index, y]) => [index, place(index, y)] as const, - ) - const overflow = new Set( - placed.flatMap(([index, label]) => - fits(index, label.x) ? [] : [index], - ), - ) - if (overflow.size === 0) { - for (const [index, label] of placed) placements[index] = label - break + connector: [ + point(angle, options.radius), + point(angle, elbowRadius), + [direction * column, y], + ], } - kept = kept.filter((index) => !overflow.has(index)) } } return placements } +/** + * Hides labels until the widest text fits beside the outermost elbow. Each step + * hides the lower-priority one of the outermost-elbow label and the widest + * label; on equal priority, the outermost-elbow label, which moves the column. + * Labels that do not fit even beside their own elbow are hidden first. + */ +function fitColumn( + indexes: readonly number[], + elbowX: (index: number) => number, + room: number, + labels: readonly RadialLabelInput[], +): number[] { + const fitting = indexes.filter( + (index) => elbowX(index) + labels[index]!.width <= room, + ) + const byElbow = [...fitting].sort((a, b) => elbowX(b) - elbowX(a) || a - b) + const byWidth = [...fitting].sort( + (a, b) => labels[b]!.width - labels[a]!.width || a - b, + ) + const hidden = new Set() + let elbowIndex = 0 + let widthIndex = 0 + for (;;) { + while (hidden.has(byElbow[elbowIndex]!)) elbowIndex += 1 + while (hidden.has(byWidth[widthIndex]!)) widthIndex += 1 + const outer = byElbow[elbowIndex] + const widest = byWidth[widthIndex] + if (outer === undefined || widest === undefined) break + if (elbowX(outer) + labels[widest]!.width <= room) break + hidden.add( + labels[widest]!.priority < labels[outer]!.priority ? widest : outer, + ) + } + return fitting.filter((index) => !hidden.has(index)) +} + function placeSide( ordered: readonly number[], ideal: (index: number) => number, diff --git a/packages/charts-core/src/polar-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts index fb5ecd2e..7bfbf820 100644 --- a/packages/charts-core/src/polar-radial-labels.test.ts +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -30,7 +30,11 @@ const lineHeight = 17 function render( size: { width: number; height: number }, - options: { maxShift?: number; rows?: readonly Row[] } = {}, + options: { + maxShift?: number + lineHeight?: number + rows?: readonly Row[] + } = {}, ) { const { rows = browsers, ...labelOptions } = options const slices = pie(rows, { value: 'share' }) @@ -193,6 +197,47 @@ describe('radialLabels', () => { ).toBe(true) }) }) + + it('keeps connectors clear of other labels in a crowded fixture', () => { + const rows = Array.from({ length: 20 }, (_, index) => ({ + name: index === 1 ? 'Slice 1 with a much longer name' : `Slice ${index}`, + share: index === 0 ? 50 : 1, + })) + const grow = 2 + + for (const size of [ + { width: 640, height: 320 }, + { width: 220, height: 320 }, + { width: 640, height: 100 }, + ]) { + const { labels, connectors } = render(size, { + rows, + lineHeight: undefined, + }) + expect(labels.length).toBeGreaterThan(0) + for (const connector of connectors) { + const points = [ + ...connector.path!.matchAll(/(-?[\d.e]+),(-?[\d.e]+)/g), + ].map(([, x, y]) => ({ x: Number(x), y: Number(y) })) + for (const label of labels) { + if (connector.key === `${label.key}:connector`) continue + const box = measureSceneLabelBounds(label, estimateSceneText) + const rect = { + x1: box.x - grow, + y1: box.y - grow, + x2: box.x + box.width + grow, + y2: box.y + box.height + grow, + } + points.slice(1).forEach((end, index) => { + expect( + segmentHitsRect(points[index]!, end, rect), + `${connector.key} crosses ${label.text} at ${size.width}x${size.height}`, + ).toBe(false) + }) + } + } + } + }) }) describe('radialLabels SVG output', () => { @@ -209,6 +254,33 @@ describe('radialLabels SVG output', () => { }) }) +function segmentHitsRect( + start: { x: number; y: number }, + end: { x: number; y: number }, + rect: { x1: number; y1: number; x2: number; y2: number }, +): boolean { + const dx = end.x - start.x + const dy = end.y - start.y + let low = 0 + let high = 1 + for (const [p, q] of [ + [-dx, start.x - rect.x1], + [dx, rect.x2 - start.x], + [-dy, start.y - rect.y1], + [dy, rect.y2 - start.y], + ] as const) { + if (p === 0) { + if (q < 0) return false + continue + } + const t = q / p + if (p < 0) low = Math.max(low, t) + else high = Math.min(high, t) + if (low > high) return false + } + return true +} + function flatten(nodes: readonly SceneNode[]): SceneNode[] { return nodes.flatMap((node) => node.kind === 'group' ? [node, ...flatten(node.children)] : [node], From 7f95e284a135b7b493e7390055cc75c4e8d9e47d Mon Sep 17 00:00:00 2001 From: hshoja Date: Thu, 15 Oct 2026 17:47:52 +0200 Subject: [PATCH 08/10] refactor(polar): keep radialLabels spacing internal 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. --- docs/reference/marks/polar.md | 13 ++++++----- .../charts-core/docs/reference/marks/polar.md | 13 ++++++----- .../src/polar-radial-labels.test.ts | 10 +++------ packages/charts-core/src/polar.ts | 22 ++++++------------- 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 7293d691..4fcdc08f 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -383,14 +383,15 @@ function radialLabels( and keeps crowded labels readable. `angle` defaults to the numeric `angle` field of each row, so `pie()` output needs no angle channel. `radius` defaults to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow `offset` pixels outside it (default 16) on the slice angle, and run horizontally to a text column `elbow` pixels -beyond the outermost visible elbow on that side (default 12). The text sits -`padding` pixels after the column (default 4). Every connector stays on the -inner side of the column, so no connector crosses another label's text. +largest mapped radius, bend at an elbow 16 pixels outside it on the slice +angle, and run horizontally to a text column 12 pixels beyond the outermost +visible elbow on that side. The text starts 4 pixels after the column. Every +connector stays on the inner side of the column, so no connector crosses +another label's text. Labels form one vertical stack on each side of the center, in angular order. -Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` -defaults to 12). When labels overlap, the mark moves them apart as little as +Each label gets `fontSize * 1.4` pixels of height (`fontSize` defaults to +12). When labels overlap, the mark moves them apart as little as possible, and keeps every label inside the polar container's chart bounds. When a side has more labels than fit, the labels with the lowest `priority` are hidden first. `priority` defaults to the numeric `value` field, so the diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 7293d691..4fcdc08f 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -383,14 +383,15 @@ function radialLabels( and keeps crowded labels readable. `angle` defaults to the numeric `angle` field of each row, so `pie()` output needs no angle channel. `radius` defaults to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow `offset` pixels outside it (default 16) on the slice angle, and run horizontally to a text column `elbow` pixels -beyond the outermost visible elbow on that side (default 12). The text sits -`padding` pixels after the column (default 4). Every connector stays on the -inner side of the column, so no connector crosses another label's text. +largest mapped radius, bend at an elbow 16 pixels outside it on the slice +angle, and run horizontally to a text column 12 pixels beyond the outermost +visible elbow on that side. The text starts 4 pixels after the column. Every +connector stays on the inner side of the column, so no connector crosses +another label's text. Labels form one vertical stack on each side of the center, in angular order. -Each label gets `lineHeight` pixels (default `fontSize * 1.4`; `fontSize` -defaults to 12). When labels overlap, the mark moves them apart as little as +Each label gets `fontSize * 1.4` pixels of height (`fontSize` defaults to +12). When labels overlap, the mark moves them apart as little as possible, and keeps every label inside the polar container's chart bounds. When a side has more labels than fit, the labels with the lowest `priority` are hidden first. `priority` defaults to the numeric `value` field, so the diff --git a/packages/charts-core/src/polar-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts index 7bfbf820..07efb587 100644 --- a/packages/charts-core/src/polar-radial-labels.test.ts +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -26,13 +26,13 @@ const browsers: readonly Row[] = [ { name: 'Other', share: 0.6 }, ] -const lineHeight = 17 +// Default 12px font with 1.4 line spacing. +const lineHeight = 16.8 function render( size: { width: number; height: number }, options: { maxShift?: number - lineHeight?: number rows?: readonly Row[] } = {}, ) { @@ -53,7 +53,6 @@ function render( text: 'name', key: 'name', color: 'name', - lineHeight, ...labelOptions, }), ], @@ -210,10 +209,7 @@ describe('radialLabels', () => { { width: 220, height: 320 }, { width: 640, height: 100 }, ]) { - const { labels, connectors } = render(size, { - rows, - lineHeight: undefined, - }) + const { labels, connectors } = render(size, { rows }) expect(labels.length).toBeGreaterThan(0) for (const connector of connectors) { const points = [ diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index fab33a5e..4735026b 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -1829,6 +1829,9 @@ export function radialText( ) } +// Connector and text spacing in pixels. +const radialLabelSpacing = { offset: 16, elbow: 12, padding: 4 } + export interface RadialLabelsOptions extends RadialPathOptions { text?: Channel /** @@ -1840,14 +1843,6 @@ export interface RadialLabelsOptions extends RadialPathOptions { fontSize?: number fontWeight?: number opacity?: number - /** Vertical pixel space for one label. Defaults to `fontSize * 1.4`. */ - lineHeight?: number - /** Pixel gap between the mapped radius and the connector elbow. Defaults to 16. */ - offset?: number - /** Horizontal connector length after the elbow. Defaults to 12. */ - elbow?: number - /** Pixel gap between the connector and the text. Defaults to 4. */ - padding?: number /** Hides a label that would move more pixels than this from its slice. */ maxShift?: number /** Connector color. Defaults to the label fill. */ @@ -1909,9 +1904,8 @@ export function radialLabels( ): PolarMark { const data = asArray(source) const fontSize = options.fontSize ?? 12 - const lineHeight = options.lineHeight ?? fontSize * 1.4 - if (!isFiniteNumber(lineHeight) || lineHeight <= 0) { - throw new TypeError('radialLabels: lineHeight must be positive and finite') + if (!isFiniteNumber(fontSize) || fontSize <= 0) { + throw new TypeError('radialLabels: fontSize must be positive and finite') } return createPolarMark( ({ markIndex, parentId, states: defaultStates }) => { @@ -2011,10 +2005,8 @@ export function radialLabels( }) const placements = layoutRadialLabels(candidates, { radius: Math.max(0, ...candidates.map(({ radius }) => radius)), - offset: options.offset ?? 16, - elbow: options.elbow ?? 12, - padding: options.padding ?? 4, - lineHeight, + ...radialLabelSpacing, + lineHeight: fontSize * 1.4, top: layout.chart.y - layout.centerY, bottom: layout.chart.y + layout.chart.height - layout.centerY, left: layout.chart.x - layout.centerX, From 54e922a872427b7ca4b34609ec0c28c5ed04b827 Mon Sep 17 00:00:00 2001 From: hshoja Date: Thu, 15 Oct 2026 19:58:08 +0200 Subject: [PATCH 09/10] refactor(polar): reuse numberProperty and align radialLabels docs 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. --- API-FRICTION.md | 24 ++--- docs/reference/marks/polar.md | 90 ++++++++----------- .../charts-core/docs/reference/marks/polar.md | 90 ++++++++----------- packages/charts-core/src/polar.ts | 12 +-- 4 files changed, 85 insertions(+), 131 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 990a266a..4bacdab7 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9346,19 +9346,11 @@ Each entry records: - Status: resolved - Severity: medium - Owner: API -- Observed in: a production pie with many small adjacent slices. The - `radialRule` plus `radialText` composition placed every label on its slice - angle, so neighboring labels overlapped. The application needed its own - pixel layout, connector paths, and hiding rules outside the polar mark. -- Decision: add `radialLabels`, which stacks labels on each side of the - center, 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. -- Verification: unit tests cover ideal placement, minimal spreading, capacity - hiding by priority, equal-priority ties, `maxShift`, vertical and horizontal - bounds, long labels, connector geometry, and connectors on the inner side - of each text column. Rendered chart tests cover connectors clear of other - labels' text boxes, non-overlapping labels per side, short, narrow, and resized charts, stable - keys, and SVG, Canvas, and React Native output. A motion test covers moving - kept labels and connectors and fading removed labels. Charts that do not - import `radialLabels` keep the same polar bundle size within 0.01 kB gzip. +- 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. diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 4fcdc08f..d6d251e4 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -379,59 +379,43 @@ function radialLabels( ): PolarMark ``` -`radialLabels` draws outside labels with connectors for pie and donut slices, -and keeps crowded labels readable. `angle` defaults to the numeric `angle` -field of each row, so `pie()` output needs no angle channel. `radius` defaults -to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow 16 pixels outside it on the slice -angle, and run horizontally to a text column 12 pixels beyond the outermost -visible elbow on that side. The text starts 4 pixels after the column. Every -connector stays on the inner side of the column, so no connector crosses -another label's text. - -Labels form one vertical stack on each side of the center, in angular order. -Each label gets `fontSize * 1.4` pixels of height (`fontSize` defaults to -12). When labels overlap, the mark moves them apart as little as -possible, and keeps every label inside the polar container's chart bounds. -When a side has more labels than fit, the labels with the lowest `priority` -are hidden first. `priority` defaults to the numeric `value` field, so the -smallest `pie()` slices are hidden first. On equal priority, the later row is -hidden first. Set `maxShift` to also hide labels that would move more than -that many pixels from their slice; the mark hides the lowest-priority label -in the crowded group and lays out again. - -The mark measures each label with the chart's text measurer and keeps every -label inside the left and right chart edges. A label that does not fit even -beside its own elbow is hidden first. While the widest remaining label still -does not fit beside the column, the mark hides the lower-priority one of that -label and the label with the outermost elbow; on equal priority it hides the -outermost-elbow label, because that label sets the column. Long labels and -narrow charts therefore lose labels instead of overflowing; widen the chart, -lower `radiusRatio`, or shorten `text` to keep more labels. Layout runs on every -render, so a resized chart can show labels that a smaller size hid. - -The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, -`color`, fill, font size and weight, opacity, and connector `stroke`, -`strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each -visible label emits one interaction point at its final text position. Hidden -labels emit no nodes and no points, so pair the mark with a tooltip or legend -when every slice name must stay available. - -The mark emits only label and polyline scene nodes, so SVG, Canvas, and React -Native paint it the same way. Label keys come from `key`; each connector uses -the label key with a `:connector` suffix. With the motion renderer, kept labels -and connectors move to their new layout, and hidden or removed labels fade -out. - -```tsx -radialLabels(slices, { - text: (row) => `${row.name} ${row.share}%`, - key: 'name', - color: 'name', - fontSize: 12, - maxShift: 24, -}) -``` +`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` diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 4fcdc08f..d6d251e4 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -379,59 +379,43 @@ function radialLabels( ): PolarMark ``` -`radialLabels` draws outside labels with connectors for pie and donut slices, -and keeps crowded labels readable. `angle` defaults to the numeric `angle` -field of each row, so `pie()` output needs no angle channel. `radius` defaults -to `1`, the outer edge of a `[0, 1]` radius scale. Connectors start at the -largest mapped radius, bend at an elbow 16 pixels outside it on the slice -angle, and run horizontally to a text column 12 pixels beyond the outermost -visible elbow on that side. The text starts 4 pixels after the column. Every -connector stays on the inner side of the column, so no connector crosses -another label's text. - -Labels form one vertical stack on each side of the center, in angular order. -Each label gets `fontSize * 1.4` pixels of height (`fontSize` defaults to -12). When labels overlap, the mark moves them apart as little as -possible, and keeps every label inside the polar container's chart bounds. -When a side has more labels than fit, the labels with the lowest `priority` -are hidden first. `priority` defaults to the numeric `value` field, so the -smallest `pie()` slices are hidden first. On equal priority, the later row is -hidden first. Set `maxShift` to also hide labels that would move more than -that many pixels from their slice; the mark hides the lowest-priority label -in the crowded group and lays out again. - -The mark measures each label with the chart's text measurer and keeps every -label inside the left and right chart edges. A label that does not fit even -beside its own elbow is hidden first. While the widest remaining label still -does not fit beside the column, the mark hides the lower-priority one of that -label and the label with the outermost elbow; on equal priority it hides the -outermost-elbow label, because that label sets the column. Long labels and -narrow charts therefore lose labels instead of overflowing; widen the chart, -lower `radiusRatio`, or shorten `text` to keep more labels. Layout runs on every -render, so a resized chart can show labels that a smaller size hid. - -The mark also accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, -`color`, fill, font size and weight, opacity, and connector `stroke`, -`strokeOpacity`, and `strokeWidth`. `stroke` defaults to the label fill. Each -visible label emits one interaction point at its final text position. Hidden -labels emit no nodes and no points, so pair the mark with a tooltip or legend -when every slice name must stay available. - -The mark emits only label and polyline scene nodes, so SVG, Canvas, and React -Native paint it the same way. Label keys come from `key`; each connector uses -the label key with a `:connector` suffix. With the motion renderer, kept labels -and connectors move to their new layout, and hidden or removed labels fade -out. - -```tsx -radialLabels(slices, { - text: (row) => `${row.name} ${row.share}%`, - key: 'name', - color: 'name', - fontSize: 12, - maxShift: 24, -}) -``` +`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` diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index 4735026b..f747f5c5 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -1836,7 +1836,7 @@ export interface RadialLabelsOptions extends RadialPathOptions { text?: Channel /** * Labels with lower priority hide first when labels do not fit. Defaults to - * the numeric `value` field, so `pie()` rows keep their largest slices. + * datum `value`, so `pie()` rows keep their largest slices. */ priority?: Channel fill?: VisualChannel @@ -1916,7 +1916,7 @@ export function radialLabels( typeof options.angle === 'number' ? data.map(() => options.angle as number) : channelValues(data, options.angle, (datum) => - numericField(datum, 'angle'), + numberProperty(datum, 'angle'), ) const radiusValues = typeof options.radius === 'number' @@ -1926,7 +1926,7 @@ export function radialLabels( datum == null ? '' : String(datum), ) const priorityValues = channelValues(data, options.priority, (datum) => - numericField(datum, 'value'), + numberProperty(datum, 'value'), ) const groups = channelValues(data, options.z, () => null) const colorValues = @@ -2115,12 +2115,6 @@ export function radialLabels( ) } -function numericField(datum: unknown, field: string): number | undefined { - if (datum == null || typeof datum !== 'object') return undefined - const value = (datum as Record)[field] - return typeof value === 'number' ? value : undefined -} - export interface RadialRuleOptions< TDatum, > extends ChartMarkMotionOptions { From dfddb479fa8efdf7337ea649cc34625d784352fc Mon Sep 17 00:00:00 2001 From: hshoja Date: Fri, 16 Oct 2026 15:33:02 +0200 Subject: [PATCH 10/10] refactor(polar): move radialLabels to its own module Charts that do not use radialLabels should keep their exact polar bundle. Passing the chart text measurer through the polar container and adding the mark to polar.ts both changed every polar bundle. The mark now lives in polar-labels.ts and uses the built-in text estimate, polar.ts only re-exports it, and the shared option types move to a types-only module. --- docs/reference/marks/polar.md | 7 +- .../charts-core/docs/reference/marks/polar.md | 7 +- packages/charts-core/src/polar-labels.ts | 323 +++++++++++++++++ .../charts-core/src/polar-mark-internal.ts | 3 - .../charts-core/src/polar-types-internal.ts | 35 ++ packages/charts-core/src/polar.ts | 331 +----------------- scripts/public-callback-contract.mjs | 8 +- 7 files changed, 376 insertions(+), 338 deletions(-) create mode 100644 packages/charts-core/src/polar-labels.ts create mode 100644 packages/charts-core/src/polar-types-internal.ts diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index d6d251e4..869ea8ab 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -408,9 +408,10 @@ 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. +first because it sets the column. Text widths come from the built-in text +estimate used by static SVG, so unusual fonts can differ by a few pixels. +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 diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index d6d251e4..869ea8ab 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -408,9 +408,10 @@ 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. +first because it sets the column. Text widths come from the built-in text +estimate used by static SVG, so unusual fonts can differ by a few pixels. +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 diff --git a/packages/charts-core/src/polar-labels.ts b/packages/charts-core/src/polar-labels.ts new file mode 100644 index 00000000..8de28094 --- /dev/null +++ b/packages/charts-core/src/polar-labels.ts @@ -0,0 +1,323 @@ +import { + channelValues, + inferredKeyValues, + isChartKey, + isChartValue, + isFiniteNumber, + markStates, + visualValue, +} from './mark' +import { + estimateSceneText, + measureSceneLabelBounds, + physicalTextAnchor, +} from './guide-layout' +import { withPolarFocusGeometry } from './polar-focus-internal' +import { layoutRadialLabels } from './polar-label-layout-internal' +import { createPolarMark } from './polar-mark-internal' +import { valueKey } from './scales' +import { toArray } from './transform-internal' +import type { PolarMark } from './polar' +import type { + InitializedPolarMark, + PolarResolvedScale, +} from './polar-mark-internal' +import type { + InferredPolarChannelOutput, + InferredPolarScaleId, + RadialPathOptions, +} from './polar-types-internal' +import type { + Channel, + ChartPoint, + ChartValue, + SceneNode, + VisualChannel, +} from './types' + +// Connector and text spacing in pixels. +const radialLabelSpacing = { offset: 16, elbow: 12, padding: 4 } + +export interface RadialLabelsOptions extends RadialPathOptions { + text?: Channel + /** + * Labels with lower priority hide first when labels do not fit. Defaults to + * datum `value`, so `pie()` rows keep their largest slices. + */ + priority?: Channel + fill?: VisualChannel + fontSize?: number + fontWeight?: number + opacity?: number + /** Hides a label that would move more pixels than this from its slice. */ + maxShift?: number + /** Connector color. Defaults to the label fill. */ + stroke?: VisualChannel + strokeOpacity?: number + strokeWidth?: number +} + +type RadialLabelsCallOptions< + TDatum, + TAngle extends RadialLabelsOptions['angle'], + TRadius extends RadialLabelsOptions['radius'], + TAngleScaleId extends string | undefined, + TRadiusScaleId extends string | undefined, +> = Omit< + RadialLabelsOptions, + 'angle' | 'radius' | 'angleScale' | 'radiusScale' +> & { + angle?: TAngle | NoInfer['angle']> + radius?: TRadius | NoInfer['radius']> + angleScale?: TAngleScaleId | NoInfer + radiusScale?: TRadiusScaleId | NoInfer +} + +/** + * Outside labels with connectors. Crowded labels are pushed apart vertically, + * and the lowest-priority labels are hidden when they still do not fit. + */ +export function radialLabels( + source: Iterable, +): PolarMark +export function radialLabels< + TDatum, + const TAngle extends RadialLabelsOptions>['angle'] = never, + const TRadius extends RadialLabelsOptions>['radius'] = never, + const TAngleScaleId extends string | undefined = undefined, + const TRadiusScaleId extends string | undefined = undefined, +>( + source: Iterable, + options: + | RadialLabelsCallOptions< + NoInfer, + TAngle, + TRadius, + TAngleScaleId, + TRadiusScaleId + > + | undefined, +): PolarMark< + TDatum, + InferredPolarChannelOutput, + InferredPolarChannelOutput, + InferredPolarScaleId, + InferredPolarScaleId +> +export function radialLabels( + source: Iterable, + options: RadialLabelsOptions> = {}, +): PolarMark { + const data = toArray(source) + const fontSize = options.fontSize ?? 12 + if (!isFiniteNumber(fontSize) || fontSize <= 0) { + throw new TypeError('radialLabels: fontSize must be positive and finite') + } + const mark = createPolarMark( + ({ markIndex, parentId, states: defaultStates }) => { + const id = options.id ?? `${parentId}:radial-labels-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (datum) => + numberProperty(datum, 'angle'), + ) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, () => 1) + const textValues = channelValues(data, options.text, (datum) => + datum == null ? '' : String(datum), + ) + const priorityValues = channelValues(data, options.priority, (datum) => + numberProperty(datum, 'value'), + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups }) + + // Scale IDs let the polar container validate them before render. + const initialized: InitializedPolarMark< + TDatum, + ChartValue, + ChartValue + > & { + angleScale: string + radiusScale: string + } = { + id, + states: markStates(data, defaultStates), + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor, theme }) => { + const angle = layout.scales[angleScale]! + const radius = layout.scales[radiusScale]! + const candidates = data.flatMap((_datum, datumIndex) => { + const anglePosition = position(angle, angleValues[datumIndex]) + const radiusPosition = position(radius, radiusValues[datumIndex]) + const text = textValues[datumIndex] + if ( + text == null || + text === '' || + !isFiniteNumber(anglePosition) || + !isFiniteNumber(radiusPosition) + ) { + return [] + } + const priority = priorityValues[datumIndex] + return [ + { + datumIndex, + angle: anglePosition, + radius: radiusPosition, + priority: isFiniteNumber(priority) ? priority : 0, + width: measureSceneLabelBounds( + { + kind: 'label', + key: '', + x: 0, + y: 0, + text: String(text), + anchor: 'start', + baseline: 'middle', + fontSize, + fontWeight: options.fontWeight, + }, + estimateSceneText, + ).width, + }, + ] + }) + const placements = layoutRadialLabels(candidates, { + radius: Math.max(0, ...candidates.map(({ radius }) => radius)), + ...radialLabelSpacing, + lineHeight: fontSize * 1.4, + top: layout.chart.y - layout.centerY, + bottom: layout.chart.y + layout.chart.height - layout.centerY, + left: layout.chart.x - layout.centerX, + right: layout.chart.x + layout.chart.width - layout.centerX, + maxShift: options.maxShift ?? Infinity, + }) + const connectors: SceneNode[] = [] + const labels: SceneNode[] = [] + const points: ChartPoint[] = [] + candidates.forEach((candidate, candidateIndex) => { + const placement = placements[candidateIndex] + if (!placement?.visible) return + const { datumIndex } = candidate + const datum = data[datumIndex] as TDatum + const group = groups[datumIndex] ?? null + const colorValue = colorValues[datumIndex] ?? null + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + colorValue == null ? theme.foreground : resolveColor(colorValue), + ) + const stroke = visualValue( + options.stroke, + datum, + datumIndex, + data, + fill, + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const [start, ...rest] = placement.connector + connectors.push({ + kind: 'polyline', + key: `${key}:connector`, + points: [], + path: `M${start!.join(',')}${rest.map((point) => `L${point.join(',')}`).join('')}`, + style: { + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1, + opacity: options.opacity, + lineCap: 'round', + lineJoin: 'round', + }, + }) + labels.push({ + kind: 'label', + key, + x: placement.x, + y: placement.y, + text: String(textValues[datumIndex]), + anchor: physicalTextAnchor( + placement.side === 'left' ? 'right' : 'left', + layout.direction, + ), + baseline: 'middle', + fontSize, + fontWeight: options.fontWeight, + style: { fill, opacity: options.opacity }, + }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValues[datumIndex] as ChartValue, + yValue: radiusValues[datumIndex] as ChartValue, + x: layout.centerX + placement.x, + y: layout.centerY + placement.y, + color: fill, + }, + layout, + candidate.angle, + candidate.radius, + placement.x, + placement.y, + ), + ) + }) + return { + nodes: [ + { + kind: 'group', + key: id, + className: options.className + ? `ts-chart__radial-labels ts-chart__text ${options.className}` + : 'ts-chart__radial-labels ts-chart__text', + ariaHidden: true, + children: [...connectors, ...labels], + }, + ], + points, + } + }, + } + return initialized + }, + options.motion, + options.renderer, + ) + return mark as unknown as PolarMark +} + +function position(scale: PolarResolvedScale, value: unknown): number { + return isChartValue(value) ? scale.map(value) : Number.NaN +} + +function numberProperty(value: unknown, key: string): number | undefined { + if (!value || typeof value !== 'object') return undefined + const property = (value as Record)[key] + return isFiniteNumber(property) ? property : undefined +} diff --git a/packages/charts-core/src/polar-mark-internal.ts b/packages/charts-core/src/polar-mark-internal.ts index 1cf94176..1507d7db 100644 --- a/packages/charts-core/src/polar-mark-internal.ts +++ b/packages/charts-core/src/polar-mark-internal.ts @@ -6,7 +6,6 @@ import type { ChartMarkState, InitializedMark, ChartTheme, - ChartTextMeasurer, ChartTextTypography, ChartValue, MarkScene, @@ -63,8 +62,6 @@ export interface PolarMarkRenderContext { layout: PolarLayoutContext color: (value: ChartKey | null | undefined) => string theme: ChartTheme - /** Typography-aware text measurer from the chart layout. */ - measureText?: ChartTextMeasurer } export interface PolarMark< diff --git a/packages/charts-core/src/polar-types-internal.ts b/packages/charts-core/src/polar-types-internal.ts new file mode 100644 index 00000000..00ff576d --- /dev/null +++ b/packages/charts-core/src/polar-types-internal.ts @@ -0,0 +1,35 @@ +import type { + Channel, + ChannelOutput, + ChartKey, + ChartMarkMotionOptions, + ChartValue, +} from './types' + +export type InferredPolarChannelOutput = [TChannel] extends [ + never, +] + ? number + : ChannelOutput + +export type InferredPolarScaleId = [ + NonNullable, +] extends [never] + ? TFallback + : Extract, string> + +export interface RadialPathOptions< + TDatum, +> extends ChartMarkMotionOptions { + id?: string + className?: string + /** Named angle scale. Omit to use the reserved `angle` scale. */ + angleScale?: string + /** Named radius scale. Omit to use the reserved `radius` scale. */ + radiusScale?: string + angle?: number | Channel + radius?: number | Channel + key?: Channel + z?: Channel + color?: Channel +} diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index f747f5c5..0bf46f14 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -19,23 +19,22 @@ import { resolveCompositeChildMotion } from './composite-motion-internal' import { createMarkWithScaleValues } from './mark-with-scale-values' import { createPolarMark as createInternalPolarMark } from './polar-mark-internal' import { focusGroupAngle, withPolarFocusGeometry } from './polar-focus-internal' -import { layoutRadialLabels } from './polar-label-layout-internal' +import type { + InferredPolarChannelOutput, + InferredPolarScaleId, + RadialPathOptions, +} from './polar-types-internal' import { resolvePolarSector, tracePolarArcBoundary, } from './polar-sector-internal' import { resolveNumericScale, resolveScaleInput } from './scale-input' import { valueKey } from './scales' -import { - estimateSceneText, - measureSceneLabelBounds, - physicalTextAnchor, -} from './guide-layout' +import { physicalTextAnchor } from './guide-layout' import type { Arc, CurveFactory, CurveFactoryLineOnly } from 'd3-shape' import type { Channel, ChannelAccessor, - ChannelOutput, ChartAxisValue, ChartBounds, ChartKey, @@ -69,6 +68,7 @@ import type { export { pie } from './polar-pie' export { focusGroupAngle } export type { PieDatum, PieOptions } from './polar-pie' +export { radialLabels, type RadialLabelsOptions } from './polar-labels' const tau = Math.PI * 2 @@ -337,12 +337,7 @@ export function polar( } } for (const mark of marks) { - const rendered = mark.render({ - layout, - color, - theme, - measureText: chartLayout.measureText, - }) + const rendered = mark.render({ layout, color, theme }) if (mark.states) { nodes.push({ kind: 'group', @@ -595,16 +590,6 @@ interface RadialBarBaseOptions extends ChartMarkMotionOptions { opacity?: number } -type InferredPolarChannelOutput = [TChannel] extends [never] - ? number - : ChannelOutput - -type InferredPolarScaleId = [ - NonNullable, -] extends [never] - ? TFallback - : Extract, string> - export interface RadialBarRadiusOptions< TDatum, > extends RadialBarBaseOptions { @@ -1103,20 +1088,6 @@ export function radialBarAngle( ) } -interface RadialPathOptions extends ChartMarkMotionOptions { - id?: string - className?: string - /** Named angle scale. Omit to use the reserved `angle` scale. */ - angleScale?: string - /** Named radius scale. Omit to use the reserved `radius` scale. */ - radiusScale?: string - angle?: number | Channel - radius?: number | Channel - key?: Channel - z?: Channel - color?: Channel -} - export interface RadialLineOptions extends RadialPathOptions { states?: readonly ChartMarkState>[] curve?: CurveFactory | CurveFactoryLineOnly @@ -1829,292 +1800,6 @@ export function radialText( ) } -// Connector and text spacing in pixels. -const radialLabelSpacing = { offset: 16, elbow: 12, padding: 4 } - -export interface RadialLabelsOptions extends RadialPathOptions { - text?: Channel - /** - * Labels with lower priority hide first when labels do not fit. Defaults to - * datum `value`, so `pie()` rows keep their largest slices. - */ - priority?: Channel - fill?: VisualChannel - fontSize?: number - fontWeight?: number - opacity?: number - /** Hides a label that would move more pixels than this from its slice. */ - maxShift?: number - /** Connector color. Defaults to the label fill. */ - stroke?: VisualChannel - strokeOpacity?: number - strokeWidth?: number -} - -type RadialLabelsCallOptions< - TDatum, - TAngle extends RadialLabelsOptions['angle'], - TRadius extends RadialLabelsOptions['radius'], - TAngleScaleId extends string | undefined, - TRadiusScaleId extends string | undefined, -> = Omit< - RadialLabelsOptions, - 'angle' | 'radius' | 'angleScale' | 'radiusScale' -> & { - angle?: TAngle | NoInfer['angle']> - radius?: TRadius | NoInfer['radius']> - angleScale?: TAngleScaleId | NoInfer - radiusScale?: TRadiusScaleId | NoInfer -} - -/** - * Outside labels with connectors. Crowded labels are pushed apart vertically, - * and the lowest-priority labels are hidden when they still do not fit. - */ -export function radialLabels( - source: Iterable, -): PolarMark -export function radialLabels< - TDatum, - const TAngle extends RadialLabelsOptions>['angle'] = never, - const TRadius extends RadialLabelsOptions>['radius'] = never, - const TAngleScaleId extends string | undefined = undefined, - const TRadiusScaleId extends string | undefined = undefined, ->( - source: Iterable, - options: - | RadialLabelsCallOptions< - NoInfer, - TAngle, - TRadius, - TAngleScaleId, - TRadiusScaleId - > - | undefined, -): PolarMark< - TDatum, - InferredPolarChannelOutput, - InferredPolarChannelOutput, - InferredPolarScaleId, - InferredPolarScaleId -> -export function radialLabels( - source: Iterable, - options: RadialLabelsOptions> = {}, -): PolarMark { - const data = asArray(source) - const fontSize = options.fontSize ?? 12 - if (!isFiniteNumber(fontSize) || fontSize <= 0) { - throw new TypeError('radialLabels: fontSize must be positive and finite') - } - return createPolarMark( - ({ markIndex, parentId, states: defaultStates }) => { - const id = options.id ?? `${parentId}:radial-labels-${markIndex}` - const angleScale = options.angleScale ?? 'angle' - const radiusScale = options.radiusScale ?? 'radius' - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (datum) => - numberProperty(datum, 'angle'), - ) - const radiusValues = - typeof options.radius === 'number' - ? data.map(() => options.radius as number) - : channelValues(data, options.radius, () => 1) - const textValues = channelValues(data, options.text, (datum) => - datum == null ? '' : String(datum), - ) - const priorityValues = channelValues(data, options.priority, (datum) => - numberProperty(datum, 'value'), - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups }) - - return { - id, - states: markStates(data, defaultStates), - angleScale, - radiusScale, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: radiusValues.filter(isChartValue), - includeZeroRadius: false, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ - layout, - color: resolveColor, - theme, - measureText = estimateSceneText, - }) => { - const angle = requiredPolarScale( - layout, - angleScale, - 'angle', - `Polar mark "${id}"`, - ) - const radius = requiredPolarScale( - layout, - radiusScale, - 'radius', - `Polar mark "${id}"`, - ) - const candidates = data.flatMap((_datum, datumIndex) => { - const anglePosition = mapPolarScale(angle, angleValues[datumIndex]) - const radiusPosition = mapPolarScale( - radius, - radiusValues[datumIndex], - ) - const text = textValues[datumIndex] - if ( - text == null || - text === '' || - !isFiniteNumber(anglePosition) || - !isFiniteNumber(radiusPosition) - ) { - return [] - } - const priority = priorityValues[datumIndex] - return [ - { - datumIndex, - angle: anglePosition, - radius: radiusPosition, - priority: isFiniteNumber(priority) ? priority : 0, - width: measureSceneLabelBounds( - { - kind: 'label', - key: '', - x: 0, - y: 0, - text: String(text), - anchor: 'start', - baseline: 'middle', - fontSize, - fontWeight: options.fontWeight, - }, - measureText, - ).width, - }, - ] - }) - const placements = layoutRadialLabels(candidates, { - radius: Math.max(0, ...candidates.map(({ radius }) => radius)), - ...radialLabelSpacing, - lineHeight: fontSize * 1.4, - top: layout.chart.y - layout.centerY, - bottom: layout.chart.y + layout.chart.height - layout.centerY, - left: layout.chart.x - layout.centerX, - right: layout.chart.x + layout.chart.width - layout.centerX, - maxShift: options.maxShift ?? Infinity, - }) - const connectors: SceneNode[] = [] - const labels: SceneNode[] = [] - const points: ChartPoint[] = [] - candidates.forEach((candidate, candidateIndex) => { - const placement = placements[candidateIndex] - if (!placement?.visible) return - const { datumIndex } = candidate - const datum = data[datumIndex] as TDatum - const group = groups[datumIndex] ?? null - const colorValue = colorValues[datumIndex] ?? null - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - colorValue == null ? theme.foreground : resolveColor(colorValue), - ) - const stroke = visualValue( - options.stroke, - datum, - datumIndex, - data, - fill, - ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const [start, ...rest] = placement.connector - connectors.push({ - kind: 'polyline', - key: `${key}:connector`, - points: [], - path: `M${start!.join(',')}${rest.map((point) => `L${point.join(',')}`).join('')}`, - style: { - fill: 'none', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1, - opacity: options.opacity, - lineCap: 'round', - lineJoin: 'round', - }, - }) - labels.push({ - kind: 'label', - key, - x: placement.x, - y: placement.y, - text: String(textValues[datumIndex]), - anchor: physicalTextAnchor( - placement.side === 'left' ? 'right' : 'left', - layout.direction, - ), - baseline: 'middle', - fontSize, - fontWeight: options.fontWeight, - style: { fill, opacity: options.opacity }, - }) - points.push( - withPolarFocusGeometry( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValues[datumIndex] as ChartValue, - yValue: radiusValues[datumIndex] as ChartValue, - x: layout.centerX + placement.x, - y: layout.centerY + placement.y, - color: fill, - }, - layout, - candidate.angle, - candidate.radius, - placement.x, - placement.y, - ), - ) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__radial-labels ts-chart__text', - options.className, - ), - ariaHidden: true, - children: [...connectors, ...labels], - }, - ], - points, - } - }, - } - }, - options.motion, - options.renderer, - ) -} - export interface RadialRuleOptions< TDatum, > extends ChartMarkMotionOptions { diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index df5f165f..69680a5c 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -224,10 +224,6 @@ const callbackInventory = { '@tanstack/charts:src/polar-mark-internal.ts:InitializedPolarMark', 'motion', ], - [ - '@tanstack/charts:src/polar-mark-internal.ts:PolarMarkRenderContext', - 'measureText', - ], ['@tanstack/charts:src/polar-pie.ts:PieOptions', 'value'], [ '@tanstack/charts:src/polar.ts:RadialArcOptions', @@ -253,11 +249,11 @@ const callbackInventory = { 'angle radius radius1 radius2', ], [ - '@tanstack/charts:src/polar.ts:RadialLabelsOptions', + '@tanstack/charts:src/polar-labels.ts:RadialLabelsOptions', 'fill priority stroke text', ], [ - '@tanstack/charts:src/polar.ts:RadialPathOptions', + '@tanstack/charts:src/polar-types-internal.ts:RadialPathOptions', 'angle color key radius z', ], [