@voila.dev/ui-chart 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +57 -0
  2. package/src/components/chart-area.tsx +124 -0
  3. package/src/components/chart-bars.tsx +105 -0
  4. package/src/components/chart-cursor.tsx +105 -0
  5. package/src/components/chart-data-table.tsx +55 -0
  6. package/src/components/chart-empty.tsx +22 -0
  7. package/src/components/chart-grid.tsx +81 -0
  8. package/src/components/chart-label-list.tsx +178 -0
  9. package/src/components/chart-legend.tsx +106 -0
  10. package/src/components/chart-line.tsx +100 -0
  11. package/src/components/chart-pie.tsx +130 -0
  12. package/src/components/chart-points.tsx +94 -0
  13. package/src/components/chart-polar-angle-axis.tsx +76 -0
  14. package/src/components/chart-polar-grid.tsx +64 -0
  15. package/src/components/chart-radar.tsx +103 -0
  16. package/src/components/chart-radial-bar.tsx +122 -0
  17. package/src/components/chart-reference-line.tsx +60 -0
  18. package/src/components/chart-root.tsx +188 -0
  19. package/src/components/chart-skeleton.tsx +45 -0
  20. package/src/components/chart-slice.tsx +59 -0
  21. package/src/components/chart-style.tsx +78 -0
  22. package/src/components/chart-tooltip.tsx +205 -0
  23. package/src/components/chart-x-axis.tsx +94 -0
  24. package/src/components/chart-y-axis.tsx +90 -0
  25. package/src/components/chart.tsx +146 -0
  26. package/src/context/chart-context.tsx +70 -0
  27. package/src/core/axis.ts +53 -0
  28. package/src/core/bars.ts +160 -0
  29. package/src/core/chart-model.ts +156 -0
  30. package/src/core/config.ts +68 -0
  31. package/src/core/format.ts +86 -0
  32. package/src/core/geometry.ts +271 -0
  33. package/src/core/polar.ts +138 -0
  34. package/src/core/scales.ts +167 -0
  35. package/src/core/series.ts +70 -0
  36. package/src/core/ticks.ts +118 -0
  37. package/src/core/types.ts +71 -0
  38. package/src/hooks/use-chart-dimensions.ts +50 -0
  39. package/src/hooks/use-chart-pointer.ts +152 -0
  40. package/src/styles.css +10 -0
