@a4ui/core 0.31.1 → 0.33.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.
Files changed (60) hide show
  1. package/dist/NumberInput-Bnh2u7pp.js +381 -0
  2. package/dist/charts/BarList.d.ts +33 -0
  3. package/dist/charts/CategoryBar.d.ts +23 -0
  4. package/dist/charts/GaugeChart.d.ts +43 -0
  5. package/dist/charts/LineChart.d.ts +39 -0
  6. package/dist/charts/RadarChart.d.ts +34 -0
  7. package/dist/charts/StatusTracker.d.ts +27 -0
  8. package/dist/charts/index.d.ts +6 -0
  9. package/dist/charts.js +693 -100
  10. package/dist/commerce.js +2 -2
  11. package/dist/elements.css +206 -0
  12. package/dist/full.css +206 -0
  13. package/dist/index.d.ts +18 -1
  14. package/dist/index.js +5086 -3915
  15. package/dist/motion-jYMWmZqB.js +119 -0
  16. package/dist/ui/AnimatedBeam.d.ts +34 -0
  17. package/dist/ui/BentoGrid.d.ts +39 -0
  18. package/dist/ui/BorderBeam.d.ts +27 -0
  19. package/dist/ui/Callout.d.ts +27 -0
  20. package/dist/ui/Confetti.d.ts +46 -0
  21. package/dist/ui/CursorTrail.d.ts +25 -0
  22. package/dist/ui/DiffViewer.d.ts +33 -0
  23. package/dist/ui/Dock.d.ts +31 -0
  24. package/dist/ui/Globe.d.ts +44 -0
  25. package/dist/ui/Meteors.d.ts +23 -0
  26. package/dist/ui/ModelPicker.d.ts +38 -0
  27. package/dist/ui/ReasoningTrace.d.ts +28 -0
  28. package/dist/ui/Snippet.d.ts +19 -0
  29. package/dist/ui/SuggestionChips.d.ts +25 -0
  30. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/WorldMap.d.ts +36 -0
  33. package/package.json +1 -1
  34. package/src/charts/BarList.tsx +83 -0
  35. package/src/charts/CategoryBar.tsx +95 -0
  36. package/src/charts/GaugeChart.tsx +175 -0
  37. package/src/charts/LineChart.tsx +264 -0
  38. package/src/charts/RadarChart.tsx +215 -0
  39. package/src/charts/StatusTracker.tsx +81 -0
  40. package/src/charts/index.ts +6 -0
  41. package/src/index.ts +31 -1
  42. package/src/ui/AnimatedBeam.tsx +187 -0
  43. package/src/ui/BentoGrid.tsx +89 -0
  44. package/src/ui/BorderBeam.tsx +96 -0
  45. package/src/ui/Callout.tsx +66 -0
  46. package/src/ui/Confetti.tsx +131 -0
  47. package/src/ui/CursorTrail.tsx +88 -0
  48. package/src/ui/DiffViewer.tsx +166 -0
  49. package/src/ui/Dock.tsx +124 -0
  50. package/src/ui/Globe.tsx +317 -0
  51. package/src/ui/Meteors.tsx +109 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/ReasoningTrace.tsx +83 -0
  54. package/src/ui/Snippet.tsx +64 -0
  55. package/src/ui/SuggestionChips.tsx +65 -0
  56. package/src/ui/ToolCallTimeline.tsx +137 -0
  57. package/src/ui/UsageMeter.tsx +71 -0
  58. package/src/ui/WorldMap.tsx +191 -0
  59. package/dist/NumberInput-BQhVucw-.js +0 -491
  60. package/dist/cn-B6yFEsav.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,83 @@
