@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.
- package/dist/NumberInput-Bnh2u7pp.js +381 -0
- package/dist/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -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/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +6 -0
- package/dist/charts.js +693 -100
- package/dist/commerce.js +2 -2
- package/dist/elements.css +206 -0
- package/dist/full.css +206 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +5086 -3915
- 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/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/Dock.d.ts +31 -0
- package/dist/ui/Globe.d.ts +44 -0
- package/dist/ui/Meteors.d.ts +23 -0
- package/dist/ui/ModelPicker.d.ts +38 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/GaugeChart.tsx +175 -0
- package/src/charts/LineChart.tsx +264 -0
- package/src/charts/RadarChart.tsx +215 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +6 -0
- package/src/index.ts +31 -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/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/Dock.tsx +124 -0
- package/src/ui/Globe.tsx +317 -0
- package/src/ui/Meteors.tsx +109 -0
- package/src/ui/ModelPicker.tsx +119 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/WorldMap.tsx +191 -0
- package/dist/NumberInput-BQhVucw-.js +0 -491
- package/dist/cn-B6yFEsav.js +0 -8
package/package.json
CHANGED
|
@@ -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
|
+
}
|