@@ -0,0 +1,178 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ import { useChartContext } from "#/context/chart-context.tsx";
5
+ import { type BarRect, barRects } from "#/core/bars.ts";
6
+ import { readNumber } from "#/core/chart-model.ts";
7
+ import { formatTickValue } from "#/core/format.ts";
8
+ import { seriesPoints } from "#/core/series.ts";
9
+ import type {
10
+ ChartDatum,
11
+ ChartDiscreteScale,
12
+ ChartLinearScale,
13
+ ChartOrientation,
14
+ ChartPoint,
15
+ } from "#/core/types.ts";
16
+
17
+ /**
18
+ * The value written beside each point of a series. Small charts read better
19
+ * with the numbers on them than with an axis the reader has to trace back to.
20
+ */
21
+
22
+ export interface ChartLabelListProps extends React.ComponentProps<"g"> {
23
+ /** Series to label. Defaults to the first of the root's value keys. */
24
+ readonly seriesKey?: string;
25
+ /**
26
+ * Which mark the labels belong to, because the two anchor differently.
27
+ *
28
+ * `points` (the default) puts each label on the series point — right for
29
+ * `Chart.Line`, `Chart.Area` and `Chart.Points`.
30
+ *
31
+ * `bars` puts it at the end of that series' OWN bar. A grouped bar chart
32
+ * splits its category slot into one lane per series, while the series point
33
+ * sits at the centre of the whole slot — so the label lands over the middle
34
+ * of the group rather than over the bar it describes. With a single series
35
+ * the two coincide, which is why this only appears once a second key is
36
+ * added. Stacked bars have the same problem for a different reason: every
37
+ * segment but the first starts off a running total, not the baseline.
38
+ *
39
+ * Pass `bars` beside any `Chart.Bars`, mirroring the `stacked` and `gap` you
40
+ * gave it so the lanes line up.
41
+ */
42
+ readonly marks?: "points" | "bars";
43
+ /** Mirrors `Chart.Bars`; only read when `marks` is `bars`. */
44
+ readonly stacked?: boolean;
45
+ /** Mirrors `Chart.Bars`; only read when `marks` is `bars`. */
46
+ readonly gap?: number;
47
+ /** Distance from the mark, along the value axis. */
48
+ readonly offset?: number;
49
+ readonly formatter?: (value: number) => string;
50
+ }
51
+
52
+ /**
53
+ * The end of a bar that points away from the baseline — where its label goes —
54
+ * centred across the lane the bar occupies.
55
+ */
56
+ function barAnchor(rect: BarRect, orientation: ChartOrientation): ChartPoint {
57
+ if (orientation === "vertical") {
58
+ return {
59
+ x: rect.x + rect.width / 2,
60
+ y: rect.value < 0 ? rect.y + rect.height : rect.y,
61
+ };
62
+ }
63
+ return {
64
+ x: rect.value < 0 ? rect.x : rect.x + rect.width,
65
+ y: rect.y + rect.height / 2,
66
+ };
67
+ }
68
+
69
+ interface AnchorOptions {
70
+ readonly marks: "points" | "bars";
71
+ readonly data: ReadonlyArray<ChartDatum>;
72
+ readonly keys: ReadonlyArray<string>;
73
+ readonly key: string;
74
+ readonly categories: ReadonlyArray<string>;
75
+ readonly categoryScale: ChartDiscreteScale;
76
+ readonly valueScale: ChartLinearScale;
77
+ readonly orientation: ChartOrientation;
78
+ readonly stacked: boolean;
79
+ readonly gap: number | undefined;
80
+ }
81
+
82
+ /** One anchor per datum, in datum order, for whichever mark is being labelled. */
83
+ function anchorsFor(options: AnchorOptions): ReadonlyArray<ChartPoint> {
84
+ if (options.marks === "points") {
85
+ return seriesPoints(options);
86
+ }
87
+
88
+ // Laid out with the FULL key set, not just the labelled one: which lane a bar
89
+ // occupies depends on how many series share its slot.
90
+ const rects = barRects(options);
91
+ const byIndex = new Map(
92
+ rects
93
+ .filter((rect) => rect.key === options.key)
94
+ .map((rect) => [rect.index, rect] as const),
95
+ );
96
+ return options.data.map((_, index) => {
97
+ const rect = byIndex.get(index);
98
+ // Unreachable for a key that is in `keys`, which it always is; the origin
99
+ // is a harmless fallback rather than a crash mid-render.
100
+ return rect === undefined
101
+ ? { x: 0, y: 0 }
102
+ : barAnchor(rect, options.orientation);
103
+ });
104
+ }
105
+
106
+ function ChartLabelList({
107
+ className,
108
+ seriesKey,
109
+ marks = "points",
110
+ stacked,
111
+ gap,
112
+ offset = 8,
113
+ formatter = formatTickValue,
114
+ ...props
115
+ }: ChartLabelListProps) {
116
+ const {
117
+ data,
118
+ categories,
119
+ categoryScale,
120
+ valueScale,
121
+ orientation,
122
+ valueKeys,
123
+ value,
124
+ } = useChartContext();
125
+
126
+ const key = seriesKey ?? valueKeys[0];
127
+ if (key === undefined) {
128
+ return null;
129
+ }
130
+
131
+ const isVertical = orientation === "vertical";
132
+ const anchors = anchorsFor({
133
+ marks,
134
+ data,
135
+ keys: valueKeys,
136
+ key,
137
+ categories,
138
+ categoryScale,
139
+ valueScale,
140
+ orientation,
141
+ stacked: stacked ?? value?.stacked ?? false,
142
+ gap,
143
+ });
144
+
145
+ return (
146
+ <g
147
+ data-slot="chart-label-list"
148
+ data-series={key}
149
+ className={cn("fill-foreground", className)}
150
+ {...props}
151
+ >
152
+ {anchors.map((point, index) => {
153
+ // Labels sit outside the mark, which means below it for a negative
154
+ // value and to its left on a horizontal chart.
155
+ const datumValue = readNumber(data[index] ?? {}, key);
156
+ const away = datumValue < 0 ? -offset : offset;
157
+ return (
158
+ <text
159
+ key={`${categories[index] ?? index}`}
160
+ data-slot="chart-label"
161
+ data-index={index}
162
+ x={isVertical ? point.x : point.x + away}
163
+ y={isVertical ? point.y - away : point.y}
164
+ dy={isVertical ? 0 : "0.32em"}
165
+ textAnchor={
166
+ isVertical ? "middle" : datumValue < 0 ? "end" : "start"
167
+ }
168
+ className="stroke-none text-[10px] tabular-nums"
169
+ >
170
+ {formatter(datumValue)}
171
+ </text>
172
+ );
173
+ })}
174
+ </g>
175
+ );
176
+ }
177
+
178
+ export { ChartLabelList };
@@ -0,0 +1,106 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ import { useChartContext } from "#/context/chart-context.tsx";
6
+ import { configKeyFor, seriesColor } from "#/core/config.ts";
7
+
8
+ /**
9
+ * The colour key. Portals into the root's overlay layer, so it composes inside
10
+ * `Chart.Root` like every other part while still being ordinary HTML — leave
11
+ * room for it in the root's `margin`.
12
+ */
13
+
14
+ export interface ChartLegendProps {
15
+ readonly content?: React.ReactNode;
16
+ readonly align?: "top" | "bottom";
17
+ readonly className?: string;
18
+ }
19
+
20
+ function ChartLegend({
21
+ content,
22
+ align = "bottom",
23
+ className,
24
+ }: ChartLegendProps) {
25
+ const { overlay } = useChartContext();
26
+ if (overlay === null) {
27
+ return null;
28
+ }
29
+
30
+ return createPortal(
31
+ <div
32
+ data-slot="chart-legend"
33
+ data-align={align}
34
+ className={cn(
35
+ "pointer-events-auto absolute inset-x-0",
36
+ align === "top" ? "top-0" : "bottom-0",
37
+ className,
38
+ )}
39
+ >
40
+ {content ?? <ChartLegendContent />}
41
+ </div>,
42
+ overlay,
43
+ );
44
+ }
45
+
46
+ export interface ChartLegendContentProps extends React.ComponentProps<"div"> {
47
+ readonly hideIcon?: boolean;
48
+ /**
49
+ * Field naming the config entry for each row. Pass it on charts coloured per
50
+ * row (a pie): without it the legend lists the series instead.
51
+ */
52
+ readonly nameKey?: string;
53
+ }
54
+
55
+ function ChartLegendContent({
56
+ className,
57
+ hideIcon = false,
58
+ nameKey,
59
+ ...props
60
+ }: ChartLegendContentProps) {
61
+ const { config, data, valueKeys } = useChartContext();
62
+
63
+ const entries =
64
+ nameKey === undefined
65
+ ? valueKeys.map((key, index) => ({ key, index }))
66
+ : data.map((datum, index) => ({
67
+ key: configKeyFor(datum, String(index), nameKey),
68
+ index,
69
+ }));
70
+
71
+ return (
72
+ <div
73
+ data-slot="chart-legend-content"
74
+ className={cn(
75
+ "flex flex-wrap items-center justify-center gap-x-4 gap-y-1",
76
+ className,
77
+ )}
78
+ {...props}
79
+ >
80
+ {entries.map(({ key, index }) => {
81
+ const Icon = config[key]?.icon;
82
+ return (
83
+ <div
84
+ key={`${key}-${index}`}
85
+ data-slot="chart-legend-item"
86
+ data-series={key}
87
+ className="flex items-center gap-1.5 text-muted-foreground text-xs"
88
+ >
89
+ {Icon !== undefined && !hideIcon ? (
90
+ <Icon />
91
+ ) : (
92
+ <span
93
+ data-slot="chart-legend-swatch"
94
+ className="h-2 w-2 shrink-0 rounded-[2px]"
95
+ style={{ backgroundColor: seriesColor(config, key, index) }}
96
+ />
97
+ )}
98
+ {config[key]?.label ?? key}
99
+ </div>
100
+ );
101
+ })}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export { ChartLegend, ChartLegendContent };
@@ -0,0 +1,100 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ import { useChartContext } from "#/context/chart-context.tsx";
5
+ import { seriesColor } from "#/core/config.ts";
6
+ import { linePath } from "#/core/geometry.ts";
7
+ import { seriesPoints } from "#/core/series.ts";
8
+ import type { ChartCurve } from "#/core/types.ts";
9
+
10
+ /**
11
+ * One line per series. The active datum grows a dot, so scrubbing with a finger
12
+ * shows where you are without needing the tooltip to be readable at the same
13
+ * time.
14
+ */
15
+
16
+ export interface ChartLineProps extends React.ComponentProps<"g"> {
17
+ readonly keys?: ReadonlyArray<string>;
18
+ readonly curve?: ChartCurve;
19
+ readonly strokeWidth?: number;
20
+ /** Draw a dot on every point, not only the active one. */
21
+ readonly dots?: boolean;
22
+ }
23
+
24
+ const ACTIVE_DOT_RADIUS = 4;
25
+ const DOT_RADIUS = 2.5;
26
+
27
+ function ChartLine({
28
+ className,
29
+ keys,
30
+ curve = "monotone",
31
+ strokeWidth = 2,
32
+ dots = false,
33
+ ...props
34
+ }: ChartLineProps) {
35
+ const {
36
+ data,
37
+ categories,
38
+ categoryScale,
39
+ valueScale,
40
+ orientation,
41
+ valueKeys,
42
+ config,
43
+ active,
44
+ } = useChartContext();
45
+
46
+ const drawnKeys = keys ?? valueKeys;
47
+
48
+ return (
49
+ <g data-slot="chart-line" className={cn(className)} {...props}>
50
+ {drawnKeys.map((key, seriesIndex) => {
51
+ const points = seriesPoints({
52
+ data,
53
+ categories,
54
+ categoryScale,
55
+ valueScale,
56
+ orientation,
57
+ key,
58
+ });
59
+ const color = seriesColor(config, key, seriesIndex);
60
+ return (
61
+ <g key={key} data-slot="chart-line-series" data-series={key}>
62
+ <path
63
+ data-slot="chart-line-path"
64
+ data-chart-animate=""
65
+ d={linePath(points, curve)}
66
+ fill="none"
67
+ stroke={color}
68
+ strokeWidth={strokeWidth}
69
+ strokeLinecap="round"
70
+ strokeLinejoin="round"
71
+ />
72
+ {points.map((point, index) => {
73
+ const isActive = active?.index === index;
74
+ if (!dots && !isActive) {
75
+ return null;
76
+ }
77
+ return (
78
+ <circle
79
+ key={`${key}-${categories[index] ?? index}`}
80
+ data-slot="chart-dot"
81
+ data-series={key}
82
+ data-index={index}
83
+ data-state={isActive ? "active" : "idle"}
84
+ cx={point.x}
85
+ cy={point.y}
86
+ r={isActive ? ACTIVE_DOT_RADIUS : DOT_RADIUS}
87
+ fill={color}
88
+ className="stroke-background"
89
+ strokeWidth={isActive ? 2 : 0}
90
+ />
91
+ );
92
+ })}
93
+ </g>
94
+ );
95
+ })}
96
+ </g>
97
+ );
98
+ }
99
+
100
+ export { ChartLine };
@@ -0,0 +1,130 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ import { ChartSlice } from "#/components/chart-slice.tsx";
5
+ import { useChartContext } from "#/context/chart-context.tsx";
6
+ import { readNumber } from "#/core/chart-model.ts";
7
+ import { configKeyFor, seriesColor } from "#/core/config.ts";
8
+ import { polarFrame, polarToCartesian, sliceAngles } from "#/core/polar.ts";
9
+
10
+ /**
11
+ * The pie mark, and its donut variant. Each row of data is one wedge, so the
12
+ * colours come from the row (via `nameKey`) rather than from a series — which
13
+ * is also how the tooltip and legend read a round chart.
14
+ */
15
+
16
+ export interface ChartPieProps extends React.ComponentProps<"g"> {
17
+ /** Field holding each wedge's size. Defaults to the first value key. */
18
+ readonly dataKey?: string;
19
+ /** Field naming each wedge's config entry. Defaults to the category field. */
20
+ readonly nameKey?: string;
21
+ /** Fraction of the outer radius left hollow. `0` draws a full pie. */
22
+ readonly innerRadiusRatio?: number;
23
+ readonly padAngle?: number;
24
+ readonly startAngle?: number;
25
+ readonly endAngle?: number;
26
+ /** Pixels kept clear around the wedges, for the active one to lift into. */
27
+ readonly inset?: number;
28
+ }
29
+
30
+ /** Milliseconds each wedge waits behind the one before it, on entry. */
31
+ const STAGGER_MS = 40;
32
+
33
+ function ChartPie({
34
+ className,
35
+ dataKey,
36
+ nameKey,
37
+ innerRadiusRatio = 0,
38
+ padAngle = 1,
39
+ startAngle = 0,
40
+ endAngle = 360,
41
+ inset = 8,
42
+ ...props
43
+ }: ChartPieProps) {
44
+ const {
45
+ data,
46
+ innerWidth,
47
+ innerHeight,
48
+ valueKeys,
49
+ config,
50
+ category,
51
+ categories,
52
+ active,
53
+ setActive,
54
+ } = useChartContext();
55
+
56
+ const key = dataKey ?? valueKeys[0];
57
+ if (key === undefined) {
58
+ return null;
59
+ }
60
+
61
+ const { cx, cy, radius } = polarFrame({ innerWidth, innerHeight, inset });
62
+ const innerRadius = radius * innerRadiusRatio;
63
+ const slices = sliceAngles(
64
+ data.map((datum) => readNumber(datum, key)),
65
+ { startAngle, endAngle, padAngle },
66
+ );
67
+
68
+ return (
69
+ <g
70
+ data-slot="chart-pie"
71
+ data-variant={innerRadiusRatio > 0 ? "donut" : "pie"}
72
+ className={cn(className)}
73
+ {...props}
74
+ >
75
+ {slices.map((slice) => {
76
+ const datum = data[slice.index];
77
+ const configKey = configKeyFor(
78
+ datum,
79
+ categories[slice.index] ?? String(slice.index),
80
+ nameKey ?? category?.key,
81
+ );
82
+ const state =
83
+ active === null
84
+ ? "idle"
85
+ : active.index === slice.index
86
+ ? "active"
87
+ : "muted";
88
+ // The tooltip follows the wedge's own middle: a pointer inside a ring
89
+ // is nowhere near the category slots the root's scrubbing uses.
90
+ const centroid = polarToCartesian(
91
+ cx,
92
+ cy,
93
+ (innerRadius + radius) / 2,
94
+ (slice.startAngle + slice.endAngle) / 2,
95
+ );
96
+ return (
97
+ <ChartSlice
98
+ key={configKey}
99
+ data-index={slice.index}
100
+ data-series={configKey}
101
+ cx={cx}
102
+ cy={cy}
103
+ innerRadius={innerRadius}
104
+ outerRadius={radius}
105
+ startAngle={slice.startAngle}
106
+ endAngle={slice.endAngle}
107
+ state={state}
108
+ fill={seriesColor(config, configKey, slice.index)}
109
+ style={
110
+ {
111
+ "--chart-enter-delay": `${slice.index * STAGGER_MS}ms`,
112
+ } as React.CSSProperties
113
+ }
114
+ onPointerEnter={() =>
115
+ setActive({ index: slice.index, x: centroid.x, y: centroid.y })
116
+ }
117
+ onPointerLeave={() => setActive(null)}
118
+ />
119
+ );
120
+ })}
121
+ </g>
122
+ );
123
+ }
124
+
125
+ /** A pie with its middle cut out — room for a total, and easier to compare. */
126
+ function ChartDonut({ innerRadiusRatio = 0.6, ...props }: ChartPieProps) {
127
+ return <ChartPie innerRadiusRatio={innerRadiusRatio} {...props} />;
128
+ }
129
+
130
+ export { ChartDonut, ChartPie };
@@ -0,0 +1,94 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ import { useChartContext } from "#/context/chart-context.tsx";
5
+ import { readNumber } from "#/core/chart-model.ts";
6
+ import { seriesColor } from "#/core/config.ts";
7
+ import { seriesPoints } from "#/core/series.ts";
8
+
9
+ /** The scatter mark: one dot per datum per series, optionally sized by a field. */
10
+
11
+ export interface ChartPointsProps extends React.ComponentProps<"g"> {
12
+ readonly keys?: ReadonlyArray<string>;
13
+ readonly radius?: number;
14
+ /** Field driving the dot area, for a bubble chart. */
15
+ readonly sizeKey?: string;
16
+ readonly maxRadius?: number;
17
+ }
18
+
19
+ /** Milliseconds each dot waits behind the one before it, on entry. */
20
+ const STAGGER_MS = 16;
21
+
22
+ function ChartPoints({
23
+ className,
24
+ keys,
25
+ radius = 4,
26
+ sizeKey,
27
+ maxRadius = 14,
28
+ ...props
29
+ }: ChartPointsProps) {
30
+ const {
31
+ data,
32
+ categories,
33
+ categoryScale,
34
+ valueScale,
35
+ orientation,
36
+ valueKeys,
37
+ config,
38
+ active,
39
+ } = useChartContext();
40
+
41
+ const drawnKeys = keys ?? valueKeys;
42
+ // Area, not radius, carries the size: doubling a value should double the ink.
43
+ const largestSize =
44
+ sizeKey === undefined
45
+ ? 0
46
+ : Math.max(...data.map((datum) => readNumber(datum, sizeKey)), 0);
47
+
48
+ const radiusFor = (index: number): number => {
49
+ if (sizeKey === undefined || largestSize <= 0) {
50
+ return radius;
51
+ }
52
+ const share = readNumber(data[index] ?? {}, sizeKey) / largestSize;
53
+ return Math.max(2, maxRadius * Math.sqrt(share));
54
+ };
55
+
56
+ return (
57
+ <g data-slot="chart-points" className={cn(className)} {...props}>
58
+ {drawnKeys.map((key, seriesIndex) => {
59
+ const color = seriesColor(config, key, seriesIndex);
60
+ return seriesPoints({
61
+ data,
62
+ categories,
63
+ categoryScale,
64
+ valueScale,
65
+ orientation,
66
+ key,
67
+ }).map((point, index) => (
68
+ <circle
69
+ key={`${key}-${categories[index] ?? index}`}
70
+ data-slot="chart-point"
71
+ data-series={key}
72
+ data-index={index}
73
+ data-state={active?.index === index ? "active" : "idle"}
74
+ data-chart-animate=""
75
+ cx={point.x}
76
+ cy={point.y}
77
+ r={radiusFor(index)}
78
+ fill={color}
79
+ fillOpacity={active?.index === index ? 1 : 0.75}
80
+ className="stroke-background"
81
+ strokeWidth={1}
82
+ style={
83
+ {
84
+ "--chart-enter-delay": `${index * STAGGER_MS}ms`,
85
+ } as React.CSSProperties
86
+ }
87
+ />
88
+ ));
89
+ })}
90
+ </g>
91
+ );
92
+ }
93
+
94
+ export { ChartPoints };
@@ -0,0 +1,76 @@
1
+ import { cn } from "@voila.dev/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ import { useChartContext } from "#/context/chart-context.tsx";
5
+ import { axisAngle, polarFrame, polarToCartesian } from "#/core/polar.ts";
6
+
7
+ /**
8
+ * The category labels around a radar chart. Each one is anchored away from the
9
+ * centre, so a label on the left reads right-to-left into the chart rather than
10
+ * overlapping it.
11
+ */
12
+
13
+ export interface ChartPolarAngleAxisProps extends React.ComponentProps<"g"> {
14
+ readonly tickFormatter?: (value: string) => string;
15
+ /** Pixels between the outer ring and the labels. */
16
+ readonly tickMargin?: number;
17
+ readonly inset?: number;
18
+ }
19
+
20
+ /** Below this much horizontal offset a label is treated as centred. */
21
+ const ANCHOR_EPSILON = 1;
22
+
23
+ function ChartPolarAngleAxis({
24
+ className,
25
+ tickFormatter,
26
+ tickMargin = 10,
27
+ inset = 24,
28
+ ...props
29
+ }: ChartPolarAngleAxisProps) {
30
+ const { innerWidth, innerHeight, categories } = useChartContext();
31
+ const { cx, cy, radius } = polarFrame({ innerWidth, innerHeight, inset });
32
+ const count = categories.length;
33
+
34
+ if (count === 0) {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <g
40
+ data-slot="chart-polar-angle-axis"
41
+ className={cn("fill-muted-foreground text-[10px]", className)}
42
+ {...props}
43
+ >
44
+ {categories.map((category, index) => {
45
+ const point = polarToCartesian(
46
+ cx,
47
+ cy,
48
+ radius + tickMargin,
49
+ axisAngle(index, count),
50
+ );
51
+ const offset = point.x - cx;
52
+ return (
53
+ <text
54
+ key={category}
55
+ data-slot="chart-polar-tick"
56
+ x={point.x}
57
+ y={point.y}
58
+ dy="0.32em"
59
+ textAnchor={
60
+ Math.abs(offset) < ANCHOR_EPSILON
61
+ ? "middle"
62
+ : offset > 0
63
+ ? "start"
64
+ : "end"
65
+ }
66
+ className="stroke-none"
67
+ >
68
+ {tickFormatter ? tickFormatter(category) : category}
69
+ </text>
70
+ );
71
+ })}
72
+ </g>
73
+ );
74
+ }
75
+
76
+ export { ChartPolarAngleAxis };