1
+ // BarList — a ranked horizontal bar list built from plain flex `div`s (no
2
+ // SVG, no charting library). Rows are sorted by value descending; each row
3
+ // is a full-width track with a low-opacity theme-toned fill sized to
4
+ // `value / max`, the `name` overlaid at the left (truncated, linked when
5
+ // `href` is set) and the formatted value at the right. Colors come from
6
+ // theme tokens only.
7
+ import { For, Show, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ export interface BarListDatum {
12
+ name: string
13
+ value: number
14
+ href?: string
15
+ }
16
+
17
+ export interface BarListProps {
18
+ data: BarListDatum[]
19
+ /** Formats the raw value shown at the row's end. Default `String`. */
20
+ valueFormat?: (n: number) => string
21
+ /** Fill color token. Default 'primary'. */
22
+ tone?: 'primary' | 'accent'
23
+ class?: string
24
+ }
25
+
26
+ /**
27
+ * Ranked horizontal bar list: rows sorted by `value` descending, each a
28
+ * full-width track with a low-opacity fill sized to `value / max` of the
29
+ * tallest row, the `name` overlaid at the left (truncated, rendered as a
30
+ * link when `href` is set) and the formatted value at the right. Compact
31
+ * and reflows with its container.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <BarList
36
+ * data={[
37
+ * { name: '/dashboard', value: 842, href: '/pages/dashboard' },
38
+ * { name: '/settings', value: 231 },
39
+ * ]}
40
+ * tone="accent"
41
+ * />
42
+ * ```
43
+ */
44
+ export function BarList(props: BarListProps): JSX.Element {
45
+ const tone = () => props.tone ?? 'primary'
46
+ const format = () => props.valueFormat ?? ((n: number) => String(n))
47
+ const fillClass = () => (tone() === 'accent' ? 'bg-accent/15' : 'bg-primary/15')
48
+ const sorted = () => [...props.data].sort((a, b) => b.value - a.value)
49
+ const max = () => Math.max(...props.data.map((d) => d.value), 0) || 1
50
+
51
+ return (
52
+ <div class={cn('flex w-full flex-col gap-1.5', props.class)}>
53
+ <For each={sorted()}>
54
+ {(datum) => (
55
+ <div class="relative isolate flex h-8 w-full min-w-0 items-center overflow-hidden rounded-md bg-muted/40">
56
+ <div
57
+ class={cn('absolute inset-y-0 left-0 rounded-md transition-all', fillClass())}
58
+ style={{ width: `${Math.max((datum.value / max()) * 100, 0)}%` }}
59
+ />
60
+ <Show
61
+ when={datum.href}
62
+ fallback={
63
+ <span class="relative z-10 min-w-0 flex-1 truncate px-2.5 text-sm text-foreground">
64
+ {datum.name}
65
+ </span>
66
+ }
67
+ >
68
+ <a
69
+ href={datum.href}
70
+ class="relative z-10 min-w-0 flex-1 truncate px-2.5 text-sm text-foreground underline-offset-2 hover:underline"
71
+ >
72
+ {datum.name}
73
+ </a>
74
+ </Show>
75
+ <span class="relative z-10 shrink-0 px-2.5 text-sm tabular-nums text-muted-foreground">
76
+ {format()(datum.value)}
77
+ </span>
78
+ </div>
79
+ )}
80
+ </For>
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,95 @@
1
+ // CategoryBar — a single horizontal segmented bar built from plain flex
2
+ // `div`s (no SVG, no charting library). Each segment's width is its share of
3
+ // the total (via CSS `flex-grow`), segments join seamlessly inside a
4
+ // rounded track, an optional caret marks a value along the total (pure-CSS
5
+ // border triangle), and cumulative boundary labels sit beneath. `success`/
6
+ // `warning` tones reuse `Alert`/`Badge`'s `emerald-500`/`amber-500` (the repo
7
+ // has no dedicated CSS token for them); `primary`/`accent`/`destructive` are
8
+ // the shared semantic tokens.
9
+ import { createMemo, For, Show, type JSX } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+
13
+ export type CategoryBarTone = 'primary' | 'accent' | 'success' | 'warning' | 'destructive'
14
+
15
+ export interface CategoryBarProps {
16
+ values: number[]
17
+ /** A value along the cumulative total to mark with a caret above the bar. */
18
+ marker?: number
19
+ /** Per-segment color tokens; cycles the default order below when omitted or shorter than `values`. */
20
+ tones?: CategoryBarTone[]
21
+ class?: string
22
+ }
23
+
24
+ const DEFAULT_TONES: CategoryBarTone[] = ['primary', 'accent', 'success', 'warning', 'destructive']
25
+
26
+ const TONE_CLASSES: Record<CategoryBarTone, string> = {
27
+ primary: 'bg-primary',
28
+ accent: 'bg-accent',
29
+ success: 'bg-emerald-500',
30
+ warning: 'bg-amber-500',
31
+ destructive: 'bg-destructive',
32
+ }
33
+
34
+ /** Cumulative running sum of `values`, one boundary per entry (same length as `values`). */
35
+ function cumulativeBounds(values: number[]): number[] {
36
+ let running = 0
37
+ return values.map((v) => (running += v))
38
+ }
39
+
40
+ /**
41
+ * Single segmented bar: each `values[i]` sets a segment's flex-grow share of
42
+ * the total, colored by `tones[i]` (cycling a sensible default), joined
43
+ * seamlessly inside one rounded track. An optional `marker` renders a caret
44
+ * above the bar at that value's position along the total, and cumulative
45
+ * boundary labels sit beneath (muted, tabular-nums).
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * <CategoryBar values={[40, 25, 20, 15]} marker={62} />
50
+ * ```
51
+ */
52
+ export function CategoryBar(props: CategoryBarProps): JSX.Element {
53
+ const total = createMemo(() => props.values.reduce((sum, v) => sum + v, 0) || 1)
54
+ const bounds = createMemo(() => cumulativeBounds(props.values))
55
+ const tones = () => (props.tones && props.tones.length > 0 ? props.tones : DEFAULT_TONES)
56
+ const markerPct = createMemo(() =>
57
+ props.marker == null ? undefined : Math.min(Math.max((props.marker / total()) * 100, 0), 100),
58
+ )
59
+
60
+ return (
61
+ <div class={cn('w-full', props.class)}>
62
+ <div class="relative">
63
+ <Show when={markerPct() !== undefined}>
64
+ <div
65
+ class="absolute -top-1.5 h-0 w-0 -translate-x-1/2 border-x-4 border-t-4 border-x-transparent border-t-foreground"
66
+ style={{ left: `${markerPct()}%` }}
67
+ aria-hidden="true"
68
+ />
69
+ </Show>
70
+ <div class="flex h-2.5 w-full overflow-hidden rounded-full bg-muted">
71
+ <For each={props.values}>
72
+ {(value, i) => (
73
+ <div
74
+ class={cn('h-full', TONE_CLASSES[tones()[i() % tones().length] ?? 'primary'])}
75
+ style={{ flex: `${Math.max(value, 0)} 0 0%` }}
76
+ />
77
+ )}
78
+ </For>
79
+ </div>
80
+ </div>
81
+ <div class="relative mt-1 h-4 w-full text-xs tabular-nums text-muted-foreground">
82
+ <For each={bounds()}>
83
+ {(bound) => (
84
+ <span
85
+ class="absolute -translate-x-1/2 whitespace-nowrap"
86
+ style={{ left: `${Math.min(Math.max((bound / total()) * 100, 0), 100)}%` }}
87
+ >
88
+ {bound}
89
+ </span>
90
+ )}
91
+ </For>
92
+ </div>
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,175 @@
1
+ // GaugeChart — a native SVG radial gauge (no charting library). A single
2
+ // 270° arc (from -135° to +135°, leaving a 90° gap at the bottom) is drawn
3
+ // twice: a static background track and a value arc revealed proportionally
4
+ // to `value` within `[min, max]` via the classic stroke-dasharray/
5
+ // stroke-dashoffset trick (dash length == full path length, offset shifts
6
+ // how much of it is "on" from the arc's start). The value arc's color comes
7
+ // from `tone`, or from the highest `threshold` the value has reached. The
8
+ // sweep animates on mount and on value change (CSS transition on
9
+ // stroke-dashoffset), jumping instantly under `motionReduced()`.
10
+ import { createEffect, createSignal, on, onMount, Show, type JSX } from 'solid-js'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { motionReduced } from '../lib/motion'
14
+
15
+ export interface GaugeThreshold {
16
+ value: number
17
+ tone: 'primary' | 'accent' | 'destructive' | 'muted'
18
+ }
19
+
20
+ export interface GaugeChartProps {
21
+ value: number
22
+ /** Value mapped to the arc's start (-135°). Default 0. */
23
+ min?: number
24
+ /** Value mapped to the arc's end (+135°). Default 100. */
25
+ max?: number
26
+ label?: JSX.Element
27
+ unit?: string
28
+ /** Overall SVG width/height in px (square). Default 160. */
29
+ size?: number
30
+ /** Value arc color when `thresholds` are omitted. Default 'primary'. */
31
+ tone?: 'primary' | 'accent'
32
+ /** Recolor the value arc once `value` reaches a threshold's `value` (highest match wins). */
33
+ thresholds?: GaugeThreshold[]
34
+ class?: string
35
+ }
36
+
37
+ // Explicit tone -> CSS custom property name.
38
+ const TONE_TOKENS: Record<GaugeThreshold['tone'], string> = {
39
+ primary: '--primary',
40
+ accent: '--accent',
41
+ destructive: '--destructive',
42
+ muted: '--muted',
43
+ }
44
+
45
+ const DEG_TO_RAD = Math.PI / 180
46
+ const START_ANGLE = -135
47
+ const END_ANGLE = 135
48
+ const SWEEP_ANGLE = END_ANGLE - START_ANGLE // 270°
49
+
50
+ /** Cartesian point on a circle of radius `r` centered at `(cx, cy)`, `angleDeg` measured clockwise from 12 o'clock. */
51
+ function pointOnArc(cx: number, cy: number, r: number, angleDeg: number): { x: number; y: number } {
52
+ const rad = angleDeg * DEG_TO_RAD
53
+ return { x: cx + r * Math.sin(rad), y: cy - r * Math.cos(rad) }
54
+ }
55
+
56
+ /** SVG path `d` for the fixed 270° arc (shared by the track and the value arc). */
57
+ function gaugeArcPath(cx: number, cy: number, r: number): string {
58
+ const start = pointOnArc(cx, cy, r, START_ANGLE)
59
+ const end = pointOnArc(cx, cy, r, END_ANGLE)
60
+ return `M ${start.x} ${start.y} A ${r} ${r} 0 1 1 ${end.x} ${end.y}`
61
+ }
62
+
63
+ /** Highest threshold whose `value` the gauge has reached, falling back to `tone` when none match. */
64
+ function resolveTone(value: number, tone: 'primary' | 'accent', thresholds?: GaugeThreshold[]): string {
65
+ if (!thresholds || thresholds.length === 0) return TONE_TOKENS[tone]
66
+ let resolved = TONE_TOKENS[tone]
67
+ for (const threshold of [...thresholds].sort((a, b) => a.value - b.value)) {
68
+ if (value >= threshold.value) resolved = TONE_TOKENS[threshold.tone]
69
+ }
70
+ return resolved
71
+ }
72
+
73
+ /**
74
+ * SVG radial gauge: a 270° arc (from -135° to +135°) with a background track
75
+ * and a value arc filled proportionally to `value` within `[min, max]`,
76
+ * colored by `tone` or by the highest `threshold` reached. The center shows
77
+ * the value plus optional `unit`/`label`. The sweep animates on mount and on
78
+ * value change, jumping instantly under reduced motion.
79
+ *
80
+ * @example
81
+ * ```tsx
82
+ * <GaugeChart
83
+ * value={72}
84
+ * unit="%"
85
+ * label="CPU load"
86
+ * thresholds={[
87
+ * { value: 0, tone: 'primary' },
88
+ * { value: 60, tone: 'accent' },
89
+ * { value: 85, tone: 'destructive' },
90
+ * ]}
91
+ * />
92
+ * ```
93
+ */
94
+ export function GaugeChart(props: GaugeChartProps): JSX.Element {
95
+ const size = () => props.size ?? 160
96
+ const min = () => props.min ?? 0
97
+ const max = () => props.max ?? 100
98
+ const tone = () => props.tone ?? 'primary'
99
+ const thickness = () => size() / 8
100
+ const center = () => size() / 2
101
+ const radius = () => Math.max(size() / 2 - thickness() / 2, 0)
102
+ const totalLength = () => radius() * (SWEEP_ANGLE * DEG_TO_RAD)
103
+ const arcPath = () => gaugeArcPath(center(), center(), radius())
104
+
105
+ const clampedValue = () => Math.min(Math.max(props.value, min()), max())
106
+ const fraction = () => {
107
+ const range = max() - min()
108
+ return range > 0 ? (clampedValue() - min()) / range : 0
109
+ }
110
+ const strokeToken = () => resolveTone(clampedValue(), tone(), props.thresholds)
111
+
112
+ // Drives the visible sweep; starts at 0 so the mount animation reveals the
113
+ // arc from empty, then tracks `fraction()` (deferred, so mount handles the
114
+ // initial reveal and this only reacts to later value changes).
115
+ const [displayedFraction, setDisplayedFraction] = createSignal(0)
116
+
117
+ onMount(() => {
118
+ if (motionReduced()) {
119
+ setDisplayedFraction(fraction())
120
+ return
121
+ }
122
+ requestAnimationFrame(() => setDisplayedFraction(fraction()))
123
+ })
124
+
125
+ createEffect(on(fraction, (f) => setDisplayedFraction(f), { defer: true }))
126
+
127
+ const dashOffset = () => totalLength() * (1 - displayedFraction())
128
+ const transition = () => (motionReduced() ? 'none' : 'stroke-dashoffset 700ms ease-out')
129
+
130
+ const displayValue = () => Math.round(clampedValue())
131
+ const ariaLabel = () => `${displayValue()}${props.unit ?? ''}`
132
+
133
+ return (
134
+ <div
135
+ class={cn('relative inline-flex items-center justify-center', props.class)}
136
+ style={{ width: `${size()}px`, height: `${size()}px` }}
137
+ role="meter"
138
+ aria-valuenow={clampedValue()}
139
+ aria-valuemin={min()}
140
+ aria-valuemax={max()}
141
+ aria-label={ariaLabel()}
142
+ >
143
+ <svg width={size()} height={size()} viewBox={`0 0 ${size()} ${size()}`} role="img" aria-hidden="true">
144
+ <path
145
+ d={arcPath()}
146
+ fill="none"
147
+ stroke="hsl(var(--muted))"
148
+ stroke-width={thickness()}
149
+ stroke-linecap="round"
150
+ />
151
+ <path
152
+ d={arcPath()}
153
+ fill="none"
154
+ stroke={`hsl(var(${strokeToken()}))`}
155
+ stroke-width={thickness()}
156
+ stroke-linecap="round"
157
+ stroke-dasharray={`${totalLength()} ${totalLength()}`}
158
+ stroke-dashoffset={dashOffset()}
159
+ style={{ transition: transition() }}
160
+ />
161
+ </svg>
162
+ <div class="absolute inset-0 flex flex-col items-center justify-center gap-0.5 px-4 text-center">
163
+ <span class="text-2xl font-semibold tabular-nums text-foreground">
164
+ {displayValue()}
165
+ <Show when={props.unit}>
166
+ <span class="text-base font-medium">{props.unit}</span>
167
+ </Show>
168
+ </span>
169
+ <Show when={props.label}>
170
+ <span class="text-xs text-muted-foreground">{props.label}</span>
171
+ </Show>
172
+ </div>
173
+ </div>
174
+ )
175
+ }
@@ -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
+ }