@shadow-js/ui 0.0.0-stage → 1.0.1
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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/dist/cli.mjs +2281 -0
- package/dist/dev/cli/schema.d.ts +71 -0
- package/dist/dev/index.d.ts +6 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/locales/ar.d.ts +139 -0
- package/dist/dev/locales/ar.js +127 -0
- package/dist/dev/locales/ar.js.map +1 -0
- package/dist/dev/locales/en.d.ts +139 -0
- package/dist/dev/locales/en.js +127 -0
- package/dist/dev/locales/en.js.map +1 -0
- package/dist/dev/registry/utils/utils.d.ts +27 -0
- package/dist/dev/registry/utils/utils.js +26 -0
- package/dist/dev/registry/utils/utils.js.map +1 -0
- package/dist/dev/theme/theme-logic.d.ts +185 -0
- package/dist/dev/theme/theme-logic.js +183 -0
- package/dist/dev/theme/theme-logic.js.map +1 -0
- package/dist/dev/theme/theme-provider.d.ts +88 -0
- package/dist/dev/theme/theme-provider.js +149 -0
- package/dist/dev/theme/theme-provider.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/locales/ar.js +2 -0
- package/dist/prod/locales/ar.js.map +1 -0
- package/dist/prod/locales/en.js +2 -0
- package/dist/prod/locales/en.js.map +1 -0
- package/dist/prod/registry/utils/utils.js +2 -0
- package/dist/prod/registry/utils/utils.js.map +1 -0
- package/dist/prod/theme/theme-logic.js +2 -0
- package/dist/prod/theme/theme-logic.js.map +1 -0
- package/dist/prod/theme/theme-provider.js +2 -0
- package/dist/prod/theme/theme-provider.js.map +1 -0
- package/package.json +102 -4
- package/registry/accordion/accordion.tsx +210 -0
- package/registry/accordion/item.json +4 -0
- package/registry/alert/alert.tsx +200 -0
- package/registry/alert/item.json +4 -0
- package/registry/alert-dialog/alert-dialog.tsx +530 -0
- package/registry/alert-dialog/item.json +4 -0
- package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
- package/registry/aspect-ratio/item.json +4 -0
- package/registry/attachment/attachment.tsx +459 -0
- package/registry/attachment/item.json +4 -0
- package/registry/avatar/avatar.tsx +385 -0
- package/registry/avatar/item.json +4 -0
- package/registry/badge/badge.tsx +108 -0
- package/registry/badge/item.json +4 -0
- package/registry/breadcrumb/breadcrumb.tsx +354 -0
- package/registry/breadcrumb/item.json +4 -0
- package/registry/bubble/bubble.tsx +260 -0
- package/registry/bubble/item.json +4 -0
- package/registry/button/button.tsx +125 -0
- package/registry/button/item.json +4 -0
- package/registry/button-group/button-group.tsx +189 -0
- package/registry/button-group/item.json +4 -0
- package/registry/calendar/calendar.tsx +348 -0
- package/registry/calendar/item.json +4 -0
- package/registry/card/card.tsx +311 -0
- package/registry/card/item.json +4 -0
- package/registry/carousel/carousel.tsx +851 -0
- package/registry/carousel/item.json +4 -0
- package/registry/chart/chart.tsx +1956 -0
- package/registry/chart/item.json +4 -0
- package/registry/chart-scale/chart-scale.ts +670 -0
- package/registry/chart-scale/item.json +5 -0
- package/registry/checkbox/checkbox.tsx +79 -0
- package/registry/checkbox/item.json +4 -0
- package/registry/code-block/code-block.tsx +470 -0
- package/registry/code-block/item.json +4 -0
- package/registry/collapsible/collapsible.tsx +117 -0
- package/registry/collapsible/item.json +4 -0
- package/registry/combobox/combobox.tsx +977 -0
- package/registry/combobox/item.json +4 -0
- package/registry/command/command.tsx +757 -0
- package/registry/command/item.json +4 -0
- package/registry/context-menu/context-menu.tsx +697 -0
- package/registry/context-menu/item.json +4 -0
- package/registry/data-table/data-table.tsx +437 -0
- package/registry/data-table/item.json +4 -0
- package/registry/date-picker/date-picker.tsx +349 -0
- package/registry/date-picker/item.json +4 -0
- package/registry/dialog/dialog.tsx +412 -0
- package/registry/dialog/item.json +4 -0
- package/registry/direction/direction.tsx +88 -0
- package/registry/direction/item.json +4 -0
- package/registry/drawer/drawer.tsx +787 -0
- package/registry/drawer/item.json +4 -0
- package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
- package/registry/dropdown-menu/item.json +4 -0
- package/registry/empty/empty.tsx +240 -0
- package/registry/empty/item.json +4 -0
- package/registry/field/field.tsx +624 -0
- package/registry/field/item.json +4 -0
- package/registry/form/form.tsx +1112 -0
- package/registry/form/item.json +4 -0
- package/registry/hover-card/hover-card.tsx +144 -0
- package/registry/hover-card/item.json +4 -0
- package/registry/icons/icons.tsx +1691 -0
- package/registry/icons/item.json +4 -0
- package/registry/input/input.tsx +43 -0
- package/registry/input/item.json +4 -0
- package/registry/input-group/input-group.tsx +315 -0
- package/registry/input-group/item.json +4 -0
- package/registry/input-otp/input-otp.tsx +329 -0
- package/registry/input-otp/item.json +4 -0
- package/registry/item/item.json +4 -0
- package/registry/item/item.tsx +439 -0
- package/registry/kbd/item.json +4 -0
- package/registry/kbd/kbd.tsx +65 -0
- package/registry/label/item.json +4 -0
- package/registry/label/label.tsx +60 -0
- package/registry/marker/item.json +4 -0
- package/registry/marker/marker.tsx +175 -0
- package/registry/menubar/item.json +4 -0
- package/registry/menubar/menubar.tsx +694 -0
- package/registry/message/item.json +4 -0
- package/registry/message/message.tsx +280 -0
- package/registry/message-scroller/item.json +4 -0
- package/registry/message-scroller/message-scroller.tsx +262 -0
- package/registry/native-select/item.json +4 -0
- package/registry/native-select/native-select.tsx +113 -0
- package/registry/navigation-menu/item.json +4 -0
- package/registry/navigation-menu/navigation-menu.tsx +362 -0
- package/registry/pagination/item.json +4 -0
- package/registry/pagination/pagination.tsx +460 -0
- package/registry/password-input/item.json +5 -0
- package/registry/password-input/password-input.tsx +494 -0
- package/registry/popover/item.json +4 -0
- package/registry/popover/popover.tsx +284 -0
- package/registry/progress/item.json +4 -0
- package/registry/progress/progress.tsx +190 -0
- package/registry/questionnaire/item.json +4 -0
- package/registry/questionnaire/questionnaire.tsx +569 -0
- package/registry/radio-group/item.json +4 -0
- package/registry/radio-group/radio-group.tsx +101 -0
- package/registry/resizable/item.json +4 -0
- package/registry/resizable/resizable.tsx +455 -0
- package/registry/scroll-area/item.json +4 -0
- package/registry/scroll-area/scroll-area.tsx +149 -0
- package/registry/select/item.json +4 -0
- package/registry/select/select.tsx +571 -0
- package/registry/separator/item.json +4 -0
- package/registry/separator/separator.tsx +52 -0
- package/registry/sheet/item.json +4 -0
- package/registry/sheet/sheet.tsx +476 -0
- package/registry/sidebar/item.json +4 -0
- package/registry/sidebar/sidebar.tsx +1870 -0
- package/registry/skeleton/item.json +4 -0
- package/registry/skeleton/skeleton.tsx +47 -0
- package/registry/slider/item.json +4 -0
- package/registry/slider/slider.tsx +149 -0
- package/registry/spinner/item.json +4 -0
- package/registry/spinner/spinner.tsx +51 -0
- package/registry/switch/item.json +4 -0
- package/registry/switch/switch.tsx +93 -0
- package/registry/table/item.json +4 -0
- package/registry/table/table.tsx +373 -0
- package/registry/tabs/item.json +4 -0
- package/registry/tabs/tabs.tsx +209 -0
- package/registry/textarea/item.json +4 -0
- package/registry/textarea/textarea.tsx +51 -0
- package/registry/toast/item.json +4 -0
- package/registry/toast/toast.tsx +949 -0
- package/registry/toggle/item.json +4 -0
- package/registry/toggle/toggle.tsx +110 -0
- package/registry/toggle-group/item.json +4 -0
- package/registry/toggle-group/toggle-group.tsx +189 -0
- package/registry/tooltip/item.json +4 -0
- package/registry/tooltip/tooltip.tsx +169 -0
- package/registry/trace/item.json +4 -0
- package/registry/trace/trace.tsx +356 -0
- package/registry/ui-strings/item.json +5 -0
- package/registry/ui-strings/ui-strings.tsx +454 -0
- package/registry/use-highlighted-code/item.json +5 -0
- package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
- package/registry/use-mobile/item.json +5 -0
- package/registry/use-mobile/use-mobile.ts +46 -0
- package/registry/utils/item.json +5 -0
- package/registry/utils/utils.ts +38 -0
- package/registry.json +3274 -0
- package/styles/animations.css +321 -0
- package/styles/theme-core.css +55 -0
- package/styles/theme.css +12 -0
- package/styles/themes/gray.css +69 -0
- package/styles/themes/neutral.css +69 -0
- package/styles/themes/slate.css +69 -0
- package/styles/themes/stone.css +69 -0
- package/styles/themes/zinc.css +69 -0
- package/styles/typeset.css +251 -0
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
// biome-ignore-all lint/a11y/noAriaHiddenOnFocusable: the SVG drawing parts that are hidden (grid, labels, shapes) are never focusable; the rule cannot tell them from interactive elements
|
|
2
|
+
// biome-ignore-all lint/a11y/noInteractiveElementToNoninteractiveRole: a data point is a focus stop that carries a name, the pattern charting libraries use for keyboard access to points
|
|
3
|
+
import {
|
|
4
|
+
type Accessor,
|
|
5
|
+
children,
|
|
6
|
+
onCleanup,
|
|
7
|
+
onMount,
|
|
8
|
+
splitProps,
|
|
9
|
+
useMemo,
|
|
10
|
+
useSignal,
|
|
11
|
+
} from "@shadow-js/core";
|
|
12
|
+
import {
|
|
13
|
+
createPartContext,
|
|
14
|
+
type Direction,
|
|
15
|
+
getRovingTarget,
|
|
16
|
+
useDirection,
|
|
17
|
+
useDirectionAround,
|
|
18
|
+
useRefSignal,
|
|
19
|
+
} from "@shadow-js/primitives";
|
|
20
|
+
import { type ComponentProps, For, type JSX, Show } from "@shadow-js/web";
|
|
21
|
+
import {
|
|
22
|
+
arcPath,
|
|
23
|
+
areaPath,
|
|
24
|
+
bandPositions,
|
|
25
|
+
type ChartCurve,
|
|
26
|
+
cssKey,
|
|
27
|
+
decimateIndices,
|
|
28
|
+
linearScale,
|
|
29
|
+
linePath,
|
|
30
|
+
nearestIndex,
|
|
31
|
+
niceDomain,
|
|
32
|
+
niceMax,
|
|
33
|
+
niceTicks,
|
|
34
|
+
pieAngles,
|
|
35
|
+
pointPositions,
|
|
36
|
+
polarPoint,
|
|
37
|
+
slicePath,
|
|
38
|
+
stackColumns,
|
|
39
|
+
} from "@/lib/chart-scale";
|
|
40
|
+
import { useUiLocale, useUiStrings } from "@/lib/ui-strings";
|
|
41
|
+
import { cn } from "@/lib/utils";
|
|
42
|
+
|
|
43
|
+
export { areaPath, linearScale, linePath, nearestIndex, niceMax, polarPoint, slicePath };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An icon for a series, drawn instead of the color swatch in the legend and the tooltip. Any component
|
|
47
|
+
* that takes a `class` and renders an inline SVG fits, such as the icons of `@/components/ui/icons`.
|
|
48
|
+
* @see {@link ChartConfig}
|
|
49
|
+
* @since 1.0.0
|
|
50
|
+
*/
|
|
51
|
+
export type ChartIcon = (props: { readonly class?: string }) => JSX.Element;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The series of a chart, keyed by the data key of each series (or by the name of each slice of a pie).
|
|
55
|
+
* Each series has a `label` for the legend and the tooltip, an optional `color` and an optional `icon`.
|
|
56
|
+
* Without a color a series uses the theme's `--chart-1` to `--chart-5` tokens, in order, cycling.
|
|
57
|
+
* @see {@link ChartContainer}
|
|
58
|
+
* @since 1.0.0
|
|
59
|
+
*/
|
|
60
|
+
export type ChartConfig = Readonly<
|
|
61
|
+
Record<
|
|
62
|
+
string,
|
|
63
|
+
{
|
|
64
|
+
/** The name shown in the legend and the tooltip. */
|
|
65
|
+
readonly label: string;
|
|
66
|
+
/** Any CSS color. @default the theme's `--chart-1` to `--chart-5`, by series position */
|
|
67
|
+
readonly color?: string | undefined;
|
|
68
|
+
/** An icon shown instead of the color swatch. @default none */
|
|
69
|
+
readonly icon?: ChartIcon | undefined;
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One row of chart data: an object with the category value (`xKey`, or `nameKey` for a pie) and one
|
|
76
|
+
* numeric value per series key.
|
|
77
|
+
* @see {@link ChartSeriesProps}
|
|
78
|
+
* @since 1.0.0
|
|
79
|
+
*/
|
|
80
|
+
export type ChartDatum = Readonly<Record<string, unknown>>;
|
|
81
|
+
|
|
82
|
+
/** The color of a series: its config color, else a theme chart token. @internal */
|
|
83
|
+
function seriesColor(config: ChartConfig, key: string, index: number): string {
|
|
84
|
+
return config[key]?.color ?? `var(--chart-${(index % 5) + 1})`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The label of a series. @internal */
|
|
88
|
+
function seriesLabel(config: ChartConfig, key: string): string {
|
|
89
|
+
return config[key]?.label ?? key;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A value as a finite number: anything else counts as zero. @internal */
|
|
93
|
+
function num(value: unknown): number {
|
|
94
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
95
|
+
return Number.isFinite(number) ? number : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Most data points drawn before a series is thinned. @internal */
|
|
99
|
+
const DEFAULT_MAX_POINTS = 5000;
|
|
100
|
+
/** Most rows in the table for screen readers. @internal */
|
|
101
|
+
const TABLE_LIMIT = 500;
|
|
102
|
+
/** Most points that get a focus target each; past it the chart itself is the one tab stop. @internal */
|
|
103
|
+
const TARGET_LIMIT = 200;
|
|
104
|
+
|
|
105
|
+
/** What a chart container shares with the charts inside it. @internal */
|
|
106
|
+
interface ChartContextValue {
|
|
107
|
+
readonly config: Accessor<ChartConfig>;
|
|
108
|
+
readonly width: Accessor<number>;
|
|
109
|
+
readonly dir: Accessor<Direction>;
|
|
110
|
+
readonly locale: Accessor<string>;
|
|
111
|
+
readonly format: Accessor<Intl.NumberFormat>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const chartContext = createPartContext<ChartContextValue>("ChartContainer");
|
|
115
|
+
|
|
116
|
+
/** A number format for a locale; an invalid locale tag falls back to US English. @internal */
|
|
117
|
+
function makeFormat(locale: string): Intl.NumberFormat {
|
|
118
|
+
try {
|
|
119
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof RangeError)) throw error;
|
|
122
|
+
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The settings of the container above, or those of a chart that has none. @internal */
|
|
127
|
+
function useEnvironment(): ChartContextValue {
|
|
128
|
+
const container = chartContext.useOptional();
|
|
129
|
+
const uiLocale = useUiLocale();
|
|
130
|
+
const direction = useDirection();
|
|
131
|
+
const locale = (): string => uiLocale() ?? "en-US";
|
|
132
|
+
const format = useMemo(() => makeFormat(locale()));
|
|
133
|
+
return (
|
|
134
|
+
container ?? {
|
|
135
|
+
config: () => ({}),
|
|
136
|
+
width: () => 600,
|
|
137
|
+
dir: direction,
|
|
138
|
+
locale,
|
|
139
|
+
format,
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Props of {@link ChartContainer}: the series configuration and the initial width, plus the attributes of
|
|
146
|
+
* the rendered `div`.
|
|
147
|
+
* @see {@link ChartContainer}
|
|
148
|
+
* @since 1.0.0
|
|
149
|
+
*/
|
|
150
|
+
export interface ChartContainerProps extends ComponentProps<"div"> {
|
|
151
|
+
/**
|
|
152
|
+
* The series: a label, and optionally a color and an icon, for each key used in the data.
|
|
153
|
+
* @default none (required)
|
|
154
|
+
*/
|
|
155
|
+
readonly config: ChartConfig;
|
|
156
|
+
/**
|
|
157
|
+
* The width in pixels used before the container is measured. The charts then follow the measured width.
|
|
158
|
+
* @default 600
|
|
159
|
+
*/
|
|
160
|
+
readonly initialWidth?: number | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* The language of the numbers (axis labels, tooltip, table), such as `"ar-EG"` for Arabic-Indic digits.
|
|
163
|
+
* Pass it explicitly when the server and the browser could disagree about the default.
|
|
164
|
+
* @default the locale of the UI strings provider, else `"en-US"`
|
|
165
|
+
*/
|
|
166
|
+
readonly locale?: string | undefined;
|
|
167
|
+
/**
|
|
168
|
+
* The reading direction. In right-to-left text the x axis runs from right to left, the y axis sits on the
|
|
169
|
+
* right, and the arrow keys follow what is on screen.
|
|
170
|
+
* @default a direction provider, else the nearest `dir` attribute above the chart, else the document's
|
|
171
|
+
*/
|
|
172
|
+
readonly dir?: Direction | undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The frame of a chart. It measures its own width with a `ResizeObserver` (started after mount, and
|
|
177
|
+
* disconnected when the container is removed), so the charts inside fit it, and it gives each series
|
|
178
|
+
* color to the charts inside as the `--color-<key>` CSS variable. It also gives them the locale of the
|
|
179
|
+
* numbers and the reading direction. Put one or more charts inside it.
|
|
180
|
+
*
|
|
181
|
+
* Keyboard and ARIA: the container has no role. Each chart is an `svg` named by its kind and series
|
|
182
|
+
* (`role="group"` with `aria-roledescription="chart"`), whose data points are one roving tab stop: the
|
|
183
|
+
* arrow keys move between them (they follow the screen in right-to-left text), Home and End jump to the
|
|
184
|
+
* ends, and Escape hides the tooltip. The focused point is announced through a polite live region. A
|
|
185
|
+
* visually hidden table holds every value for screen readers.
|
|
186
|
+
*
|
|
187
|
+
* shadcn/ui differences: the prop is `class`, not `className`, and every part has a `data-slot`. The chart
|
|
188
|
+
* is drawn by ShadowJS with no charting library, so `ChartTooltip` and `ChartLegend` are parts of one of the
|
|
189
|
+
* chart components below instead of Recharts children, and a color is one CSS color (no `theme` pair: use a
|
|
190
|
+
* token such as `var(--chart-1)` that follows the theme).
|
|
191
|
+
*
|
|
192
|
+
* @param props The container props: see {@link ChartContainerProps}.
|
|
193
|
+
* @returns The container.
|
|
194
|
+
* @example
|
|
195
|
+
* import { ChartArea, ChartContainer } from "@/components/ui/chart";
|
|
196
|
+
*
|
|
197
|
+
* const rows = [
|
|
198
|
+
* { day: "Mon", visits: 120 },
|
|
199
|
+
* { day: "Tue", visits: 180 },
|
|
200
|
+
* ];
|
|
201
|
+
*
|
|
202
|
+
* export function Visits() {
|
|
203
|
+
* return (
|
|
204
|
+
* <ChartContainer config={{ visits: { label: "Visits" } }}>
|
|
205
|
+
* <ChartArea data={rows} xKey="day" series={["visits"]} />
|
|
206
|
+
* </ChartContainer>
|
|
207
|
+
* );
|
|
208
|
+
* }
|
|
209
|
+
* @see {@link ChartArea}
|
|
210
|
+
* @see {@link ChartConfig}
|
|
211
|
+
* @since 1.0.0
|
|
212
|
+
*/
|
|
213
|
+
function ChartContainer(props: ChartContainerProps): JSX.Element {
|
|
214
|
+
const [local, rest] = splitProps(props, [
|
|
215
|
+
"class",
|
|
216
|
+
"config",
|
|
217
|
+
"initialWidth",
|
|
218
|
+
"locale",
|
|
219
|
+
"dir",
|
|
220
|
+
"children",
|
|
221
|
+
]);
|
|
222
|
+
const uiLocale = useUiLocale();
|
|
223
|
+
const [width, setWidth] = useSignal<number>((local.initialWidth as number | undefined) ?? 600);
|
|
224
|
+
const [element, setElement] = useRefSignal<HTMLElement>();
|
|
225
|
+
let node: HTMLElement | undefined;
|
|
226
|
+
const dir = useDirectionAround(() => local.dir as Direction | undefined, element);
|
|
227
|
+
const config = (): ChartConfig => local.config as ChartConfig;
|
|
228
|
+
const locale = (): string => (local.locale as string | undefined) ?? uiLocale() ?? "en-US";
|
|
229
|
+
const format = useMemo(() => makeFormat(locale()));
|
|
230
|
+
// Started after mount, so the server render and the first client render measure nothing.
|
|
231
|
+
onMount(() => {
|
|
232
|
+
setElement(node);
|
|
233
|
+
if (node === undefined || typeof ResizeObserver === "undefined") return;
|
|
234
|
+
const observer = new ResizeObserver((entries) => {
|
|
235
|
+
const measured = entries[0]?.contentRect.width ?? 0;
|
|
236
|
+
if (measured > 0) setWidth(Math.round(measured));
|
|
237
|
+
});
|
|
238
|
+
observer.observe(node);
|
|
239
|
+
onCleanup(() => observer.disconnect());
|
|
240
|
+
});
|
|
241
|
+
const variables = (): Record<string, string> => {
|
|
242
|
+
const entries = Object.keys(config()).map((key, index) => [
|
|
243
|
+
`--color-${cssKey(key)}`,
|
|
244
|
+
seriesColor(config(), key, index),
|
|
245
|
+
]);
|
|
246
|
+
return Object.fromEntries(entries);
|
|
247
|
+
};
|
|
248
|
+
return (
|
|
249
|
+
<chartContext.Provider value={{ config, width, dir, locale, format }}>
|
|
250
|
+
<div
|
|
251
|
+
data-slot="chart-container"
|
|
252
|
+
ref={(created: HTMLElement) => {
|
|
253
|
+
node = created;
|
|
254
|
+
}}
|
|
255
|
+
style={variables()}
|
|
256
|
+
class={cn("flex w-full justify-center text-xs", local.class)}
|
|
257
|
+
{...rest}
|
|
258
|
+
>
|
|
259
|
+
{local.children as JSX.Element}
|
|
260
|
+
</div>
|
|
261
|
+
</chartContext.Provider>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One line of the tooltip: a series and its value at the active point. {@link ChartTooltipContent} draws
|
|
267
|
+
* one for each series, and its `formatter` receives it.
|
|
268
|
+
* @see {@link ChartTooltipContent}
|
|
269
|
+
* @since 1.0.0
|
|
270
|
+
*/
|
|
271
|
+
export interface ChartTooltipRow {
|
|
272
|
+
/** The data key of the series (the name of the slice in a pie). */
|
|
273
|
+
readonly key: string;
|
|
274
|
+
/** The label of the series from the chart config. */
|
|
275
|
+
readonly label: string;
|
|
276
|
+
/** The color of the series. */
|
|
277
|
+
readonly color: string;
|
|
278
|
+
/** The icon of the series from the chart config, if it has one. */
|
|
279
|
+
readonly icon: ChartIcon | undefined;
|
|
280
|
+
/** The value. */
|
|
281
|
+
readonly value: number;
|
|
282
|
+
/** The value as text, in the locale of the chart (or from `valueFormatter`). */
|
|
283
|
+
readonly text: string;
|
|
284
|
+
/** The share of the whole as text (`"55%"`), for the slices of a pie. Absent elsewhere. */
|
|
285
|
+
readonly share: string | undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The data point a pointer or the keyboard is on, as the tooltip, the legend and the live region see it.
|
|
290
|
+
* @see {@link ChartTooltipContent}
|
|
291
|
+
* @since 1.0.0
|
|
292
|
+
*/
|
|
293
|
+
export interface ChartActivePoint {
|
|
294
|
+
/** The position of the point among the points that are drawn. */
|
|
295
|
+
readonly index: number;
|
|
296
|
+
/** The category of the point (the x value); empty for a pie and a radial chart. */
|
|
297
|
+
readonly label: string;
|
|
298
|
+
/** The values at the point, one for each series. */
|
|
299
|
+
readonly rows: readonly ChartTooltipRow[];
|
|
300
|
+
/** The text the live region announces. */
|
|
301
|
+
readonly text: string;
|
|
302
|
+
/** The inline style that places the tooltip next to the point. */
|
|
303
|
+
readonly style: Readonly<Record<string, string>>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One entry of the legend. @internal */
|
|
307
|
+
interface ChartLegendItem {
|
|
308
|
+
readonly key: string;
|
|
309
|
+
readonly label: string;
|
|
310
|
+
readonly color: string;
|
|
311
|
+
readonly icon: ChartIcon | undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What a chart shares with its tooltip and legend parts. @internal */
|
|
315
|
+
interface ChartPlotValue {
|
|
316
|
+
readonly items: Accessor<readonly ChartLegendItem[]>;
|
|
317
|
+
readonly active: Accessor<ChartActivePoint | undefined>;
|
|
318
|
+
/** Set by the parts, while they render, so the chart knows not to draw its default ones. */
|
|
319
|
+
readonly slots: { tooltip: boolean; legend: boolean };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const plotContext = createPartContext<ChartPlotValue>("Chart");
|
|
323
|
+
|
|
324
|
+
/** The state of the active point and the keys, the pointer and the focus that move it. @internal */
|
|
325
|
+
function useActivePoint(options: {
|
|
326
|
+
readonly count: Accessor<number>;
|
|
327
|
+
readonly dir: Accessor<Direction>;
|
|
328
|
+
readonly targets: Accessor<boolean>;
|
|
329
|
+
readonly root: Accessor<Element | undefined>;
|
|
330
|
+
}) {
|
|
331
|
+
const [active, setActive] = useSignal(-1);
|
|
332
|
+
const [current, setCurrent] = useSignal(0);
|
|
333
|
+
let focusInside = false;
|
|
334
|
+
let pointerInside = false;
|
|
335
|
+
|
|
336
|
+
const goTo = (index: number): void => {
|
|
337
|
+
setCurrent(index);
|
|
338
|
+
setActive(index);
|
|
339
|
+
if (options.targets()) {
|
|
340
|
+
const target = options.root()?.querySelector<SVGElement>(`[data-chart-point="${index}"]`);
|
|
341
|
+
target?.focus({ preventScroll: true });
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
active,
|
|
347
|
+
current,
|
|
348
|
+
show: (index: number): void => {
|
|
349
|
+
pointerInside = true;
|
|
350
|
+
if (index !== active()) setActive(index);
|
|
351
|
+
},
|
|
352
|
+
clear: (): void => {
|
|
353
|
+
setActive(-1);
|
|
354
|
+
},
|
|
355
|
+
leave: (touch: boolean): void => {
|
|
356
|
+
pointerInside = false;
|
|
357
|
+
// A finger that lifts leaves the chart at once; its tooltip stays until the next tap elsewhere.
|
|
358
|
+
if (!touch && !focusInside) setActive(-1);
|
|
359
|
+
},
|
|
360
|
+
focusIn: (index: number | undefined): void => {
|
|
361
|
+
focusInside = true;
|
|
362
|
+
if (index !== undefined) {
|
|
363
|
+
setCurrent(index);
|
|
364
|
+
setActive(index);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
focusOut: (): void => {
|
|
368
|
+
focusInside = false;
|
|
369
|
+
if (!pointerInside) setActive(-1);
|
|
370
|
+
},
|
|
371
|
+
key: (event: KeyboardEvent): void => {
|
|
372
|
+
if (event.key === "Escape") {
|
|
373
|
+
if (active() >= 0) setActive(-1);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const count = options.count();
|
|
377
|
+
if (count === 0) return;
|
|
378
|
+
const from = active() >= 0 ? active() : options.targets() ? current() : -1;
|
|
379
|
+
const next = getRovingTarget({
|
|
380
|
+
key: event.key,
|
|
381
|
+
orientation: "horizontal",
|
|
382
|
+
dir: options.dir(),
|
|
383
|
+
loop: false,
|
|
384
|
+
disabled: new Array<boolean>(count).fill(false),
|
|
385
|
+
current: from,
|
|
386
|
+
});
|
|
387
|
+
if (next === undefined) return;
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
goTo(next);
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Hides the active point when a pointer goes down anywhere outside the chart (a tap elsewhere). @internal */
|
|
395
|
+
function dismissOutside(root: Accessor<Element | undefined>, clear: () => void): void {
|
|
396
|
+
onMount(() => {
|
|
397
|
+
const listener = (event: Event): void => {
|
|
398
|
+
const element = root();
|
|
399
|
+
if (element !== undefined && !element.contains(event.target as Node | null)) clear();
|
|
400
|
+
};
|
|
401
|
+
document.addEventListener("pointerdown", listener);
|
|
402
|
+
onCleanup(() => document.removeEventListener("pointerdown", listener));
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Props of the cartesian charts: {@link ChartArea}, {@link ChartLine} and {@link ChartBar}. They take the
|
|
408
|
+
* rows, the category key, the series keys and the layout, plus the attributes of the rendered `figure`.
|
|
409
|
+
* `children` may hold a {@link ChartTooltip} and a {@link ChartLegend}, which replace the default ones.
|
|
410
|
+
* @see {@link ChartArea}
|
|
411
|
+
* @since 1.0.0
|
|
412
|
+
*/
|
|
413
|
+
export interface ChartSeriesProps extends ComponentProps<"figure"> {
|
|
414
|
+
/**
|
|
415
|
+
* The rows to draw, in order along the x axis. The y axis starts at zero (and goes below it for negative
|
|
416
|
+
* values) unless `yDomain` says otherwise. A very long series is thinned for drawing: see `maxPoints`.
|
|
417
|
+
* @default none (required)
|
|
418
|
+
*/
|
|
419
|
+
readonly data: readonly ChartDatum[];
|
|
420
|
+
/**
|
|
421
|
+
* The key of the category (x) value in each row, such as `"month"`.
|
|
422
|
+
* @default none (required)
|
|
423
|
+
*/
|
|
424
|
+
readonly xKey: string;
|
|
425
|
+
/**
|
|
426
|
+
* The keys of the series to draw, each one a numeric value in the rows.
|
|
427
|
+
* @default none (required)
|
|
428
|
+
*/
|
|
429
|
+
readonly series: readonly string[];
|
|
430
|
+
/**
|
|
431
|
+
* The height of the plot in pixels.
|
|
432
|
+
* @default 240
|
|
433
|
+
*/
|
|
434
|
+
readonly height?: number | undefined;
|
|
435
|
+
/**
|
|
436
|
+
* Shows the default legend under the chart. A {@link ChartLegend} in `children` replaces it.
|
|
437
|
+
* @default true
|
|
438
|
+
*/
|
|
439
|
+
readonly legend?: boolean | undefined;
|
|
440
|
+
/**
|
|
441
|
+
* Shows the default tooltip next to the active point. A {@link ChartTooltip} in `children` replaces it.
|
|
442
|
+
* Turning it off does not silence the live region, which announces the active point either way.
|
|
443
|
+
* @default true
|
|
444
|
+
*/
|
|
445
|
+
readonly tooltip?: boolean | undefined;
|
|
446
|
+
/**
|
|
447
|
+
* Grid lines: `true` draws one for each y tick, `"both"` adds one for each x value, `false` draws none.
|
|
448
|
+
* @default true
|
|
449
|
+
*/
|
|
450
|
+
readonly grid?: boolean | "both" | undefined;
|
|
451
|
+
/**
|
|
452
|
+
* Shows the x axis (the category labels).
|
|
453
|
+
* @default true
|
|
454
|
+
*/
|
|
455
|
+
readonly xAxis?: boolean | undefined;
|
|
456
|
+
/**
|
|
457
|
+
* Shows the y axis (the value labels).
|
|
458
|
+
* @default true
|
|
459
|
+
*/
|
|
460
|
+
readonly yAxis?: boolean | undefined;
|
|
461
|
+
/**
|
|
462
|
+
* How a line or an area joins its points: `"linear"`, `"monotone"` (smooth, no overshoot) or `"step"`.
|
|
463
|
+
* Bars ignore it.
|
|
464
|
+
* @default "linear"
|
|
465
|
+
*/
|
|
466
|
+
readonly curve?: ChartCurve | undefined;
|
|
467
|
+
/**
|
|
468
|
+
* Stacks the series on top of each other, for bars and areas. The y axis then reaches the sum.
|
|
469
|
+
* @default false
|
|
470
|
+
*/
|
|
471
|
+
readonly stacked?: boolean | undefined;
|
|
472
|
+
/**
|
|
473
|
+
* Draws a dot on every point of a line or an area (the active point always has dots).
|
|
474
|
+
* @default false
|
|
475
|
+
*/
|
|
476
|
+
readonly dots?: boolean | undefined;
|
|
477
|
+
/**
|
|
478
|
+
* The lowest and the highest value of the y axis, instead of the range of the data.
|
|
479
|
+
* @default the data, rounded to a nice number and including zero
|
|
480
|
+
*/
|
|
481
|
+
readonly yDomain?: readonly [number, number] | undefined;
|
|
482
|
+
/**
|
|
483
|
+
* The text of an x label, from the category and its position.
|
|
484
|
+
* @default the category as it is
|
|
485
|
+
*/
|
|
486
|
+
readonly xTickFormatter?: ((value: string, index: number) => string) | undefined;
|
|
487
|
+
/**
|
|
488
|
+
* The text of a y label.
|
|
489
|
+
* @default the number in the locale of the chart
|
|
490
|
+
*/
|
|
491
|
+
readonly yTickFormatter?: ((value: number) => string) | undefined;
|
|
492
|
+
/**
|
|
493
|
+
* The text of a value in the tooltip, the table and the live region.
|
|
494
|
+
* @default the number in the locale of the chart
|
|
495
|
+
*/
|
|
496
|
+
readonly valueFormatter?: ((value: number, key: string) => string) | undefined;
|
|
497
|
+
/**
|
|
498
|
+
* The most points drawn. A longer series is thinned: each run of rows keeps its lowest and highest value
|
|
499
|
+
* of every series, so a spike is never lost, and the first and last rows. The screen reader table is
|
|
500
|
+
* thinned the same way, to at most 500 rows.
|
|
501
|
+
* @default 5000
|
|
502
|
+
*/
|
|
503
|
+
readonly maxPoints?: number | undefined;
|
|
504
|
+
/**
|
|
505
|
+
* Draws the cursor (a line, or a band behind a bar) at the active point.
|
|
506
|
+
* @default true
|
|
507
|
+
*/
|
|
508
|
+
readonly cursor?: boolean | undefined;
|
|
509
|
+
/**
|
|
510
|
+
* The accessible name of the chart.
|
|
511
|
+
* @default "Line chart of Sales, Returns" (the kind and the series labels, from the UI strings)
|
|
512
|
+
*/
|
|
513
|
+
readonly label?: string | undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** A cartesian chart of one kind. @internal */
|
|
517
|
+
function Cartesian(
|
|
518
|
+
props: ChartSeriesProps & { readonly kind: "line" | "area" | "bar" },
|
|
519
|
+
): JSX.Element {
|
|
520
|
+
const environment = useEnvironment();
|
|
521
|
+
const t = useUiStrings();
|
|
522
|
+
const [local, rest] = splitProps(props, [
|
|
523
|
+
"data",
|
|
524
|
+
"xKey",
|
|
525
|
+
"series",
|
|
526
|
+
"height",
|
|
527
|
+
"legend",
|
|
528
|
+
"tooltip",
|
|
529
|
+
"grid",
|
|
530
|
+
"xAxis",
|
|
531
|
+
"yAxis",
|
|
532
|
+
"curve",
|
|
533
|
+
"stacked",
|
|
534
|
+
"dots",
|
|
535
|
+
"yDomain",
|
|
536
|
+
"xTickFormatter",
|
|
537
|
+
"yTickFormatter",
|
|
538
|
+
"valueFormatter",
|
|
539
|
+
"maxPoints",
|
|
540
|
+
"cursor",
|
|
541
|
+
"label",
|
|
542
|
+
"kind",
|
|
543
|
+
"class",
|
|
544
|
+
"children",
|
|
545
|
+
]);
|
|
546
|
+
const [svg, setSvg] = useRefSignal<SVGSVGElement>();
|
|
547
|
+
const config = (): ChartConfig => environment.config();
|
|
548
|
+
const width = (): number => environment.width();
|
|
549
|
+
const height = (): number => (local.height as number | undefined) ?? 240;
|
|
550
|
+
const mirrored = (): boolean => environment.dir() === "rtl";
|
|
551
|
+
const keys = (): readonly string[] => local.series as readonly string[];
|
|
552
|
+
const rows = (): readonly ChartDatum[] => local.data as readonly ChartDatum[];
|
|
553
|
+
const xKey = (): string => local.xKey as string;
|
|
554
|
+
const stacked = (): boolean => local.kind !== "line" && local.stacked === true;
|
|
555
|
+
const valueText = (value: number, key: string): string =>
|
|
556
|
+
(local.valueFormatter as ((value: number, key: string) => string) | undefined)?.(value, key) ??
|
|
557
|
+
environment.format().format(value);
|
|
558
|
+
|
|
559
|
+
const columns = useMemo(() => keys().map((key) => rows().map((row) => num(row[key]))));
|
|
560
|
+
const limit = (): number =>
|
|
561
|
+
Math.max(2, Math.floor((local.maxPoints as number | undefined) ?? DEFAULT_MAX_POINTS));
|
|
562
|
+
const drawn = useMemo(() => decimateIndices(columns(), limit()));
|
|
563
|
+
const count = (): number => drawn().length;
|
|
564
|
+
const shown = useMemo(() =>
|
|
565
|
+
columns().map((column) => drawn().map((index) => column[index] ?? 0)),
|
|
566
|
+
);
|
|
567
|
+
const labels = useMemo(() => drawn().map((index) => String(rows()[index]?.[xKey()] ?? "")));
|
|
568
|
+
const stack = useMemo(() => stackColumns(shown()));
|
|
569
|
+
|
|
570
|
+
const extent = useMemo((): [number, number] => {
|
|
571
|
+
let low = 0;
|
|
572
|
+
let high = 0;
|
|
573
|
+
const lists = stacked() ? [...stack().lower, ...stack().upper] : shown();
|
|
574
|
+
for (const list of lists) {
|
|
575
|
+
for (const value of list) {
|
|
576
|
+
if (value < low) low = value;
|
|
577
|
+
if (value > high) high = value;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return [low, high];
|
|
581
|
+
});
|
|
582
|
+
const domain = useMemo((): [number, number] => {
|
|
583
|
+
const given = local.yDomain as readonly [number, number] | undefined;
|
|
584
|
+
return given === undefined ? niceDomain(extent()[0], extent()[1]) : [given[0], given[1]];
|
|
585
|
+
});
|
|
586
|
+
const ticks = useMemo(() => {
|
|
587
|
+
const [low, high] = domain();
|
|
588
|
+
return niceTicks(low, high).filter((tick) => tick >= low && tick <= high);
|
|
589
|
+
});
|
|
590
|
+
const tickText = (tick: number): string =>
|
|
591
|
+
(local.yTickFormatter as ((value: number) => string) | undefined)?.(tick) ??
|
|
592
|
+
environment.format().format(tick);
|
|
593
|
+
|
|
594
|
+
const padding = { top: 12, end: 12 };
|
|
595
|
+
const bottom = (): number => (local.xAxis === false ? 8 : 28);
|
|
596
|
+
const axisWidth = (): number => {
|
|
597
|
+
if (local.yAxis === false) return 12;
|
|
598
|
+
const longest = ticks().reduce((most, tick) => Math.max(most, tickText(tick).length), 1);
|
|
599
|
+
return Math.max(40, Math.ceil(longest * 7) + 16);
|
|
600
|
+
};
|
|
601
|
+
const plotStart = (): number => (mirrored() ? padding.end : axisWidth());
|
|
602
|
+
const plotEnd = (): number => width() - (mirrored() ? axisWidth() : padding.end);
|
|
603
|
+
const plotBottom = (): number => height() - bottom();
|
|
604
|
+
const yOf = (value: number): number =>
|
|
605
|
+
linearScale([domain()[0], domain()[1]], [plotBottom(), padding.top], value);
|
|
606
|
+
const zero = (): number => yOf(Math.min(Math.max(0, domain()[0]), domain()[1]));
|
|
607
|
+
|
|
608
|
+
const layout = useMemo(() => {
|
|
609
|
+
const from = mirrored() ? plotEnd() : plotStart();
|
|
610
|
+
const to = mirrored() ? plotStart() : plotEnd();
|
|
611
|
+
if (local.kind === "bar") {
|
|
612
|
+
const bands = bandPositions(count(), from, to, 0.2);
|
|
613
|
+
return { xs: bands.centers, span: Math.abs(bands.step), group: bands.bandwidth };
|
|
614
|
+
}
|
|
615
|
+
const xs = pointPositions(count(), from, to);
|
|
616
|
+
return {
|
|
617
|
+
xs,
|
|
618
|
+
span: count() <= 1 ? Math.abs(to - from) : Math.abs(to - from) / (count() - 1),
|
|
619
|
+
group: 0,
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
const xs = (): readonly number[] => layout().xs;
|
|
623
|
+
|
|
624
|
+
const pointsOf = (series: number): Array<[number, number]> => {
|
|
625
|
+
const list = stacked() ? (stack().upper[series] ?? []) : (shown()[series] ?? []);
|
|
626
|
+
return list.map((value, index) => [xs()[index] ?? 0, yOf(value)]);
|
|
627
|
+
};
|
|
628
|
+
const lowerPointsOf = (series: number): Array<[number, number]> | number =>
|
|
629
|
+
stacked()
|
|
630
|
+
? (stack().lower[series] ?? []).map((value, index) => [xs()[index] ?? 0, yOf(value)])
|
|
631
|
+
: zero();
|
|
632
|
+
const curve = (): ChartCurve => (local.curve as ChartCurve | undefined) ?? "linear";
|
|
633
|
+
|
|
634
|
+
const barBox = (
|
|
635
|
+
series: number,
|
|
636
|
+
index: number,
|
|
637
|
+
): { x: number; y: number; width: number; height: number } => {
|
|
638
|
+
const center = xs()[index] ?? 0;
|
|
639
|
+
if (stacked()) {
|
|
640
|
+
const top = yOf(stack().upper[series]?.[index] ?? 0);
|
|
641
|
+
const base = yOf(stack().lower[series]?.[index] ?? 0);
|
|
642
|
+
return {
|
|
643
|
+
x: center - layout().group / 2,
|
|
644
|
+
y: Math.min(top, base),
|
|
645
|
+
width: layout().group,
|
|
646
|
+
height: Math.abs(base - top),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const each = layout().group / Math.max(keys().length, 1);
|
|
650
|
+
const offset = (series + 0.5) * each - layout().group / 2;
|
|
651
|
+
const y = yOf(shown()[series]?.[index] ?? 0);
|
|
652
|
+
return {
|
|
653
|
+
x: center + (mirrored() ? -offset : offset) - each / 2,
|
|
654
|
+
y: Math.min(y, zero()),
|
|
655
|
+
width: each,
|
|
656
|
+
height: Math.abs(zero() - y),
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const items = useMemo((): ChartLegendItem[] =>
|
|
661
|
+
keys().map((key, index) => ({
|
|
662
|
+
key,
|
|
663
|
+
label: seriesLabel(config(), key),
|
|
664
|
+
color: seriesColor(config(), key, index),
|
|
665
|
+
icon: config()[key]?.icon,
|
|
666
|
+
})),
|
|
667
|
+
);
|
|
668
|
+
const listSeparator = (): string => t("chart.separator", ", ");
|
|
669
|
+
|
|
670
|
+
const targets = (): boolean => count() > 0 && count() <= TARGET_LIMIT;
|
|
671
|
+
const motion = useActivePoint({ count, dir: environment.dir, targets, root: svg });
|
|
672
|
+
dismissOutside(svg, motion.clear);
|
|
673
|
+
|
|
674
|
+
const point = useMemo((): ChartActivePoint | undefined => {
|
|
675
|
+
const index = motion.active();
|
|
676
|
+
if (index < 0 || index >= count()) return undefined;
|
|
677
|
+
const label = labels()[index] ?? "";
|
|
678
|
+
const list = items().map((item, series): ChartTooltipRow => {
|
|
679
|
+
const value = shown()[series]?.[index] ?? 0;
|
|
680
|
+
return { ...item, value, text: valueText(value, item.key), share: undefined };
|
|
681
|
+
});
|
|
682
|
+
const values = list
|
|
683
|
+
.map((row) => t("chart.value", "{name}: {value}", { name: row.label, value: row.text }))
|
|
684
|
+
.join(listSeparator());
|
|
685
|
+
const share = (xs()[index] ?? 0) / Math.max(width(), 1);
|
|
686
|
+
return {
|
|
687
|
+
index,
|
|
688
|
+
label,
|
|
689
|
+
rows: list,
|
|
690
|
+
text: t("chart.announce", "{label}: {values}", { label, values }),
|
|
691
|
+
style: {
|
|
692
|
+
left: `${(share * 100).toFixed(2)}%`,
|
|
693
|
+
transform: share > 0.6 ? "translateX(calc(-100% - 0.75rem))" : "translateX(0.75rem)",
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const accessibleName = (): string => {
|
|
699
|
+
const name = local.label as string | undefined;
|
|
700
|
+
if (name !== undefined) return name;
|
|
701
|
+
const series = items()
|
|
702
|
+
.map((item) => item.label)
|
|
703
|
+
.join(listSeparator());
|
|
704
|
+
if (local.kind === "line") return t("chart.label.line", "Line chart of {series}", { series });
|
|
705
|
+
if (local.kind === "area") return t("chart.label.area", "Area chart of {series}", { series });
|
|
706
|
+
return t("chart.label.bar", "Bar chart of {series}", { series });
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const listed = useMemo(() => decimateIndices(columns(), Math.min(limit(), TABLE_LIMIT)));
|
|
710
|
+
const indices = useMemo(() => Array.from({ length: count() }, (_, index) => index));
|
|
711
|
+
const tabStop = (): number => Math.min(motion.current(), Math.max(count() - 1, 0));
|
|
712
|
+
// About as many x labels as fit side by side, from the longest label; the rest are left out.
|
|
713
|
+
const labelRoom = (): number =>
|
|
714
|
+
Math.min(160, 16 + 7 * labels().reduce((most, label) => Math.max(most, label.length), 1));
|
|
715
|
+
const xLabelStep = (): number =>
|
|
716
|
+
Math.max(
|
|
717
|
+
1,
|
|
718
|
+
Math.ceil(count() / Math.max(1, Math.floor((plotEnd() - plotStart()) / labelRoom()))),
|
|
719
|
+
);
|
|
720
|
+
const xTickIndices = useMemo(() => indices().filter((index) => index % xLabelStep() === 0));
|
|
721
|
+
const indexAt = (event: PointerEvent): number => {
|
|
722
|
+
const box = svg()?.getBoundingClientRect();
|
|
723
|
+
if (box === undefined) return -1;
|
|
724
|
+
const x = ((event.clientX - box.left) / Math.max(box.width, 1)) * width();
|
|
725
|
+
return nearestIndex(xs(), x);
|
|
726
|
+
};
|
|
727
|
+
const targetOf = (event: Event): number | undefined => {
|
|
728
|
+
const found = (event.target as Element | null)?.closest?.("[data-chart-point]");
|
|
729
|
+
const index =
|
|
730
|
+
found === null || found === undefined
|
|
731
|
+
? undefined
|
|
732
|
+
: Number(found.getAttribute("data-chart-point"));
|
|
733
|
+
return index === undefined || Number.isNaN(index) ? undefined : index;
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
return (
|
|
737
|
+
<figure
|
|
738
|
+
data-slot="chart"
|
|
739
|
+
data-kind={local.kind}
|
|
740
|
+
class={cn("relative flex w-full flex-col", local.class)}
|
|
741
|
+
{...rest}
|
|
742
|
+
>
|
|
743
|
+
<plotContext.Provider
|
|
744
|
+
value={{ items, active: point, slots: { tooltip: false, legend: false } }}
|
|
745
|
+
>
|
|
746
|
+
<svg
|
|
747
|
+
data-slot="chart-plot"
|
|
748
|
+
ref={(created: SVGSVGElement) => setSvg(created)}
|
|
749
|
+
viewBox={`0 0 ${width()} ${height()}`}
|
|
750
|
+
role="group"
|
|
751
|
+
aria-roledescription={t("chart.roledescription", "chart")}
|
|
752
|
+
aria-label={accessibleName()}
|
|
753
|
+
tabindex={targets() || count() === 0 ? undefined : 0}
|
|
754
|
+
style={{ height: `${height()}px` }}
|
|
755
|
+
class="w-full overflow-visible rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent"
|
|
756
|
+
onPointerMove={(event: PointerEvent) => motion.show(indexAt(event))}
|
|
757
|
+
onPointerDown={(event: PointerEvent) => {
|
|
758
|
+
if (event.pointerType === "touch") motion.show(indexAt(event));
|
|
759
|
+
}}
|
|
760
|
+
onPointerLeave={(event: PointerEvent) => motion.leave(event.pointerType === "touch")}
|
|
761
|
+
onKeyDown={motion.key}
|
|
762
|
+
onFocusIn={(event: FocusEvent) => motion.focusIn(targetOf(event))}
|
|
763
|
+
onFocusOut={motion.focusOut}
|
|
764
|
+
>
|
|
765
|
+
<g aria-hidden="true">
|
|
766
|
+
<For each={local.grid === false ? [] : ticks()}>
|
|
767
|
+
{(tick) => (
|
|
768
|
+
<line
|
|
769
|
+
data-slot="chart-grid"
|
|
770
|
+
x1={plotStart()}
|
|
771
|
+
x2={plotEnd()}
|
|
772
|
+
y1={yOf(tick)}
|
|
773
|
+
y2={yOf(tick)}
|
|
774
|
+
class="stroke-border"
|
|
775
|
+
stroke-width={1}
|
|
776
|
+
/>
|
|
777
|
+
)}
|
|
778
|
+
</For>
|
|
779
|
+
<For each={local.grid === "both" ? indices() : []}>
|
|
780
|
+
{(index) => (
|
|
781
|
+
<line
|
|
782
|
+
data-slot="chart-grid"
|
|
783
|
+
x1={xs()[index] ?? 0}
|
|
784
|
+
x2={xs()[index] ?? 0}
|
|
785
|
+
y1={padding.top}
|
|
786
|
+
y2={plotBottom()}
|
|
787
|
+
class="stroke-border"
|
|
788
|
+
stroke-width={1}
|
|
789
|
+
/>
|
|
790
|
+
)}
|
|
791
|
+
</For>
|
|
792
|
+
<Show when={local.yAxis !== false}>
|
|
793
|
+
<For each={ticks()}>
|
|
794
|
+
{(tick) => (
|
|
795
|
+
<text
|
|
796
|
+
data-slot="chart-y-tick"
|
|
797
|
+
x={mirrored() ? plotEnd() + 8 : plotStart() - 8}
|
|
798
|
+
y={yOf(tick)}
|
|
799
|
+
dy="0.32em"
|
|
800
|
+
text-anchor={mirrored() ? "start" : "end"}
|
|
801
|
+
class="fill-muted-foreground"
|
|
802
|
+
>
|
|
803
|
+
{tickText(tick)}
|
|
804
|
+
</text>
|
|
805
|
+
)}
|
|
806
|
+
</For>
|
|
807
|
+
</Show>
|
|
808
|
+
<Show when={local.xAxis !== false}>
|
|
809
|
+
<For each={xTickIndices()}>
|
|
810
|
+
{(index) => (
|
|
811
|
+
<text
|
|
812
|
+
data-slot="chart-x-tick"
|
|
813
|
+
x={xs()[index] ?? 0}
|
|
814
|
+
y={height() - 8}
|
|
815
|
+
text-anchor="middle"
|
|
816
|
+
class="fill-muted-foreground"
|
|
817
|
+
>
|
|
818
|
+
{(
|
|
819
|
+
local.xTickFormatter as ((value: string, index: number) => string) | undefined
|
|
820
|
+
)?.(labels()[index] ?? "", index) ?? labels()[index]}
|
|
821
|
+
</text>
|
|
822
|
+
)}
|
|
823
|
+
</For>
|
|
824
|
+
</Show>
|
|
825
|
+
</g>
|
|
826
|
+
<Show when={local.cursor !== false && motion.active() >= 0 && local.kind === "bar"}>
|
|
827
|
+
<rect
|
|
828
|
+
data-slot="chart-cursor"
|
|
829
|
+
x={(xs()[motion.active()] ?? 0) - layout().span / 2}
|
|
830
|
+
y={padding.top}
|
|
831
|
+
width={layout().span}
|
|
832
|
+
height={Math.max(0, plotBottom() - padding.top)}
|
|
833
|
+
class="fill-muted/50"
|
|
834
|
+
aria-hidden="true"
|
|
835
|
+
/>
|
|
836
|
+
</Show>
|
|
837
|
+
<Show when={local.cursor !== false && motion.active() >= 0 && local.kind !== "bar"}>
|
|
838
|
+
<line
|
|
839
|
+
data-slot="chart-cursor"
|
|
840
|
+
x1={xs()[motion.active()] ?? 0}
|
|
841
|
+
x2={xs()[motion.active()] ?? 0}
|
|
842
|
+
y1={padding.top}
|
|
843
|
+
y2={plotBottom()}
|
|
844
|
+
class="stroke-border"
|
|
845
|
+
aria-hidden="true"
|
|
846
|
+
/>
|
|
847
|
+
</Show>
|
|
848
|
+
<For each={keys()}>
|
|
849
|
+
{(key, series) => (
|
|
850
|
+
<Show
|
|
851
|
+
when={local.kind === "bar"}
|
|
852
|
+
fallback={
|
|
853
|
+
<g aria-hidden="true">
|
|
854
|
+
<Show when={local.kind === "area"}>
|
|
855
|
+
<path
|
|
856
|
+
data-series={key}
|
|
857
|
+
d={areaPath(pointsOf(series()), lowerPointsOf(series()), curve())}
|
|
858
|
+
fill={items()[series()]?.color}
|
|
859
|
+
fill-opacity={0.25}
|
|
860
|
+
class="animate-chart-fade motion-reduce:animate-none"
|
|
861
|
+
/>
|
|
862
|
+
</Show>
|
|
863
|
+
<path
|
|
864
|
+
data-series={local.kind === "line" ? key : undefined}
|
|
865
|
+
d={linePath(pointsOf(series()), curve())}
|
|
866
|
+
pathLength={1}
|
|
867
|
+
fill="none"
|
|
868
|
+
stroke={items()[series()]?.color}
|
|
869
|
+
stroke-width={2}
|
|
870
|
+
stroke-linecap="round"
|
|
871
|
+
stroke-linejoin="round"
|
|
872
|
+
class="animate-chart-draw [stroke-dasharray:1] motion-reduce:animate-none"
|
|
873
|
+
/>
|
|
874
|
+
<Show when={local.dots === true}>
|
|
875
|
+
<For each={indices()}>
|
|
876
|
+
{(index) => (
|
|
877
|
+
<circle
|
|
878
|
+
cx={xs()[index] ?? 0}
|
|
879
|
+
cy={pointsOf(series())[index]?.[1] ?? 0}
|
|
880
|
+
r={3}
|
|
881
|
+
fill={items()[series()]?.color}
|
|
882
|
+
/>
|
|
883
|
+
)}
|
|
884
|
+
</For>
|
|
885
|
+
</Show>
|
|
886
|
+
</g>
|
|
887
|
+
}
|
|
888
|
+
>
|
|
889
|
+
<g aria-hidden="true">
|
|
890
|
+
<For each={indices()}>
|
|
891
|
+
{(index) => (
|
|
892
|
+
<rect
|
|
893
|
+
data-series={key}
|
|
894
|
+
data-index={index}
|
|
895
|
+
x={barBox(series(), index).x}
|
|
896
|
+
y={barBox(series(), index).y}
|
|
897
|
+
width={barBox(series(), index).width}
|
|
898
|
+
height={barBox(series(), index).height}
|
|
899
|
+
fill={items()[series()]?.color}
|
|
900
|
+
rx={2}
|
|
901
|
+
class={cn(
|
|
902
|
+
"[transform-box:fill-box] animate-chart-rise motion-reduce:animate-none",
|
|
903
|
+
(shown()[series()]?.[index] ?? 0) < 0 ? "origin-top" : "origin-bottom",
|
|
904
|
+
)}
|
|
905
|
+
/>
|
|
906
|
+
)}
|
|
907
|
+
</For>
|
|
908
|
+
</g>
|
|
909
|
+
</Show>
|
|
910
|
+
)}
|
|
911
|
+
</For>
|
|
912
|
+
<Show when={local.kind !== "bar" && motion.active() >= 0}>
|
|
913
|
+
<g aria-hidden="true">
|
|
914
|
+
<For each={keys()}>
|
|
915
|
+
{(_, series) => (
|
|
916
|
+
<circle
|
|
917
|
+
data-slot="chart-dot"
|
|
918
|
+
cx={xs()[motion.active()] ?? 0}
|
|
919
|
+
cy={pointsOf(series())[motion.active()]?.[1] ?? 0}
|
|
920
|
+
r={4}
|
|
921
|
+
fill={items()[series()]?.color}
|
|
922
|
+
class="stroke-background"
|
|
923
|
+
stroke-width={2}
|
|
924
|
+
/>
|
|
925
|
+
)}
|
|
926
|
+
</For>
|
|
927
|
+
</g>
|
|
928
|
+
</Show>
|
|
929
|
+
<Show when={targets()}>
|
|
930
|
+
<For each={indices()}>
|
|
931
|
+
{(index) => (
|
|
932
|
+
<rect
|
|
933
|
+
data-slot="chart-point"
|
|
934
|
+
data-chart-point={index}
|
|
935
|
+
role="img"
|
|
936
|
+
aria-label={labels()[index]}
|
|
937
|
+
tabindex={tabStop() === index ? 0 : -1}
|
|
938
|
+
x={(xs()[index] ?? 0) - layout().span / 2}
|
|
939
|
+
y={padding.top}
|
|
940
|
+
width={layout().span}
|
|
941
|
+
height={Math.max(0, plotBottom() - padding.top)}
|
|
942
|
+
fill="transparent"
|
|
943
|
+
class="outline-none"
|
|
944
|
+
/>
|
|
945
|
+
)}
|
|
946
|
+
</For>
|
|
947
|
+
</Show>
|
|
948
|
+
</svg>
|
|
949
|
+
<table class="sr-only" data-slot="chart-table">
|
|
950
|
+
<caption>
|
|
951
|
+
{listed().length < rows().length
|
|
952
|
+
? t(
|
|
953
|
+
"chart.captionTruncated",
|
|
954
|
+
"{series}. Showing {shown, number} of {total, plural, one {# data point} other {# data points}}.",
|
|
955
|
+
{
|
|
956
|
+
series: items()
|
|
957
|
+
.map((item) => item.label)
|
|
958
|
+
.join(listSeparator()),
|
|
959
|
+
shown: listed().length,
|
|
960
|
+
total: rows().length,
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
: items()
|
|
964
|
+
.map((item) => item.label)
|
|
965
|
+
.join(listSeparator())}
|
|
966
|
+
</caption>
|
|
967
|
+
<thead>
|
|
968
|
+
<tr>
|
|
969
|
+
<th scope="col">{xKey()}</th>
|
|
970
|
+
<For each={items()} key={(item: ChartLegendItem) => item.key}>
|
|
971
|
+
{(item) => <th scope="col">{item().label}</th>}
|
|
972
|
+
</For>
|
|
973
|
+
</tr>
|
|
974
|
+
</thead>
|
|
975
|
+
<tbody>
|
|
976
|
+
<For each={listed()}>
|
|
977
|
+
{(index) => (
|
|
978
|
+
<tr>
|
|
979
|
+
<th scope="row">{String(rows()[index]?.[xKey()] ?? "")}</th>
|
|
980
|
+
<For each={keys()}>
|
|
981
|
+
{(key) => <td>{valueText(num(rows()[index]?.[key]), key)}</td>}
|
|
982
|
+
</For>
|
|
983
|
+
</tr>
|
|
984
|
+
)}
|
|
985
|
+
</For>
|
|
986
|
+
</tbody>
|
|
987
|
+
</table>
|
|
988
|
+
<div
|
|
989
|
+
data-slot="chart-announcer"
|
|
990
|
+
role="status"
|
|
991
|
+
aria-live="polite"
|
|
992
|
+
aria-atomic="true"
|
|
993
|
+
class="sr-only"
|
|
994
|
+
>
|
|
995
|
+
{point()?.text ?? ""}
|
|
996
|
+
</div>
|
|
997
|
+
<ChartSlots tooltip={local.tooltip !== false} legend={local.legend !== false}>
|
|
998
|
+
{local.children as JSX.Element}
|
|
999
|
+
</ChartSlots>
|
|
1000
|
+
</plotContext.Provider>
|
|
1001
|
+
</figure>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The parts the caller put in the chart, then the default ones that were not replaced. @internal */
|
|
1006
|
+
function ChartSlots(props: {
|
|
1007
|
+
readonly tooltip: boolean;
|
|
1008
|
+
readonly legend: boolean;
|
|
1009
|
+
readonly children?: JSX.Element | undefined;
|
|
1010
|
+
}): JSX.Element {
|
|
1011
|
+
const plot = plotContext.use("Chart");
|
|
1012
|
+
const resolved = children(() => props.children);
|
|
1013
|
+
// Read once now, so the parts register before the defaults are decided.
|
|
1014
|
+
resolved();
|
|
1015
|
+
return (
|
|
1016
|
+
<>
|
|
1017
|
+
{resolved() as JSX.Element}
|
|
1018
|
+
<Show when={props.tooltip && !plot.slots.tooltip}>
|
|
1019
|
+
<ChartTooltip />
|
|
1020
|
+
</Show>
|
|
1021
|
+
<Show when={props.legend && !plot.slots.legend}>
|
|
1022
|
+
<ChartLegend />
|
|
1023
|
+
</Show>
|
|
1024
|
+
</>
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Props of {@link ChartTooltip}: the attributes of the `div` that holds the content.
|
|
1030
|
+
* @see {@link ChartTooltip}
|
|
1031
|
+
* @since 1.0.0
|
|
1032
|
+
*/
|
|
1033
|
+
export type ChartTooltipProps = ComponentProps<"div">;
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The tooltip of a chart: a box next to the active data point (the one under the pointer, or the one the
|
|
1037
|
+
* keyboard is on) that holds {@link ChartTooltipContent} or the content you pass. Put it inside a chart.
|
|
1038
|
+
* A chart draws a default tooltip unless it has this part (or `tooltip={false}`).
|
|
1039
|
+
*
|
|
1040
|
+
* Keyboard and ARIA: the box is hidden from assistive technology (`aria-hidden`) and ignores the pointer;
|
|
1041
|
+
* the same text is announced by the chart's polite live region, so it is not read twice. Escape hides it.
|
|
1042
|
+
*
|
|
1043
|
+
* shadcn/ui differences: the content is `children`, not a `content` prop, and `class` replaces `className`.
|
|
1044
|
+
*
|
|
1045
|
+
* @param props The tooltip props: see {@link ChartTooltipProps}.
|
|
1046
|
+
* @returns The tooltip while there is an active point.
|
|
1047
|
+
* @throws Error when rendered outside a chart.
|
|
1048
|
+
* @example
|
|
1049
|
+
* import { ChartContainer, ChartLine, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
|
1050
|
+
*
|
|
1051
|
+
* const rows = [
|
|
1052
|
+
* { day: "Mon", visits: 120 },
|
|
1053
|
+
* { day: "Tue", visits: 180 },
|
|
1054
|
+
* ];
|
|
1055
|
+
*
|
|
1056
|
+
* export function Visits() {
|
|
1057
|
+
* return (
|
|
1058
|
+
* <ChartContainer config={{ visits: { label: "Visits" } }}>
|
|
1059
|
+
* <ChartLine data={rows} xKey="day" series={["visits"]}>
|
|
1060
|
+
* <ChartTooltip>
|
|
1061
|
+
* <ChartTooltipContent indicator="line" />
|
|
1062
|
+
* </ChartTooltip>
|
|
1063
|
+
* </ChartLine>
|
|
1064
|
+
* </ChartContainer>
|
|
1065
|
+
* );
|
|
1066
|
+
* }
|
|
1067
|
+
* @see {@link ChartTooltipContent}
|
|
1068
|
+
* @since 1.0.0
|
|
1069
|
+
*/
|
|
1070
|
+
function ChartTooltip(props: ChartTooltipProps): JSX.Element {
|
|
1071
|
+
const plot = plotContext.use("ChartTooltip");
|
|
1072
|
+
plot.slots.tooltip = true;
|
|
1073
|
+
const [local, rest] = splitProps(props, ["class", "style", "children"]);
|
|
1074
|
+
return (
|
|
1075
|
+
<Show when={plot.active()}>
|
|
1076
|
+
{(point) => (
|
|
1077
|
+
<div
|
|
1078
|
+
data-slot="chart-tooltip"
|
|
1079
|
+
aria-hidden="true"
|
|
1080
|
+
style={point().style}
|
|
1081
|
+
class={cn("pointer-events-none absolute top-0 z-10", local.class)}
|
|
1082
|
+
{...rest}
|
|
1083
|
+
>
|
|
1084
|
+
{(local.children as JSX.Element | undefined) ?? <ChartTooltipContent />}
|
|
1085
|
+
</div>
|
|
1086
|
+
)}
|
|
1087
|
+
</Show>
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** One row of the tooltip: the caller's `formatter` draws it, else the default row. @internal */
|
|
1092
|
+
function TooltipRow(props: {
|
|
1093
|
+
readonly row: Accessor<ChartTooltipRow>;
|
|
1094
|
+
readonly position: Accessor<number>;
|
|
1095
|
+
readonly indicator: "dot" | "line" | "dashed";
|
|
1096
|
+
readonly hideIndicator: boolean;
|
|
1097
|
+
readonly formatter: ((row: ChartTooltipRow, index: number) => JSX.Element) | undefined;
|
|
1098
|
+
}): JSX.Element {
|
|
1099
|
+
return (
|
|
1100
|
+
<Show
|
|
1101
|
+
when={props.formatter}
|
|
1102
|
+
fallback={
|
|
1103
|
+
<div
|
|
1104
|
+
data-slot="chart-tooltip-row"
|
|
1105
|
+
class="flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:text-muted-foreground"
|
|
1106
|
+
>
|
|
1107
|
+
<Show when={!props.hideIndicator}>
|
|
1108
|
+
<Show
|
|
1109
|
+
when={props.row().icon}
|
|
1110
|
+
fallback={
|
|
1111
|
+
<span
|
|
1112
|
+
data-slot="chart-tooltip-indicator"
|
|
1113
|
+
data-indicator={props.indicator}
|
|
1114
|
+
style={
|
|
1115
|
+
props.indicator === "dashed"
|
|
1116
|
+
? { "border-color": props.row().color, background: "transparent" }
|
|
1117
|
+
: { background: props.row().color }
|
|
1118
|
+
}
|
|
1119
|
+
class={cn(
|
|
1120
|
+
"shrink-0 rounded-[2px]",
|
|
1121
|
+
props.indicator === "dot" && "size-2.5 self-center",
|
|
1122
|
+
props.indicator === "line" && "w-1",
|
|
1123
|
+
props.indicator === "dashed" && "w-0 border-[1.5px] border-dashed",
|
|
1124
|
+
)}
|
|
1125
|
+
/>
|
|
1126
|
+
}
|
|
1127
|
+
>
|
|
1128
|
+
{(Icon) => <>{Icon()({})}</>}
|
|
1129
|
+
</Show>
|
|
1130
|
+
</Show>
|
|
1131
|
+
<div class="flex flex-1 items-center justify-between gap-4 leading-none">
|
|
1132
|
+
<span data-slot="chart-tooltip-name" class="text-muted-foreground">
|
|
1133
|
+
{props.row().label}
|
|
1134
|
+
</span>
|
|
1135
|
+
<span class="flex items-baseline gap-1.5">
|
|
1136
|
+
<span
|
|
1137
|
+
data-slot="chart-tooltip-value"
|
|
1138
|
+
class="font-mono font-medium text-foreground tabular-nums"
|
|
1139
|
+
>
|
|
1140
|
+
{props.row().text}
|
|
1141
|
+
</span>
|
|
1142
|
+
<Show when={props.row().share}>
|
|
1143
|
+
<span data-slot="chart-tooltip-share" class="text-muted-foreground">
|
|
1144
|
+
{props.row().share}
|
|
1145
|
+
</span>
|
|
1146
|
+
</Show>
|
|
1147
|
+
</span>
|
|
1148
|
+
</div>
|
|
1149
|
+
</div>
|
|
1150
|
+
}
|
|
1151
|
+
>
|
|
1152
|
+
{(format) => <>{format()(props.row(), props.position())}</>}
|
|
1153
|
+
</Show>
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Props of {@link ChartTooltipContent}.
|
|
1159
|
+
* @see {@link ChartTooltipContent}
|
|
1160
|
+
* @since 1.0.0
|
|
1161
|
+
*/
|
|
1162
|
+
export interface ChartTooltipContentProps extends ComponentProps<"div"> {
|
|
1163
|
+
/**
|
|
1164
|
+
* The mark in front of each row: a `"dot"`, a vertical `"line"` or a `"dashed"` line.
|
|
1165
|
+
* @default "dot"
|
|
1166
|
+
*/
|
|
1167
|
+
readonly indicator?: "dot" | "line" | "dashed" | undefined;
|
|
1168
|
+
/**
|
|
1169
|
+
* Leaves out the category line above the rows.
|
|
1170
|
+
* @default false
|
|
1171
|
+
*/
|
|
1172
|
+
readonly hideLabel?: boolean | undefined;
|
|
1173
|
+
/**
|
|
1174
|
+
* Leaves out the mark in front of each row.
|
|
1175
|
+
* @default false
|
|
1176
|
+
*/
|
|
1177
|
+
readonly hideIndicator?: boolean | undefined;
|
|
1178
|
+
/**
|
|
1179
|
+
* The category line, from the category and the active point.
|
|
1180
|
+
* @default the category as it is
|
|
1181
|
+
*/
|
|
1182
|
+
readonly labelFormatter?:
|
|
1183
|
+
| ((label: string, point: ChartActivePoint) => JSX.Element | string)
|
|
1184
|
+
| undefined;
|
|
1185
|
+
/**
|
|
1186
|
+
* Draws a row yourself, from the row and its position.
|
|
1187
|
+
* @default the mark, the series name and the value
|
|
1188
|
+
*/
|
|
1189
|
+
readonly formatter?: ((row: ChartTooltipRow, index: number) => JSX.Element) | undefined;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* The content of a {@link ChartTooltip}: the category of the active point, then a row for each series with
|
|
1194
|
+
* its mark, its name and its value. Pie slices also show their share.
|
|
1195
|
+
*
|
|
1196
|
+
* Keyboard and ARIA: no keys of its own; it is shown by the chart's pointer and keyboard handling and is
|
|
1197
|
+
* hidden from assistive technology with its tooltip.
|
|
1198
|
+
*
|
|
1199
|
+
* shadcn/ui differences: `labelFormatter` and `formatter` receive the ShadowJS rows
|
|
1200
|
+
* ({@link ChartTooltipRow}) and return elements, and there is no `nameKey` or `labelKey`: names come from
|
|
1201
|
+
* the chart config.
|
|
1202
|
+
*
|
|
1203
|
+
* @param props The content props: see {@link ChartTooltipContentProps}.
|
|
1204
|
+
* @returns The content while there is an active point.
|
|
1205
|
+
* @throws Error when rendered outside a chart.
|
|
1206
|
+
* @example
|
|
1207
|
+
* import { ChartContainer, ChartLine, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
|
1208
|
+
*
|
|
1209
|
+
* export function Hidden() {
|
|
1210
|
+
* return (
|
|
1211
|
+
* <ChartContainer config={{ visits: { label: "Visits" } }}>
|
|
1212
|
+
* <ChartLine data={[{ day: "Mon", visits: 1 }]} xKey="day" series={["visits"]}>
|
|
1213
|
+
* <ChartTooltip>
|
|
1214
|
+
* <ChartTooltipContent hideLabel indicator="dashed" />
|
|
1215
|
+
* </ChartTooltip>
|
|
1216
|
+
* </ChartLine>
|
|
1217
|
+
* </ChartContainer>
|
|
1218
|
+
* );
|
|
1219
|
+
* }
|
|
1220
|
+
* @see {@link ChartTooltip}
|
|
1221
|
+
* @since 1.0.0
|
|
1222
|
+
*/
|
|
1223
|
+
function ChartTooltipContent(props: ChartTooltipContentProps): JSX.Element {
|
|
1224
|
+
const plot = plotContext.use("ChartTooltipContent");
|
|
1225
|
+
const [local, rest] = splitProps(props, [
|
|
1226
|
+
"class",
|
|
1227
|
+
"indicator",
|
|
1228
|
+
"hideLabel",
|
|
1229
|
+
"hideIndicator",
|
|
1230
|
+
"labelFormatter",
|
|
1231
|
+
"formatter",
|
|
1232
|
+
]);
|
|
1233
|
+
const indicator = (): "dot" | "line" | "dashed" =>
|
|
1234
|
+
(local.indicator as "dot" | "line" | "dashed" | undefined) ?? "dot";
|
|
1235
|
+
return (
|
|
1236
|
+
<Show when={plot.active()}>
|
|
1237
|
+
{(point) => (
|
|
1238
|
+
<div
|
|
1239
|
+
data-slot="chart-tooltip-content"
|
|
1240
|
+
class={cn(
|
|
1241
|
+
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
|
1242
|
+
local.class,
|
|
1243
|
+
)}
|
|
1244
|
+
{...rest}
|
|
1245
|
+
>
|
|
1246
|
+
<Show when={local.hideLabel !== true && point().label !== ""}>
|
|
1247
|
+
<div data-slot="chart-tooltip-label" class="font-medium">
|
|
1248
|
+
{(
|
|
1249
|
+
local.labelFormatter as
|
|
1250
|
+
| ((label: string, point: ChartActivePoint) => JSX.Element | string)
|
|
1251
|
+
| undefined
|
|
1252
|
+
)?.(point().label, point()) ?? point().label}
|
|
1253
|
+
</div>
|
|
1254
|
+
</Show>
|
|
1255
|
+
<div class="grid gap-1.5">
|
|
1256
|
+
<For each={point().rows} key={(row: ChartTooltipRow) => row.key}>
|
|
1257
|
+
{(row, position) => (
|
|
1258
|
+
<TooltipRow
|
|
1259
|
+
row={row}
|
|
1260
|
+
position={position}
|
|
1261
|
+
indicator={indicator()}
|
|
1262
|
+
hideIndicator={local.hideIndicator === true}
|
|
1263
|
+
formatter={
|
|
1264
|
+
local.formatter as
|
|
1265
|
+
| ((row: ChartTooltipRow, index: number) => JSX.Element)
|
|
1266
|
+
| undefined
|
|
1267
|
+
}
|
|
1268
|
+
/>
|
|
1269
|
+
)}
|
|
1270
|
+
</For>
|
|
1271
|
+
</div>
|
|
1272
|
+
</div>
|
|
1273
|
+
)}
|
|
1274
|
+
</Show>
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Props of {@link ChartLegend}: the attributes of the `div` that holds the content, where the legend
|
|
1280
|
+
* sits, and the series for a legend used outside a chart.
|
|
1281
|
+
* @see {@link ChartLegend}
|
|
1282
|
+
* @since 1.0.0
|
|
1283
|
+
*/
|
|
1284
|
+
export interface ChartLegendProps extends ComponentProps<"div"> {
|
|
1285
|
+
/**
|
|
1286
|
+
* Where the legend sits: `"bottom"` under the chart, `"top"` above it.
|
|
1287
|
+
* @default "bottom"
|
|
1288
|
+
*/
|
|
1289
|
+
readonly verticalAlign?: "top" | "bottom" | undefined;
|
|
1290
|
+
/**
|
|
1291
|
+
* The keys of the series to list, for a legend placed in a {@link ChartContainer} outside any chart.
|
|
1292
|
+
* Inside a chart the legend lists the chart's series.
|
|
1293
|
+
* @default none
|
|
1294
|
+
*/
|
|
1295
|
+
readonly series?: readonly string[] | undefined;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* The legend of a chart: a box under (or above) the chart that holds {@link ChartLegendContent} or the
|
|
1300
|
+
* content you pass. Put it inside a chart, or on its own inside a {@link ChartContainer} with `series`. A
|
|
1301
|
+
* chart draws a default legend unless it has this part (or `legend={false}`).
|
|
1302
|
+
*
|
|
1303
|
+
* Keyboard and ARIA: a plain list; no keys and no role of its own besides the list.
|
|
1304
|
+
*
|
|
1305
|
+
* shadcn/ui differences: the content is `children`, not a `content` prop, `verticalAlign` takes `"top"` and
|
|
1306
|
+
* `"bottom"`, and `class` replaces `className`.
|
|
1307
|
+
*
|
|
1308
|
+
* @param props The legend props: see {@link ChartLegendProps}.
|
|
1309
|
+
* @returns The legend.
|
|
1310
|
+
* @example
|
|
1311
|
+
* import { ChartContainer, ChartLegend } from "@/components/ui/chart";
|
|
1312
|
+
*
|
|
1313
|
+
* export function Key() {
|
|
1314
|
+
* return (
|
|
1315
|
+
* <ChartContainer config={{ visits: { label: "Visits" } }}>
|
|
1316
|
+
* <ChartLegend series={["visits"]} />
|
|
1317
|
+
* </ChartContainer>
|
|
1318
|
+
* );
|
|
1319
|
+
* }
|
|
1320
|
+
* @see {@link ChartLegendContent}
|
|
1321
|
+
* @since 1.0.0
|
|
1322
|
+
*/
|
|
1323
|
+
function ChartLegend(props: ChartLegendProps): JSX.Element {
|
|
1324
|
+
const plot = plotContext.useOptional();
|
|
1325
|
+
if (plot !== undefined) plot.slots.legend = true;
|
|
1326
|
+
const [local, rest] = splitProps(props, ["class", "verticalAlign", "series", "children"]);
|
|
1327
|
+
const align = (): "top" | "bottom" =>
|
|
1328
|
+
(local.verticalAlign as "top" | "bottom" | undefined) ?? "bottom";
|
|
1329
|
+
return (
|
|
1330
|
+
<div
|
|
1331
|
+
data-slot="chart-legend"
|
|
1332
|
+
data-align={align()}
|
|
1333
|
+
class={cn(
|
|
1334
|
+
"flex items-center justify-center",
|
|
1335
|
+
align() === "top" ? "order-first pb-3" : "pt-3",
|
|
1336
|
+
local.class,
|
|
1337
|
+
)}
|
|
1338
|
+
{...rest}
|
|
1339
|
+
>
|
|
1340
|
+
{(local.children as JSX.Element | undefined) ?? (
|
|
1341
|
+
<ChartLegendContent series={local.series as readonly string[] | undefined} />
|
|
1342
|
+
)}
|
|
1343
|
+
</div>
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Props of {@link ChartLegendContent}.
|
|
1349
|
+
* @see {@link ChartLegendContent}
|
|
1350
|
+
* @since 1.0.0
|
|
1351
|
+
*/
|
|
1352
|
+
export interface ChartLegendContentProps extends ComponentProps<"ul"> {
|
|
1353
|
+
/**
|
|
1354
|
+
* Leaves out the swatch and the icon in front of each name.
|
|
1355
|
+
* @default false
|
|
1356
|
+
*/
|
|
1357
|
+
readonly hideIcon?: boolean | undefined;
|
|
1358
|
+
/**
|
|
1359
|
+
* The keys of the series to list when there is no chart around the legend.
|
|
1360
|
+
* @default none
|
|
1361
|
+
*/
|
|
1362
|
+
readonly series?: readonly string[] | undefined;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* The content of a {@link ChartLegend}: a list with a color swatch (or the series icon) and the label of
|
|
1367
|
+
* each series.
|
|
1368
|
+
*
|
|
1369
|
+
* Keyboard and ARIA: a `ul` of `li` items; no keys. The swatch is decorative.
|
|
1370
|
+
*
|
|
1371
|
+
* shadcn/ui differences: `class` replaces `className`; there is no `nameKey`.
|
|
1372
|
+
*
|
|
1373
|
+
* @param props The content props: see {@link ChartLegendContentProps}.
|
|
1374
|
+
* @returns The list.
|
|
1375
|
+
* @example
|
|
1376
|
+
* import { ChartContainer, ChartLegend, ChartLegendContent } from "@/components/ui/chart";
|
|
1377
|
+
*
|
|
1378
|
+
* export function Plain() {
|
|
1379
|
+
* return (
|
|
1380
|
+
* <ChartContainer config={{ visits: { label: "Visits" } }}>
|
|
1381
|
+
* <ChartLegend series={["visits"]}>
|
|
1382
|
+
* <ChartLegendContent hideIcon series={["visits"]} />
|
|
1383
|
+
* </ChartLegend>
|
|
1384
|
+
* </ChartContainer>
|
|
1385
|
+
* );
|
|
1386
|
+
* }
|
|
1387
|
+
* @see {@link ChartLegend}
|
|
1388
|
+
* @since 1.0.0
|
|
1389
|
+
*/
|
|
1390
|
+
function ChartLegendContent(props: ChartLegendContentProps): JSX.Element {
|
|
1391
|
+
const plot = plotContext.useOptional();
|
|
1392
|
+
const container = chartContext.useOptional();
|
|
1393
|
+
const [local, rest] = splitProps(props, ["class", "hideIcon", "series"]);
|
|
1394
|
+
const items = (): readonly ChartLegendItem[] => {
|
|
1395
|
+
if (plot !== undefined) return plot.items();
|
|
1396
|
+
const config = container?.config() ?? {};
|
|
1397
|
+
return ((local.series as readonly string[] | undefined) ?? []).map((key, index) => ({
|
|
1398
|
+
key,
|
|
1399
|
+
label: seriesLabel(config, key),
|
|
1400
|
+
color: seriesColor(config, key, index),
|
|
1401
|
+
icon: config[key]?.icon,
|
|
1402
|
+
}));
|
|
1403
|
+
};
|
|
1404
|
+
return (
|
|
1405
|
+
<ul
|
|
1406
|
+
data-slot="chart-legend-content"
|
|
1407
|
+
class={cn("flex flex-wrap items-center justify-center gap-4", local.class)}
|
|
1408
|
+
{...rest}
|
|
1409
|
+
>
|
|
1410
|
+
<For each={items()} key={(item: ChartLegendItem) => item.key}>
|
|
1411
|
+
{(item) => (
|
|
1412
|
+
<li
|
|
1413
|
+
data-slot="chart-legend-item"
|
|
1414
|
+
class="flex items-center gap-1.5 text-muted-foreground [&>svg]:size-3 [&>svg]:text-muted-foreground"
|
|
1415
|
+
>
|
|
1416
|
+
<Show when={local.hideIcon !== true}>
|
|
1417
|
+
<Show
|
|
1418
|
+
when={item().icon}
|
|
1419
|
+
fallback={
|
|
1420
|
+
<span
|
|
1421
|
+
data-slot="chart-legend-swatch"
|
|
1422
|
+
aria-hidden="true"
|
|
1423
|
+
class="size-2 shrink-0 rounded-[2px]"
|
|
1424
|
+
style={{ background: item().color }}
|
|
1425
|
+
/>
|
|
1426
|
+
}
|
|
1427
|
+
>
|
|
1428
|
+
{(Icon) => <>{Icon()({})}</>}
|
|
1429
|
+
</Show>
|
|
1430
|
+
</Show>
|
|
1431
|
+
{item().label}
|
|
1432
|
+
</li>
|
|
1433
|
+
)}
|
|
1434
|
+
</For>
|
|
1435
|
+
</ul>
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* An area chart: one filled area per series, drawn from zero up to the value (or stacked). Use it to show a
|
|
1441
|
+
* total or a trend over an ordered x axis. Put it inside a {@link ChartContainer}.
|
|
1442
|
+
*
|
|
1443
|
+
* @param props The chart props: see {@link ChartSeriesProps}.
|
|
1444
|
+
* @returns The chart.
|
|
1445
|
+
* @example
|
|
1446
|
+
* import { ChartArea, ChartContainer } from "@/components/ui/chart";
|
|
1447
|
+
*
|
|
1448
|
+
* const rows = [
|
|
1449
|
+
* { month: "Jan", sales: 40 },
|
|
1450
|
+
* { month: "Feb", sales: 65 },
|
|
1451
|
+
* ];
|
|
1452
|
+
*
|
|
1453
|
+
* export function Sales() {
|
|
1454
|
+
* return (
|
|
1455
|
+
* <ChartContainer config={{ sales: { label: "Sales" } }}>
|
|
1456
|
+
* <ChartArea data={rows} xKey="month" series={["sales"]} curve="monotone" />
|
|
1457
|
+
* </ChartContainer>
|
|
1458
|
+
* );
|
|
1459
|
+
* }
|
|
1460
|
+
* @see {@link ChartLine}
|
|
1461
|
+
* @see {@link ChartBar}
|
|
1462
|
+
* @since 1.0.0
|
|
1463
|
+
*/
|
|
1464
|
+
function ChartArea(props: ChartSeriesProps): JSX.Element {
|
|
1465
|
+
return <Cartesian {...props} kind="area" />;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* A line chart: one line per series, with the points spaced evenly along the x axis. Use it to compare
|
|
1470
|
+
* trends. Put it inside a {@link ChartContainer}.
|
|
1471
|
+
*
|
|
1472
|
+
* @param props The chart props: see {@link ChartSeriesProps}.
|
|
1473
|
+
* @returns The chart.
|
|
1474
|
+
* @example
|
|
1475
|
+
* import { ChartContainer, ChartLine } from "@/components/ui/chart";
|
|
1476
|
+
*
|
|
1477
|
+
* const rows = [
|
|
1478
|
+
* { month: "Jan", sales: 40, returns: 4 },
|
|
1479
|
+
* { month: "Feb", sales: 65, returns: 7 },
|
|
1480
|
+
* ];
|
|
1481
|
+
*
|
|
1482
|
+
* export function Trend() {
|
|
1483
|
+
* return (
|
|
1484
|
+
* <ChartContainer
|
|
1485
|
+
* config={{ sales: { label: "Sales" }, returns: { label: "Returns" } }}
|
|
1486
|
+
* >
|
|
1487
|
+
* <ChartLine data={rows} xKey="month" series={["sales", "returns"]} dots />
|
|
1488
|
+
* </ChartContainer>
|
|
1489
|
+
* );
|
|
1490
|
+
* }
|
|
1491
|
+
* @see {@link ChartArea}
|
|
1492
|
+
* @since 1.0.0
|
|
1493
|
+
*/
|
|
1494
|
+
function ChartLine(props: ChartSeriesProps): JSX.Element {
|
|
1495
|
+
return <Cartesian {...props} kind="line" />;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* A bar chart: one bar per series in each category, the bars of a category side by side (or stacked). Use
|
|
1500
|
+
* it to compare values across categories. Put it inside a {@link ChartContainer}.
|
|
1501
|
+
*
|
|
1502
|
+
* @param props The chart props: see {@link ChartSeriesProps}.
|
|
1503
|
+
* @returns The chart.
|
|
1504
|
+
* @example
|
|
1505
|
+
* import { ChartBar, ChartContainer } from "@/components/ui/chart";
|
|
1506
|
+
*
|
|
1507
|
+
* const rows = [
|
|
1508
|
+
* { month: "Jan", sales: 40 },
|
|
1509
|
+
* { month: "Feb", sales: 65 },
|
|
1510
|
+
* ];
|
|
1511
|
+
*
|
|
1512
|
+
* export function Bars() {
|
|
1513
|
+
* return (
|
|
1514
|
+
* <ChartContainer config={{ sales: { label: "Sales" } }}>
|
|
1515
|
+
* <ChartBar data={rows} xKey="month" series={["sales"]} />
|
|
1516
|
+
* </ChartContainer>
|
|
1517
|
+
* );
|
|
1518
|
+
* }
|
|
1519
|
+
* @see {@link ChartLine}
|
|
1520
|
+
* @since 1.0.0
|
|
1521
|
+
*/
|
|
1522
|
+
function ChartBar(props: ChartSeriesProps): JSX.Element {
|
|
1523
|
+
return <Cartesian {...props} kind="bar" />;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Props of {@link ChartPie} and {@link ChartRadial}: the rows, which keys hold the name and the value, and
|
|
1528
|
+
* the layout, plus the attributes of the rendered `figure`.
|
|
1529
|
+
* @see {@link ChartPie}
|
|
1530
|
+
* @since 1.0.0
|
|
1531
|
+
*/
|
|
1532
|
+
export interface ChartPolarProps extends ComponentProps<"figure"> {
|
|
1533
|
+
/**
|
|
1534
|
+
* The rows to draw, one slice (or ring) each.
|
|
1535
|
+
* @default none (required)
|
|
1536
|
+
*/
|
|
1537
|
+
readonly data: readonly ChartDatum[];
|
|
1538
|
+
/**
|
|
1539
|
+
* The key of the name of each row, which is also its key in the chart config.
|
|
1540
|
+
* @default none (required)
|
|
1541
|
+
*/
|
|
1542
|
+
readonly nameKey: string;
|
|
1543
|
+
/**
|
|
1544
|
+
* The key of the numeric value of each row.
|
|
1545
|
+
* @default none (required)
|
|
1546
|
+
*/
|
|
1547
|
+
readonly valueKey: string;
|
|
1548
|
+
/**
|
|
1549
|
+
* The size of the drawing in pixels; it scales down to fit the container (at most 240 wide).
|
|
1550
|
+
* @default 240
|
|
1551
|
+
*/
|
|
1552
|
+
readonly height?: number | undefined;
|
|
1553
|
+
/**
|
|
1554
|
+
* Shows the default legend under the chart. A {@link ChartLegend} in `children` replaces it.
|
|
1555
|
+
* @default true
|
|
1556
|
+
*/
|
|
1557
|
+
readonly legend?: boolean | undefined;
|
|
1558
|
+
/**
|
|
1559
|
+
* Shows the default tooltip. A {@link ChartTooltip} in `children` replaces it.
|
|
1560
|
+
* @default true
|
|
1561
|
+
*/
|
|
1562
|
+
readonly tooltip?: boolean | undefined;
|
|
1563
|
+
/**
|
|
1564
|
+
* The text of a value in the tooltip, the table and the live region.
|
|
1565
|
+
* @default the number in the locale of the chart
|
|
1566
|
+
*/
|
|
1567
|
+
readonly valueFormatter?: ((value: number, key: string) => string) | undefined;
|
|
1568
|
+
/**
|
|
1569
|
+
* The accessible name of the chart.
|
|
1570
|
+
* @default "Pie chart of Chrome, Safari" (the kind and the names, from the UI strings)
|
|
1571
|
+
*/
|
|
1572
|
+
readonly label?: string | undefined;
|
|
1573
|
+
/**
|
|
1574
|
+
* Content in the middle of a donut or a radial chart, such as the total. It is placed over the drawing
|
|
1575
|
+
* and ignores the pointer.
|
|
1576
|
+
* @default none
|
|
1577
|
+
*/
|
|
1578
|
+
readonly center?: JSX.Element | undefined;
|
|
1579
|
+
/**
|
|
1580
|
+
* The inner radius as a share of the outer one: `0` is a pie, `0.6` a donut. For a radial chart it is
|
|
1581
|
+
* the share of the radius the rings leave empty.
|
|
1582
|
+
* @default 0 for a pie, 0.3 for a radial chart
|
|
1583
|
+
*/
|
|
1584
|
+
readonly innerRadius?: number | undefined;
|
|
1585
|
+
/**
|
|
1586
|
+
* The gap between two slices, in degrees. Pie only.
|
|
1587
|
+
* @default 0
|
|
1588
|
+
*/
|
|
1589
|
+
readonly gap?: number | undefined;
|
|
1590
|
+
/**
|
|
1591
|
+
* The value that fills a ring completely. Radial only.
|
|
1592
|
+
* @default the largest value, rounded to a nice number
|
|
1593
|
+
*/
|
|
1594
|
+
readonly max?: number | undefined;
|
|
1595
|
+
/**
|
|
1596
|
+
* Where the arc starts, in degrees clockwise from the top. Radial only; a pie always starts at the top.
|
|
1597
|
+
* @default 0
|
|
1598
|
+
*/
|
|
1599
|
+
readonly startAngle?: number | undefined;
|
|
1600
|
+
/**
|
|
1601
|
+
* Where the arc ends, in degrees clockwise from the top. Radial only.
|
|
1602
|
+
* @default startAngle plus 360
|
|
1603
|
+
*/
|
|
1604
|
+
readonly endAngle?: number | undefined;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** A pie, a donut or a radial chart. @internal */
|
|
1608
|
+
function Polar(props: ChartPolarProps & { readonly kind: "pie" | "radial" }): JSX.Element {
|
|
1609
|
+
const environment = useEnvironment();
|
|
1610
|
+
const t = useUiStrings();
|
|
1611
|
+
const [local, rest] = splitProps(props, [
|
|
1612
|
+
"data",
|
|
1613
|
+
"nameKey",
|
|
1614
|
+
"valueKey",
|
|
1615
|
+
"height",
|
|
1616
|
+
"legend",
|
|
1617
|
+
"tooltip",
|
|
1618
|
+
"valueFormatter",
|
|
1619
|
+
"label",
|
|
1620
|
+
"center",
|
|
1621
|
+
"innerRadius",
|
|
1622
|
+
"gap",
|
|
1623
|
+
"max",
|
|
1624
|
+
"startAngle",
|
|
1625
|
+
"endAngle",
|
|
1626
|
+
"kind",
|
|
1627
|
+
"class",
|
|
1628
|
+
"children",
|
|
1629
|
+
]);
|
|
1630
|
+
const [svg, setSvg] = useRefSignal<SVGSVGElement>();
|
|
1631
|
+
// Resolved once: reading the prop twice would build its elements twice (and break hydration keys).
|
|
1632
|
+
const center = children(() => local.center);
|
|
1633
|
+
center();
|
|
1634
|
+
const config = (): ChartConfig => environment.config();
|
|
1635
|
+
const size = (): number => (local.height as number | undefined) ?? 240;
|
|
1636
|
+
const mirrored = (): boolean => environment.dir() === "rtl";
|
|
1637
|
+
const rows = (): readonly ChartDatum[] => local.data as readonly ChartDatum[];
|
|
1638
|
+
const names = useMemo(() => rows().map((row) => String(row[local.nameKey as string] ?? "")));
|
|
1639
|
+
const values = useMemo(() => rows().map((row) => num(row[local.valueKey as string])));
|
|
1640
|
+
const valueText = (value: number, key: string): string =>
|
|
1641
|
+
(local.valueFormatter as ((value: number, key: string) => string) | undefined)?.(value, key) ??
|
|
1642
|
+
environment.format().format(value);
|
|
1643
|
+
const percent = (fraction: number): string => {
|
|
1644
|
+
try {
|
|
1645
|
+
return new Intl.NumberFormat(environment.locale(), {
|
|
1646
|
+
style: "percent",
|
|
1647
|
+
maximumFractionDigits: 1,
|
|
1648
|
+
}).format(fraction);
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
if (!(error instanceof RangeError)) throw error;
|
|
1651
|
+
return `${Math.round(fraction * 100)}%`;
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
const items = useMemo((): ChartLegendItem[] =>
|
|
1655
|
+
names().map((name, index) => ({
|
|
1656
|
+
key: name,
|
|
1657
|
+
label: seriesLabel(config(), name),
|
|
1658
|
+
color: seriesColor(config(), name, index),
|
|
1659
|
+
icon: config()[name]?.icon,
|
|
1660
|
+
})),
|
|
1661
|
+
);
|
|
1662
|
+
const listSeparator = (): string => t("chart.separator", ", ");
|
|
1663
|
+
const centerX = (): number => size() / 2;
|
|
1664
|
+
const outer = (): number => size() / 2 - 2;
|
|
1665
|
+
const radial = (): boolean => local.kind === "radial";
|
|
1666
|
+
const innerShare = (): number =>
|
|
1667
|
+
Math.min(0.95, Math.max(0, (local.innerRadius as number | undefined) ?? (radial() ? 0.3 : 0)));
|
|
1668
|
+
const arcStart = (): number => (((local.startAngle as number | undefined) ?? 0) * Math.PI) / 180;
|
|
1669
|
+
const arcEnd = (): number =>
|
|
1670
|
+
((local.endAngle as number | undefined) ??
|
|
1671
|
+
((local.startAngle as number | undefined) ?? 0) + 360) *
|
|
1672
|
+
(Math.PI / 180);
|
|
1673
|
+
const maximum = (): number =>
|
|
1674
|
+
(local.max as number | undefined) ??
|
|
1675
|
+
niceMax(values().reduce((most, value) => Math.max(most, value), 0));
|
|
1676
|
+
const total = (): number => values().reduce((sum, value) => sum + Math.max(0, value), 0);
|
|
1677
|
+
const angles = useMemo(() =>
|
|
1678
|
+
pieAngles(values(), {
|
|
1679
|
+
start: arcStart(),
|
|
1680
|
+
end: arcStart() + Math.PI * 2,
|
|
1681
|
+
pad: (((local.gap as number | undefined) ?? 0) * Math.PI) / 180,
|
|
1682
|
+
}),
|
|
1683
|
+
);
|
|
1684
|
+
|
|
1685
|
+
/** The path of the slice, ring or bar, and of its track. */
|
|
1686
|
+
const shapes = useMemo(() => {
|
|
1687
|
+
const cx = centerX();
|
|
1688
|
+
if (!radial()) {
|
|
1689
|
+
return angles().map((angle) => ({
|
|
1690
|
+
d: arcPath(cx, cx, outer() * innerShare(), outer(), angle.start, angle.end),
|
|
1691
|
+
track: "",
|
|
1692
|
+
mid: (angle.start + angle.end) / 2,
|
|
1693
|
+
radius: (outer() * (1 + innerShare())) / 2,
|
|
1694
|
+
}));
|
|
1695
|
+
}
|
|
1696
|
+
const rings = Math.max(rows().length, 1);
|
|
1697
|
+
const base = outer() * innerShare();
|
|
1698
|
+
const step = (outer() - base) / rings;
|
|
1699
|
+
return values().map((value, index) => {
|
|
1700
|
+
const to = outer() - index * step;
|
|
1701
|
+
const from = to - step * 0.8;
|
|
1702
|
+
const sweep = Math.min(Math.max(value / (maximum() || 1), 0), 1) * (arcEnd() - arcStart());
|
|
1703
|
+
return {
|
|
1704
|
+
d: arcPath(cx, cx, from, to, arcStart(), arcStart() + sweep),
|
|
1705
|
+
track: arcPath(cx, cx, from, to, arcStart(), arcEnd()),
|
|
1706
|
+
mid: arcStart() + sweep / 2,
|
|
1707
|
+
radius: (from + to) / 2,
|
|
1708
|
+
};
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
const targets = (): boolean => rows().length > 0;
|
|
1713
|
+
const count = (): number => rows().length;
|
|
1714
|
+
const motion = useActivePoint({ count, dir: environment.dir, targets, root: svg });
|
|
1715
|
+
dismissOutside(svg, motion.clear);
|
|
1716
|
+
|
|
1717
|
+
const point = useMemo((): ChartActivePoint | undefined => {
|
|
1718
|
+
const index = motion.active();
|
|
1719
|
+
if (index < 0 || index >= count()) return undefined;
|
|
1720
|
+
const item = items()[index] as ChartLegendItem;
|
|
1721
|
+
const value = values()[index] ?? 0;
|
|
1722
|
+
const text = valueText(value, item.key);
|
|
1723
|
+
const fraction = total() === 0 ? 0 : Math.max(0, value) / total();
|
|
1724
|
+
const share = radial() ? undefined : percent(fraction);
|
|
1725
|
+
const shape = shapes()[index];
|
|
1726
|
+
const [x] = polarPoint(centerX(), centerX(), shape?.radius ?? 0, shape?.mid ?? 0);
|
|
1727
|
+
const anchor = mirrored() ? size() - x : x;
|
|
1728
|
+
return {
|
|
1729
|
+
index,
|
|
1730
|
+
label: "",
|
|
1731
|
+
rows: [{ ...item, value, text, share }],
|
|
1732
|
+
text:
|
|
1733
|
+
share === undefined
|
|
1734
|
+
? t("chart.value", "{name}: {value}", { name: item.label, value: text })
|
|
1735
|
+
: t("chart.share", "{name}: {value} ({percent})", {
|
|
1736
|
+
name: item.label,
|
|
1737
|
+
value: text,
|
|
1738
|
+
percent: share,
|
|
1739
|
+
}),
|
|
1740
|
+
style: {
|
|
1741
|
+
left: `calc(50% + ${(anchor - centerX()).toFixed(2)}px)`,
|
|
1742
|
+
transform: "translateX(0.75rem)",
|
|
1743
|
+
},
|
|
1744
|
+
};
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
const accessibleName = (): string => {
|
|
1748
|
+
const name = local.label as string | undefined;
|
|
1749
|
+
if (name !== undefined) return name;
|
|
1750
|
+
const series = items()
|
|
1751
|
+
.map((item) => item.label)
|
|
1752
|
+
.join(listSeparator());
|
|
1753
|
+
if (radial()) return t("chart.label.radial", "Radial chart of {series}", { series });
|
|
1754
|
+
return innerShare() > 0
|
|
1755
|
+
? t("chart.label.donut", "Donut chart of {series}", { series })
|
|
1756
|
+
: t("chart.label.pie", "Pie chart of {series}", { series });
|
|
1757
|
+
};
|
|
1758
|
+
const indices = useMemo(() => Array.from({ length: count() }, (_, index) => index));
|
|
1759
|
+
const tabStop = (): number => Math.min(motion.current(), Math.max(count() - 1, 0));
|
|
1760
|
+
const targetOf = (event: Event): number | undefined => {
|
|
1761
|
+
const found = (event.target as Element | null)?.closest?.("[data-chart-point]");
|
|
1762
|
+
const index =
|
|
1763
|
+
found === null || found === undefined
|
|
1764
|
+
? undefined
|
|
1765
|
+
: Number(found.getAttribute("data-chart-point"));
|
|
1766
|
+
return index === undefined || Number.isNaN(index) ? undefined : index;
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
return (
|
|
1770
|
+
<figure
|
|
1771
|
+
data-slot="chart"
|
|
1772
|
+
data-kind={radial() ? "radial" : innerShare() > 0 ? "donut" : "pie"}
|
|
1773
|
+
class={cn("relative flex w-full flex-col", local.class)}
|
|
1774
|
+
{...rest}
|
|
1775
|
+
>
|
|
1776
|
+
<plotContext.Provider
|
|
1777
|
+
value={{ items, active: point, slots: { tooltip: false, legend: false } }}
|
|
1778
|
+
>
|
|
1779
|
+
<div class="relative mx-auto w-full max-w-60">
|
|
1780
|
+
<svg
|
|
1781
|
+
data-slot="chart-plot"
|
|
1782
|
+
ref={(created: SVGSVGElement) => setSvg(created)}
|
|
1783
|
+
viewBox={`0 0 ${size()} ${size()}`}
|
|
1784
|
+
role="group"
|
|
1785
|
+
aria-roledescription={t("chart.roledescription", "chart")}
|
|
1786
|
+
aria-label={accessibleName()}
|
|
1787
|
+
class="mx-auto block w-full"
|
|
1788
|
+
onKeyDown={motion.key}
|
|
1789
|
+
onFocusIn={(event: FocusEvent) => motion.focusIn(targetOf(event))}
|
|
1790
|
+
onFocusOut={motion.focusOut}
|
|
1791
|
+
>
|
|
1792
|
+
<g transform={mirrored() ? `translate(${size()} 0) scale(-1 1)` : undefined}>
|
|
1793
|
+
<Show when={radial()}>
|
|
1794
|
+
<g aria-hidden="true">
|
|
1795
|
+
<For each={indices()}>
|
|
1796
|
+
{(index) => (
|
|
1797
|
+
<path data-track="" d={shapes()[index]?.track} class="fill-muted" />
|
|
1798
|
+
)}
|
|
1799
|
+
</For>
|
|
1800
|
+
</g>
|
|
1801
|
+
</Show>
|
|
1802
|
+
<For each={indices()}>
|
|
1803
|
+
{(index) => (
|
|
1804
|
+
<path
|
|
1805
|
+
data-slice=""
|
|
1806
|
+
data-chart-point={index}
|
|
1807
|
+
data-slot="chart-point"
|
|
1808
|
+
role="img"
|
|
1809
|
+
aria-label={items()[index]?.label}
|
|
1810
|
+
tabindex={tabStop() === index ? 0 : -1}
|
|
1811
|
+
d={shapes()[index]?.d}
|
|
1812
|
+
fill={items()[index]?.color}
|
|
1813
|
+
data-dimmed={
|
|
1814
|
+
motion.active() >= 0 && motion.active() !== index ? "true" : undefined
|
|
1815
|
+
}
|
|
1816
|
+
class="animate-chart-fade stroke-background outline-none transition-opacity data-[dimmed=true]:opacity-60 motion-reduce:animate-none motion-reduce:transition-none"
|
|
1817
|
+
stroke-width={radial() ? 0 : 1}
|
|
1818
|
+
onPointerMove={() => motion.show(index)}
|
|
1819
|
+
onPointerDown={(event: PointerEvent) => {
|
|
1820
|
+
if (event.pointerType === "touch") motion.show(index);
|
|
1821
|
+
}}
|
|
1822
|
+
onPointerLeave={(event: PointerEvent) =>
|
|
1823
|
+
motion.leave(event.pointerType === "touch")
|
|
1824
|
+
}
|
|
1825
|
+
/>
|
|
1826
|
+
)}
|
|
1827
|
+
</For>
|
|
1828
|
+
</g>
|
|
1829
|
+
</svg>
|
|
1830
|
+
<Show when={center.count() > 0}>
|
|
1831
|
+
<div
|
|
1832
|
+
data-slot="chart-center"
|
|
1833
|
+
class="pointer-events-none absolute inset-0 flex items-center justify-center text-center"
|
|
1834
|
+
>
|
|
1835
|
+
{center() as JSX.Element}
|
|
1836
|
+
</div>
|
|
1837
|
+
</Show>
|
|
1838
|
+
</div>
|
|
1839
|
+
<table class="sr-only" data-slot="chart-table">
|
|
1840
|
+
<caption>
|
|
1841
|
+
{items()
|
|
1842
|
+
.map((item) => item.label)
|
|
1843
|
+
.join(listSeparator())}
|
|
1844
|
+
</caption>
|
|
1845
|
+
<thead>
|
|
1846
|
+
<tr>
|
|
1847
|
+
<th scope="col">{local.nameKey as string}</th>
|
|
1848
|
+
<th scope="col">{local.valueKey as string}</th>
|
|
1849
|
+
</tr>
|
|
1850
|
+
</thead>
|
|
1851
|
+
<tbody>
|
|
1852
|
+
<For each={indices()}>
|
|
1853
|
+
{(index) => (
|
|
1854
|
+
<tr>
|
|
1855
|
+
<th scope="row">{items()[index]?.label}</th>
|
|
1856
|
+
<td>{valueText(values()[index] ?? 0, items()[index]?.key ?? "")}</td>
|
|
1857
|
+
</tr>
|
|
1858
|
+
)}
|
|
1859
|
+
</For>
|
|
1860
|
+
</tbody>
|
|
1861
|
+
</table>
|
|
1862
|
+
<div
|
|
1863
|
+
data-slot="chart-announcer"
|
|
1864
|
+
role="status"
|
|
1865
|
+
aria-live="polite"
|
|
1866
|
+
aria-atomic="true"
|
|
1867
|
+
class="sr-only"
|
|
1868
|
+
>
|
|
1869
|
+
{point()?.text ?? ""}
|
|
1870
|
+
</div>
|
|
1871
|
+
<ChartSlots tooltip={local.tooltip !== false} legend={local.legend !== false}>
|
|
1872
|
+
{local.children as JSX.Element}
|
|
1873
|
+
</ChartSlots>
|
|
1874
|
+
</plotContext.Provider>
|
|
1875
|
+
</figure>
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* A pie chart of one value per row: each row is a slice, sized by its share of the total. Each slice is
|
|
1881
|
+
* named by the row's `nameKey` value and takes the color of that name in the config, or a theme chart token
|
|
1882
|
+
* by position. With `innerRadius` it is a donut, and `center` puts content in the hole. Put it inside a
|
|
1883
|
+
* {@link ChartContainer} to use its colors.
|
|
1884
|
+
*
|
|
1885
|
+
* Keyboard and ARIA: the slices are one roving tab stop, each named by its slice name; the arrow keys,
|
|
1886
|
+
* Home and End move between them (they follow the screen in right-to-left text, where the slices run
|
|
1887
|
+
* counterclockwise), and the active slice is announced. A visually hidden table holds the values.
|
|
1888
|
+
*
|
|
1889
|
+
* @param props The chart props: see {@link ChartPolarProps}.
|
|
1890
|
+
* @returns The chart.
|
|
1891
|
+
* @example
|
|
1892
|
+
* import { ChartContainer, ChartPie } from "@/components/ui/chart";
|
|
1893
|
+
*
|
|
1894
|
+
* const rows = [
|
|
1895
|
+
* { browser: "Chrome", share: 60 },
|
|
1896
|
+
* { browser: "Safari", share: 40 },
|
|
1897
|
+
* ];
|
|
1898
|
+
*
|
|
1899
|
+
* export function Browsers() {
|
|
1900
|
+
* return (
|
|
1901
|
+
* <ChartContainer config={{ Chrome: { label: "Chrome" }, Safari: { label: "Safari" } }}>
|
|
1902
|
+
* <ChartPie data={rows} nameKey="browser" valueKey="share" innerRadius={0.6} />
|
|
1903
|
+
* </ChartContainer>
|
|
1904
|
+
* );
|
|
1905
|
+
* }
|
|
1906
|
+
* @see {@link ChartRadial}
|
|
1907
|
+
* @see {@link ChartContainer}
|
|
1908
|
+
* @since 1.0.0
|
|
1909
|
+
*/
|
|
1910
|
+
function ChartPie(props: ChartPolarProps): JSX.Element {
|
|
1911
|
+
return <Polar {...props} kind="pie" />;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* A radial chart: one concentric ring per row, the first on the outside, swept from the start angle by the
|
|
1916
|
+
* row's share of `max`, over a faint track. Use it to show progress toward a goal. Put it inside a
|
|
1917
|
+
* {@link ChartContainer}.
|
|
1918
|
+
*
|
|
1919
|
+
* Keyboard and ARIA: as for {@link ChartPie}: the rings are one roving tab stop and are announced.
|
|
1920
|
+
*
|
|
1921
|
+
* @param props The chart props: see {@link ChartPolarProps}.
|
|
1922
|
+
* @returns The chart.
|
|
1923
|
+
* @example
|
|
1924
|
+
* import { ChartContainer, ChartRadial } from "@/components/ui/chart";
|
|
1925
|
+
*
|
|
1926
|
+
* const rows = [
|
|
1927
|
+
* { goal: "Move", done: 75 },
|
|
1928
|
+
* { goal: "Exercise", done: 40 },
|
|
1929
|
+
* ];
|
|
1930
|
+
*
|
|
1931
|
+
* export function Goals() {
|
|
1932
|
+
* return (
|
|
1933
|
+
* <ChartContainer config={{ Move: { label: "Move" }, Exercise: { label: "Exercise" } }}>
|
|
1934
|
+
* <ChartRadial data={rows} nameKey="goal" valueKey="done" max={100} />
|
|
1935
|
+
* </ChartContainer>
|
|
1936
|
+
* );
|
|
1937
|
+
* }
|
|
1938
|
+
* @see {@link ChartPie}
|
|
1939
|
+
* @since 1.0.0
|
|
1940
|
+
*/
|
|
1941
|
+
function ChartRadial(props: ChartPolarProps): JSX.Element {
|
|
1942
|
+
return <Polar {...props} kind="radial" />;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
export {
|
|
1946
|
+
ChartArea,
|
|
1947
|
+
ChartBar,
|
|
1948
|
+
ChartContainer,
|
|
1949
|
+
ChartLegend,
|
|
1950
|
+
ChartLegendContent,
|
|
1951
|
+
ChartLine,
|
|
1952
|
+
ChartPie,
|
|
1953
|
+
ChartRadial,
|
|
1954
|
+
ChartTooltip,
|
|
1955
|
+
ChartTooltipContent,
|
|
1956
|
+
};
|