@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,64 @@
|
|
|
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 { polygonPath } from "#/core/geometry.ts";
|
|
6
|
+
import { polarFrame, ringPoints, ringRadii } from "#/core/polar.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The web behind a radar chart: rings at even value steps plus one spoke per
|
|
10
|
+
* axis. The rings are polygons, not circles, so they meet the spokes exactly
|
|
11
|
+
* where the data will.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ChartPolarGridProps extends React.ComponentProps<"g"> {
|
|
15
|
+
readonly rings?: number;
|
|
16
|
+
readonly spokes?: boolean;
|
|
17
|
+
readonly inset?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ChartPolarGrid({
|
|
21
|
+
className,
|
|
22
|
+
rings = 4,
|
|
23
|
+
spokes = true,
|
|
24
|
+
inset = 24,
|
|
25
|
+
...props
|
|
26
|
+
}: ChartPolarGridProps) {
|
|
27
|
+
const { innerWidth, innerHeight, categories } = useChartContext();
|
|
28
|
+
const { cx, cy, radius } = polarFrame({ innerWidth, innerHeight, inset });
|
|
29
|
+
const axisCount = categories.length;
|
|
30
|
+
|
|
31
|
+
if (axisCount === 0 || radius <= 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<g
|
|
37
|
+
data-slot="chart-polar-grid"
|
|
38
|
+
className={cn("fill-none stroke-border/60", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{ringRadii(radius, rings).map((ringRadius) => (
|
|
42
|
+
<path
|
|
43
|
+
key={ringRadius}
|
|
44
|
+
data-slot="chart-polar-ring"
|
|
45
|
+
d={polygonPath(ringPoints(cx, cy, ringRadius, axisCount))}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
{spokes
|
|
49
|
+
? ringPoints(cx, cy, radius, axisCount).map((point, index) => (
|
|
50
|
+
<line
|
|
51
|
+
key={categories[index] ?? index}
|
|
52
|
+
data-slot="chart-polar-spoke"
|
|
53
|
+
x1={cx}
|
|
54
|
+
y1={cy}
|
|
55
|
+
x2={point.x}
|
|
56
|
+
y2={point.y}
|
|
57
|
+
/>
|
|
58
|
+
))
|
|
59
|
+
: null}
|
|
60
|
+
</g>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { ChartPolarGrid };
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { polygonPath } from "#/core/geometry.ts";
|
|
8
|
+
import { axisAngle, polarFrame, polarToCartesian } from "#/core/polar.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One closed shape per series, with a vertex on every category axis. Radii come
|
|
12
|
+
* from the value axis's domain, so two radar series on the same chart are
|
|
13
|
+
* directly comparable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChartRadarProps extends React.ComponentProps<"g"> {
|
|
17
|
+
readonly keys?: ReadonlyArray<string>;
|
|
18
|
+
readonly fillOpacity?: number;
|
|
19
|
+
readonly strokeWidth?: number;
|
|
20
|
+
readonly dots?: boolean;
|
|
21
|
+
readonly inset?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DOT_RADIUS = 3;
|
|
25
|
+
|
|
26
|
+
function ChartRadar({
|
|
27
|
+
className,
|
|
28
|
+
keys,
|
|
29
|
+
fillOpacity = 0.35,
|
|
30
|
+
strokeWidth = 2,
|
|
31
|
+
dots = false,
|
|
32
|
+
inset = 24,
|
|
33
|
+
...props
|
|
34
|
+
}: ChartRadarProps) {
|
|
35
|
+
const {
|
|
36
|
+
data,
|
|
37
|
+
innerWidth,
|
|
38
|
+
innerHeight,
|
|
39
|
+
categories,
|
|
40
|
+
valueKeys,
|
|
41
|
+
valueScale,
|
|
42
|
+
config,
|
|
43
|
+
} = useChartContext();
|
|
44
|
+
|
|
45
|
+
const drawnKeys = keys ?? valueKeys;
|
|
46
|
+
const { cx, cy, radius } = polarFrame({ innerWidth, innerHeight, inset });
|
|
47
|
+
const [domainStart, domainEnd] = valueScale.domain;
|
|
48
|
+
const span = domainEnd - domainStart;
|
|
49
|
+
const count = categories.length;
|
|
50
|
+
|
|
51
|
+
if (count === 0 || radius <= 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const radiusFor = (value: number): number =>
|
|
56
|
+
span === 0 ? 0 : Math.max(0, ((value - domainStart) / span) * radius);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<g data-slot="chart-radar" className={cn(className)} {...props}>
|
|
60
|
+
{drawnKeys.map((key, seriesIndex) => {
|
|
61
|
+
const color = seriesColor(config, key, seriesIndex);
|
|
62
|
+
const vertices = data.map((datum, index) =>
|
|
63
|
+
polarToCartesian(
|
|
64
|
+
cx,
|
|
65
|
+
cy,
|
|
66
|
+
radiusFor(readNumber(datum, key)),
|
|
67
|
+
axisAngle(index, count),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return (
|
|
71
|
+
<g key={key} data-slot="chart-radar-series" data-series={key}>
|
|
72
|
+
<path
|
|
73
|
+
data-slot="chart-radar-shape"
|
|
74
|
+
data-chart-animate=""
|
|
75
|
+
d={polygonPath(vertices)}
|
|
76
|
+
fill={color}
|
|
77
|
+
fillOpacity={fillOpacity}
|
|
78
|
+
stroke={color}
|
|
79
|
+
strokeWidth={strokeWidth}
|
|
80
|
+
strokeLinejoin="round"
|
|
81
|
+
/>
|
|
82
|
+
{dots
|
|
83
|
+
? vertices.map((vertex, index) => (
|
|
84
|
+
<circle
|
|
85
|
+
key={categories[index] ?? index}
|
|
86
|
+
data-slot="chart-dot"
|
|
87
|
+
data-series={key}
|
|
88
|
+
data-index={index}
|
|
89
|
+
cx={vertex.x}
|
|
90
|
+
cy={vertex.y}
|
|
91
|
+
r={DOT_RADIUS}
|
|
92
|
+
fill={color}
|
|
93
|
+
/>
|
|
94
|
+
))
|
|
95
|
+
: null}
|
|
96
|
+
</g>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</g>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { ChartRadar };
|
|
@@ -0,0 +1,122 @@
|
|
|
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 { configKeyFor, seriesColor } from "#/core/config.ts";
|
|
7
|
+
import { arcPath } from "#/core/geometry.ts";
|
|
8
|
+
import { polarFrame } from "#/core/polar.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bars bent around the centre — one concentric track per row. Reads as a set of
|
|
12
|
+
* gauges, which is what it is usually asked to be: a completion rate, a fill
|
|
13
|
+
* rate, a share of a target.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChartRadialBarProps extends React.ComponentProps<"g"> {
|
|
17
|
+
readonly dataKey?: string;
|
|
18
|
+
readonly nameKey?: string;
|
|
19
|
+
/** Value that fills a whole track. Defaults to the value axis maximum. */
|
|
20
|
+
readonly max?: number;
|
|
21
|
+
readonly startAngle?: number;
|
|
22
|
+
readonly endAngle?: number;
|
|
23
|
+
/** Fraction of the radius left hollow in the middle. */
|
|
24
|
+
readonly innerRadiusRatio?: number;
|
|
25
|
+
/** Draw the unfilled remainder of each track. */
|
|
26
|
+
readonly background?: boolean;
|
|
27
|
+
readonly inset?: number;
|
|
28
|
+
/** Pixels between two tracks. */
|
|
29
|
+
readonly gap?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ChartRadialBar({
|
|
33
|
+
className,
|
|
34
|
+
dataKey,
|
|
35
|
+
nameKey,
|
|
36
|
+
max,
|
|
37
|
+
startAngle = 0,
|
|
38
|
+
endAngle = 360,
|
|
39
|
+
innerRadiusRatio = 0.3,
|
|
40
|
+
background = true,
|
|
41
|
+
inset = 8,
|
|
42
|
+
gap = 4,
|
|
43
|
+
...props
|
|
44
|
+
}: ChartRadialBarProps) {
|
|
45
|
+
const {
|
|
46
|
+
data,
|
|
47
|
+
innerWidth,
|
|
48
|
+
innerHeight,
|
|
49
|
+
valueKeys,
|
|
50
|
+
valueScale,
|
|
51
|
+
config,
|
|
52
|
+
category,
|
|
53
|
+
categories,
|
|
54
|
+
active,
|
|
55
|
+
setActive,
|
|
56
|
+
} = useChartContext();
|
|
57
|
+
|
|
58
|
+
const key = dataKey ?? valueKeys[0];
|
|
59
|
+
if (key === undefined || data.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { cx, cy, radius } = polarFrame({ innerWidth, innerHeight, inset });
|
|
64
|
+
const innerRadius = radius * innerRadiusRatio;
|
|
65
|
+
const trackDepth = (radius - innerRadius) / data.length;
|
|
66
|
+
const ceiling = max ?? valueScale.domain[1];
|
|
67
|
+
const span = endAngle - startAngle;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<g data-slot="chart-radial-bar" className={cn(className)} {...props}>
|
|
71
|
+
{data.map((datum, index) => {
|
|
72
|
+
// Track zero is the outermost, so the first row of data reads first.
|
|
73
|
+
const trackOuter = radius - index * trackDepth;
|
|
74
|
+
const trackInner = trackOuter - trackDepth + gap;
|
|
75
|
+
const fraction =
|
|
76
|
+
ceiling === 0 ? 0 : Math.min(1, readNumber(datum, key) / ceiling);
|
|
77
|
+
const configKey = configKeyFor(
|
|
78
|
+
datum,
|
|
79
|
+
categories[index] ?? String(index),
|
|
80
|
+
nameKey ?? category?.key,
|
|
81
|
+
);
|
|
82
|
+
const track = {
|
|
83
|
+
cx,
|
|
84
|
+
cy,
|
|
85
|
+
innerRadius: trackInner,
|
|
86
|
+
outerRadius: trackOuter,
|
|
87
|
+
};
|
|
88
|
+
return (
|
|
89
|
+
<g
|
|
90
|
+
key={configKey}
|
|
91
|
+
data-slot="chart-radial-track"
|
|
92
|
+
data-series={configKey}
|
|
93
|
+
data-index={index}
|
|
94
|
+
data-state={active?.index === index ? "active" : "idle"}
|
|
95
|
+
onPointerEnter={() => setActive({ index, x: cx, y: cy })}
|
|
96
|
+
onPointerLeave={() => setActive(null)}
|
|
97
|
+
>
|
|
98
|
+
{background ? (
|
|
99
|
+
<path
|
|
100
|
+
data-slot="chart-radial-background"
|
|
101
|
+
d={arcPath({ ...track, startAngle, endAngle })}
|
|
102
|
+
className="fill-muted"
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
<path
|
|
106
|
+
data-slot="chart-radial-value"
|
|
107
|
+
data-chart-animate=""
|
|
108
|
+
d={arcPath({
|
|
109
|
+
...track,
|
|
110
|
+
startAngle,
|
|
111
|
+
endAngle: startAngle + span * fraction,
|
|
112
|
+
})}
|
|
113
|
+
fill={seriesColor(config, configKey, index)}
|
|
114
|
+
/>
|
|
115
|
+
</g>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</g>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { ChartRadialBar };
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { formatTickValue } from "#/core/format.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A marker line across the plot at one value — a target, a threshold, or the
|
|
9
|
+
* zero baseline a chart with negative values needs in order to be readable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ChartReferenceLineProps extends React.ComponentProps<"g"> {
|
|
13
|
+
/** Position on the value axis. */
|
|
14
|
+
readonly value: number;
|
|
15
|
+
readonly label?: string;
|
|
16
|
+
readonly strokeDasharray?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ChartReferenceLine({
|
|
20
|
+
className,
|
|
21
|
+
value,
|
|
22
|
+
label,
|
|
23
|
+
strokeDasharray = "4 4",
|
|
24
|
+
...props
|
|
25
|
+
}: ChartReferenceLineProps) {
|
|
26
|
+
const { valueScale, orientation, innerWidth, innerHeight } =
|
|
27
|
+
useChartContext();
|
|
28
|
+
const offset = valueScale.scale(value);
|
|
29
|
+
const isVertical = orientation === "vertical";
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<g
|
|
33
|
+
data-slot="chart-reference-line"
|
|
34
|
+
data-value={value}
|
|
35
|
+
className={cn("stroke-border", className)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<line
|
|
39
|
+
x1={isVertical ? 0 : offset}
|
|
40
|
+
x2={isVertical ? innerWidth : offset}
|
|
41
|
+
y1={isVertical ? offset : 0}
|
|
42
|
+
y2={isVertical ? offset : innerHeight}
|
|
43
|
+
strokeDasharray={strokeDasharray}
|
|
44
|
+
/>
|
|
45
|
+
<text
|
|
46
|
+
data-slot="chart-reference-label"
|
|
47
|
+
x={isVertical ? innerWidth : offset}
|
|
48
|
+
y={isVertical ? offset : 0}
|
|
49
|
+
dx={isVertical ? -4 : 4}
|
|
50
|
+
dy={isVertical ? -4 : 10}
|
|
51
|
+
textAnchor={isVertical ? "end" : "start"}
|
|
52
|
+
className="fill-muted-foreground stroke-none text-[10px]"
|
|
53
|
+
>
|
|
54
|
+
{label ?? formatTickValue(value)}
|
|
55
|
+
</text>
|
|
56
|
+
</g>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { ChartReferenceLine };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { ChartDataTable } from "#/components/chart-data-table.tsx";
|
|
5
|
+
import { ChartStyle } from "#/components/chart-style.tsx";
|
|
6
|
+
import {
|
|
7
|
+
type ChartContextValue,
|
|
8
|
+
ChartProvider,
|
|
9
|
+
} from "#/context/chart-context.tsx";
|
|
10
|
+
import {
|
|
11
|
+
buildChartModel,
|
|
12
|
+
type ChartCategorySpec,
|
|
13
|
+
type ChartValueSpec,
|
|
14
|
+
} from "#/core/chart-model.ts";
|
|
15
|
+
import { type ChartConfig, seriesLabelText } from "#/core/config.ts";
|
|
16
|
+
import type {
|
|
17
|
+
ChartDatum,
|
|
18
|
+
ChartMargin,
|
|
19
|
+
ChartOrientation,
|
|
20
|
+
} from "#/core/types.ts";
|
|
21
|
+
import { useChartDimensions } from "#/hooks/use-chart-dimensions.ts";
|
|
22
|
+
import { useChartPointer } from "#/hooks/use-chart-pointer.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The frame every other chart part draws into: it measures itself, derives the
|
|
26
|
+
* scales, owns the active datum and publishes all of it on context.
|
|
27
|
+
*
|
|
28
|
+
* Marks are placed inside the plotting area, which is the SVG inset by
|
|
29
|
+
* `margin`. Chrome that is not SVG — tooltip, legend — portals into an overlay
|
|
30
|
+
* layer above it, so composition stays flat at the call site.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MARGIN: ChartMargin = {
|
|
34
|
+
top: 8,
|
|
35
|
+
right: 8,
|
|
36
|
+
bottom: 24,
|
|
37
|
+
left: 40,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface ChartRootProps
|
|
41
|
+
extends Omit<React.ComponentProps<"div">, "children"> {
|
|
42
|
+
/** Per-series labels, colours and icons. */
|
|
43
|
+
readonly config: ChartConfig;
|
|
44
|
+
readonly data?: ReadonlyArray<ChartDatum>;
|
|
45
|
+
/** The category axis: which field names each row. */
|
|
46
|
+
readonly x?: ChartCategorySpec;
|
|
47
|
+
/** The value axis: which fields are plotted, and how they combine. */
|
|
48
|
+
readonly y?: ChartValueSpec;
|
|
49
|
+
/** `horizontal` puts the categories down the side. */
|
|
50
|
+
readonly orientation?: ChartOrientation;
|
|
51
|
+
readonly margin?: Partial<ChartMargin>;
|
|
52
|
+
/** Turns pointer scrubbing and keyboard navigation off. */
|
|
53
|
+
readonly interactive?: boolean;
|
|
54
|
+
readonly children?: React.ReactNode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A one-line description of the picture, for readers who get the `role="img"`
|
|
59
|
+
* label but not the drawing. The hidden data table underneath carries the
|
|
60
|
+
* numbers themselves.
|
|
61
|
+
*/
|
|
62
|
+
function describeChart(
|
|
63
|
+
config: ChartConfig,
|
|
64
|
+
valueKeys: ReadonlyArray<string>,
|
|
65
|
+
count: number,
|
|
66
|
+
): string {
|
|
67
|
+
if (valueKeys.length === 0) {
|
|
68
|
+
return "Chart";
|
|
69
|
+
}
|
|
70
|
+
const names = valueKeys.map((key) => seriesLabelText(config, key)).join(", ");
|
|
71
|
+
return `Chart of ${names} over ${count} ${count === 1 ? "point" : "points"}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ChartRoot({
|
|
75
|
+
id,
|
|
76
|
+
className,
|
|
77
|
+
config,
|
|
78
|
+
data = [],
|
|
79
|
+
x,
|
|
80
|
+
y,
|
|
81
|
+
orientation = "vertical",
|
|
82
|
+
margin: marginProp,
|
|
83
|
+
interactive = true,
|
|
84
|
+
children,
|
|
85
|
+
"aria-label": ariaLabel,
|
|
86
|
+
...props
|
|
87
|
+
}: ChartRootProps) {
|
|
88
|
+
const generatedId = React.useId();
|
|
89
|
+
const chartId = `chart-${id ?? generatedId.replace(/:/g, "")}`;
|
|
90
|
+
const { ref, width, height } = useChartDimensions();
|
|
91
|
+
const [overlay, setOverlay] = React.useState<HTMLDivElement | null>(null);
|
|
92
|
+
|
|
93
|
+
const margin: ChartMargin = { ...DEFAULT_MARGIN, ...marginProp };
|
|
94
|
+
const innerWidth = Math.max(0, width - margin.left - margin.right);
|
|
95
|
+
const innerHeight = Math.max(0, height - margin.top - margin.bottom);
|
|
96
|
+
|
|
97
|
+
const model = React.useMemo(
|
|
98
|
+
() =>
|
|
99
|
+
buildChartModel({
|
|
100
|
+
data,
|
|
101
|
+
category: x,
|
|
102
|
+
value: y,
|
|
103
|
+
orientation,
|
|
104
|
+
innerWidth,
|
|
105
|
+
innerHeight,
|
|
106
|
+
}),
|
|
107
|
+
[data, x, y, orientation, innerWidth, innerHeight],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const { active, setActive, handlers } = useChartPointer({
|
|
111
|
+
enabled: interactive && x !== undefined,
|
|
112
|
+
count: data.length,
|
|
113
|
+
width,
|
|
114
|
+
height,
|
|
115
|
+
margin,
|
|
116
|
+
orientation,
|
|
117
|
+
categoryScale: model.categoryScale,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const description =
|
|
121
|
+
ariaLabel ?? describeChart(config, model.valueKeys, data.length);
|
|
122
|
+
|
|
123
|
+
const context: ChartContextValue = {
|
|
124
|
+
chartId,
|
|
125
|
+
config,
|
|
126
|
+
data,
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
margin,
|
|
130
|
+
innerWidth,
|
|
131
|
+
innerHeight,
|
|
132
|
+
orientation,
|
|
133
|
+
category: x,
|
|
134
|
+
value: y,
|
|
135
|
+
active,
|
|
136
|
+
setActive,
|
|
137
|
+
overlay,
|
|
138
|
+
...model,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
data-slot="chart-root"
|
|
144
|
+
data-chart={chartId}
|
|
145
|
+
data-orientation={orientation}
|
|
146
|
+
ref={ref}
|
|
147
|
+
className={cn(
|
|
148
|
+
"relative flex aspect-video w-full justify-center text-xs",
|
|
149
|
+
className,
|
|
150
|
+
)}
|
|
151
|
+
{...props}
|
|
152
|
+
>
|
|
153
|
+
<ChartStyle id={chartId} config={config} />
|
|
154
|
+
<svg
|
|
155
|
+
data-slot="chart-svg"
|
|
156
|
+
role="img"
|
|
157
|
+
aria-label={description}
|
|
158
|
+
width={width}
|
|
159
|
+
height={height}
|
|
160
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
161
|
+
className="absolute inset-0 h-full w-full rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
162
|
+
{...handlers}
|
|
163
|
+
>
|
|
164
|
+
<g
|
|
165
|
+
data-slot="chart-plot"
|
|
166
|
+
transform={`translate(${margin.left},${margin.top})`}
|
|
167
|
+
>
|
|
168
|
+
<ChartProvider value={context}>{children}</ChartProvider>
|
|
169
|
+
</g>
|
|
170
|
+
</svg>
|
|
171
|
+
<div
|
|
172
|
+
data-slot="chart-overlay"
|
|
173
|
+
ref={setOverlay}
|
|
174
|
+
className="pointer-events-none absolute inset-0"
|
|
175
|
+
/>
|
|
176
|
+
<ChartDataTable
|
|
177
|
+
caption={description}
|
|
178
|
+
categoryLabel={x?.key ?? "Category"}
|
|
179
|
+
categories={model.categories}
|
|
180
|
+
valueKeys={model.valueKeys}
|
|
181
|
+
data={data}
|
|
182
|
+
config={config}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { ChartRoot };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Skeleton } from "@voila.dev/ui/components/skeleton";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Loading placeholder shaped like a bar chart. Defaults to the root's
|
|
7
|
+
* aspect-video box — size it like the chart it stands in for (the same
|
|
8
|
+
* `h-* w-full`), or the page will jump when the data lands.
|
|
9
|
+
*
|
|
10
|
+
* The English default label matches the kit's other built-in labels; pass a
|
|
11
|
+
* localized `label` from the app.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Fixed, not random, so the server and the client render the same bars. */
|
|
15
|
+
const BAR_HEIGHTS = [40, 70, 55, 90, 65, 80, 50] as const;
|
|
16
|
+
|
|
17
|
+
export interface ChartSkeletonProps extends React.ComponentProps<"div"> {
|
|
18
|
+
readonly label?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ChartSkeleton({
|
|
22
|
+
className,
|
|
23
|
+
label = "Loading",
|
|
24
|
+
...props
|
|
25
|
+
}: ChartSkeletonProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot="chart-skeleton"
|
|
29
|
+
role="status"
|
|
30
|
+
aria-label={label}
|
|
31
|
+
className={cn("flex aspect-video w-full items-end gap-2", className)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
{BAR_HEIGHTS.map((barHeight) => (
|
|
35
|
+
<Skeleton
|
|
36
|
+
key={barHeight}
|
|
37
|
+
className="w-full"
|
|
38
|
+
style={{ height: `${barHeight}%` }}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { ChartSkeleton };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ArcOptions, arcPath } from "#/core/geometry.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One wedge of a pie or donut. Exposed on its own so a caller can build a
|
|
8
|
+
* bespoke round chart out of the same part the built-in ones use.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ChartSliceProps
|
|
12
|
+
extends ArcOptions,
|
|
13
|
+
Omit<React.ComponentProps<"path">, "d" | "cx" | "cy"> {
|
|
14
|
+
readonly state?: "idle" | "active" | "muted";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How far the active wedge lifts out of the ring. */
|
|
18
|
+
const ACTIVE_LIFT = 4;
|
|
19
|
+
|
|
20
|
+
function ChartSlice({
|
|
21
|
+
cx,
|
|
22
|
+
cy,
|
|
23
|
+
innerRadius,
|
|
24
|
+
outerRadius,
|
|
25
|
+
startAngle,
|
|
26
|
+
endAngle,
|
|
27
|
+
state = "idle",
|
|
28
|
+
className,
|
|
29
|
+
...props
|
|
30
|
+
}: ChartSliceProps) {
|
|
31
|
+
const path = arcPath({
|
|
32
|
+
cx,
|
|
33
|
+
cy,
|
|
34
|
+
innerRadius,
|
|
35
|
+
outerRadius: state === "active" ? outerRadius + ACTIVE_LIFT : outerRadius,
|
|
36
|
+
startAngle,
|
|
37
|
+
endAngle,
|
|
38
|
+
});
|
|
39
|
+
if (path === "") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<path
|
|
45
|
+
data-slot="chart-slice"
|
|
46
|
+
data-state={state}
|
|
47
|
+
data-chart-animate=""
|
|
48
|
+
d={path}
|
|
49
|
+
className={cn(
|
|
50
|
+
"stroke-background transition-opacity duration-150 data-[state=muted]:opacity-60",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
strokeWidth={2}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { ChartSlice };
|