ajo-ui 0.1.0
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/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
package/src/chart.tsx
ADDED
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import type { Children, IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callRef, clamp, dom, id as uniqueId, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import { pointReference, position, type ReservedPositionArg } from './position'
|
|
5
|
+
import type { FixedArgs, OmitArg } from './utils'
|
|
6
|
+
import { text } from './utils'
|
|
7
|
+
|
|
8
|
+
type ChartTheme = 'dark' | 'light'
|
|
9
|
+
|
|
10
|
+
/** Labels, icons, and colors configured for each chart data key. */
|
|
11
|
+
export type ChartConfig = Record<
|
|
12
|
+
string,
|
|
13
|
+
{
|
|
14
|
+
/** Human label used by tooltip and legend. */
|
|
15
|
+
label?: Children
|
|
16
|
+
/** Optional icon rendered by tooltip and legend. */
|
|
17
|
+
icon?: Children | ((attrs: { class?: string }) => Children)
|
|
18
|
+
/** CSS color for this data key. */
|
|
19
|
+
color?: string
|
|
20
|
+
/** Theme-specific colors for this data key. */
|
|
21
|
+
theme?: Partial<Record<ChartTheme, string>>
|
|
22
|
+
}
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
/** Native SVG visualization supported by ChartContainer. */
|
|
26
|
+
export type ChartType =
|
|
27
|
+
| 'area'
|
|
28
|
+
| 'bar'
|
|
29
|
+
| 'line'
|
|
30
|
+
| 'pie'
|
|
31
|
+
|
|
32
|
+
/** One keyed data row consumed by the native chart primitives. */
|
|
33
|
+
export type ChartDatum = Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
/** Data-key descriptor for one rendered chart series. */
|
|
36
|
+
export type ChartSeries = {
|
|
37
|
+
/** Object key used for numeric values. */
|
|
38
|
+
key: string
|
|
39
|
+
/** Human label. Defaults to config label or key. */
|
|
40
|
+
label?: Children
|
|
41
|
+
/** CSS color. Defaults to config color or a chart token. */
|
|
42
|
+
color?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Series shorthand accepted by ChartContainer. */
|
|
46
|
+
export type ChartSeriesInput = ChartSeries | string
|
|
47
|
+
|
|
48
|
+
/** Plot-area inset in SVG user units. */
|
|
49
|
+
export type ChartMargin = {
|
|
50
|
+
bottom: number
|
|
51
|
+
left: number
|
|
52
|
+
right: number
|
|
53
|
+
top: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolved series value supplied to tooltip and legend formatters. */
|
|
57
|
+
export type ChartPayload = {
|
|
58
|
+
color: string
|
|
59
|
+
formattedValue: string
|
|
60
|
+
index: number
|
|
61
|
+
key: string
|
|
62
|
+
label: Children
|
|
63
|
+
row: ChartDatum
|
|
64
|
+
value: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Active chart coordinate and payload exposed through chart context. */
|
|
68
|
+
export type ChartActive = {
|
|
69
|
+
index: number
|
|
70
|
+
items: ChartPayload[]
|
|
71
|
+
label: Children
|
|
72
|
+
x: number
|
|
73
|
+
y: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Arguments for the accessible Chart data and context root. */
|
|
77
|
+
export type ChartContainerArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'children' | 'gap' | 'placement' | ReservedPositionArg> & {
|
|
78
|
+
/** Optional resolved chart identity override; otherwise derived from the DOM id or generated. */
|
|
79
|
+
chartId?: string
|
|
80
|
+
/** Chart series styling keyed by data key. */
|
|
81
|
+
config: ChartConfig
|
|
82
|
+
/** Rows to render when using the native SVG chart primitives. */
|
|
83
|
+
data?: ChartDatum[]
|
|
84
|
+
/** Key used for x-axis/category labels. */
|
|
85
|
+
xKey?: string
|
|
86
|
+
/** Native chart type rendered when no children are provided. */
|
|
87
|
+
type?: ChartType
|
|
88
|
+
/** Series keys or detailed series entries. Defaults to the config keys. */
|
|
89
|
+
series?: ChartSeriesInput[]
|
|
90
|
+
/** Accessible label for the chart image. */
|
|
91
|
+
label?: string
|
|
92
|
+
/** Accessible long description for the chart image. */
|
|
93
|
+
description?: string
|
|
94
|
+
/** SVG coordinate width used by native chart primitives. */
|
|
95
|
+
width?: number
|
|
96
|
+
/** SVG coordinate height used by native chart primitives. */
|
|
97
|
+
height?: number
|
|
98
|
+
/** SVG plot margins. */
|
|
99
|
+
margin?: Partial<ChartMargin>
|
|
100
|
+
/** Format category labels. */
|
|
101
|
+
formatLabel?: (value: unknown, row: ChartDatum, index: number) => Children
|
|
102
|
+
/** Format axis, accessible mark, and tooltip values. Defaults to the host locale. */
|
|
103
|
+
formatValue?: (value: number, key: string, row: ChartDatum, index: number) => string
|
|
104
|
+
/** Color palette used when a series has no configured color. */
|
|
105
|
+
palette: string[]
|
|
106
|
+
/** Classes supplied by the styled wrapper. */
|
|
107
|
+
class?: string
|
|
108
|
+
}> & FixedArgs<'gap' | 'placement' | ReservedPositionArg>
|
|
109
|
+
|
|
110
|
+
/** Arguments for native cartesian bar, line, and area plots. */
|
|
111
|
+
export type ChartPlotArgs = OmitArg<IntrinsicElements['svg'], 'children'> & {
|
|
112
|
+
/** Show grid lines. */
|
|
113
|
+
grid?: boolean
|
|
114
|
+
/** Show axes and labels. */
|
|
115
|
+
axis?: boolean
|
|
116
|
+
/** Classes supplied by the styled wrapper. */
|
|
117
|
+
class?: string
|
|
118
|
+
axisStroke?: string
|
|
119
|
+
axisStrokeOpacity?: string
|
|
120
|
+
barClass?: string
|
|
121
|
+
gridStroke?: string
|
|
122
|
+
pointClass?: string
|
|
123
|
+
pointFill?: string
|
|
124
|
+
} & FixedArgs<'children'>
|
|
125
|
+
|
|
126
|
+
/** Arguments for a native pie or donut plot. */
|
|
127
|
+
export type ChartPieArgs = ChartPlotArgs & {
|
|
128
|
+
/** Inner radius for donut charts, in SVG user units. */
|
|
129
|
+
innerRadius?: number
|
|
130
|
+
centerLabelClass?: string
|
|
131
|
+
centerLabelFill?: string
|
|
132
|
+
sliceStroke?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Arguments for the floating active-value tooltip. */
|
|
136
|
+
export type ChartTooltipArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'children' | 'gap' | 'placement' | ReservedPositionArg> & {
|
|
137
|
+
/** Custom tooltip content, commonly a tooltip-content component. */
|
|
138
|
+
content?: Children
|
|
139
|
+
/** Classes supplied by the styled wrapper. */
|
|
140
|
+
class?: string
|
|
141
|
+
}> & FixedArgs<'defaultIndex' | 'gap' | 'placement' | ReservedPositionArg>
|
|
142
|
+
|
|
143
|
+
type ClassResolver<State> = string | ((state: State) => string | undefined)
|
|
144
|
+
|
|
145
|
+
type TooltipContentState = {
|
|
146
|
+
indicator: 'dashed' | 'dot' | 'line'
|
|
147
|
+
nestLabel: boolean
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Arguments for the default tooltip payload renderer. */
|
|
151
|
+
export type ChartTooltipContentArgs = OmitArg<IntrinsicElements['div'], 'children'> & {
|
|
152
|
+
/** Indicator shape shown beside each row. */
|
|
153
|
+
indicator?: 'dashed' | 'dot' | 'line'
|
|
154
|
+
/** Hide the category label. */
|
|
155
|
+
hideLabel?: boolean
|
|
156
|
+
/** Hide the color indicator. */
|
|
157
|
+
hideIndicator?: boolean
|
|
158
|
+
/** Additional class for the label row. */
|
|
159
|
+
labelClass?: string
|
|
160
|
+
/** Format the active label. */
|
|
161
|
+
labelFormatter?: (label: Children, payload: ChartPayload[]) => Children
|
|
162
|
+
/** Format each value row. */
|
|
163
|
+
formatter?: (value: number, key: string, item: ChartPayload, index: number) => Children
|
|
164
|
+
/** Classes supplied by the styled wrapper. */
|
|
165
|
+
class?: string
|
|
166
|
+
formattedValueClass?: string
|
|
167
|
+
iconClass?: string
|
|
168
|
+
iconWrapperClass?: string
|
|
169
|
+
indicatorClass?: ClassResolver<TooltipContentState>
|
|
170
|
+
itemClass?: ClassResolver<TooltipContentState>
|
|
171
|
+
itemLabelClass?: string
|
|
172
|
+
itemsClass?: string
|
|
173
|
+
nestedLabelClass?: string
|
|
174
|
+
valueLabelGroupClass?: string
|
|
175
|
+
valueRowClass?: ClassResolver<{ nestLabel: boolean }>
|
|
176
|
+
} & FixedArgs<'children'>
|
|
177
|
+
|
|
178
|
+
/** Arguments for the chart legend surface. */
|
|
179
|
+
export type ChartLegendArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'children'> & {
|
|
180
|
+
/** Custom legend content, commonly a legend-content component. */
|
|
181
|
+
content?: Children
|
|
182
|
+
/** Classes supplied by the styled wrapper. */
|
|
183
|
+
class?: string
|
|
184
|
+
}>
|
|
185
|
+
|
|
186
|
+
/** Arguments for the default series legend renderer. */
|
|
187
|
+
export type ChartLegendContentArgs = OmitArg<IntrinsicElements['div'], 'children'> & {
|
|
188
|
+
/** Hide configured icons and show color swatches instead. */
|
|
189
|
+
hideIcon?: boolean
|
|
190
|
+
/** Classes supplied by the styled wrapper. */
|
|
191
|
+
class?: string
|
|
192
|
+
iconClass?: string
|
|
193
|
+
iconWrapperClass?: string
|
|
194
|
+
itemClass?: string
|
|
195
|
+
swatchClass?: string
|
|
196
|
+
} & FixedArgs<'children'>
|
|
197
|
+
|
|
198
|
+
type SeriesEntry = ChartSeries & {
|
|
199
|
+
color: string
|
|
200
|
+
label: Children
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type Point = {
|
|
204
|
+
x: number
|
|
205
|
+
y: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type ChartReferencePoint = Point & {
|
|
209
|
+
svg: SVGSVGElement
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type ChartContextValue = {
|
|
213
|
+
active: ChartActive | null
|
|
214
|
+
clearActive: () => void
|
|
215
|
+
config: ChartConfig
|
|
216
|
+
data: ChartDatum[]
|
|
217
|
+
description?: string
|
|
218
|
+
formatLabel: (value: unknown, row: ChartDatum, index: number) => Children
|
|
219
|
+
formatValue: (value: number, key: string, row: ChartDatum, index: number) => string
|
|
220
|
+
height: number
|
|
221
|
+
id: string
|
|
222
|
+
label?: string
|
|
223
|
+
margin: ChartMargin
|
|
224
|
+
palette: string[]
|
|
225
|
+
releasePlot: (plot: SVGSVGElement) => void
|
|
226
|
+
series: SeriesEntry[]
|
|
227
|
+
setActive: (active: ChartActive | null, at?: ChartReferencePoint) => void
|
|
228
|
+
setTooltip: (element: HTMLElement | null) => void
|
|
229
|
+
type: ChartType
|
|
230
|
+
width: number
|
|
231
|
+
xKey?: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ChartContext = context<ChartContextValue | null>(null)
|
|
235
|
+
/** Read the stable identity resolved by the nearest ChartContainer. */
|
|
236
|
+
export const ChartIdContext = context<string | null>(null)
|
|
237
|
+
|
|
238
|
+
const DEFAULT_MARGIN: ChartMargin = { bottom: 32, left: 40, right: 16, top: 16 }
|
|
239
|
+
const DEFAULT_WIDTH = 640
|
|
240
|
+
const DEFAULT_HEIGHT = 240
|
|
241
|
+
|
|
242
|
+
const resolveRootChartId = (value: unknown, fallback: string) =>
|
|
243
|
+
value ? `chart-${encodeURIComponent(String(value))}` : fallback
|
|
244
|
+
|
|
245
|
+
const resolve = <State,>(value: ClassResolver<State> | undefined, state: State) =>
|
|
246
|
+
typeof value === 'function' ? value(state) : value
|
|
247
|
+
|
|
248
|
+
const number = (value: unknown) => {
|
|
249
|
+
const next = Number(value)
|
|
250
|
+
return Number.isFinite(next) ? next : undefined
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const colorFor = (
|
|
254
|
+
key: string,
|
|
255
|
+
index: number,
|
|
256
|
+
config: ChartConfig,
|
|
257
|
+
palette: string[],
|
|
258
|
+
override?: string,
|
|
259
|
+
) =>
|
|
260
|
+
override ?? (config[key]?.color || config[key]?.theme ? `var(--color-${key})` : palette[index % palette.length])
|
|
261
|
+
|
|
262
|
+
const seriesEntries = (
|
|
263
|
+
config: ChartConfig,
|
|
264
|
+
series: ChartSeriesInput[] | undefined,
|
|
265
|
+
data: ChartDatum[] | undefined,
|
|
266
|
+
xKey: string | undefined,
|
|
267
|
+
palette: string[],
|
|
268
|
+
) => {
|
|
269
|
+
const raw = series?.length
|
|
270
|
+
? series
|
|
271
|
+
: Object.keys(config).length
|
|
272
|
+
? Object.keys(config)
|
|
273
|
+
: Object.keys(data?.[0] ?? {}).filter(key => key !== xKey && number(data?.[0]?.[key]) != null)
|
|
274
|
+
|
|
275
|
+
return raw.map((item, index): SeriesEntry => {
|
|
276
|
+
const entry = typeof item === 'string' ? { key: item } : item
|
|
277
|
+
return {
|
|
278
|
+
...entry,
|
|
279
|
+
color: colorFor(entry.key, index, config, palette, entry.color),
|
|
280
|
+
label: entry.label ?? config[entry.key]?.label ?? entry.key,
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const defaultFormatLabel = (value: unknown, _row: ChartDatum, index: number) =>
|
|
286
|
+
value == null ? `Item ${index + 1}` : String(value)
|
|
287
|
+
|
|
288
|
+
let numberFormatter: Intl.NumberFormat | undefined
|
|
289
|
+
|
|
290
|
+
const defaultFormatValue = (value: number) =>
|
|
291
|
+
(numberFormatter ??= new Intl.NumberFormat()).format(value)
|
|
292
|
+
|
|
293
|
+
const labelFor = (chart: ChartContextValue, row: ChartDatum, index: number) =>
|
|
294
|
+
chart.formatLabel(chart.xKey ? row[chart.xKey] : undefined, row, index)
|
|
295
|
+
|
|
296
|
+
const payloadFor = (
|
|
297
|
+
chart: ChartContextValue,
|
|
298
|
+
index: number,
|
|
299
|
+
series = chart.series,
|
|
300
|
+
colors?: string[],
|
|
301
|
+
) => {
|
|
302
|
+
const row = chart.data[index] ?? {}
|
|
303
|
+
return series
|
|
304
|
+
.map((entry, entryIndex) => {
|
|
305
|
+
const value = number(row[entry.key])
|
|
306
|
+
if (value == null) return undefined
|
|
307
|
+
const color = colors?.[entryIndex] ?? entry.color
|
|
308
|
+
return {
|
|
309
|
+
color,
|
|
310
|
+
formattedValue: chart.formatValue(value, entry.key, row, index),
|
|
311
|
+
index,
|
|
312
|
+
key: entry.key,
|
|
313
|
+
label: entry.label,
|
|
314
|
+
row,
|
|
315
|
+
value,
|
|
316
|
+
} satisfies ChartPayload
|
|
317
|
+
})
|
|
318
|
+
.filter(Boolean) as ChartPayload[]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sameActiveKeys = (current: ChartActive | null, index: number, keys: string[]) =>
|
|
322
|
+
current?.index === index
|
|
323
|
+
&& current.items.length === keys.length
|
|
324
|
+
&& current.items.every((item, itemIndex) => item.key === keys[itemIndex])
|
|
325
|
+
|
|
326
|
+
const sameActiveTarget = (current: ChartActive | null, index: number, keys: string[]) =>
|
|
327
|
+
current?.index === index
|
|
328
|
+
&& current.items.every(item => keys.includes(item.key))
|
|
329
|
+
|
|
330
|
+
const sameActiveValue = (current: ChartActive | null, next: ChartActive | null) => {
|
|
331
|
+
if (!current || !next) return current === next
|
|
332
|
+
|
|
333
|
+
return sameActiveKeys(current, next.index, next.items.map(item => item.key))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const extent = (chart: ChartContextValue) => {
|
|
337
|
+
const values = chart.data.flatMap(row => chart.series.map(entry => number(row[entry.key])).filter(value => value != null))
|
|
338
|
+
const min = Math.min(0, ...values)
|
|
339
|
+
const max = Math.max(0, ...values)
|
|
340
|
+
return min === max ? { max: max + 1, min: min - 1 } : { max, min }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const scaled = (value: number, min: number, max: number, top: number, bottom: number) =>
|
|
344
|
+
bottom - ((value - min) / (max - min)) * (bottom - top)
|
|
345
|
+
|
|
346
|
+
const plotBox = (chart: ChartContextValue) => ({
|
|
347
|
+
bottom: chart.height - chart.margin.bottom,
|
|
348
|
+
left: chart.margin.left,
|
|
349
|
+
right: chart.width - chart.margin.right,
|
|
350
|
+
top: chart.margin.top,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const clientPoint = (svg: SVGSVGElement, x: number, y: number) => {
|
|
354
|
+
if (!svg.isConnected) return null
|
|
355
|
+
const matrix = svg.getScreenCTM()
|
|
356
|
+
if (!matrix) return null
|
|
357
|
+
const point = {
|
|
358
|
+
x: matrix.a * x + matrix.c * y + matrix.e,
|
|
359
|
+
y: matrix.b * x + matrix.d * y + matrix.f,
|
|
360
|
+
}
|
|
361
|
+
return Number.isFinite(point.x) && Number.isFinite(point.y) ? point : null
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const svgPoint = (svg: SVGSVGElement, clientX: number, clientY: number) => {
|
|
365
|
+
const matrix = svg.getScreenCTM()
|
|
366
|
+
if (!matrix) return null
|
|
367
|
+
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
|
|
368
|
+
if (!Number.isFinite(determinant) || determinant === 0) return null
|
|
369
|
+
const x = clientX - matrix.e
|
|
370
|
+
const y = clientY - matrix.f
|
|
371
|
+
return {
|
|
372
|
+
x: (matrix.d * x - matrix.c * y) / determinant,
|
|
373
|
+
y: (-matrix.b * x + matrix.a * y) / determinant,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const referencePoint = (svg: SVGSVGElement, point: Point): ChartReferencePoint => ({
|
|
378
|
+
svg,
|
|
379
|
+
x: point.x,
|
|
380
|
+
y: point.y,
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const plotRef = (chart: ChartContextValue, ref: unknown) => {
|
|
384
|
+
let current: SVGSVGElement | null = null
|
|
385
|
+
return (element: SVGSVGElement | null) => {
|
|
386
|
+
if (current && current !== element) chart.releasePlot(current)
|
|
387
|
+
current = element
|
|
388
|
+
callRef(ref, element)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const svgFromEvent = (event: Event) => {
|
|
393
|
+
const current = event.currentTarget as SVGElement | null
|
|
394
|
+
if (current instanceof SVGSVGElement) return current
|
|
395
|
+
if (current?.ownerSVGElement) return current.ownerSVGElement
|
|
396
|
+
|
|
397
|
+
const target = event.target as SVGElement | null
|
|
398
|
+
if (target instanceof SVGSVGElement) return target
|
|
399
|
+
return target?.ownerSVGElement ?? null
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const renderIcon = (icon: ChartConfig[string]['icon'], classes: string | undefined) =>
|
|
403
|
+
typeof icon === 'function' ? icon({ class: classes }) : icon
|
|
404
|
+
|
|
405
|
+
const axis = (
|
|
406
|
+
chart: ChartContextValue,
|
|
407
|
+
type: Exclude<ChartType, 'pie'>,
|
|
408
|
+
yTicks: number[],
|
|
409
|
+
min: number,
|
|
410
|
+
max: number,
|
|
411
|
+
axisStroke?: string,
|
|
412
|
+
axisStrokeOpacity?: string,
|
|
413
|
+
gridStroke?: string,
|
|
414
|
+
) => {
|
|
415
|
+
const box = plotBox(chart)
|
|
416
|
+
const groupWidth = (box.right - box.left) / chart.data.length
|
|
417
|
+
const xStep = chart.data.length > 1 ? (box.right - box.left) / (chart.data.length - 1) : 0
|
|
418
|
+
const rowCenter = (index: number) => type === 'bar'
|
|
419
|
+
? box.left + groupWidth * index + groupWidth / 2
|
|
420
|
+
: chart.data.length > 1 ? box.left + xStep * index : (box.left + box.right) / 2
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<g data-slot="chart-axis">
|
|
424
|
+
<line x1={box.left} x2={box.right} y1={box.bottom} y2={box.bottom} stroke={axisStroke} stroke-opacity={axisStrokeOpacity} />
|
|
425
|
+
<line x1={box.left} x2={box.left} y1={box.top} y2={box.bottom} stroke={axisStroke} stroke-opacity={axisStrokeOpacity} />
|
|
426
|
+
{yTicks.map(tick => {
|
|
427
|
+
const y = scaled(tick, min, max, box.top, box.bottom)
|
|
428
|
+
return (
|
|
429
|
+
<g key={tick}>
|
|
430
|
+
<line x1={box.left} x2={box.right} y1={y} y2={y} stroke={gridStroke} />
|
|
431
|
+
<text x={box.left - 8} y={y + 3} fill="currentColor" style="text-anchor:end">
|
|
432
|
+
{chart.formatValue(tick, '', {}, 0)}
|
|
433
|
+
</text>
|
|
434
|
+
</g>
|
|
435
|
+
)
|
|
436
|
+
})}
|
|
437
|
+
{chart.data.map((row, index) => {
|
|
438
|
+
return (
|
|
439
|
+
<text key={index} x={rowCenter(index)} y={chart.height - 8} fill="currentColor" style="text-anchor:middle">
|
|
440
|
+
{text(labelFor(chart, row, index)).slice(0, 12)}
|
|
441
|
+
</text>
|
|
442
|
+
)
|
|
443
|
+
})}
|
|
444
|
+
</g>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const linePath = (points: Point[]) =>
|
|
449
|
+
points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ')
|
|
450
|
+
|
|
451
|
+
const areaPath = (points: Point[], baseline: number) =>
|
|
452
|
+
points.length ? `${linePath(points)} L ${points[points.length - 1]!.x} ${baseline} L ${points[0]!.x} ${baseline} Z` : ''
|
|
453
|
+
|
|
454
|
+
const anglePoint = (center: number, radius: number, angle: number) => ({
|
|
455
|
+
x: center + radius * Math.cos(angle),
|
|
456
|
+
y: center + radius * Math.sin(angle),
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const slicePath = (center: number, radius: number, innerRadius: number, start: number, end: number) => {
|
|
460
|
+
const outerStart = anglePoint(center, radius, start)
|
|
461
|
+
const outerEnd = anglePoint(center, radius, end)
|
|
462
|
+
const large = end - start > Math.PI ? 1 : 0
|
|
463
|
+
|
|
464
|
+
if (innerRadius > 0) {
|
|
465
|
+
const innerStart = anglePoint(center, innerRadius, start)
|
|
466
|
+
const innerEnd = anglePoint(center, innerRadius, end)
|
|
467
|
+
return [
|
|
468
|
+
`M ${outerStart.x} ${outerStart.y}`,
|
|
469
|
+
`A ${radius} ${radius} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y}`,
|
|
470
|
+
`L ${innerEnd.x} ${innerEnd.y}`,
|
|
471
|
+
`A ${innerRadius} ${innerRadius} 0 ${large} 0 ${innerStart.x} ${innerStart.y}`,
|
|
472
|
+
'Z',
|
|
473
|
+
].join(' ')
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return [
|
|
477
|
+
`M ${center} ${center}`,
|
|
478
|
+
`L ${outerStart.x} ${outerStart.y}`,
|
|
479
|
+
`A ${radius} ${radius} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y}`,
|
|
480
|
+
'Z',
|
|
481
|
+
].join(' ')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
type ChartContainerRootArgs = ChartContainerArgs & {
|
|
485
|
+
rootId?: unknown
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const ChartContainerRoot: Stateful<ChartContainerRootArgs> = function* () {
|
|
489
|
+
let active: ChartActive | null = null
|
|
490
|
+
const fallbackId = uniqueId('chart')
|
|
491
|
+
let tooltip: HTMLElement | null = null
|
|
492
|
+
const root = dom(this) ? this : null
|
|
493
|
+
let activePoint: ChartReferencePoint | null = null
|
|
494
|
+
let lastClientPoint: Point | null = null
|
|
495
|
+
const reference = root ? pointReference(() => activePoint?.svg ?? root, () => {
|
|
496
|
+
const next = activePoint ? clientPoint(activePoint.svg, activePoint.x, activePoint.y) : null
|
|
497
|
+
if (next) lastClientPoint = next
|
|
498
|
+
if (lastClientPoint) return lastClientPoint
|
|
499
|
+
const rect = root.getBoundingClientRect()
|
|
500
|
+
return { x: rect.left, y: rect.top }
|
|
501
|
+
}) : null
|
|
502
|
+
const geometry = position(this, {
|
|
503
|
+
profile: 'chart',
|
|
504
|
+
boundary: () => root,
|
|
505
|
+
elements: () => ({ reference, floating: tooltip, arrow: null }),
|
|
506
|
+
})
|
|
507
|
+
let geometryScheduled = false
|
|
508
|
+
let geometryRestart = false
|
|
509
|
+
|
|
510
|
+
const report = (error: unknown) => {
|
|
511
|
+
if (this.signal.aborted) return
|
|
512
|
+
queueMicrotask(() => {
|
|
513
|
+
if (!this.signal.aborted) this.throw(error)
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
const scheduleGeometry = (restart = false) => {
|
|
517
|
+
geometryRestart ||= restart
|
|
518
|
+
if (geometryScheduled || this.signal.aborted) return
|
|
519
|
+
geometryScheduled = true
|
|
520
|
+
queueMicrotask(() => {
|
|
521
|
+
geometryScheduled = false
|
|
522
|
+
const shouldRestart = geometryRestart
|
|
523
|
+
geometryRestart = false
|
|
524
|
+
if (!active || !activePoint || !tooltip || this.signal.aborted) return
|
|
525
|
+
const task = shouldRestart ? geometry.start() : geometry.update()
|
|
526
|
+
void task.catch(report)
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
const setTooltip = (element: HTMLElement | null) => {
|
|
530
|
+
if (element === tooltip) return
|
|
531
|
+
geometry.stop()
|
|
532
|
+
tooltip = element
|
|
533
|
+
if (active && activePoint && element) scheduleGeometry(true)
|
|
534
|
+
}
|
|
535
|
+
const movePoint = (next: ChartReferencePoint | undefined) => {
|
|
536
|
+
if (!next) return 0
|
|
537
|
+
if (
|
|
538
|
+
activePoint?.svg === next.svg &&
|
|
539
|
+
activePoint.x === next.x &&
|
|
540
|
+
activePoint.y === next.y
|
|
541
|
+
) return 0
|
|
542
|
+
const retargeted = activePoint != null && activePoint.svg !== next.svg
|
|
543
|
+
activePoint = next
|
|
544
|
+
return retargeted ? 2 : 1
|
|
545
|
+
}
|
|
546
|
+
const setActive = (next: ChartActive | null, at?: ChartReferencePoint) => {
|
|
547
|
+
const movement = next ? movePoint(at) : 0
|
|
548
|
+
const retargeted = movement === 2
|
|
549
|
+
if (retargeted) geometry.stop()
|
|
550
|
+
if (!next) {
|
|
551
|
+
activePoint = null
|
|
552
|
+
lastClientPoint = null
|
|
553
|
+
geometry.stop()
|
|
554
|
+
}
|
|
555
|
+
if (sameActiveValue(active, next)) {
|
|
556
|
+
if (next && movement) scheduleGeometry(movement === 2)
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const first = active == null && next != null
|
|
561
|
+
|
|
562
|
+
this.next(() => active = next)
|
|
563
|
+
|
|
564
|
+
// The tooltip ref/content updates during the render above. Mount/retarget
|
|
565
|
+
// and point changes collapse into one current Adapter request.
|
|
566
|
+
if (next && at) scheduleGeometry(first || retargeted)
|
|
567
|
+
}
|
|
568
|
+
const clearActive = () => setActive(null)
|
|
569
|
+
const releasePlot = (plot: SVGSVGElement) => {
|
|
570
|
+
if (this.signal.aborted || activePoint?.svg !== plot) return
|
|
571
|
+
const releasedPoint = activePoint
|
|
572
|
+
geometry.stop()
|
|
573
|
+
activePoint = null
|
|
574
|
+
lastClientPoint = null
|
|
575
|
+
queueMicrotask(() => {
|
|
576
|
+
if (this.signal.aborted || activePoint) return
|
|
577
|
+
if (plot.isConnected) {
|
|
578
|
+
activePoint = releasedPoint
|
|
579
|
+
if (active && tooltip) scheduleGeometry(true)
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
if (active) clearActive()
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
for (const args of this) {
|
|
587
|
+
const chartId = args.chartId ?? resolveRootChartId(args.rootId, fallbackId)
|
|
588
|
+
const data = args.data ?? []
|
|
589
|
+
const width = args.width ?? DEFAULT_WIDTH
|
|
590
|
+
const height = args.height ?? DEFAULT_HEIGHT
|
|
591
|
+
const margin = { ...DEFAULT_MARGIN, ...(args.margin ?? {}) }
|
|
592
|
+
const type = args.type ?? 'bar'
|
|
593
|
+
const chart: ChartContextValue = {
|
|
594
|
+
active,
|
|
595
|
+
clearActive,
|
|
596
|
+
config: args.config,
|
|
597
|
+
data,
|
|
598
|
+
description: args.description,
|
|
599
|
+
formatLabel: args.formatLabel ?? defaultFormatLabel,
|
|
600
|
+
formatValue: args.formatValue ?? defaultFormatValue,
|
|
601
|
+
height,
|
|
602
|
+
id: chartId,
|
|
603
|
+
label: args.label,
|
|
604
|
+
margin,
|
|
605
|
+
palette: args.palette,
|
|
606
|
+
releasePlot,
|
|
607
|
+
series: seriesEntries(args.config, args.series, data, args.xKey, args.palette),
|
|
608
|
+
setActive,
|
|
609
|
+
setTooltip,
|
|
610
|
+
type,
|
|
611
|
+
width,
|
|
612
|
+
xKey: args.xKey,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
ChartContext(chart)
|
|
616
|
+
ChartIdContext(chart.id)
|
|
617
|
+
|
|
618
|
+
yield <>{args.children}</>
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
/** Unstyled chart root provider for config, data, tooltip, and legend state. */
|
|
624
|
+
const ChartContainer: Stateless<ChartContainerArgs> = ({
|
|
625
|
+
chartId,
|
|
626
|
+
children,
|
|
627
|
+
class: classes,
|
|
628
|
+
config,
|
|
629
|
+
data,
|
|
630
|
+
description,
|
|
631
|
+
formatLabel,
|
|
632
|
+
formatValue,
|
|
633
|
+
height,
|
|
634
|
+
id,
|
|
635
|
+
label,
|
|
636
|
+
margin,
|
|
637
|
+
palette,
|
|
638
|
+
series,
|
|
639
|
+
type,
|
|
640
|
+
width,
|
|
641
|
+
xKey,
|
|
642
|
+
...attrs
|
|
643
|
+
}) => {
|
|
644
|
+
return <ChartContainerRoot
|
|
645
|
+
{...rootAttrs(attrs)}
|
|
646
|
+
chartId={chartId}
|
|
647
|
+
config={config}
|
|
648
|
+
data={data}
|
|
649
|
+
description={description}
|
|
650
|
+
formatLabel={formatLabel}
|
|
651
|
+
formatValue={formatValue}
|
|
652
|
+
height={height}
|
|
653
|
+
margin={margin}
|
|
654
|
+
palette={palette}
|
|
655
|
+
rootId={id}
|
|
656
|
+
series={series}
|
|
657
|
+
type={type}
|
|
658
|
+
width={width}
|
|
659
|
+
xKey={xKey}
|
|
660
|
+
attr:class={classes}
|
|
661
|
+
attr:data-slot="chart"
|
|
662
|
+
attr:id={id}
|
|
663
|
+
label={label}
|
|
664
|
+
>
|
|
665
|
+
{children}
|
|
666
|
+
</ChartContainerRoot>
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const ChartPlot: Stateless<ChartPlotArgs & { type: Exclude<ChartType, 'pie'> }> = ({
|
|
670
|
+
axis: showAxis = true,
|
|
671
|
+
axisStroke,
|
|
672
|
+
axisStrokeOpacity,
|
|
673
|
+
barClass,
|
|
674
|
+
class: classes,
|
|
675
|
+
grid: showGrid = true,
|
|
676
|
+
gridStroke,
|
|
677
|
+
pointClass,
|
|
678
|
+
pointFill,
|
|
679
|
+
ref,
|
|
680
|
+
type,
|
|
681
|
+
...attrs
|
|
682
|
+
}) => {
|
|
683
|
+
const chart = ChartContext()
|
|
684
|
+
if (!chart || !chart.data.length || !chart.series.length) return null
|
|
685
|
+
|
|
686
|
+
const box = plotBox(chart)
|
|
687
|
+
const { max, min } = extent(chart)
|
|
688
|
+
const yTicks = Array.from({ length: 4 }, (_, index) => min + ((max - min) / 3) * index)
|
|
689
|
+
const groupWidth = (box.right - box.left) / chart.data.length
|
|
690
|
+
const xStep = chart.data.length > 1 ? (box.right - box.left) / (chart.data.length - 1) : 0
|
|
691
|
+
const baseline = scaled(0, min, max, box.top, box.bottom)
|
|
692
|
+
const rowCenter = (index: number) => type === 'bar'
|
|
693
|
+
? box.left + groupWidth * index + groupWidth / 2
|
|
694
|
+
: chart.data.length > 1 ? box.left + xStep * index : (box.left + box.right) / 2
|
|
695
|
+
const seriesKeys = chart.series.map(entry => entry.key)
|
|
696
|
+
const rowY = (index: number) => Math.min(...chart.series.map(entry =>
|
|
697
|
+
scaled(number(chart.data[index]?.[entry.key]) ?? 0, min, max, box.top, box.bottom)))
|
|
698
|
+
const rowPoint = (index: number) => ({
|
|
699
|
+
x: rowCenter(index),
|
|
700
|
+
y: rowY(index),
|
|
701
|
+
})
|
|
702
|
+
const points = (entry: SeriesEntry) => chart.data.map((row, index) => ({
|
|
703
|
+
x: rowCenter(index),
|
|
704
|
+
y: scaled(number(row[entry.key]) ?? 0, min, max, box.top, box.bottom),
|
|
705
|
+
}))
|
|
706
|
+
|
|
707
|
+
const activate = (index: number, at?: ChartReferencePoint) => {
|
|
708
|
+
if (sameActiveTarget(chart.active, index, seriesKeys)) {
|
|
709
|
+
chart.setActive(chart.active, at)
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const items = payloadFor(chart, index)
|
|
714
|
+
if (!items.length) {
|
|
715
|
+
chart.clearActive()
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const point = rowPoint(index)
|
|
720
|
+
chart.setActive({
|
|
721
|
+
index,
|
|
722
|
+
items,
|
|
723
|
+
label: labelFor(chart, chart.data[index]!, index),
|
|
724
|
+
x: point.x,
|
|
725
|
+
y: point.y,
|
|
726
|
+
}, at)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const pointerMove = (event: PointerEvent) => {
|
|
730
|
+
const svg = event.currentTarget as SVGSVGElement
|
|
731
|
+
const cursor = svgPoint(svg, event.clientX, event.clientY)
|
|
732
|
+
if (!cursor) {
|
|
733
|
+
chart.clearActive()
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
const sx = cursor.x
|
|
737
|
+
const index = type === 'bar'
|
|
738
|
+
? clamp(Math.floor((sx - box.left) / groupWidth), 0, chart.data.length - 1)
|
|
739
|
+
: xStep === 0 ? 0 : clamp(Math.round((sx - box.left) / xStep), 0, chart.data.length - 1)
|
|
740
|
+
const point = rowPoint(index)
|
|
741
|
+
activate(index, referencePoint(svg, point))
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const focusIn = (event: FocusEvent) => {
|
|
745
|
+
const target = event.target as SVGElement | null
|
|
746
|
+
const index = Number(target?.getAttribute('data-chart-index'))
|
|
747
|
+
const svg = svgFromEvent(event)
|
|
748
|
+
if (!svg || !Number.isInteger(index)) return
|
|
749
|
+
const point = rowPoint(index)
|
|
750
|
+
activate(index, referencePoint(svg, point))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return (
|
|
754
|
+
<svg
|
|
755
|
+
{...attrs}
|
|
756
|
+
aria-describedby={chart.description ? `${chart.id}-description` : undefined}
|
|
757
|
+
aria-label={chart.label}
|
|
758
|
+
class={classes}
|
|
759
|
+
data-slot={`chart-${type}`}
|
|
760
|
+
height={chart.height}
|
|
761
|
+
role="img"
|
|
762
|
+
ref={plotRef(chart, ref)}
|
|
763
|
+
viewBox={`0 0 ${chart.width} ${chart.height}`}
|
|
764
|
+
width="100%"
|
|
765
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
766
|
+
set:onfocusin={focusIn}
|
|
767
|
+
set:onpointerleave={chart.clearActive}
|
|
768
|
+
set:onpointermove={pointerMove}
|
|
769
|
+
>
|
|
770
|
+
{chart.label ? <title>{chart.label}</title> : null}
|
|
771
|
+
{chart.description ? <desc id={`${chart.id}-description`}>{chart.description}</desc> : null}
|
|
772
|
+
{showGrid ? axis(chart, type, yTicks, min, max, axisStroke, axisStrokeOpacity, gridStroke) : showAxis ? axis(chart, type, [], min, max, axisStroke, axisStrokeOpacity, gridStroke) : null}
|
|
773
|
+
{type === 'bar' ? chart.data.map((row, index) => {
|
|
774
|
+
const barWidth = clamp(groupWidth * 0.68 / chart.series.length, 6, 42)
|
|
775
|
+
const groupStart = box.left + groupWidth * index + (groupWidth - barWidth * chart.series.length) / 2
|
|
776
|
+
|
|
777
|
+
return chart.series.map((entry, seriesIndex) => {
|
|
778
|
+
const value = number(row[entry.key]) ?? 0
|
|
779
|
+
const sign = value === 0 ? 'zero' : value < 0 ? 'negative' : 'positive'
|
|
780
|
+
const y = scaled(Math.max(0, value), min, max, box.top, box.bottom)
|
|
781
|
+
const yZero = scaled(Math.min(0, value), min, max, box.top, box.bottom)
|
|
782
|
+
const height = Math.max(1, Math.abs(yZero - y))
|
|
783
|
+
const x = groupStart + seriesIndex * barWidth
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<rect
|
|
787
|
+
key={`${entry.key}-${index}`}
|
|
788
|
+
aria-label={`${text(labelFor(chart, row, index))} ${text(entry.label)} ${chart.formatValue(value, entry.key, row, index)}`}
|
|
789
|
+
class={barClass}
|
|
790
|
+
data-active={chart.active?.index === index ? 'true' : undefined}
|
|
791
|
+
data-chart-index={index}
|
|
792
|
+
data-chart-sign={sign}
|
|
793
|
+
data-chart-series={entry.key}
|
|
794
|
+
fill={entry.color}
|
|
795
|
+
focusable="true"
|
|
796
|
+
height={height}
|
|
797
|
+
style={`--chart-index:${index}`}
|
|
798
|
+
tabindex="0"
|
|
799
|
+
width={Math.max(2, barWidth - 2)}
|
|
800
|
+
x={x}
|
|
801
|
+
y={Math.min(y, yZero)}
|
|
802
|
+
set:onfocus={(event: FocusEvent) => {
|
|
803
|
+
const svg = svgFromEvent(event)
|
|
804
|
+
const point = rowPoint(index)
|
|
805
|
+
if (svg) activate(index, referencePoint(svg, point))
|
|
806
|
+
}}
|
|
807
|
+
set:onpointerenter={(event: PointerEvent) => {
|
|
808
|
+
const svg = svgFromEvent(event)
|
|
809
|
+
const point = rowPoint(index)
|
|
810
|
+
if (svg) activate(index, referencePoint(svg, point))
|
|
811
|
+
}}
|
|
812
|
+
/>
|
|
813
|
+
)
|
|
814
|
+
})
|
|
815
|
+
}) : chart.series.map(entry => {
|
|
816
|
+
const entryPoints = points(entry)
|
|
817
|
+
return (
|
|
818
|
+
<g key={entry.key} data-chart-series={entry.key}>
|
|
819
|
+
{type === 'area' ? (
|
|
820
|
+
<path d={areaPath(entryPoints, baseline)} fill={entry.color} fill-opacity="0.18" />
|
|
821
|
+
) : null}
|
|
822
|
+
<path d={linePath(entryPoints)} fill="none" pathLength={1} stroke={entry.color} stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
|
|
823
|
+
{entryPoints.map((point, index) => {
|
|
824
|
+
const row = chart.data[index]!
|
|
825
|
+
const value = number(row[entry.key]) ?? 0
|
|
826
|
+
return (
|
|
827
|
+
<circle
|
|
828
|
+
key={`${entry.key}-${index}`}
|
|
829
|
+
aria-label={`${text(labelFor(chart, row, index))} ${text(entry.label)} ${chart.formatValue(value, entry.key, row, index)}`}
|
|
830
|
+
class={pointClass}
|
|
831
|
+
cx={point.x}
|
|
832
|
+
cy={point.y}
|
|
833
|
+
data-active={chart.active?.index === index ? 'true' : undefined}
|
|
834
|
+
data-chart-index={index}
|
|
835
|
+
fill={pointFill}
|
|
836
|
+
focusable="true"
|
|
837
|
+
r="4"
|
|
838
|
+
style={`--chart-index:${index}`}
|
|
839
|
+
stroke={entry.color}
|
|
840
|
+
stroke-width="2"
|
|
841
|
+
tabindex="0"
|
|
842
|
+
set:onfocus={(event: FocusEvent) => {
|
|
843
|
+
const svg = svgFromEvent(event)
|
|
844
|
+
const activePoint = rowPoint(index)
|
|
845
|
+
if (svg) activate(index, referencePoint(svg, activePoint))
|
|
846
|
+
}}
|
|
847
|
+
set:onpointerenter={(event: PointerEvent) => {
|
|
848
|
+
const svg = svgFromEvent(event)
|
|
849
|
+
const activePoint = rowPoint(index)
|
|
850
|
+
if (svg) activate(index, referencePoint(svg, activePoint))
|
|
851
|
+
}}
|
|
852
|
+
/>
|
|
853
|
+
)
|
|
854
|
+
})}
|
|
855
|
+
</g>
|
|
856
|
+
)
|
|
857
|
+
})}
|
|
858
|
+
</svg>
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Unstyled SVG bar chart primitive for use inside ChartContainer. */
|
|
863
|
+
const ChartBar: Stateless<ChartPlotArgs> = attrs => <ChartPlot {...attrs} type="bar" />
|
|
864
|
+
|
|
865
|
+
/** Unstyled SVG line chart primitive for use inside ChartContainer. */
|
|
866
|
+
const ChartLine: Stateless<ChartPlotArgs> = attrs => <ChartPlot {...attrs} type="line" />
|
|
867
|
+
|
|
868
|
+
/** Unstyled SVG area chart primitive for use inside ChartContainer. */
|
|
869
|
+
const ChartArea: Stateless<ChartPlotArgs> = attrs => <ChartPlot {...attrs} type="area" />
|
|
870
|
+
|
|
871
|
+
/** Unstyled SVG pie/donut chart primitive for use inside ChartContainer. */
|
|
872
|
+
const ChartPie: Stateless<ChartPieArgs> = ({
|
|
873
|
+
centerLabelClass,
|
|
874
|
+
centerLabelFill,
|
|
875
|
+
class: classes,
|
|
876
|
+
innerRadius = 0,
|
|
877
|
+
ref,
|
|
878
|
+
sliceStroke,
|
|
879
|
+
...attrs
|
|
880
|
+
}) => {
|
|
881
|
+
const chart = ChartContext()
|
|
882
|
+
if (!chart || !chart.data.length || !chart.series.length) return null
|
|
883
|
+
|
|
884
|
+
const entry = chart.series[0]!
|
|
885
|
+
const values = chart.data.map(row => Math.max(0, number(row[entry.key]) ?? 0))
|
|
886
|
+
const total = values.reduce((sum, value) => sum + value, 0) || 1
|
|
887
|
+
const size = Math.min(chart.width, chart.height)
|
|
888
|
+
const center = size / 2
|
|
889
|
+
const radius = center - 12
|
|
890
|
+
let start = -Math.PI / 2
|
|
891
|
+
|
|
892
|
+
const activate = (index: number, angle: number, color: string, at?: ChartReferencePoint) => {
|
|
893
|
+
if (sameActiveTarget(chart.active, index, [entry.key])) {
|
|
894
|
+
chart.setActive(chart.active, at)
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const point = anglePoint(center, radius * 0.78, angle)
|
|
899
|
+
const row = chart.data[index]!
|
|
900
|
+
const value = values[index] ?? 0
|
|
901
|
+
chart.setActive({
|
|
902
|
+
index,
|
|
903
|
+
items: [{
|
|
904
|
+
color,
|
|
905
|
+
formattedValue: chart.formatValue(value, entry.key, row, index),
|
|
906
|
+
index,
|
|
907
|
+
key: entry.key,
|
|
908
|
+
label: labelFor(chart, row, index),
|
|
909
|
+
row,
|
|
910
|
+
value,
|
|
911
|
+
}],
|
|
912
|
+
label: labelFor(chart, row, index),
|
|
913
|
+
x: point.x,
|
|
914
|
+
y: point.y,
|
|
915
|
+
}, at)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const focusIn = (event: FocusEvent) => {
|
|
919
|
+
const target = event.target as SVGElement | null
|
|
920
|
+
const index = Number(target?.getAttribute('data-chart-index'))
|
|
921
|
+
if (!Number.isInteger(index)) return
|
|
922
|
+
const svg = svgFromEvent(event)
|
|
923
|
+
if (!svg) return
|
|
924
|
+
const totalBefore = values.slice(0, index).reduce((sum, value) => sum + value, 0)
|
|
925
|
+
const value = values[index] ?? 0
|
|
926
|
+
const start = -Math.PI / 2 + (totalBefore / total) * Math.PI * 2
|
|
927
|
+
const middle = start + ((value / total) * Math.PI * 2) / 2
|
|
928
|
+
const point = anglePoint(center, radius * 0.78, middle)
|
|
929
|
+
activate(index, middle, chart.palette[index % chart.palette.length], referencePoint(svg, point))
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const pointerMove = (event: PointerEvent) => {
|
|
933
|
+
const svg = event.currentTarget as SVGSVGElement
|
|
934
|
+
const cursorPoint = svgPoint(svg, event.clientX, event.clientY)
|
|
935
|
+
if (!cursorPoint) {
|
|
936
|
+
chart.clearActive()
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
const { x, y } = cursorPoint
|
|
940
|
+
const distance = Math.hypot(x - center, y - center)
|
|
941
|
+
if (distance > radius || distance < innerRadius) {
|
|
942
|
+
chart.clearActive()
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let angle = Math.atan2(y - center, x - center) + Math.PI / 2
|
|
947
|
+
if (angle < 0) angle += Math.PI * 2
|
|
948
|
+
|
|
949
|
+
let cursor = 0
|
|
950
|
+
for (let index = 0; index < values.length; index++) {
|
|
951
|
+
const span = ((values[index] ?? 0) / total) * Math.PI * 2
|
|
952
|
+
if (angle <= cursor + span || index === values.length - 1) {
|
|
953
|
+
const middle = -Math.PI / 2 + cursor + span / 2
|
|
954
|
+
const point = anglePoint(center, radius * 0.78, middle)
|
|
955
|
+
activate(index, middle, chart.palette[index % chart.palette.length], referencePoint(svg, point))
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
cursor += span
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return (
|
|
963
|
+
<svg
|
|
964
|
+
{...attrs}
|
|
965
|
+
aria-describedby={chart.description ? `${chart.id}-description` : undefined}
|
|
966
|
+
aria-label={chart.label}
|
|
967
|
+
class={classes}
|
|
968
|
+
data-slot="chart-pie"
|
|
969
|
+
height={chart.height}
|
|
970
|
+
role="img"
|
|
971
|
+
ref={plotRef(chart, ref)}
|
|
972
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
973
|
+
width="100%"
|
|
974
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
975
|
+
set:onfocusin={focusIn}
|
|
976
|
+
set:onpointerleave={chart.clearActive}
|
|
977
|
+
set:onpointermove={pointerMove}
|
|
978
|
+
>
|
|
979
|
+
{chart.label ? <title>{chart.label}</title> : null}
|
|
980
|
+
{chart.description ? <desc id={`${chart.id}-description`}>{chart.description}</desc> : null}
|
|
981
|
+
{values.map((value, index) => {
|
|
982
|
+
const angle = (value / total) * Math.PI * 2
|
|
983
|
+
const end = start + angle
|
|
984
|
+
const middle = start + angle / 2
|
|
985
|
+
const color = chart.palette[index % chart.palette.length]
|
|
986
|
+
const path = slicePath(center, radius, innerRadius, start, end)
|
|
987
|
+
const row = chart.data[index]!
|
|
988
|
+
start = end
|
|
989
|
+
|
|
990
|
+
return (
|
|
991
|
+
<path
|
|
992
|
+
key={index}
|
|
993
|
+
aria-label={`${text(labelFor(chart, row, index))} ${chart.formatValue(value, entry.key, row, index)}`}
|
|
994
|
+
d={path}
|
|
995
|
+
data-active={chart.active?.index === index ? 'true' : undefined}
|
|
996
|
+
data-chart-index={index}
|
|
997
|
+
fill={color}
|
|
998
|
+
focusable="true"
|
|
999
|
+
style={`--chart-index:${index}`}
|
|
1000
|
+
stroke={sliceStroke}
|
|
1001
|
+
stroke-width="2"
|
|
1002
|
+
tabindex="0"
|
|
1003
|
+
set:onfocus={(event: FocusEvent) => {
|
|
1004
|
+
const svg = svgFromEvent(event)
|
|
1005
|
+
const point = anglePoint(center, radius * 0.78, middle)
|
|
1006
|
+
if (svg) activate(index, middle, color, referencePoint(svg, point))
|
|
1007
|
+
}}
|
|
1008
|
+
set:onpointerenter={(event: PointerEvent) => {
|
|
1009
|
+
const svg = svgFromEvent(event)
|
|
1010
|
+
const point = anglePoint(center, radius * 0.78, middle)
|
|
1011
|
+
if (svg) activate(index, middle, color, referencePoint(svg, point))
|
|
1012
|
+
}}
|
|
1013
|
+
/>
|
|
1014
|
+
)
|
|
1015
|
+
})}
|
|
1016
|
+
{innerRadius > 0 ? (
|
|
1017
|
+
<text x={center} y={center} fill={centerLabelFill} class={centerLabelClass} style="text-anchor:middle;dominant-baseline:middle">
|
|
1018
|
+
{chart.formatValue(total, entry.key, {}, 0)}
|
|
1019
|
+
</text>
|
|
1020
|
+
) : null}
|
|
1021
|
+
</svg>
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Unstyled absolute tooltip layer for native chart primitives. */
|
|
1026
|
+
const ChartTooltip: Stateless<ChartTooltipArgs> = ({
|
|
1027
|
+
children,
|
|
1028
|
+
class: classes,
|
|
1029
|
+
content,
|
|
1030
|
+
...attrs
|
|
1031
|
+
}) => {
|
|
1032
|
+
const chart = ChartContext()
|
|
1033
|
+
if (!chart) return null
|
|
1034
|
+
|
|
1035
|
+
if (!chart.active?.items.length) return null
|
|
1036
|
+
|
|
1037
|
+
return (
|
|
1038
|
+
<div
|
|
1039
|
+
key="chart-tooltip"
|
|
1040
|
+
{...attrs}
|
|
1041
|
+
class={classes}
|
|
1042
|
+
data-slot="chart-tooltip"
|
|
1043
|
+
ref={chart.setTooltip}
|
|
1044
|
+
style="left:0;top:0"
|
|
1045
|
+
>
|
|
1046
|
+
{content ?? children ?? <ChartTooltipContent />}
|
|
1047
|
+
</div>
|
|
1048
|
+
)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Unstyled tooltip body for native chart payloads. */
|
|
1052
|
+
const ChartTooltipContent: Stateless<ChartTooltipContentArgs> = ({
|
|
1053
|
+
class: classes,
|
|
1054
|
+
formattedValueClass,
|
|
1055
|
+
formatter,
|
|
1056
|
+
hideIndicator,
|
|
1057
|
+
hideLabel,
|
|
1058
|
+
iconClass,
|
|
1059
|
+
iconWrapperClass,
|
|
1060
|
+
indicator = 'dot',
|
|
1061
|
+
indicatorClass,
|
|
1062
|
+
itemClass,
|
|
1063
|
+
itemLabelClass,
|
|
1064
|
+
itemsClass,
|
|
1065
|
+
labelClass,
|
|
1066
|
+
labelFormatter,
|
|
1067
|
+
nestedLabelClass,
|
|
1068
|
+
valueLabelGroupClass,
|
|
1069
|
+
valueRowClass,
|
|
1070
|
+
...attrs
|
|
1071
|
+
}) => {
|
|
1072
|
+
const chart = ChartContext()
|
|
1073
|
+
const active = chart?.active
|
|
1074
|
+
if (!chart || !active?.items.length) return null
|
|
1075
|
+
|
|
1076
|
+
const nestLabel = active.items.length === 1 && indicator !== 'dot'
|
|
1077
|
+
const state = { indicator, nestLabel }
|
|
1078
|
+
|
|
1079
|
+
return (
|
|
1080
|
+
<div {...attrs} class={classes} data-slot="chart-tooltip-content">
|
|
1081
|
+
{!hideLabel && !nestLabel ? (
|
|
1082
|
+
<div class={labelClass} data-slot="chart-tooltip-label">
|
|
1083
|
+
{labelFormatter ? labelFormatter(active.label, active.items) : active.label}
|
|
1084
|
+
</div>
|
|
1085
|
+
) : null}
|
|
1086
|
+
<div class={itemsClass}>
|
|
1087
|
+
{active.items.map((item, index) => {
|
|
1088
|
+
const config = chart.config[item.key]
|
|
1089
|
+
const icon = config?.icon
|
|
1090
|
+
|
|
1091
|
+
return (
|
|
1092
|
+
<div key={`${item.key}-${index}`} class={resolve(itemClass, state)} data-slot="chart-tooltip-item">
|
|
1093
|
+
{formatter ? formatter(item.value, item.key, item, index) : (
|
|
1094
|
+
<>
|
|
1095
|
+
{icon && !hideIndicator ? (
|
|
1096
|
+
<span aria-hidden="true" class={iconWrapperClass}>{renderIcon(icon, iconClass)}</span>
|
|
1097
|
+
) : !hideIndicator ? (
|
|
1098
|
+
<span
|
|
1099
|
+
aria-hidden="true"
|
|
1100
|
+
class={resolve(indicatorClass, state)}
|
|
1101
|
+
style={`--chart-indicator:${item.color}`}
|
|
1102
|
+
/>
|
|
1103
|
+
) : null}
|
|
1104
|
+
<div class={resolve(valueRowClass, { nestLabel })}>
|
|
1105
|
+
<div class={valueLabelGroupClass}>
|
|
1106
|
+
{!hideLabel && nestLabel ? (
|
|
1107
|
+
<div class={nestedLabelClass}>
|
|
1108
|
+
{labelFormatter ? labelFormatter(active.label, active.items) : active.label}
|
|
1109
|
+
</div>
|
|
1110
|
+
) : null}
|
|
1111
|
+
<span class={itemLabelClass}>{item.label}</span>
|
|
1112
|
+
</div>
|
|
1113
|
+
<span class={formattedValueClass}>{item.formattedValue}</span>
|
|
1114
|
+
</div>
|
|
1115
|
+
</>
|
|
1116
|
+
)}
|
|
1117
|
+
</div>
|
|
1118
|
+
)
|
|
1119
|
+
})}
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Unstyled legend layer for native chart primitives. */
|
|
1126
|
+
const ChartLegend: Stateless<ChartLegendArgs> = ({
|
|
1127
|
+
children,
|
|
1128
|
+
class: classes,
|
|
1129
|
+
content,
|
|
1130
|
+
...attrs
|
|
1131
|
+
}) => (
|
|
1132
|
+
<div key="chart-legend" {...attrs} class={classes} data-slot="chart-legend">
|
|
1133
|
+
{content ?? children ?? <ChartLegendContent />}
|
|
1134
|
+
</div>
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
/** Unstyled legend content for native chart payloads. */
|
|
1138
|
+
const ChartLegendContent: Stateless<ChartLegendContentArgs> = ({
|
|
1139
|
+
class: classes,
|
|
1140
|
+
hideIcon,
|
|
1141
|
+
iconClass,
|
|
1142
|
+
iconWrapperClass,
|
|
1143
|
+
itemClass,
|
|
1144
|
+
swatchClass,
|
|
1145
|
+
...attrs
|
|
1146
|
+
}) => {
|
|
1147
|
+
const chart = ChartContext()
|
|
1148
|
+
if (!chart) return null
|
|
1149
|
+
|
|
1150
|
+
const entries = chart.type === 'pie'
|
|
1151
|
+
? chart.data.map((row, index) => ({
|
|
1152
|
+
color: chart.palette[index % chart.palette.length],
|
|
1153
|
+
icon: undefined,
|
|
1154
|
+
key: String(index),
|
|
1155
|
+
label: labelFor(chart, row, index),
|
|
1156
|
+
}))
|
|
1157
|
+
: chart.series.map(entry => ({
|
|
1158
|
+
color: entry.color,
|
|
1159
|
+
icon: chart.config[entry.key]?.icon,
|
|
1160
|
+
key: entry.key,
|
|
1161
|
+
label: entry.label,
|
|
1162
|
+
}))
|
|
1163
|
+
|
|
1164
|
+
return (
|
|
1165
|
+
<div
|
|
1166
|
+
{...attrs}
|
|
1167
|
+
class={classes}
|
|
1168
|
+
data-slot="chart-legend-content"
|
|
1169
|
+
>
|
|
1170
|
+
{entries.map(entry => (
|
|
1171
|
+
<div key={entry.key} class={itemClass} data-slot="chart-legend-item">
|
|
1172
|
+
{entry.icon && !hideIcon ? (
|
|
1173
|
+
<span aria-hidden="true" class={iconWrapperClass}>{renderIcon(entry.icon, iconClass)}</span>
|
|
1174
|
+
) : (
|
|
1175
|
+
<span aria-hidden="true" class={swatchClass} style={`background:${entry.color}`} />
|
|
1176
|
+
)}
|
|
1177
|
+
<span>{entry.label}</span>
|
|
1178
|
+
</div>
|
|
1179
|
+
))}
|
|
1180
|
+
</div>
|
|
1181
|
+
)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
export {
|
|
1185
|
+
ChartArea,
|
|
1186
|
+
ChartBar,
|
|
1187
|
+
ChartContainer,
|
|
1188
|
+
ChartLegend,
|
|
1189
|
+
ChartLegendContent,
|
|
1190
|
+
ChartLine,
|
|
1191
|
+
ChartPie,
|
|
1192
|
+
ChartTooltip,
|
|
1193
|
+
ChartTooltipContent,
|
|
1194
|
+
}
|