@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
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/ui-chart",
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Charts with zero charting library. SVG you can read, scales included.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ui.voila.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/voila-voila-dev/ui.git",
|
|
11
|
+
"directory": "packages/ui-chart"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"charts",
|
|
16
|
+
"svg",
|
|
17
|
+
"dataviz",
|
|
18
|
+
"tailwindcss"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.test.tsx",
|
|
27
|
+
"!src/**/*.stories.ts",
|
|
28
|
+
"!src/**/*.stories.tsx"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": [
|
|
31
|
+
"**/*.css"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
"./styles.css": "./src/styles.css",
|
|
35
|
+
"./components/*": "./src/components/*.tsx",
|
|
36
|
+
"./core/*": "./src/core/*.ts",
|
|
37
|
+
"./hooks/*": "./src/hooks/*.ts"
|
|
38
|
+
},
|
|
39
|
+
"imports": {
|
|
40
|
+
"#/*": "./src/*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "biome check",
|
|
44
|
+
"check-types": "tsc --noEmit",
|
|
45
|
+
"format": "biome format",
|
|
46
|
+
"lint": "biome lint",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@voila.dev/ui": "0.0.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"react": "^19.0.0",
|
|
54
|
+
"react-dom": "^19.0.0",
|
|
55
|
+
"tailwindcss": "^4.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { areaPath, linePath } from "#/core/geometry.ts";
|
|
7
|
+
import { seriesPoints, stackBases } from "#/core/series.ts";
|
|
8
|
+
import type { ChartCurve } from "#/core/types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A filled band under each series, with the same outline the line mark would
|
|
12
|
+
* draw. Stacked areas sit on the running total of the series below them, so the
|
|
13
|
+
* top edge reads as the total rather than as the last series.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChartAreaProps extends React.ComponentProps<"g"> {
|
|
17
|
+
readonly keys?: ReadonlyArray<string>;
|
|
18
|
+
readonly curve?: ChartCurve;
|
|
19
|
+
readonly stacked?: boolean;
|
|
20
|
+
readonly fillOpacity?: number;
|
|
21
|
+
readonly strokeWidth?: number;
|
|
22
|
+
/** Fade the fill towards the baseline instead of filling flat. */
|
|
23
|
+
readonly gradient?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ChartArea({
|
|
27
|
+
className,
|
|
28
|
+
keys,
|
|
29
|
+
curve = "monotone",
|
|
30
|
+
stacked,
|
|
31
|
+
fillOpacity = 0.25,
|
|
32
|
+
strokeWidth = 2,
|
|
33
|
+
gradient = true,
|
|
34
|
+
...props
|
|
35
|
+
}: ChartAreaProps) {
|
|
36
|
+
const {
|
|
37
|
+
chartId,
|
|
38
|
+
data,
|
|
39
|
+
categories,
|
|
40
|
+
categoryScale,
|
|
41
|
+
valueScale,
|
|
42
|
+
orientation,
|
|
43
|
+
valueKeys,
|
|
44
|
+
config,
|
|
45
|
+
value,
|
|
46
|
+
} = useChartContext();
|
|
47
|
+
|
|
48
|
+
const drawnKeys = keys ?? valueKeys;
|
|
49
|
+
const isStacked = stacked ?? value?.stacked ?? false;
|
|
50
|
+
const bases = isStacked ? stackBases(data, drawnKeys) : undefined;
|
|
51
|
+
const baseline = valueScale.scale(valueScale.domain[0]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<g data-slot="chart-area" className={cn(className)} {...props}>
|
|
55
|
+
{drawnKeys.map((key, seriesIndex) => {
|
|
56
|
+
const base = bases?.get(key);
|
|
57
|
+
const points = seriesPoints({
|
|
58
|
+
data,
|
|
59
|
+
categories,
|
|
60
|
+
categoryScale,
|
|
61
|
+
valueScale,
|
|
62
|
+
orientation,
|
|
63
|
+
key,
|
|
64
|
+
base,
|
|
65
|
+
});
|
|
66
|
+
const color = seriesColor(config, key, seriesIndex);
|
|
67
|
+
const gradientId = `${chartId}-fill-${key}`;
|
|
68
|
+
// A stacked band closes onto the series below it rather than onto the
|
|
69
|
+
// baseline, so its lower edge is the same curve run backwards. The
|
|
70
|
+
// empty key contributes nothing, leaving just the running total.
|
|
71
|
+
const lowerPoints = base
|
|
72
|
+
? seriesPoints({
|
|
73
|
+
data,
|
|
74
|
+
categories,
|
|
75
|
+
categoryScale,
|
|
76
|
+
valueScale,
|
|
77
|
+
orientation,
|
|
78
|
+
key: "",
|
|
79
|
+
base,
|
|
80
|
+
})
|
|
81
|
+
: undefined;
|
|
82
|
+
return (
|
|
83
|
+
<g key={key} data-slot="chart-area-series" data-series={key}>
|
|
84
|
+
{gradient ? (
|
|
85
|
+
<defs>
|
|
86
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
87
|
+
<stop offset="5%" stopColor={color} stopOpacity={0.7} />
|
|
88
|
+
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
|
89
|
+
</linearGradient>
|
|
90
|
+
</defs>
|
|
91
|
+
) : null}
|
|
92
|
+
<path
|
|
93
|
+
data-slot="chart-area-path"
|
|
94
|
+
data-chart-animate=""
|
|
95
|
+
d={
|
|
96
|
+
lowerPoints === undefined
|
|
97
|
+
? areaPath(points, baseline, curve)
|
|
98
|
+
: `${linePath(points, curve)}${linePath(
|
|
99
|
+
[...lowerPoints].reverse(),
|
|
100
|
+
curve,
|
|
101
|
+
).replace(/^M/, "L")}Z`
|
|
102
|
+
}
|
|
103
|
+
fill={gradient ? `url(#${gradientId})` : color}
|
|
104
|
+
fillOpacity={gradient ? 1 : fillOpacity}
|
|
105
|
+
stroke="none"
|
|
106
|
+
/>
|
|
107
|
+
<path
|
|
108
|
+
data-slot="chart-area-line"
|
|
109
|
+
data-chart-animate=""
|
|
110
|
+
d={linePath(points, curve)}
|
|
111
|
+
fill="none"
|
|
112
|
+
stroke={color}
|
|
113
|
+
strokeWidth={strokeWidth}
|
|
114
|
+
strokeLinecap="round"
|
|
115
|
+
strokeLinejoin="round"
|
|
116
|
+
/>
|
|
117
|
+
</g>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</g>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { ChartArea };
|
|
@@ -0,0 +1,105 @@
|
|
|
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 { barRects } from "#/core/bars.ts";
|
|
6
|
+
import { seriesColor } from "#/core/config.ts";
|
|
7
|
+
import { roundedBarPath } from "#/core/geometry.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bar mark. Grouped by default, stacked on request, and upright or on its
|
|
11
|
+
* side depending on the root's orientation — one component rather than three,
|
|
12
|
+
* because the only thing that changes between them is where the geometry lands.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ChartBarsProps
|
|
16
|
+
extends Omit<React.ComponentProps<"g">, "fill"> {
|
|
17
|
+
/** Series to draw. Defaults to the root's value keys. */
|
|
18
|
+
readonly keys?: ReadonlyArray<string>;
|
|
19
|
+
readonly stacked?: boolean;
|
|
20
|
+
readonly radius?: number;
|
|
21
|
+
/** Pixels between two bars of the same category. */
|
|
22
|
+
readonly gap?: number;
|
|
23
|
+
/** Per-datum fill, for charts coloured by row rather than by series. */
|
|
24
|
+
readonly fill?: (datum: Record<string, unknown>, index: number) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Milliseconds each bar waits behind the one before it, on entry. */
|
|
28
|
+
const STAGGER_MS = 24;
|
|
29
|
+
|
|
30
|
+
function ChartBars({
|
|
31
|
+
className,
|
|
32
|
+
keys,
|
|
33
|
+
stacked,
|
|
34
|
+
radius = 4,
|
|
35
|
+
gap,
|
|
36
|
+
fill,
|
|
37
|
+
...props
|
|
38
|
+
}: ChartBarsProps) {
|
|
39
|
+
const {
|
|
40
|
+
data,
|
|
41
|
+
categories,
|
|
42
|
+
categoryScale,
|
|
43
|
+
valueScale,
|
|
44
|
+
orientation,
|
|
45
|
+
valueKeys,
|
|
46
|
+
config,
|
|
47
|
+
active,
|
|
48
|
+
value,
|
|
49
|
+
} = useChartContext();
|
|
50
|
+
|
|
51
|
+
const drawnKeys = keys ?? valueKeys;
|
|
52
|
+
const rects = barRects({
|
|
53
|
+
data,
|
|
54
|
+
keys: drawnKeys,
|
|
55
|
+
categories,
|
|
56
|
+
categoryScale,
|
|
57
|
+
valueScale,
|
|
58
|
+
orientation,
|
|
59
|
+
stacked: stacked ?? value?.stacked ?? false,
|
|
60
|
+
radius,
|
|
61
|
+
gap,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<g data-slot="chart-bars" className={cn(className)} {...props}>
|
|
66
|
+
{rects.map((rect) => {
|
|
67
|
+
const path = roundedBarPath(rect);
|
|
68
|
+
if (path === "") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// Dimming the rest is how the active bar stands out; with nothing
|
|
72
|
+
// active every bar is at full strength.
|
|
73
|
+
const state =
|
|
74
|
+
active === null
|
|
75
|
+
? "idle"
|
|
76
|
+
: active.index === rect.index
|
|
77
|
+
? "active"
|
|
78
|
+
: "muted";
|
|
79
|
+
return (
|
|
80
|
+
<path
|
|
81
|
+
key={`${rect.key}-${rect.index}`}
|
|
82
|
+
data-slot="chart-bar"
|
|
83
|
+
data-series={rect.key}
|
|
84
|
+
data-index={rect.index}
|
|
85
|
+
data-state={state}
|
|
86
|
+
data-chart-animate=""
|
|
87
|
+
d={path}
|
|
88
|
+
fill={
|
|
89
|
+
fill?.(data[rect.index] ?? {}, rect.index) ??
|
|
90
|
+
seriesColor(config, rect.key, drawnKeys.indexOf(rect.key))
|
|
91
|
+
}
|
|
92
|
+
className="transition-opacity duration-150 data-[state=muted]:opacity-50"
|
|
93
|
+
style={
|
|
94
|
+
{
|
|
95
|
+
"--chart-enter-delay": `${rect.index * STAGGER_MS}ms`,
|
|
96
|
+
} as React.CSSProperties
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</g>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { ChartBars };
|
|
@@ -0,0 +1,105 @@
|
|
|
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 { ChartOrientation } from "#/core/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The mark under the reader's finger: a shaded band over the whole category on
|
|
9
|
+
* a bar chart, a single line through it on a line chart. Drawn behind the marks
|
|
10
|
+
* if you place it before them, which is usually what you want.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ChartCursorProps extends React.ComponentProps<"g"> {
|
|
14
|
+
readonly variant?: "band" | "line";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Where the cursor sits and how far it reaches, in plotting-area pixels. */
|
|
18
|
+
interface CursorGeometry {
|
|
19
|
+
readonly offset: number;
|
|
20
|
+
readonly thickness: number;
|
|
21
|
+
readonly innerWidth: number;
|
|
22
|
+
readonly innerHeight: number;
|
|
23
|
+
readonly orientation: ChartOrientation;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function CursorBand({
|
|
27
|
+
geometry,
|
|
28
|
+
className,
|
|
29
|
+
}: {
|
|
30
|
+
readonly geometry: CursorGeometry;
|
|
31
|
+
readonly className: string | undefined;
|
|
32
|
+
}) {
|
|
33
|
+
const isVertical = geometry.orientation === "vertical";
|
|
34
|
+
return (
|
|
35
|
+
<rect
|
|
36
|
+
x={isVertical ? geometry.offset : 0}
|
|
37
|
+
y={isVertical ? 0 : geometry.offset}
|
|
38
|
+
width={isVertical ? geometry.thickness : geometry.innerWidth}
|
|
39
|
+
height={isVertical ? geometry.innerHeight : geometry.thickness}
|
|
40
|
+
className={cn("fill-muted/60", className)}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function CursorLine({
|
|
46
|
+
geometry,
|
|
47
|
+
className,
|
|
48
|
+
}: {
|
|
49
|
+
readonly geometry: CursorGeometry;
|
|
50
|
+
readonly className: string | undefined;
|
|
51
|
+
}) {
|
|
52
|
+
const isVertical = geometry.orientation === "vertical";
|
|
53
|
+
return (
|
|
54
|
+
<line
|
|
55
|
+
x1={isVertical ? geometry.offset : 0}
|
|
56
|
+
x2={isVertical ? geometry.offset : geometry.innerWidth}
|
|
57
|
+
y1={isVertical ? 0 : geometry.offset}
|
|
58
|
+
y2={isVertical ? geometry.innerHeight : geometry.offset}
|
|
59
|
+
strokeDasharray="4 4"
|
|
60
|
+
className={cn("stroke-border", className)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ChartCursor({ className, variant, ...props }: ChartCursorProps) {
|
|
66
|
+
const {
|
|
67
|
+
active,
|
|
68
|
+
categories,
|
|
69
|
+
categoryScale,
|
|
70
|
+
orientation,
|
|
71
|
+
innerWidth,
|
|
72
|
+
innerHeight,
|
|
73
|
+
} = useChartContext();
|
|
74
|
+
|
|
75
|
+
const category = active === null ? undefined : categories[active.index];
|
|
76
|
+
if (category === undefined) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A band scale has slots wide enough to shade; a point scale does not, so it
|
|
81
|
+
// gets a line through the point instead.
|
|
82
|
+
const shape = variant ?? (categoryScale.kind === "band" ? "band" : "line");
|
|
83
|
+
const geometry: CursorGeometry = {
|
|
84
|
+
offset:
|
|
85
|
+
shape === "band"
|
|
86
|
+
? categoryScale.scale(category)
|
|
87
|
+
: categoryScale.center(category),
|
|
88
|
+
thickness: shape === "band" ? categoryScale.bandwidth : 0,
|
|
89
|
+
innerWidth,
|
|
90
|
+
innerHeight,
|
|
91
|
+
orientation,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<g data-slot="chart-cursor" data-variant={shape} {...props}>
|
|
96
|
+
{shape === "band" ? (
|
|
97
|
+
<CursorBand geometry={geometry} className={className} />
|
|
98
|
+
) : (
|
|
99
|
+
<CursorLine geometry={geometry} className={className} />
|
|
100
|
+
)}
|
|
101
|
+
</g>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { ChartCursor };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readNumber } from "#/core/chart-model.ts";
|
|
2
|
+
import { type ChartConfig, seriesLabelText } from "#/core/config.ts";
|
|
3
|
+
import { formatTickValue } from "#/core/format.ts";
|
|
4
|
+
import type { ChartDatum } from "#/core/types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The chart's content, as a table, for anyone who cannot read the picture.
|
|
8
|
+
* Visually hidden but present in the accessibility tree, so a screen reader
|
|
9
|
+
* gets the actual numbers rather than a one-line summary of them.
|
|
10
|
+
*/
|
|
11
|
+
export function ChartDataTable({
|
|
12
|
+
caption,
|
|
13
|
+
categoryLabel,
|
|
14
|
+
categories,
|
|
15
|
+
valueKeys,
|
|
16
|
+
data,
|
|
17
|
+
config,
|
|
18
|
+
}: {
|
|
19
|
+
readonly caption: string;
|
|
20
|
+
readonly categoryLabel: string;
|
|
21
|
+
readonly categories: ReadonlyArray<string>;
|
|
22
|
+
readonly valueKeys: ReadonlyArray<string>;
|
|
23
|
+
readonly data: ReadonlyArray<ChartDatum>;
|
|
24
|
+
readonly config: ChartConfig;
|
|
25
|
+
}) {
|
|
26
|
+
if (data.length === 0 || valueKeys.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<table data-slot="chart-data-table" className="sr-only">
|
|
32
|
+
<caption>{caption}</caption>
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<th scope="col">{categoryLabel}</th>
|
|
36
|
+
{valueKeys.map((key) => (
|
|
37
|
+
<th key={key} scope="col">
|
|
38
|
+
{seriesLabelText(config, key)}
|
|
39
|
+
</th>
|
|
40
|
+
))}
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
{data.map((datum, index) => (
|
|
45
|
+
<tr key={`${categories[index]}-${index}`}>
|
|
46
|
+
<th scope="row">{categories[index]}</th>
|
|
47
|
+
{valueKeys.map((key) => (
|
|
48
|
+
<td key={key}>{formatTickValue(readNumber(datum, key))}</td>
|
|
49
|
+
))}
|
|
50
|
+
</tr>
|
|
51
|
+
))}
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rendered in place of a chart when there is no data. Defaults to the root's
|
|
6
|
+
* aspect-video box — size it like the chart it stands in for. Pass localized
|
|
7
|
+
* content as children (plain text, or an `Empty` block).
|
|
8
|
+
*/
|
|
9
|
+
function ChartEmpty({ className, ...props }: React.ComponentProps<"div">) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="chart-empty"
|
|
13
|
+
className={cn(
|
|
14
|
+
"flex aspect-video w-full flex-col items-center justify-center gap-1 rounded-lg border border-dashed text-balance text-muted-foreground text-sm",
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { ChartEmpty };
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reference lines behind the marks. `horizontal` and `vertical` name the
|
|
8
|
+
* direction the lines run, not the axis they come from, so a vertical bar chart
|
|
9
|
+
* with `horizontal` grid lines reads the way it looks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ChartGridProps extends React.ComponentProps<"g"> {
|
|
13
|
+
readonly horizontal?: boolean;
|
|
14
|
+
readonly vertical?: boolean;
|
|
15
|
+
readonly tickCount?: number;
|
|
16
|
+
/** Dash pattern for the lines. Pass `undefined` for solid. */
|
|
17
|
+
readonly strokeDasharray?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ChartGrid({
|
|
21
|
+
className,
|
|
22
|
+
horizontal = true,
|
|
23
|
+
vertical = false,
|
|
24
|
+
tickCount = 5,
|
|
25
|
+
strokeDasharray = "3 3",
|
|
26
|
+
...props
|
|
27
|
+
}: ChartGridProps) {
|
|
28
|
+
const { categoryScale, valueScale, orientation, innerWidth, innerHeight } =
|
|
29
|
+
useChartContext();
|
|
30
|
+
const isVertical = orientation === "vertical";
|
|
31
|
+
|
|
32
|
+
// The value axis contributes evenly spaced lines; the category axis
|
|
33
|
+
// contributes one line per slot centre.
|
|
34
|
+
const valueOffsets = valueScale
|
|
35
|
+
.ticks(tickCount)
|
|
36
|
+
.map((tick) => valueScale.scale(tick));
|
|
37
|
+
const categoryOffsets = categoryScale.domain.map((category) =>
|
|
38
|
+
categoryScale.center(category),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const horizontalOffsets = isVertical ? valueOffsets : categoryOffsets;
|
|
42
|
+
const verticalOffsets = isVertical ? categoryOffsets : valueOffsets;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<g
|
|
46
|
+
data-slot="chart-grid"
|
|
47
|
+
className={cn("stroke-border/60", className)}
|
|
48
|
+
strokeDasharray={strokeDasharray}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{horizontal
|
|
52
|
+
? horizontalOffsets.map((offset) => (
|
|
53
|
+
<line
|
|
54
|
+
key={`horizontal-${offset}`}
|
|
55
|
+
data-slot="chart-grid-line"
|
|
56
|
+
data-direction="horizontal"
|
|
57
|
+
x1={0}
|
|
58
|
+
x2={innerWidth}
|
|
59
|
+
y1={offset}
|
|
60
|
+
y2={offset}
|
|
61
|
+
/>
|
|
62
|
+
))
|
|
63
|
+
: null}
|
|
64
|
+
{vertical
|
|
65
|
+
? verticalOffsets.map((offset) => (
|
|
66
|
+
<line
|
|
67
|
+
key={`vertical-${offset}`}
|
|
68
|
+
data-slot="chart-grid-line"
|
|
69
|
+
data-direction="vertical"
|
|
70
|
+
x1={offset}
|
|
71
|
+
x2={offset}
|
|
72
|
+
y1={0}
|
|
73
|
+
y2={innerHeight}
|
|
74
|
+
/>
|
|
75
|
+
))
|
|
76
|
+
: null}
|
|
77
|
+
</g>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { ChartGrid };
|