@a4ui/core 0.37.0 → 0.38.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/README.md +8 -7
- package/dist/Collapse-Cr5cDqAl.js +375 -0
- package/dist/charts/AreaChart.d.ts +38 -0
- package/dist/charts/BarChart.d.ts +17 -1
- package/dist/charts/PieChart.d.ts +29 -0
- package/dist/charts/RadialBarChart.d.ts +33 -0
- package/dist/charts/index.d.ts +5 -0
- package/dist/charts/shared.d.ts +111 -0
- package/dist/charts.js +1493 -780
- package/dist/commerce.js +625 -634
- package/dist/elements.css +1076 -8
- package/dist/elements.iife.js +1 -6
- package/dist/elements.js +7774 -9133
- package/dist/full.css +1076 -8
- package/dist/index.d.ts +30 -3
- package/dist/index.js +17502 -15098
- package/dist/layout/Aurora.d.ts +16 -0
- package/dist/layout/EffectsToggle.d.ts +1 -1
- package/dist/layout/SpaceBackground.d.ts +1 -1
- package/dist/lib/anchored.d.ts +16 -0
- package/dist/motion-CTIBwMCA.js +104 -0
- package/dist/styles.css +110 -3
- package/dist/ui/AspectRatio.d.ts +19 -0
- package/dist/ui/Attachment.d.ts +70 -0
- package/dist/ui/ButtonGroup.d.ts +37 -0
- package/dist/ui/ChatMarker.d.ts +20 -0
- package/dist/ui/ChatThread.d.ts +32 -8
- package/dist/ui/ContactSection.d.ts +49 -0
- package/dist/ui/CtaBanner.d.ts +28 -0
- package/dist/ui/DateField.d.ts +2 -2
- package/dist/ui/Drawer.d.ts +5 -2
- package/dist/ui/Faq.d.ts +29 -0
- package/dist/ui/FeatureGrid.d.ts +35 -0
- package/dist/ui/Field.d.ts +148 -0
- package/dist/ui/Hero.d.ts +48 -0
- package/dist/ui/InputGroup.d.ts +93 -0
- package/dist/ui/Item.d.ts +63 -0
- package/dist/ui/Label.d.ts +23 -0
- package/dist/ui/Menubar.d.ts +83 -0
- package/dist/ui/Message.d.ts +51 -5
- package/dist/ui/Modal.d.ts +8 -2
- package/dist/ui/NativeSelect.d.ts +26 -0
- package/dist/ui/NavigationMenu.d.ts +85 -0
- package/dist/ui/PromptComposer.d.ts +5 -0
- package/dist/ui/Prose.d.ts +21 -0
- package/dist/ui/Questionnaire.d.ts +71 -0
- package/dist/ui/ScrollArea.d.ts +30 -0
- package/dist/ui/Sidebar.d.ts +229 -0
- package/dist/ui/SiteFooter.d.ts +39 -0
- package/dist/ui/SiteNav.d.ts +35 -0
- package/dist/ui/TeamGrid.d.ts +33 -0
- package/dist/ui/Testimonials.d.ts +37 -0
- package/dist/ui/internal/sectionKit.d.ts +22 -0
- package/dist/ui/internal/socialIcons.d.ts +9 -0
- package/package.json +38 -26
- package/preset.d.ts +23 -0
- package/src/charts/AreaChart.tsx +206 -0
- package/src/charts/BarChart.tsx +241 -5
- package/src/charts/DonutChart.tsx +8 -11
- package/src/charts/LineChart.tsx +2 -3
- package/src/charts/PieChart.tsx +145 -0
- package/src/charts/RadialBarChart.tsx +141 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/shared.tsx +304 -0
- package/src/index.ts +192 -3
- package/src/layout/Aurora.tsx +36 -7
- package/src/layout/sceneEffects.ts +22 -3
- package/src/lib/anchored.ts +57 -0
- package/src/ui/AspectRatio.tsx +35 -0
- package/src/ui/Attachment.tsx +189 -0
- package/src/ui/ButtonGroup.tsx +89 -0
- package/src/ui/ChatMarker.tsx +58 -0
- package/src/ui/ChatThread.tsx +115 -23
- package/src/ui/ContactSection.tsx +199 -0
- package/src/ui/CtaBanner.tsx +62 -0
- package/src/ui/DateField.tsx +17 -7
- package/src/ui/DateRangePicker.tsx +15 -5
- package/src/ui/Drawer.tsx +37 -4
- package/src/ui/Faq.tsx +57 -0
- package/src/ui/FeatureGrid.tsx +77 -0
- package/src/ui/Field.tsx +374 -0
- package/src/ui/Hero.tsx +146 -0
- package/src/ui/InputGroup.tsx +219 -0
- package/src/ui/Item.tsx +185 -0
- package/src/ui/Label.tsx +50 -0
- package/src/ui/Menubar.tsx +212 -0
- package/src/ui/Message.tsx +139 -13
- package/src/ui/Modal.tsx +41 -5
- package/src/ui/MultiSelect.tsx +15 -5
- package/src/ui/NativeSelect.tsx +66 -0
- package/src/ui/NavigationMenu.tsx +262 -0
- package/src/ui/PromptComposer.tsx +11 -0
- package/src/ui/Prose.tsx +63 -0
- package/src/ui/Questionnaire.tsx +331 -0
- package/src/ui/ScrollArea.tsx +70 -0
- package/src/ui/Sidebar.tsx +753 -0
- package/src/ui/SiteFooter.tsx +112 -0
- package/src/ui/SiteNav.tsx +146 -0
- package/src/ui/TeamGrid.tsx +81 -0
- package/src/ui/Testimonials.tsx +103 -0
- package/src/ui/TimeField.tsx +15 -5
- package/src/ui/internal/sectionKit.tsx +72 -0
- package/src/ui/internal/socialIcons.tsx +41 -0
- package/dist/NumberInput-Bnh2u7pp.js +0 -381
- package/dist/motion-jYMWmZqB.js +0 -119
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// PieChart — SVG pie (solid wedges, no charting library). Shares its arc math
|
|
2
|
+
// with DonutChart / RadialBarChart (`sliceAngles`, `arcPath`). Each wedge
|
|
3
|
+
// exposes `data-value` and `data-angle` (degrees) so proportions are testable.
|
|
4
|
+
import { createMemo, For, Show, type JSX } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import {
|
|
8
|
+
ChartLegend,
|
|
9
|
+
ChartTooltip,
|
|
10
|
+
FOCUS_CLASS,
|
|
11
|
+
arcPath,
|
|
12
|
+
createChartNav,
|
|
13
|
+
createHiddenSet,
|
|
14
|
+
polar,
|
|
15
|
+
seriesColor,
|
|
16
|
+
sliceAngles,
|
|
17
|
+
type ChartColor,
|
|
18
|
+
} from './shared'
|
|
19
|
+
|
|
20
|
+
export interface PieDatum {
|
|
21
|
+
label: string
|
|
22
|
+
value: number
|
|
23
|
+
/** Token name; defaults to cycling `chart-1..5`. */
|
|
24
|
+
color?: ChartColor
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PieChartProps {
|
|
28
|
+
data: PieDatum[]
|
|
29
|
+
/** SVG width/height in px. Default 200. */
|
|
30
|
+
size?: number
|
|
31
|
+
/** Hover / keyboard tooltip. Default true. */
|
|
32
|
+
showTooltip?: boolean
|
|
33
|
+
/** Toggleable legend. Default true. */
|
|
34
|
+
showLegend?: boolean
|
|
35
|
+
class?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pie chart whose wedge angles are proportional to the values. Left / Right
|
|
40
|
+
* arrows move between wedges and show the tooltip; legend entries toggle
|
|
41
|
+
* wedges (the rest re-share the circle).
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <PieChart data={[{ label: 'Direct', value: 42 }, { label: 'Social', value: 28, color: 'accent' }]} />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function PieChart(props: PieChartProps): JSX.Element {
|
|
49
|
+
const size = () => props.size ?? 200
|
|
50
|
+
const r = () => size() / 2 - 4
|
|
51
|
+
const { hidden, toggle } = createHiddenSet()
|
|
52
|
+
const colorOf = (i: number) => seriesColor(props.data[i].color, i)
|
|
53
|
+
const visible = () => props.data.map((d, i) => ({ d, i })).filter((x) => !hidden().has(x.d.label))
|
|
54
|
+
const total = () => visible().reduce((s, x) => s + x.d.value, 0)
|
|
55
|
+
|
|
56
|
+
const slices = createMemo(() => {
|
|
57
|
+
const vis = visible()
|
|
58
|
+
return sliceAngles(vis.map((x) => x.d.value)).map((a) => ({
|
|
59
|
+
...a,
|
|
60
|
+
datum: vis[a.index].d,
|
|
61
|
+
orig: vis[a.index].i,
|
|
62
|
+
}))
|
|
63
|
+
})
|
|
64
|
+
const nav = createChartNav(() => slices().length)
|
|
65
|
+
const active = () => {
|
|
66
|
+
const i = nav.active()
|
|
67
|
+
return i === null ? undefined : slices()[i]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const summary = () => {
|
|
71
|
+
const sum = props.data.reduce((s, x) => s + x.value, 0) || 1
|
|
72
|
+
return `Pie chart. ${props.data
|
|
73
|
+
.map((d) => `${d.label} ${d.value} (${Math.round((d.value / sum) * 100)}%)`)
|
|
74
|
+
.join(', ')}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tipPos = () => {
|
|
78
|
+
const s = active()
|
|
79
|
+
if (!s) return { left: '50%', top: '0', anchor: 0.5 }
|
|
80
|
+
const p = polar(size() / 2, size() / 2, r() * 0.6, s.start + s.angle / 2)
|
|
81
|
+
return { left: `${(p.x / size()) * 100}%`, top: `${(p.y / size()) * 100}%`, anchor: p.x / size() }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div class={cn('inline-block', props.class)}>
|
|
86
|
+
<div
|
|
87
|
+
class={cn('relative', FOCUS_CLASS)}
|
|
88
|
+
style={{ width: `${size()}px`, height: `${size()}px` }}
|
|
89
|
+
tabindex="0"
|
|
90
|
+
role="img"
|
|
91
|
+
aria-label={summary()}
|
|
92
|
+
data-chart="pie"
|
|
93
|
+
onKeyDown={nav.onKeyDown}
|
|
94
|
+
onFocus={nav.onFocus}
|
|
95
|
+
onBlur={nav.onBlur}
|
|
96
|
+
onPointerLeave={() => nav.setActive(null)}
|
|
97
|
+
>
|
|
98
|
+
<svg width={size()} height={size()} viewBox={`0 0 ${size()} ${size()}`} aria-hidden="true">
|
|
99
|
+
<For each={slices()}>
|
|
100
|
+
{(s, i) => (
|
|
101
|
+
<path
|
|
102
|
+
data-slice
|
|
103
|
+
data-label={s.datum.label}
|
|
104
|
+
data-value={s.value}
|
|
105
|
+
data-angle={s.angle}
|
|
106
|
+
d={arcPath(size() / 2, size() / 2, r(), 0, s.start, s.angle)}
|
|
107
|
+
fill={colorOf(s.orig)}
|
|
108
|
+
stroke="hsl(var(--background))"
|
|
109
|
+
stroke-width={1.5}
|
|
110
|
+
opacity={nav.active() === null || nav.active() === i() ? 1 : 0.6}
|
|
111
|
+
onPointerEnter={() => nav.setActive(i())}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</For>
|
|
115
|
+
</svg>
|
|
116
|
+
<Show when={(props.showTooltip ?? true) && active()}>
|
|
117
|
+
{(s) => (
|
|
118
|
+
<ChartTooltip
|
|
119
|
+
title={s().datum.label}
|
|
120
|
+
left={tipPos().left}
|
|
121
|
+
top={tipPos().top}
|
|
122
|
+
anchor={tipPos().anchor}
|
|
123
|
+
rows={[
|
|
124
|
+
{ label: 'Value', value: s().value, color: colorOf(s().orig) },
|
|
125
|
+
{
|
|
126
|
+
label: 'Share',
|
|
127
|
+
value: `${Math.round((s().value / (total() || 1)) * 100)}%`,
|
|
128
|
+
color: colorOf(s().orig),
|
|
129
|
+
},
|
|
130
|
+
]}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</Show>
|
|
134
|
+
</div>
|
|
135
|
+
<Show when={props.showLegend ?? true}>
|
|
136
|
+
<ChartLegend
|
|
137
|
+
class="mt-2"
|
|
138
|
+
hidden={hidden()}
|
|
139
|
+
onToggle={toggle}
|
|
140
|
+
items={props.data.map((d, i) => ({ key: d.label, label: d.label, color: colorOf(i) }))}
|
|
141
|
+
/>
|
|
142
|
+
</Show>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// RadialBarChart — concentric progress rings, one per datum (no charting
|
|
2
|
+
// library). The first datum is the outermost ring; each ring sweeps
|
|
3
|
+
// `value / max` of a full turn from 12 o'clock. Rings expose `data-value` and
|
|
4
|
+
// `data-angle` (degrees).
|
|
5
|
+
import { createMemo, For, Show, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import {
|
|
9
|
+
ChartLegend,
|
|
10
|
+
ChartTooltip,
|
|
11
|
+
FOCUS_CLASS,
|
|
12
|
+
arcPath,
|
|
13
|
+
createChartNav,
|
|
14
|
+
createHiddenSet,
|
|
15
|
+
seriesColor,
|
|
16
|
+
type ChartColor,
|
|
17
|
+
} from './shared'
|
|
18
|
+
|
|
19
|
+
export interface RadialBarDatum {
|
|
20
|
+
label: string
|
|
21
|
+
value: number
|
|
22
|
+
/** Token name; defaults to cycling `chart-1..5`. */
|
|
23
|
+
color?: ChartColor
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RadialBarChartProps {
|
|
27
|
+
data: RadialBarDatum[]
|
|
28
|
+
/** Value that fills a whole ring. Default: the largest value. */
|
|
29
|
+
max?: number
|
|
30
|
+
/** SVG width/height in px. Default 220. */
|
|
31
|
+
size?: number
|
|
32
|
+
/** Ring thickness in px. Default 14. */
|
|
33
|
+
thickness?: number
|
|
34
|
+
/** Hover / keyboard tooltip. Default true. */
|
|
35
|
+
showTooltip?: boolean
|
|
36
|
+
/** Toggleable legend. Default true. */
|
|
37
|
+
showLegend?: boolean
|
|
38
|
+
class?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Radial bar chart: one concentric ring per datum, each filled to `value /
|
|
43
|
+
* max`. Arrow keys move between rings and show the tooltip; legend entries
|
|
44
|
+
* toggle rings.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <RadialBarChart max={100} data={[{ label: 'Move', value: 72 }, { label: 'Stand', value: 40, color: 'accent' }]} />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function RadialBarChart(props: RadialBarChartProps): JSX.Element {
|
|
52
|
+
const size = () => props.size ?? 220
|
|
53
|
+
const thickness = () => props.thickness ?? 14
|
|
54
|
+
const gap = 4
|
|
55
|
+
const { hidden, toggle } = createHiddenSet()
|
|
56
|
+
const colorOf = (i: number) => seriesColor(props.data[i].color, i)
|
|
57
|
+
const visible = () => props.data.map((d, i) => ({ d, i })).filter((x) => !hidden().has(x.d.label))
|
|
58
|
+
const max = () => props.max ?? (Math.max(...props.data.map((d) => d.value), 0) || 1)
|
|
59
|
+
|
|
60
|
+
const rings = createMemo(() =>
|
|
61
|
+
visible().map((x, n) => {
|
|
62
|
+
const outer = size() / 2 - 2 - n * (thickness() + gap)
|
|
63
|
+
const angle = Math.min(Math.max(x.d.value / max(), 0), 1) * 360
|
|
64
|
+
return { datum: x.d, orig: x.i, outer, inner: outer - thickness(), angle }
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
const nav = createChartNav(() => rings().length)
|
|
68
|
+
const active = () => {
|
|
69
|
+
const i = nav.active()
|
|
70
|
+
return i === null ? undefined : rings()[i]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const summary = () =>
|
|
74
|
+
`Radial bar chart. ${props.data.map((d) => `${d.label} ${d.value} of ${max()}`).join(', ')}`
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div class={cn('inline-block', props.class)}>
|
|
78
|
+
<div
|
|
79
|
+
class={cn('relative', FOCUS_CLASS)}
|
|
80
|
+
style={{ width: `${size()}px`, height: `${size()}px` }}
|
|
81
|
+
tabindex="0"
|
|
82
|
+
role="img"
|
|
83
|
+
aria-label={summary()}
|
|
84
|
+
data-chart="radial-bar"
|
|
85
|
+
onKeyDown={nav.onKeyDown}
|
|
86
|
+
onFocus={nav.onFocus}
|
|
87
|
+
onBlur={nav.onBlur}
|
|
88
|
+
onPointerLeave={() => nav.setActive(null)}
|
|
89
|
+
>
|
|
90
|
+
<svg width={size()} height={size()} viewBox={`0 0 ${size()} ${size()}`} aria-hidden="true">
|
|
91
|
+
<For each={rings()}>
|
|
92
|
+
{(ring, i) => (
|
|
93
|
+
<g
|
|
94
|
+
onPointerEnter={() => nav.setActive(i())}
|
|
95
|
+
opacity={nav.active() === null || nav.active() === i() ? 1 : 0.6}
|
|
96
|
+
>
|
|
97
|
+
<path
|
|
98
|
+
d={arcPath(size() / 2, size() / 2, ring.outer, ring.inner, 0, 360)}
|
|
99
|
+
fill="hsl(var(--muted))"
|
|
100
|
+
fill-rule="evenodd"
|
|
101
|
+
/>
|
|
102
|
+
<Show when={ring.angle > 0}>
|
|
103
|
+
<path
|
|
104
|
+
data-ring
|
|
105
|
+
data-label={ring.datum.label}
|
|
106
|
+
data-value={ring.datum.value}
|
|
107
|
+
data-angle={ring.angle}
|
|
108
|
+
d={arcPath(size() / 2, size() / 2, ring.outer, ring.inner, 0, ring.angle)}
|
|
109
|
+
fill={colorOf(ring.orig)}
|
|
110
|
+
fill-rule="evenodd"
|
|
111
|
+
/>
|
|
112
|
+
</Show>
|
|
113
|
+
</g>
|
|
114
|
+
)}
|
|
115
|
+
</For>
|
|
116
|
+
</svg>
|
|
117
|
+
<Show when={(props.showTooltip ?? true) && active()}>
|
|
118
|
+
{(ring) => (
|
|
119
|
+
<ChartTooltip
|
|
120
|
+
title={ring().datum.label}
|
|
121
|
+
left="50%"
|
|
122
|
+
top="0"
|
|
123
|
+
rows={[
|
|
124
|
+
{ label: 'Value', value: ring().datum.value, color: colorOf(ring().orig) },
|
|
125
|
+
{ label: 'Of', value: max(), color: colorOf(ring().orig) },
|
|
126
|
+
]}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
</Show>
|
|
130
|
+
</div>
|
|
131
|
+
<Show when={props.showLegend ?? true}>
|
|
132
|
+
<ChartLegend
|
|
133
|
+
class="mt-2"
|
|
134
|
+
hidden={hidden()}
|
|
135
|
+
onToggle={toggle}
|
|
136
|
+
items={props.data.map((d, i) => ({ key: d.label, label: d.label, color: colorOf(i) }))}
|
|
137
|
+
/>
|
|
138
|
+
</Show>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
package/src/charts/index.ts
CHANGED
|
@@ -9,3 +9,17 @@ export { RadarChart, type RadarSeries, type RadarChartProps } from './RadarChart
|
|
|
9
9
|
export { BarList, type BarListDatum, type BarListProps } from './BarList'
|
|
10
10
|
export { StatusTracker, type StatusSegment, type StatusTrackerProps } from './StatusTracker'
|
|
11
11
|
export { CategoryBar, type CategoryBarTone, type CategoryBarProps } from './CategoryBar'
|
|
12
|
+
export { AreaChart, type AreaDatum, type AreaChartProps } from './AreaChart'
|
|
13
|
+
export { PieChart, type PieDatum, type PieChartProps } from './PieChart'
|
|
14
|
+
export { RadialBarChart, type RadialBarDatum, type RadialBarChartProps } from './RadialBarChart'
|
|
15
|
+
export {
|
|
16
|
+
ChartTooltip,
|
|
17
|
+
ChartLegend,
|
|
18
|
+
type ChartSeries,
|
|
19
|
+
type ChartColor,
|
|
20
|
+
type ChartTooltipProps,
|
|
21
|
+
type ChartTooltipRow,
|
|
22
|
+
type ChartLegendProps,
|
|
23
|
+
type ChartLegendItem,
|
|
24
|
+
} from './shared'
|
|
25
|
+
export type { BarChartMode } from './BarChart'
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Shared building blocks for the multi-series charts: the series config type,
|
|
2
|
+
// a single token -> color helper, ChartTooltip, ChartLegend, keyboard / pointer
|
|
3
|
+
// category navigation, and the arc math used by DonutChart, PieChart and
|
|
4
|
+
// RadialBarChart. Colors always resolve to theme CSS variables.
|
|
5
|
+
import { createSignal, For, Show, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** Token names a series can be tinted with. `chart-1..5` cycle the theme's data hues. */
|
|
10
|
+
export type ChartColor =
|
|
11
|
+
'primary' | 'accent' | 'destructive' | 'muted' | 'chart-1' | 'chart-2' | 'chart-3' | 'chart-4' | 'chart-5'
|
|
12
|
+
|
|
13
|
+
/** Describes one series of a multi-series chart. */
|
|
14
|
+
export interface ChartSeries {
|
|
15
|
+
/** Key of the series' value inside each datum's `values` record. */
|
|
16
|
+
key: string
|
|
17
|
+
/** Human label shown in the legend and tooltip. */
|
|
18
|
+
label: string
|
|
19
|
+
/** Token name; defaults to cycling `chart-1..5` by index. */
|
|
20
|
+
color?: ChartColor
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const COLOR_VARS: Record<ChartColor, string> = {
|
|
24
|
+
primary: '--primary',
|
|
25
|
+
accent: '--accent',
|
|
26
|
+
destructive: '--destructive',
|
|
27
|
+
muted: '--muted-foreground',
|
|
28
|
+
'chart-1': '--primary',
|
|
29
|
+
'chart-2': '--accent',
|
|
30
|
+
'chart-3': '--data-emit',
|
|
31
|
+
'chart-4': '--data-received',
|
|
32
|
+
'chart-5': '--data-net',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const COLOR_CYCLE: ChartColor[] = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']
|
|
36
|
+
|
|
37
|
+
/** The CSS custom property (e.g. `--primary`) for a series color or index. */
|
|
38
|
+
export function colorVar(color: ChartColor | undefined, index: number): string {
|
|
39
|
+
return COLOR_VARS[color ?? COLOR_CYCLE[index % COLOR_CYCLE.length]]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `hsl(var(--token) / alpha)` for a series color. */
|
|
43
|
+
export function seriesColor(color: ChartColor | undefined, index: number, alpha?: number): string {
|
|
44
|
+
const v = colorVar(color, index)
|
|
45
|
+
return alpha === undefined ? `hsl(var(${v}))` : `hsl(var(${v}) / ${alpha})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Value lookup that fails loudly when a datum lacks a declared series key. */
|
|
49
|
+
export function seriesValue(values: Record<string, number> | undefined, key: string, label: string): number {
|
|
50
|
+
const v = values?.[key]
|
|
51
|
+
if (v === undefined) throw new Error(`Chart datum "${label}" has no value for series "${key}"`)
|
|
52
|
+
return v
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ChartTooltipRow {
|
|
56
|
+
label: string
|
|
57
|
+
value: string | number
|
|
58
|
+
color: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ChartTooltipProps {
|
|
62
|
+
title?: string
|
|
63
|
+
rows: ChartTooltipRow[]
|
|
64
|
+
/** CSS `left` inside the (relative) chart wrapper, e.g. `42%`. */
|
|
65
|
+
left: string
|
|
66
|
+
/** CSS `top` inside the wrapper. Default `0`. */
|
|
67
|
+
top?: string
|
|
68
|
+
/** Horizontal anchor 0..1 used to keep the tooltip inside the chart. Default 0.5. */
|
|
69
|
+
anchor?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Floating tooltip listing every series value at the active x. Position it
|
|
74
|
+
* inside a `relative` wrapper; it ignores pointer events and is announced
|
|
75
|
+
* politely to assistive tech.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```tsx
|
|
79
|
+
* <ChartTooltip title="Mon" left="20%" rows={[{ label: 'Sales', value: 12, color: 'hsl(var(--primary))' }]} />
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function ChartTooltip(props: ChartTooltipProps): JSX.Element {
|
|
83
|
+
const shift = () => {
|
|
84
|
+
const a = props.anchor ?? 0.5
|
|
85
|
+
return a > 0.8 ? '-100%' : a < 0.2 ? '0%' : '-50%'
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
data-chart-tooltip
|
|
90
|
+
aria-live="polite"
|
|
91
|
+
class="pointer-events-none absolute z-10 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md"
|
|
92
|
+
style={{ left: props.left, top: props.top ?? '0', transform: `translate(${shift()}, -100%)` }}
|
|
93
|
+
>
|
|
94
|
+
<Show when={props.title}>
|
|
95
|
+
<div class="mb-0.5 font-medium text-foreground">{props.title}</div>
|
|
96
|
+
</Show>
|
|
97
|
+
<For each={props.rows}>
|
|
98
|
+
{(row) => (
|
|
99
|
+
<div class="flex items-center gap-1.5" data-chart-tooltip-row>
|
|
100
|
+
<span class="inline-block size-1.5 rounded-full" style={{ 'background-color': row.color }} />
|
|
101
|
+
<span>{row.label}:</span>
|
|
102
|
+
<span class="font-medium text-foreground">{row.value}</span>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</For>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ChartLegendItem {
|
|
111
|
+
key: string
|
|
112
|
+
label: string
|
|
113
|
+
color: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ChartLegendProps {
|
|
117
|
+
items: ChartLegendItem[]
|
|
118
|
+
/** Keys currently hidden. */
|
|
119
|
+
hidden?: ReadonlySet<string>
|
|
120
|
+
/** When set, items become toggle buttons. */
|
|
121
|
+
onToggle?: (key: string) => void
|
|
122
|
+
class?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Legend with colored swatches. With `onToggle` each entry is a toggle button
|
|
127
|
+
* (`aria-pressed`) that hides / shows its series.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```tsx
|
|
131
|
+
* <ChartLegend items={[{ key: 'a', label: 'Sales', color: 'hsl(var(--primary))' }]} onToggle={toggle} />
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function ChartLegend(props: ChartLegendProps): JSX.Element {
|
|
135
|
+
return (
|
|
136
|
+
<ul class={cn('flex flex-wrap gap-x-3 gap-y-1', props.class)} data-chart-legend>
|
|
137
|
+
<For each={props.items}>
|
|
138
|
+
{(item) => {
|
|
139
|
+
const off = () => props.hidden?.has(item.key) ?? false
|
|
140
|
+
const inner = (
|
|
141
|
+
<>
|
|
142
|
+
<span
|
|
143
|
+
class="inline-block size-2 rounded-full"
|
|
144
|
+
style={{
|
|
145
|
+
'background-color': off() ? 'transparent' : item.color,
|
|
146
|
+
'box-shadow': `inset 0 0 0 1px ${item.color}`,
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
<span class={off() ? 'line-through opacity-60' : undefined}>{item.label}</span>
|
|
150
|
+
</>
|
|
151
|
+
)
|
|
152
|
+
return (
|
|
153
|
+
<li>
|
|
154
|
+
<Show
|
|
155
|
+
when={props.onToggle}
|
|
156
|
+
fallback={
|
|
157
|
+
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">{inner}</span>
|
|
158
|
+
}
|
|
159
|
+
>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
aria-pressed={!off()}
|
|
163
|
+
data-series={item.key}
|
|
164
|
+
class="inline-flex items-center gap-1.5 rounded text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
165
|
+
onClick={() => props.onToggle?.(item.key)}
|
|
166
|
+
>
|
|
167
|
+
{inner}
|
|
168
|
+
</button>
|
|
169
|
+
</Show>
|
|
170
|
+
</li>
|
|
171
|
+
)
|
|
172
|
+
}}
|
|
173
|
+
</For>
|
|
174
|
+
</ul>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Signal-backed set of hidden series keys with a toggle. */
|
|
179
|
+
export function createHiddenSet() {
|
|
180
|
+
const [hidden, setHidden] = createSignal<ReadonlySet<string>>(new Set())
|
|
181
|
+
const toggle = (key: string) =>
|
|
182
|
+
setHidden((prev) => {
|
|
183
|
+
const next = new Set(prev)
|
|
184
|
+
if (next.has(key)) next.delete(key)
|
|
185
|
+
else next.add(key)
|
|
186
|
+
return next
|
|
187
|
+
})
|
|
188
|
+
return { hidden, toggle }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Pointer + keyboard navigation over `count` categories. Arrow keys / Home /
|
|
193
|
+
* End move the active index, Escape clears it; keyboard focus (focus-visible)
|
|
194
|
+
* activates the first one.
|
|
195
|
+
*/
|
|
196
|
+
export function createChartNav(count: () => number) {
|
|
197
|
+
const [active, setActive] = createSignal<number | null>(null)
|
|
198
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
199
|
+
const n = count()
|
|
200
|
+
if (n === 0) return
|
|
201
|
+
const cur = active()
|
|
202
|
+
let next: number | null | undefined
|
|
203
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = cur === null ? 0 : Math.min(cur + 1, n - 1)
|
|
204
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = cur === null ? n - 1 : Math.max(cur - 1, 0)
|
|
205
|
+
else if (e.key === 'Home') next = 0
|
|
206
|
+
else if (e.key === 'End') next = n - 1
|
|
207
|
+
else if (e.key === 'Escape' && cur !== null) next = null
|
|
208
|
+
if (next === undefined) return
|
|
209
|
+
e.preventDefault()
|
|
210
|
+
setActive(next)
|
|
211
|
+
}
|
|
212
|
+
const onFocus = (e: FocusEvent) => {
|
|
213
|
+
if (active() === null && count() > 0 && (e.currentTarget as HTMLElement).matches(':focus-visible'))
|
|
214
|
+
setActive(0)
|
|
215
|
+
}
|
|
216
|
+
const onBlur = () => setActive(null)
|
|
217
|
+
return { active, setActive, onKeyDown, onFocus, onBlur }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Common focusable-wrapper classes (visible focus ring). */
|
|
221
|
+
export const FOCUS_CLASS = 'rounded outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
222
|
+
|
|
223
|
+
export interface SliceAngle {
|
|
224
|
+
index: number
|
|
225
|
+
value: number
|
|
226
|
+
/** Start angle in degrees, clockwise from 12 o'clock. */
|
|
227
|
+
start: number
|
|
228
|
+
/** Sweep in degrees; always `value / total * 360`. */
|
|
229
|
+
angle: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Proportional angles (degrees) for a list of values; shared by Donut, Pie and RadialBar. */
|
|
233
|
+
export function sliceAngles(values: number[]): SliceAngle[] {
|
|
234
|
+
const total = values.reduce((s, v) => s + v, 0) || 1
|
|
235
|
+
let start = 0
|
|
236
|
+
return values.map((value, index) => {
|
|
237
|
+
const angle = (value / total) * 360
|
|
238
|
+
const out = { index, value, start, angle }
|
|
239
|
+
start += angle
|
|
240
|
+
return out
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Point on a circle, angle in degrees clockwise from 12 o'clock. */
|
|
245
|
+
export function polar(cx: number, cy: number, r: number, deg: number): { x: number; y: number } {
|
|
246
|
+
const rad = ((deg - 90) * Math.PI) / 180
|
|
247
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** SVG path for a pie wedge (`inner` = 0) or ring segment; handles full circles. */
|
|
251
|
+
export function arcPath(
|
|
252
|
+
cx: number,
|
|
253
|
+
cy: number,
|
|
254
|
+
outer: number,
|
|
255
|
+
inner: number,
|
|
256
|
+
start: number,
|
|
257
|
+
angle: number,
|
|
258
|
+
): string {
|
|
259
|
+
const f = (n: number) => +n.toFixed(3)
|
|
260
|
+
if (angle >= 359.999) {
|
|
261
|
+
const half = (r: number, rev: boolean) => {
|
|
262
|
+
const a = polar(cx, cy, r, 0)
|
|
263
|
+
const b = polar(cx, cy, r, 180)
|
|
264
|
+
return `M ${f(a.x)} ${f(a.y)} A ${r} ${r} 0 1 ${rev ? 0 : 1} ${f(b.x)} ${f(b.y)} A ${r} ${r} 0 1 ${rev ? 0 : 1} ${f(a.x)} ${f(a.y)}`
|
|
265
|
+
}
|
|
266
|
+
return inner > 0 ? `${half(outer, false)} ${half(inner, true)} Z` : `${half(outer, false)} Z`
|
|
267
|
+
}
|
|
268
|
+
const end = start + angle
|
|
269
|
+
const large = angle > 180 ? 1 : 0
|
|
270
|
+
const o1 = polar(cx, cy, outer, start)
|
|
271
|
+
const o2 = polar(cx, cy, outer, end)
|
|
272
|
+
if (inner <= 0) {
|
|
273
|
+
return `M ${f(cx)} ${f(cy)} L ${f(o1.x)} ${f(o1.y)} A ${outer} ${outer} 0 ${large} 1 ${f(o2.x)} ${f(o2.y)} Z`
|
|
274
|
+
}
|
|
275
|
+
const i1 = polar(cx, cy, inner, start)
|
|
276
|
+
const i2 = polar(cx, cy, inner, end)
|
|
277
|
+
return `M ${f(o1.x)} ${f(o1.y)} A ${outer} ${outer} 0 ${large} 1 ${f(o2.x)} ${f(o2.y)} L ${f(i2.x)} ${f(i2.y)} A ${inner} ${inner} 0 ${large} 0 ${f(i1.x)} ${f(i1.y)} Z`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* X-axis label row aligned with points placed edge to edge (`i / (n - 1)` of the
|
|
282
|
+
* width). Each label sits on its point; the first and last are anchored to the
|
|
283
|
+
* edges so they are not clipped.
|
|
284
|
+
*/
|
|
285
|
+
export function ChartXLabels(props: { labels: string[] }): JSX.Element {
|
|
286
|
+
const frac = (i: number): number => (props.labels.length <= 1 ? 0.5 : i / (props.labels.length - 1))
|
|
287
|
+
const shift = (i: number): string =>
|
|
288
|
+
props.labels.length <= 1 ? '-50%' : i === 0 ? '0%' : i === props.labels.length - 1 ? '-100%' : '-50%'
|
|
289
|
+
return (
|
|
290
|
+
<div class="relative mt-1 h-4 w-full text-xs text-muted-foreground" aria-hidden="true">
|
|
291
|
+
<For each={props.labels}>
|
|
292
|
+
{(label, i) => (
|
|
293
|
+
<span
|
|
294
|
+
data-x-label
|
|
295
|
+
class="absolute top-0 whitespace-nowrap"
|
|
296
|
+
style={{ left: `${frac(i()) * 100}%`, transform: `translateX(${shift(i())})` }}
|
|
297
|
+
>
|
|
298
|
+
{label}
|
|
299
|
+
</span>
|
|
300
|
+
)}
|
|
301
|
+
</For>
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|