diff --git a/.changeset/crowded-pie-labels.md b/.changeset/crowded-pie-labels.md new file mode 100644 index 00000000..67ac7061 --- /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. 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 09c20aac..4bacdab7 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,17 @@ Each entry records: direction changes. A focus dot appears during entrance while the data path and its running animation remain intact. Concurrent inline states remain a separate feature; this addresses the documented alternatives requested. + +### F-315 - Crowded pie labels overlapped + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: issue #227, `radialRule` plus `radialText` labels of small + adjacent slices overlapped, so the application owned its own label layout +- Decision: add `radialLabels`, which stacks labels in one column per side, + moves overlapping labels apart, and hides lower-priority labels that do not + fit the chart height, width, or optional `maxShift`. +- Verification: layout and rendered tests cover spacing, bounds, priority + ties, connectors clear of other labels, stable keys, motion, and SVG, + Canvas, and React Native output. diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 4ac35fda..869ea8ab 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,54 @@ 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` 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. 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 +key. With the motion renderer, kept labels and connectors move to their new +layout, and hidden labels fade out. + ## `radialRule` ```ts diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 4ac35fda..869ea8ab 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,54 @@ 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` 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. 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 +key. With the motion renderer, kept labels and connectors move to their new +layout, and hidden labels fade out. + ## `radialRule` ```ts 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/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[] = [] 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..28a41367 --- /dev/null +++ b/packages/charts-core/src/polar-label-layout-internal.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' +import { layoutRadialLabels } from './polar-label-layout-internal' +import type { + RadialLabelInput, + RadialLabelLayoutOptions, + RadialLabelPlacement, +} from './polar-label-layout-internal' + +const options: RadialLabelLayoutOptions = { + radius: 100, + offset: 20, + elbow: 10, + padding: 4, + 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 }] : [], + ) +} + +describe('layoutRadialLabels', () => { + it('keeps separated labels at their ideal height', () => { + const placements = layoutRadialLabels([label(60), label(240)], 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] + const placements = visible( + layoutRadialLabels( + angles.map((angle) => label(angle)), + 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(degrees(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( + [ + 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 }, + ) + + expect(placements.map((placement) => placement.visible)).toEqual([ + true, + false, + true, + false, + true, + ]) + }) + + 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] + const placements = layoutRadialLabels( + 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(degrees(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((angle) => label(angle)), + { ...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('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 + 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([label(270)], options) + + expect(placement).toMatchObject({ + visible: true, + side: 'left', + 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) + }) + + 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 new file mode 100644 index 00000000..c488b851 --- /dev/null +++ b/packages/charts-core/src/polar-label-layout-internal.ts @@ -0,0 +1,255 @@ +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. On equal + * priority, the later input label is hidden first. + */ + readonly priority: number + /** Measured text width in pixels. */ + readonly width: 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 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 +} + +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. Labels whose text would + * cross the left or right edge are hidden first. + */ +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 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 + for (const [index, y] of positions) { + const angle = labels[index]!.angle + placements[index] = { + visible: true, + x: direction * (column + options.padding), + y, + side, + connector: [ + point(angle, options.radius), + point(angle, elbowRadius), + [direction * column, y], + ], + } + } + } + + 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, + labels: readonly RadialLabelInput[], + options: RadialLabelLayoutOptions, +): Map { + const capacity = Math.max( + 0, + Math.floor((options.bottom - options.top) / options.lineHeight), + ) + let kept = + ordered.length > capacity + ? withHighestPriority(ordered, capacity, labels) + : ordered + + 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 } +} + +/** 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[], + labels: readonly RadialLabelInput[], +): number[] { + 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) +} + +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-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-radial-labels.test.ts b/packages/charts-core/src/polar-radial-labels.test.ts new file mode 100644 index 00000000..07efb587 --- /dev/null +++ b/packages/charts-core/src/polar-radial-labels.test.ts @@ -0,0 +1,284 @@ +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 { renderChartSvg } from './svg' +import type { SceneLabel, SceneNode, ScenePolyline } from './types' + +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 }, + { 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 }, +] + +// Default 12px font with 1.4 line spacing. +const lineHeight = 16.8 + +function render( + size: { width: number; height: number }, + options: { + maxShift?: number + rows?: readonly Row[] + } = {}, +) { + const { rows = browsers, ...labelOptions } = options + const slices = pie(rows, { 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', + ...labelOptions, + }), + ], + }), + ], + scales: { x: null, y: null }, + margin: 0, + }) + 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 => + 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('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(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) + }) + }) + + 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 }) + 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', () => { + 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 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], + ) +} 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 ac7c836e..0bf46f14 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -19,6 +19,11 @@ 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 type { + InferredPolarChannelOutput, + InferredPolarScaleId, + RadialPathOptions, +} from './polar-types-internal' import { resolvePolarSector, tracePolarArcBoundary, @@ -30,7 +35,6 @@ import type { Arc, CurveFactory, CurveFactoryLineOnly } from 'd3-shape' import type { Channel, ChannelAccessor, - ChannelOutput, ChartAxisValue, ChartBounds, ChartKey, @@ -64,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 @@ -585,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 { @@ -1093,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 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[] = [ { diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 048a75fa..69680a5c 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -249,7 +249,11 @@ const callbackInventory = { 'angle radius radius1 radius2', ], [ - '@tanstack/charts:src/polar.ts:RadialPathOptions', + '@tanstack/charts:src/polar-labels.ts:RadialLabelsOptions', + 'fill priority stroke text', + ], + [ + '@tanstack/charts:src/polar-types-internal.ts:RadialPathOptions', 'angle color key radius z', ], [