@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.
- package/package.json +57 -0
- package/src/components/chart-area.tsx +124 -0
- package/src/components/chart-bars.tsx +105 -0
- package/src/components/chart-cursor.tsx +105 -0
- package/src/components/chart-data-table.tsx +55 -0
- package/src/components/chart-empty.tsx +22 -0
- package/src/components/chart-grid.tsx +81 -0
- package/src/components/chart-label-list.tsx +178 -0
- package/src/components/chart-legend.tsx +106 -0
- package/src/components/chart-line.tsx +100 -0
- package/src/components/chart-pie.tsx +130 -0
- package/src/components/chart-points.tsx +94 -0
- package/src/components/chart-polar-angle-axis.tsx +76 -0
- package/src/components/chart-polar-grid.tsx +64 -0
- package/src/components/chart-radar.tsx +103 -0
- package/src/components/chart-radial-bar.tsx +122 -0
- package/src/components/chart-reference-line.tsx +60 -0
- package/src/components/chart-root.tsx +188 -0
- package/src/components/chart-skeleton.tsx +45 -0
- package/src/components/chart-slice.tsx +59 -0
- package/src/components/chart-style.tsx +78 -0
- package/src/components/chart-tooltip.tsx +205 -0
- package/src/components/chart-x-axis.tsx +94 -0
- package/src/components/chart-y-axis.tsx +90 -0
- package/src/components/chart.tsx +146 -0
- package/src/context/chart-context.tsx +70 -0
- package/src/core/axis.ts +53 -0
- package/src/core/bars.ts +160 -0
- package/src/core/chart-model.ts +156 -0
- package/src/core/config.ts +68 -0
- package/src/core/format.ts +86 -0
- package/src/core/geometry.ts +271 -0
- package/src/core/polar.ts +138 -0
- package/src/core/scales.ts +167 -0
- package/src/core/series.ts +70 -0
- package/src/core/ticks.ts +118 -0
- package/src/core/types.ts +71 -0
- package/src/hooks/use-chart-dimensions.ts +50 -0
- package/src/hooks/use-chart-pointer.ts +152 -0
- 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 };
|