@a4ui/core 0.31.0 → 0.32.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/dist/NumberInput-Bnh2u7pp.js +381 -0
- package/dist/charts/GaugeChart.d.ts +43 -0
- package/dist/charts/LineChart.d.ts +39 -0
- package/dist/charts/RadarChart.d.ts +34 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +563 -100
- package/dist/commerce.js +2 -2
- package/dist/elements.css +114 -0
- package/dist/full.css +114 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4171 -3872
- package/dist/motion-jYMWmZqB.js +119 -0
- package/dist/ui/AnimatedBeam.d.ts +34 -0
- package/dist/ui/BentoGrid.d.ts +39 -0
- package/dist/ui/BorderBeam.d.ts +27 -0
- package/dist/ui/Dock.d.ts +31 -0
- package/dist/ui/Meteors.d.ts +23 -0
- package/package.json +1 -1
- package/src/charts/GaugeChart.tsx +175 -0
- package/src/charts/LineChart.tsx +264 -0
- package/src/charts/RadarChart.tsx +215 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +8 -1
- package/src/ui/AnimatedBeam.tsx +187 -0
- package/src/ui/BentoGrid.tsx +89 -0
- package/src/ui/BorderBeam.tsx +96 -0
- package/src/ui/CardSpread.tsx +18 -12
- package/src/ui/Carousel3D.tsx +28 -6
- package/src/ui/Dock.tsx +124 -0
- package/src/ui/Meteors.tsx +109 -0
- package/dist/NumberInput-BQhVucw-.js +0 -491
- package/dist/cn-B6yFEsav.js +0 -8
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// LineChart — a multi-series SVG line chart (no charting library). Every
|
|
2
|
+
// series is scaled against the global min/max across *all* series so they
|
|
3
|
+
// stay comparable, then drawn as one polyline each, with an optional
|
|
4
|
+
// low-opacity area fill down to the baseline. Colors come from theme tokens
|
|
5
|
+
// only, so the chart recolors with the active palette automatically.
|
|
6
|
+
import { createSignal, For, Show, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface LineSeries {
|
|
11
|
+
name?: string
|
|
12
|
+
tone?: 'primary' | 'accent' | 'muted' | 'foreground'
|
|
13
|
+
data: number[]
|
|
14
|
+
area?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LineChartProps {
|
|
18
|
+
series: LineSeries[]
|
|
19
|
+
labels?: string[]
|
|
20
|
+
/** Height of the plot area in px. Default 200. */
|
|
21
|
+
height?: number
|
|
22
|
+
/** Render a small circle at each data point. Default false. */
|
|
23
|
+
showDots?: boolean
|
|
24
|
+
/** Show a crosshair + value tooltip on pointer hover. Default false. */
|
|
25
|
+
showTooltip?: boolean
|
|
26
|
+
class?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Logical SVG width; the element itself scales to its container via viewBox. */
|
|
30
|
+
const VIEW_W = 400
|
|
31
|
+
const PAD_Y = 4
|
|
32
|
+
const TONE_CYCLE: NonNullable<LineSeries['tone']>[] = ['primary', 'accent', 'muted']
|
|
33
|
+
|
|
34
|
+
function toneFor(series: LineSeries, index: number): NonNullable<LineSeries['tone']> {
|
|
35
|
+
return series.tone ?? TONE_CYCLE[index % TONE_CYCLE.length]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toneColor(tone: NonNullable<LineSeries['tone']>): string {
|
|
39
|
+
return tone === 'foreground' ? 'hsl(var(--foreground))' : `hsl(var(--${tone}))`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toneColorAlpha(tone: NonNullable<LineSeries['tone']>, alpha: number): string {
|
|
43
|
+
const varName = tone === 'foreground' ? '--foreground' : `--${tone}`
|
|
44
|
+
return `hsl(var(${varName}) / ${alpha})`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Point count used for the x-axis: the longest `data` array across all series. */
|
|
48
|
+
function pointCount(series: LineSeries[]): number {
|
|
49
|
+
return Math.max(0, ...series.map((s) => s.data.length))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Global min/max across every series' values, so all series share one y-scale. */
|
|
53
|
+
function globalRange(series: LineSeries[]): { min: number; max: number } {
|
|
54
|
+
const values = series.flatMap((s) => s.data)
|
|
55
|
+
if (values.length === 0) return { min: 0, max: 1 }
|
|
56
|
+
const min = Math.min(...values)
|
|
57
|
+
const max = Math.max(...values)
|
|
58
|
+
return min === max ? { min: min - 1, max: max + 1 } : { min, max }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function yFor(value: number, min: number, max: number, h: number): number {
|
|
62
|
+
const span = max - min || 1
|
|
63
|
+
const innerH = Math.max(h - PAD_Y * 2, 0)
|
|
64
|
+
return PAD_Y + innerH - ((value - min) / span) * innerH
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function xFor(index: number, count: number): number {
|
|
68
|
+
if (count <= 1) return VIEW_W / 2
|
|
69
|
+
return index * (VIEW_W / (count - 1))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pointsFor(
|
|
73
|
+
data: number[],
|
|
74
|
+
count: number,
|
|
75
|
+
min: number,
|
|
76
|
+
max: number,
|
|
77
|
+
h: number,
|
|
78
|
+
): { x: number; y: number }[] {
|
|
79
|
+
return data.map((value, i) => ({ x: xFor(i, count), y: yFor(value, min, max, h) }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Multi-series SVG line chart scaled to fit its container (responsive
|
|
84
|
+
* viewBox, `preserveAspectRatio="none"`), with a shared y-scale across all
|
|
85
|
+
* series. Series with `area: true` also get a faint fill to the baseline.
|
|
86
|
+
* Assumes equal-length `data` arrays across series; if lengths differ, the
|
|
87
|
+
* x-axis is built from the longest one.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```tsx
|
|
91
|
+
* <LineChart
|
|
92
|
+
* series={[
|
|
93
|
+
* { name: 'Revenue', tone: 'primary', data: [4, 6, 5, 9, 8, 12], area: true },
|
|
94
|
+
* { name: 'Costs', tone: 'muted', data: [2, 3, 3, 4, 5, 6] },
|
|
95
|
+
* ]}
|
|
96
|
+
* labels={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}
|
|
97
|
+
* showDots
|
|
98
|
+
* showTooltip
|
|
99
|
+
* />
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function LineChart(props: LineChartProps): JSX.Element {
|
|
103
|
+
const height = () => props.height ?? 200
|
|
104
|
+
const count = () => pointCount(props.series)
|
|
105
|
+
const range = () => globalRange(props.series)
|
|
106
|
+
const namedSeries = () => props.series.filter((s) => s.name)
|
|
107
|
+
|
|
108
|
+
const seriesPoints = () =>
|
|
109
|
+
props.series.map((s) => pointsFor(s.data, count(), range().min, range().max, height()))
|
|
110
|
+
|
|
111
|
+
const areaPath = (points: { x: number; y: number }[]): string => {
|
|
112
|
+
if (points.length === 0) return ''
|
|
113
|
+
const line = points.map((p) => `${p.x},${p.y}`).join(' L ')
|
|
114
|
+
const first = points[0]
|
|
115
|
+
const last = points[points.length - 1]
|
|
116
|
+
return `M ${first.x},${height()} L ${line} L ${last.x},${height()} Z`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let svgRef: SVGSVGElement | undefined
|
|
120
|
+
const [hoverIndex, setHoverIndex] = createSignal<number | null>(null)
|
|
121
|
+
|
|
122
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
123
|
+
if (!svgRef || count() === 0) return
|
|
124
|
+
const rect = svgRef.getBoundingClientRect()
|
|
125
|
+
const fraction = rect.width === 0 ? 0 : (event.clientX - rect.left) / rect.width
|
|
126
|
+
const clamped = Math.min(1, Math.max(0, fraction))
|
|
127
|
+
const idx = count() <= 1 ? 0 : Math.round(clamped * (count() - 1))
|
|
128
|
+
setHoverIndex(idx)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handlePointerLeave = () => setHoverIndex(null)
|
|
132
|
+
|
|
133
|
+
const ariaLabel = () => {
|
|
134
|
+
const names = namedSeries()
|
|
135
|
+
.map((s) => s.name)
|
|
136
|
+
.join(', ')
|
|
137
|
+
const seriesLabel = names || `${props.series.length} series`
|
|
138
|
+
return `Line chart with ${seriesLabel}, ${count()} data points each`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div class={cn('w-full', props.class)}>
|
|
143
|
+
<Show when={namedSeries().length >= 2}>
|
|
144
|
+
<div class="mb-2 flex flex-wrap gap-x-3 gap-y-1">
|
|
145
|
+
<For each={props.series}>
|
|
146
|
+
{(s, i) => (
|
|
147
|
+
<Show when={s.name}>
|
|
148
|
+
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
149
|
+
<span
|
|
150
|
+
class="inline-block size-2 rounded-full"
|
|
151
|
+
style={{ 'background-color': toneColor(toneFor(s, i())) }}
|
|
152
|
+
/>
|
|
153
|
+
{s.name}
|
|
154
|
+
</span>
|
|
155
|
+
</Show>
|
|
156
|
+
)}
|
|
157
|
+
</For>
|
|
158
|
+
</div>
|
|
159
|
+
</Show>
|
|
160
|
+
|
|
161
|
+
<div class="relative w-full">
|
|
162
|
+
<svg
|
|
163
|
+
ref={svgRef}
|
|
164
|
+
class="block w-full overflow-visible"
|
|
165
|
+
viewBox={`0 0 ${VIEW_W} ${height()}`}
|
|
166
|
+
preserveAspectRatio="none"
|
|
167
|
+
height={height()}
|
|
168
|
+
role="img"
|
|
169
|
+
aria-label={ariaLabel()}
|
|
170
|
+
onPointerMove={props.showTooltip ? handlePointerMove : undefined}
|
|
171
|
+
onPointerLeave={props.showTooltip ? handlePointerLeave : undefined}
|
|
172
|
+
>
|
|
173
|
+
<For each={props.series}>
|
|
174
|
+
{(s, i) => {
|
|
175
|
+
const points = () => seriesPoints()[i()]
|
|
176
|
+
const tone = () => toneFor(s, i())
|
|
177
|
+
const polylinePoints = () =>
|
|
178
|
+
points()
|
|
179
|
+
.map((p) => `${p.x},${p.y}`)
|
|
180
|
+
.join(' ')
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
<Show when={s.area}>
|
|
184
|
+
<path d={areaPath(points())} fill={toneColorAlpha(tone(), 0.15)} stroke="none" />
|
|
185
|
+
</Show>
|
|
186
|
+
<polyline
|
|
187
|
+
points={polylinePoints()}
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke={toneColor(tone())}
|
|
190
|
+
stroke-width={2}
|
|
191
|
+
stroke-linecap="round"
|
|
192
|
+
stroke-linejoin="round"
|
|
193
|
+
vector-effect="non-scaling-stroke"
|
|
194
|
+
/>
|
|
195
|
+
<Show when={props.showDots}>
|
|
196
|
+
<For each={points()}>
|
|
197
|
+
{(p) => (
|
|
198
|
+
<circle
|
|
199
|
+
cx={p.x}
|
|
200
|
+
cy={p.y}
|
|
201
|
+
r={2.5}
|
|
202
|
+
fill={toneColor(tone())}
|
|
203
|
+
vector-effect="non-scaling-stroke"
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
</For>
|
|
207
|
+
</Show>
|
|
208
|
+
</>
|
|
209
|
+
)
|
|
210
|
+
}}
|
|
211
|
+
</For>
|
|
212
|
+
|
|
213
|
+
<Show when={props.showTooltip && hoverIndex() !== null}>
|
|
214
|
+
<line
|
|
215
|
+
x1={xFor(hoverIndex() ?? 0, count())}
|
|
216
|
+
x2={xFor(hoverIndex() ?? 0, count())}
|
|
217
|
+
y1={0}
|
|
218
|
+
y2={height()}
|
|
219
|
+
stroke="hsl(var(--border))"
|
|
220
|
+
stroke-width={1}
|
|
221
|
+
vector-effect="non-scaling-stroke"
|
|
222
|
+
/>
|
|
223
|
+
</Show>
|
|
224
|
+
</svg>
|
|
225
|
+
|
|
226
|
+
<Show when={props.showTooltip && hoverIndex() !== null}>
|
|
227
|
+
<div
|
|
228
|
+
class="pointer-events-none absolute top-0 z-10 -translate-y-1 rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md"
|
|
229
|
+
style={{
|
|
230
|
+
left: `${((hoverIndex() ?? 0) / Math.max(count() - 1, 1)) * 100}%`,
|
|
231
|
+
transform: `translateX(${
|
|
232
|
+
(hoverIndex() ?? 0) / Math.max(count() - 1, 1) > 0.8 ? '-100%' : '-8px'
|
|
233
|
+
}) translateY(-100%)`,
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<Show when={props.labels?.[hoverIndex() ?? -1]}>
|
|
237
|
+
<div class="mb-0.5 font-medium text-foreground">{props.labels?.[hoverIndex() ?? -1]}</div>
|
|
238
|
+
</Show>
|
|
239
|
+
<For each={props.series}>
|
|
240
|
+
{(s, i) => (
|
|
241
|
+
<Show when={s.data[hoverIndex() ?? -1] !== undefined}>
|
|
242
|
+
<div class="flex items-center gap-1.5">
|
|
243
|
+
<span
|
|
244
|
+
class="inline-block size-1.5 rounded-full"
|
|
245
|
+
style={{ 'background-color': toneColor(toneFor(s, i())) }}
|
|
246
|
+
/>
|
|
247
|
+
<span>{s.name ?? `Series ${i() + 1}`}:</span>
|
|
248
|
+
<span class="font-medium text-foreground">{s.data[hoverIndex() ?? -1]}</span>
|
|
249
|
+
</div>
|
|
250
|
+
</Show>
|
|
251
|
+
)}
|
|
252
|
+
</For>
|
|
253
|
+
</div>
|
|
254
|
+
</Show>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<Show when={props.labels && props.labels.length > 0}>
|
|
258
|
+
<div class="mt-1 flex w-full text-xs text-muted-foreground">
|
|
259
|
+
<For each={props.labels}>{(label) => <span class="flex-1 truncate text-center">{label}</span>}</For>
|
|
260
|
+
</div>
|
|
261
|
+
</Show>
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// RadarChart — a native SVG spider/radar chart (no charting library). Axes
|
|
2
|
+
// are laid out clockwise starting at 12 o'clock, one spoke per dimension.
|
|
3
|
+
// A faint concentric polygon grid marks reference levels, and each series is
|
|
4
|
+
// drawn as a translucent-filled polygon connecting its normalized values.
|
|
5
|
+
// Colors come from theme tokens, either per-series `tone` or, when omitted,
|
|
6
|
+
// cycled across `--primary` / `--accent` by index.
|
|
7
|
+
import { For, Show, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
|
|
11
|
+
export interface RadarSeries {
|
|
12
|
+
name?: string
|
|
13
|
+
tone?: 'primary' | 'accent'
|
|
14
|
+
values: number[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RadarChartProps {
|
|
18
|
+
axes: string[]
|
|
19
|
+
series: RadarSeries[]
|
|
20
|
+
/** Value that reaches the outer edge of the grid. Default: max across all series. */
|
|
21
|
+
max?: number
|
|
22
|
+
/** Overall SVG width/height in px (square). Default 240. */
|
|
23
|
+
size?: number
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Explicit tone -> CSS custom property name.
|
|
28
|
+
const TONE_TOKENS: Record<'primary' | 'accent', string> = {
|
|
29
|
+
primary: '--primary',
|
|
30
|
+
accent: '--accent',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback rotation used when a series has no explicit `tone`.
|
|
34
|
+
const CYCLE_TOKENS = ['--primary', '--accent'] as const
|
|
35
|
+
|
|
36
|
+
/** Resolve the CSS custom property a series should stroke/fill with. */
|
|
37
|
+
function tokenFor(series: RadarSeries, index: number): string {
|
|
38
|
+
if (series.tone) return TONE_TOKENS[series.tone]
|
|
39
|
+
return CYCLE_TOKENS[index % CYCLE_TOKENS.length]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RING_LEVELS = 4
|
|
43
|
+
const LABEL_OFFSET = 14
|
|
44
|
+
// Extra viewBox margin (all sides) so axis-label text isn't clipped.
|
|
45
|
+
const LABEL_PAD = 34
|
|
46
|
+
|
|
47
|
+
/** Angle (in degrees) of axis `index` of `count`, starting at 12 o'clock and running clockwise. */
|
|
48
|
+
function axisAngle(index: number, count: number): number {
|
|
49
|
+
return -90 + (360 / count) * index
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Convert a polar coordinate (center + radius + angle in degrees) to cartesian `{ x, y }`. */
|
|
53
|
+
function polarToPoint(cx: number, cy: number, r: number, angleDeg: number): { x: number; y: number } {
|
|
54
|
+
const rad = (angleDeg * Math.PI) / 180
|
|
55
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build the `x,y ...` points attribute for a polygon at `radius` across all axes. */
|
|
59
|
+
function ringPoints(cx: number, cy: number, radius: number, axisCount: number): string {
|
|
60
|
+
return Array.from({ length: axisCount }, (_, i) => {
|
|
61
|
+
const p = polarToPoint(cx, cy, radius, axisAngle(i, axisCount))
|
|
62
|
+
return `${p.x},${p.y}`
|
|
63
|
+
}).join(' ')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Text anchor for a label sitting outside the vertex at `angleDeg`. */
|
|
67
|
+
function labelAnchor(angleDeg: number): 'start' | 'middle' | 'end' {
|
|
68
|
+
const cos = Math.cos((angleDeg * Math.PI) / 180)
|
|
69
|
+
if (cos > 0.1) return 'start'
|
|
70
|
+
if (cos < -0.1) return 'end'
|
|
71
|
+
return 'middle'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Dominant baseline for a label sitting outside the vertex at `angleDeg`. */
|
|
75
|
+
function labelBaseline(angleDeg: number): 'auto' | 'hanging' | 'middle' {
|
|
76
|
+
const sin = Math.sin((angleDeg * Math.PI) / 180)
|
|
77
|
+
if (sin < -0.3) return 'auto'
|
|
78
|
+
if (sin > 0.3) return 'hanging'
|
|
79
|
+
return 'middle'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* SVG spider/radar chart: a concentric polygon grid with one spoke per axis,
|
|
84
|
+
* and one translucent-filled polygon per series connecting its values
|
|
85
|
+
* (normalized by `max`, default the max value across all series). Series
|
|
86
|
+
* colors come from each datum's `tone`, or cycle through `primary`/`accent`
|
|
87
|
+
* by index when omitted. Shows a small legend when 2+ series have a `name`.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```tsx
|
|
91
|
+
* <RadarChart
|
|
92
|
+
* axes={['Speed', 'Power', 'Range', 'Defense', 'Utility']}
|
|
93
|
+
* series={[
|
|
94
|
+
* { name: 'Model A', tone: 'primary', values: [80, 60, 90, 40, 70] },
|
|
95
|
+
* { name: 'Model B', tone: 'accent', values: [50, 85, 60, 75, 55] },
|
|
96
|
+
* ]}
|
|
97
|
+
* />
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function RadarChart(props: RadarChartProps): JSX.Element {
|
|
101
|
+
const size = () => props.size ?? 240
|
|
102
|
+
const center = () => size() / 2
|
|
103
|
+
const outerRadius = () => Math.max(size() / 2 - LABEL_OFFSET * 2, 0)
|
|
104
|
+
const axisCount = () => props.axes.length
|
|
105
|
+
const max = () => props.max ?? Math.max(1, ...props.series.flatMap((s) => s.values))
|
|
106
|
+
|
|
107
|
+
const rings = () =>
|
|
108
|
+
Array.from({ length: RING_LEVELS }, (_, i) => {
|
|
109
|
+
const level = i + 1
|
|
110
|
+
return ringPoints(center(), center(), outerRadius() * (level / RING_LEVELS), axisCount())
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const spokes = () =>
|
|
114
|
+
props.axes.map((_, i) => polarToPoint(center(), center(), outerRadius(), axisAngle(i, axisCount())))
|
|
115
|
+
|
|
116
|
+
const labels = () =>
|
|
117
|
+
props.axes.map((label, i) => {
|
|
118
|
+
const angle = axisAngle(i, axisCount())
|
|
119
|
+
const point = polarToPoint(center(), center(), outerRadius() + LABEL_OFFSET, angle)
|
|
120
|
+
return { label, x: point.x, y: point.y, anchor: labelAnchor(angle), baseline: labelBaseline(angle) }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const seriesPolygons = () => {
|
|
124
|
+
const grandMax = max()
|
|
125
|
+
return props.series.map((series, index) => {
|
|
126
|
+
const points = series.values
|
|
127
|
+
.map((value, i) => {
|
|
128
|
+
const fraction = grandMax > 0 ? Math.max(value, 0) / grandMax : 0
|
|
129
|
+
const p = polarToPoint(center(), center(), outerRadius() * fraction, axisAngle(i, axisCount()))
|
|
130
|
+
return `${p.x},${p.y}`
|
|
131
|
+
})
|
|
132
|
+
.join(' ')
|
|
133
|
+
return { series, index, points, token: tokenFor(series, index) }
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const namedSeries = () => props.series.filter((s) => s.name)
|
|
138
|
+
const showLegend = () => namedSeries().length >= 2
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div class={cn('inline-flex flex-col items-center gap-2', props.class)}>
|
|
142
|
+
<svg
|
|
143
|
+
width={size()}
|
|
144
|
+
height={size()}
|
|
145
|
+
// Pad the coordinate space so axis-label text near the left/right
|
|
146
|
+
// vertices isn't clipped by the viewBox edge (the plot scales down a
|
|
147
|
+
// touch to fit; the CSS footprint stays `size`).
|
|
148
|
+
viewBox={`${-LABEL_PAD} ${-LABEL_PAD} ${size() + LABEL_PAD * 2} ${size() + LABEL_PAD * 2}`}
|
|
149
|
+
role="img"
|
|
150
|
+
aria-label={`Radar chart comparing ${props.series.length} series across ${axisCount()} axes: ${props.axes.join(', ')}`}
|
|
151
|
+
>
|
|
152
|
+
<For each={rings()}>
|
|
153
|
+
{(points) => <polygon points={points} fill="none" stroke="hsl(var(--border))" stroke-width={1} />}
|
|
154
|
+
</For>
|
|
155
|
+
<For each={spokes()}>
|
|
156
|
+
{(point) => (
|
|
157
|
+
<line
|
|
158
|
+
x1={center()}
|
|
159
|
+
y1={center()}
|
|
160
|
+
x2={point.x}
|
|
161
|
+
y2={point.y}
|
|
162
|
+
stroke="hsl(var(--border))"
|
|
163
|
+
stroke-width={1}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</For>
|
|
167
|
+
<For each={seriesPolygons()}>
|
|
168
|
+
{({ series, points, token }) => (
|
|
169
|
+
<polygon
|
|
170
|
+
points={points}
|
|
171
|
+
fill={`hsl(var(${token}) / 0.15)`}
|
|
172
|
+
stroke={`hsl(var(${token}))`}
|
|
173
|
+
stroke-width={2}
|
|
174
|
+
stroke-linejoin="round"
|
|
175
|
+
>
|
|
176
|
+
<Show when={series.name}>
|
|
177
|
+
<title>{series.name}</title>
|
|
178
|
+
</Show>
|
|
179
|
+
</polygon>
|
|
180
|
+
)}
|
|
181
|
+
</For>
|
|
182
|
+
<For each={labels()}>
|
|
183
|
+
{(item) => (
|
|
184
|
+
<text
|
|
185
|
+
x={item.x}
|
|
186
|
+
y={item.y}
|
|
187
|
+
text-anchor={item.anchor}
|
|
188
|
+
dominant-baseline={item.baseline}
|
|
189
|
+
class="text-xs fill-muted-foreground"
|
|
190
|
+
>
|
|
191
|
+
{item.label}
|
|
192
|
+
</text>
|
|
193
|
+
)}
|
|
194
|
+
</For>
|
|
195
|
+
</svg>
|
|
196
|
+
<Show when={showLegend()}>
|
|
197
|
+
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
|
198
|
+
<For each={seriesPolygons()}>
|
|
199
|
+
{({ series, token }) => (
|
|
200
|
+
<Show when={series.name}>
|
|
201
|
+
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
202
|
+
<span
|
|
203
|
+
class="inline-block size-2 rounded-full"
|
|
204
|
+
style={{ 'background-color': `hsl(var(${token}))` }}
|
|
205
|
+
/>
|
|
206
|
+
{series.name}
|
|
207
|
+
</span>
|
|
208
|
+
</Show>
|
|
209
|
+
)}
|
|
210
|
+
</For>
|
|
211
|
+
</div>
|
|
212
|
+
</Show>
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
package/src/charts/index.ts
CHANGED
|
@@ -3,3 +3,6 @@
|
|
|
3
3
|
export { Sparkline, type SparklineProps } from './Sparkline'
|
|
4
4
|
export { BarChart, type BarDatum, type BarChartProps } from './BarChart'
|
|
5
5
|
export { DonutChart, type DonutSegment, type DonutChartProps } from './DonutChart'
|
|
6
|
+
export { LineChart, type LineSeries, type LineChartProps } from './LineChart'
|
|
7
|
+
export { GaugeChart, type GaugeThreshold, type GaugeChartProps } from './GaugeChart'
|
|
8
|
+
export { RadarChart, type RadarSeries, type RadarChartProps } from './RadarChart'
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.32.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -179,6 +179,13 @@ export { FocusBlurGroup, type FocusBlurGroupProps } from './ui/FocusBlurGroup'
|
|
|
179
179
|
export { CardSpread, type CardSpreadProps, type CardSpreadLayout } from './ui/CardSpread'
|
|
180
180
|
export { Carousel3D, type Carousel3DProps, type Carousel3DVariant } from './ui/Carousel3D'
|
|
181
181
|
export { TimeMachineStack, type TimeMachineStackProps } from './ui/TimeMachineStack'
|
|
182
|
+
|
|
183
|
+
// Spatial "wow" primitives (bento, dock, beams, meteors).
|
|
184
|
+
export { BentoGrid, BentoCard, type BentoGridProps, type BentoCardProps } from './ui/BentoGrid'
|
|
185
|
+
export { Dock, type DockItem, type DockProps } from './ui/Dock'
|
|
186
|
+
export { AnimatedBeam, type AnimatedBeamProps } from './ui/AnimatedBeam'
|
|
187
|
+
export { BorderBeam, type BorderBeamProps } from './ui/BorderBeam'
|
|
188
|
+
export { Meteors, type MeteorsProps } from './ui/Meteors'
|
|
182
189
|
export {
|
|
183
190
|
CopyButton,
|
|
184
191
|
type CopyButtonProps,
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// AnimatedBeam — an absolutely-positioned SVG overlay that draws a curved
|
|
2
|
+
// line between two elements (`fromRef` → `toRef`) inside a positioned
|
|
3
|
+
// `containerRef`, with a faint static stroke under a traveling
|
|
4
|
+
// primary→accent gradient segment that loops along the path. Path geometry
|
|
5
|
+
// is recomputed on mount, on window resize, and via a ResizeObserver on the
|
|
6
|
+
// container. No travel (static stroke only) under reduced motion.
|
|
7
|
+
import { createEffect, createSignal, createUniqueId, onCleanup, onMount, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface AnimatedBeamProps {
|
|
13
|
+
containerRef: HTMLElement | undefined
|
|
14
|
+
fromRef: HTMLElement | undefined
|
|
15
|
+
toRef: HTMLElement | undefined
|
|
16
|
+
/** Px the curve bows away from a straight line. @default 0 (straight) */
|
|
17
|
+
curvature?: number
|
|
18
|
+
/** Travel from `toRef` to `fromRef` instead of the default direction. */
|
|
19
|
+
reverse?: boolean
|
|
20
|
+
/** Seconds for one travel of the gradient segment. @default 3 */
|
|
21
|
+
duration?: number
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Point {
|
|
26
|
+
x: number
|
|
27
|
+
y: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Renders an animated SVG "beam" connecting two elements: a faint static
|
|
32
|
+
* base stroke under a traveling gradient segment that loops from `fromRef`
|
|
33
|
+
* to `toRef` (or the reverse, with `reverse`). Absolutely positioned over
|
|
34
|
+
* `containerRef`, whose bounds establish the coordinate space both
|
|
35
|
+
* endpoints are measured against. Static (no travel) under reduced motion.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* let container: HTMLDivElement | undefined
|
|
40
|
+
* let fromEl: HTMLDivElement | undefined
|
|
41
|
+
* let toEl: HTMLDivElement | undefined
|
|
42
|
+
*
|
|
43
|
+
* <div ref={container} class="relative flex items-center justify-between p-8">
|
|
44
|
+
* <div ref={fromEl} class="size-10 rounded-full bg-card" />
|
|
45
|
+
* <div ref={toEl} class="size-10 rounded-full bg-card" />
|
|
46
|
+
* <AnimatedBeam containerRef={container} fromRef={fromEl} toRef={toEl} curvature={40} />
|
|
47
|
+
* </div>
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function AnimatedBeam(props: AnimatedBeamProps): JSX.Element {
|
|
51
|
+
const [pathD, setPathD] = createSignal('')
|
|
52
|
+
const [size, setSize] = createSignal({ w: 0, h: 0 })
|
|
53
|
+
const gradientId = `beam-gradient-${createUniqueId()}`
|
|
54
|
+
|
|
55
|
+
let gradientEl: SVGLinearGradientElement | undefined
|
|
56
|
+
let start: Point = { x: 0, y: 0 }
|
|
57
|
+
let end: Point = { x: 0, y: 0 }
|
|
58
|
+
let control: Point = { x: 0, y: 0 }
|
|
59
|
+
|
|
60
|
+
const recompute = (): void => {
|
|
61
|
+
const container = props.containerRef
|
|
62
|
+
const from = props.fromRef
|
|
63
|
+
const to = props.toRef
|
|
64
|
+
if (!container || !from || !to) return
|
|
65
|
+
|
|
66
|
+
const containerRect = container.getBoundingClientRect()
|
|
67
|
+
const fromRect = from.getBoundingClientRect()
|
|
68
|
+
const toRect = to.getBoundingClientRect()
|
|
69
|
+
|
|
70
|
+
start = {
|
|
71
|
+
x: fromRect.left - containerRect.left + fromRect.width / 2,
|
|
72
|
+
y: fromRect.top - containerRect.top + fromRect.height / 2,
|
|
73
|
+
}
|
|
74
|
+
end = {
|
|
75
|
+
x: toRect.left - containerRect.left + toRect.width / 2,
|
|
76
|
+
y: toRect.top - containerRect.top + toRect.height / 2,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const curvature = props.curvature ?? 0
|
|
80
|
+
const mx = (start.x + end.x) / 2
|
|
81
|
+
const my = (start.y + end.y) / 2
|
|
82
|
+
const dx = end.x - start.x
|
|
83
|
+
const dy = end.y - start.y
|
|
84
|
+
const length = Math.hypot(dx, dy) || 1
|
|
85
|
+
control = {
|
|
86
|
+
x: mx + (-dy / length) * curvature,
|
|
87
|
+
y: my + (dx / length) * curvature,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setSize({ w: containerRect.width, h: containerRect.height })
|
|
91
|
+
setPathD(`M ${start.x} ${start.y} Q ${control.x} ${control.y} ${end.x} ${end.y}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Point at parameter `t` (0..1) along the quadratic curve. */
|
|
95
|
+
const pointAt = (t: number): Point => {
|
|
96
|
+
const it = 1 - t
|
|
97
|
+
return {
|
|
98
|
+
x: it * it * start.x + 2 * it * t * control.x + t * t * end.x,
|
|
99
|
+
y: it * it * start.y + 2 * it * t * control.y + t * t * end.y,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
createEffect(() => {
|
|
104
|
+
// Track the refs reactively — recompute picks up the initial mount and
|
|
105
|
+
// any later change (e.g. a ref that resolves after this beam mounts).
|
|
106
|
+
void props.containerRef
|
|
107
|
+
void props.fromRef
|
|
108
|
+
void props.toRef
|
|
109
|
+
recompute()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
onMount(() => {
|
|
113
|
+
const handleResize = (): void => recompute()
|
|
114
|
+
window.addEventListener('resize', handleResize)
|
|
115
|
+
|
|
116
|
+
let resizeObserver: ResizeObserver | undefined
|
|
117
|
+
if (props.containerRef && typeof ResizeObserver !== 'undefined') {
|
|
118
|
+
resizeObserver = new ResizeObserver(() => recompute())
|
|
119
|
+
resizeObserver.observe(props.containerRef)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
123
|
+
if (!motionReduced()) {
|
|
124
|
+
const segment = 0.2
|
|
125
|
+
controls = animate(0, 1, {
|
|
126
|
+
duration: props.duration ?? 3,
|
|
127
|
+
repeat: Infinity,
|
|
128
|
+
ease: 'linear',
|
|
129
|
+
onUpdate: (p: number) => {
|
|
130
|
+
const t = props.reverse ? 1 - p : p
|
|
131
|
+
const lead = props.reverse ? Math.min(1, t + segment) : t
|
|
132
|
+
const trail = props.reverse ? t : Math.max(0, t - segment)
|
|
133
|
+
const from = pointAt(trail)
|
|
134
|
+
const to = pointAt(lead)
|
|
135
|
+
gradientEl?.setAttribute('x1', String(from.x))
|
|
136
|
+
gradientEl?.setAttribute('y1', String(from.y))
|
|
137
|
+
gradientEl?.setAttribute('x2', String(to.x))
|
|
138
|
+
gradientEl?.setAttribute('y2', String(to.y))
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onCleanup(() => {
|
|
144
|
+
window.removeEventListener('resize', handleResize)
|
|
145
|
+
resizeObserver?.disconnect()
|
|
146
|
+
controls?.stop()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<svg
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
class={cn('pointer-events-none absolute inset-0', props.class)}
|
|
154
|
+
width={size().w}
|
|
155
|
+
height={size().h}
|
|
156
|
+
viewBox={`0 0 ${size().w} ${size().h}`}
|
|
157
|
+
fill="none"
|
|
158
|
+
>
|
|
159
|
+
<path d={pathD()} stroke="hsl(var(--border))" stroke-width="2" stroke-opacity="0.4" fill="none" />
|
|
160
|
+
{!motionReduced() && (
|
|
161
|
+
<>
|
|
162
|
+
<defs>
|
|
163
|
+
<linearGradient
|
|
164
|
+
ref={(el) => {
|
|
165
|
+
gradientEl = el
|
|
166
|
+
}}
|
|
167
|
+
id={gradientId}
|
|
168
|
+
gradientUnits="userSpaceOnUse"
|
|
169
|
+
>
|
|
170
|
+
<stop offset="0%" stop-color="hsl(var(--primary))" stop-opacity="0" />
|
|
171
|
+
<stop offset="50%" stop-color="hsl(var(--primary))" />
|
|
172
|
+
<stop offset="50%" stop-color="hsl(var(--accent))" />
|
|
173
|
+
<stop offset="100%" stop-color="hsl(var(--accent))" stop-opacity="0" />
|
|
174
|
+
</linearGradient>
|
|
175
|
+
</defs>
|
|
176
|
+
<path
|
|
177
|
+
d={pathD()}
|
|
178
|
+
stroke={`url(#${gradientId})`}
|
|
179
|
+
stroke-width="2"
|
|
180
|
+
stroke-linecap="round"
|
|
181
|
+
fill="none"
|
|
182
|
+
/>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</svg>
|
|
186
|
+
)
|
|
187
|
+
}
|