@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Wrapping row of dismissible/selectable pill chips (e.g. AI follow-up
|
|
2
|
+
// suggestions). Plain buttons in a flex-wrap row — no Kobalte primitive
|
|
3
|
+
// covers this shape, so it's hand-rolled like AnnouncementBar's tag chips.
|
|
4
|
+
import { X } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface SuggestionChipsProps {
|
|
11
|
+
suggestions: string[]
|
|
12
|
+
onSelect?: (s: string) => void
|
|
13
|
+
onDismiss?: (s: string) => void
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wrapping row of pill chips, e.g. AI-suggested follow-up prompts. Clicking a
|
|
19
|
+
* chip calls `onSelect`; if `onDismiss` is passed, each chip also shows a
|
|
20
|
+
* small "x" that removes it without triggering `onSelect`. The select and
|
|
21
|
+
* dismiss controls are real sibling `<button>`s (not nested — a `<button>`
|
|
22
|
+
* can't validly contain another interactive element), so both are reachable
|
|
23
|
+
* and operable independently via Tab/Enter/Space with no extra ARIA wiring.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <SuggestionChips
|
|
28
|
+
* suggestions={['Summarize this', 'Explain like I\'m 5', 'Write tests']}
|
|
29
|
+
* onSelect={(s) => sendMessage(s)}
|
|
30
|
+
* onDismiss={(s) => setSuggestions((prev) => prev.filter((x) => x !== s))}
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function SuggestionChips(props: SuggestionChipsProps): JSX.Element {
|
|
35
|
+
return (
|
|
36
|
+
<div class={cn('flex flex-wrap gap-2', props.class)}>
|
|
37
|
+
<For each={props.suggestions}>
|
|
38
|
+
{(suggestion) => (
|
|
39
|
+
<span class="inline-flex items-center gap-1 rounded-full border border-border pl-3 pr-1.5 py-1.5 text-sm text-foreground transition-colors hover:bg-muted">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={() => props.onSelect?.(suggestion)}
|
|
43
|
+
class="outline-none focus-visible:ring-2 focus-visible:ring-ring/30 rounded-sm"
|
|
44
|
+
>
|
|
45
|
+
{suggestion}
|
|
46
|
+
</button>
|
|
47
|
+
<Show when={props.onDismiss}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
aria-label={`Dismiss "${suggestion}"`}
|
|
51
|
+
onClick={(ev) => {
|
|
52
|
+
ev.stopPropagation()
|
|
53
|
+
props.onDismiss?.(suggestion)
|
|
54
|
+
}}
|
|
55
|
+
class="rounded-full p-0.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
56
|
+
>
|
|
57
|
+
<X class="h-3 w-3" />
|
|
58
|
+
</button>
|
|
59
|
+
</Show>
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</For>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// ToolCallTimeline — vertical timeline (à la Timeline.tsx's connector line +
|
|
2
|
+
// dot idiom) where each node is an AI tool call, with a Collapse-style
|
|
3
|
+
// details panel for args/result.
|
|
4
|
+
import { Check, Loader2, X } from 'lucide-solid'
|
|
5
|
+
import { type JSX, For, Show, createSignal } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** Status of a single tool call rendered by {@link ToolCallTimeline}. */
|
|
10
|
+
export type ToolCallStatus = 'pending' | 'success' | 'error'
|
|
11
|
+
|
|
12
|
+
/** A single tool invocation rendered by {@link ToolCallTimeline}. */
|
|
13
|
+
export interface ToolCall {
|
|
14
|
+
/** Tool/function name, rendered in mono. */
|
|
15
|
+
name: string
|
|
16
|
+
status: ToolCallStatus
|
|
17
|
+
/** Arguments passed to the call. Shown in a collapsible details area when present. */
|
|
18
|
+
args?: JSX.Element | string
|
|
19
|
+
/** Result returned by the call. Shown in a collapsible details area when present. */
|
|
20
|
+
result?: JSX.Element | string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ToolCallTimelineProps {
|
|
24
|
+
calls: ToolCall[]
|
|
25
|
+
class?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Vertical timeline of AI tool calls: a connector line runs down the left with
|
|
30
|
+
* a status node per call (spinner while `pending`, check when `success`, x when
|
|
31
|
+
* `error`), the tool name in mono, and — when `args`/`result` are given — a
|
|
32
|
+
* collapsible details panel underneath.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <ToolCallTimeline
|
|
37
|
+
* calls={[
|
|
38
|
+
* { name: 'search_docs', status: 'success', args: 'query: "refunds"', result: '3 matches' },
|
|
39
|
+
* { name: 'update_ticket', status: 'pending' },
|
|
40
|
+
* ]}
|
|
41
|
+
* />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function ToolCallTimeline(props: ToolCallTimelineProps): JSX.Element {
|
|
45
|
+
return (
|
|
46
|
+
<ol class={cn('relative flex flex-col gap-4', props.class)}>
|
|
47
|
+
<For each={props.calls}>
|
|
48
|
+
{(call, index) => (
|
|
49
|
+
<li class="relative flex gap-3 pl-1">
|
|
50
|
+
<Show when={index() < props.calls.length - 1}>
|
|
51
|
+
<span
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
class="absolute left-[calc(0.5rem+2px)] top-6 -bottom-4 w-px -translate-x-1/2 bg-border"
|
|
54
|
+
/>
|
|
55
|
+
</Show>
|
|
56
|
+
<StatusNode status={call.status} />
|
|
57
|
+
<div class="min-w-0 flex-1 pb-0.5">
|
|
58
|
+
<p class="pt-0.5 font-mono text-sm text-foreground">{call.name}</p>
|
|
59
|
+
<Show when={call.args !== undefined || call.result !== undefined}>
|
|
60
|
+
<ToolCallDetails args={call.args} result={call.result} />
|
|
61
|
+
</Show>
|
|
62
|
+
</div>
|
|
63
|
+
</li>
|
|
64
|
+
)}
|
|
65
|
+
</For>
|
|
66
|
+
</ol>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const STATUS_TONE: Record<ToolCallStatus, string> = {
|
|
71
|
+
pending: 'border-border bg-muted text-muted-foreground',
|
|
72
|
+
success: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-500',
|
|
73
|
+
error: 'border-destructive/30 bg-destructive/15 text-destructive',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StatusNode(props: { status: ToolCallStatus }): JSX.Element {
|
|
77
|
+
return (
|
|
78
|
+
<span
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
class={cn(
|
|
81
|
+
'relative z-[1] mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border',
|
|
82
|
+
STATUS_TONE[props.status],
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<Show when={props.status === 'pending'}>
|
|
86
|
+
<Loader2 class="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
|
87
|
+
</Show>
|
|
88
|
+
<Show when={props.status === 'success'}>
|
|
89
|
+
<Check class="h-3 w-3" />
|
|
90
|
+
</Show>
|
|
91
|
+
<Show when={props.status === 'error'}>
|
|
92
|
+
<X class="h-3 w-3" />
|
|
93
|
+
</Show>
|
|
94
|
+
</span>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reuses Collapse.tsx's grid-rows transition idiom for the args/result panel.
|
|
99
|
+
function ToolCallDetails(props: { args?: JSX.Element | string; result?: JSX.Element | string }): JSX.Element {
|
|
100
|
+
const [open, setOpen] = createSignal(false)
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div class="mt-1">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
aria-expanded={open()}
|
|
107
|
+
onClick={() => setOpen(!open())}
|
|
108
|
+
class="text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
109
|
+
>
|
|
110
|
+
{open() ? 'Hide details' : 'Show details'}
|
|
111
|
+
</button>
|
|
112
|
+
<div
|
|
113
|
+
class={cn(
|
|
114
|
+
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
115
|
+
open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<div class="overflow-hidden">
|
|
119
|
+
<div class="mt-1.5 space-y-1.5 rounded-lg border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
|
|
120
|
+
<Show when={props.args !== undefined}>
|
|
121
|
+
<div>
|
|
122
|
+
<p class="font-medium text-foreground/80">Args</p>
|
|
123
|
+
<p class="whitespace-pre-wrap font-mono">{props.args}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</Show>
|
|
126
|
+
<Show when={props.result !== undefined}>
|
|
127
|
+
<div>
|
|
128
|
+
<p class="font-medium text-foreground/80">Result</p>
|
|
129
|
+
<p class="whitespace-pre-wrap font-mono">{props.result}</p>
|
|
130
|
+
</div>
|
|
131
|
+
</Show>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Labeled consumption bar (e.g. token/quota usage) on Kobalte's Meter
|
|
2
|
+
// primitive — same idiom as Meter.tsx/Progress.tsx, plus a warning-tone fill
|
|
3
|
+
// past a configurable threshold and a "N left" remaining line.
|
|
4
|
+
import { Meter as KMeter } from '@kobalte/core/meter'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface UsageMeterProps {
|
|
11
|
+
used: number
|
|
12
|
+
limit: number
|
|
13
|
+
/** Header content shown before the "{used}/{limit}{unit}" count. */
|
|
14
|
+
label?: JSX.Element
|
|
15
|
+
/** Appended to displayed numbers, e.g. `' tokens'` or `'%'`. */
|
|
16
|
+
unit?: string
|
|
17
|
+
/** Fraction of `limit` (0–1) at which the fill switches to the warning tone. @default 0.85 */
|
|
18
|
+
warnAt?: number
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Labeled consumption bar: a header row with `label` and the raw
|
|
24
|
+
* "{used}/{limit}{unit}" count, a track whose fill width is `used / limit`
|
|
25
|
+
* (clamped to 0–100%) and switches to a warning tone once that ratio reaches
|
|
26
|
+
* `warnAt`, and a small "{remaining} left" line underneath. Built on Kobalte's
|
|
27
|
+
* `Meter` primitive, so it carries the same `role="meter"` a11y semantics as
|
|
28
|
+
* {@link Meter}.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <UsageMeter used={92} limit={100} label="API requests" unit=" req" warnAt={0.9} />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function UsageMeter(props: UsageMeterProps): JSX.Element {
|
|
36
|
+
const unit = () => props.unit ?? ''
|
|
37
|
+
const ratio = () => (props.limit > 0 ? props.used / props.limit : 0)
|
|
38
|
+
const percent = () => Math.max(0, Math.min(100, ratio() * 100))
|
|
39
|
+
const isWarning = () => ratio() >= (props.warnAt ?? 0.85)
|
|
40
|
+
const remaining = () => Math.max(0, props.limit - props.used)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<KMeter
|
|
44
|
+
value={props.used}
|
|
45
|
+
minValue={0}
|
|
46
|
+
maxValue={props.limit}
|
|
47
|
+
getValueLabel={() => `${props.used}/${props.limit}${unit()}`}
|
|
48
|
+
class={cn('flex flex-col gap-1.5', props.class)}
|
|
49
|
+
>
|
|
50
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
51
|
+
<Show when={props.label}>
|
|
52
|
+
<KMeter.Label>{props.label}</KMeter.Label>
|
|
53
|
+
</Show>
|
|
54
|
+
<KMeter.ValueLabel class="ml-auto tabular-nums text-muted-foreground" />
|
|
55
|
+
</div>
|
|
56
|
+
<KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
|
|
57
|
+
<KMeter.Fill
|
|
58
|
+
class={cn(
|
|
59
|
+
'h-full rounded-full transition-all duration-500',
|
|
60
|
+
isWarning() ? 'bg-amber-500' : 'bg-primary',
|
|
61
|
+
)}
|
|
62
|
+
style={{ width: `${percent()}%` }}
|
|
63
|
+
/>
|
|
64
|
+
</KMeter.Track>
|
|
65
|
+
<p class="text-xs text-muted-foreground">
|
|
66
|
+
{remaining()}
|
|
67
|
+
{unit()} left
|
|
68
|
+
</p>
|
|
69
|
+
</KMeter>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// WorldMap — a lightweight, self-contained SVG "map": a dotted-grid backdrop
|
|
2
|
+
// (no coastlines, no geojson/asset) with connection arcs projected from
|
|
3
|
+
// lat/lng via simple equirectangular mapping. Each arc bulges upward and
|
|
4
|
+
// carries a traveling primary→accent gradient segment, reusing
|
|
5
|
+
// AnimatedBeam's "moving gradient stops along a quadratic curve" technique.
|
|
6
|
+
// Arcs render static (no travel) under reduced motion.
|
|
7
|
+
import { createUniqueId, For, onCleanup, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface WorldMapPoint {
|
|
13
|
+
lat: number
|
|
14
|
+
lng: number
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorldMapConnection {
|
|
19
|
+
from: WorldMapPoint
|
|
20
|
+
to: WorldMapPoint
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorldMapProps {
|
|
24
|
+
/** Arcs to draw between projected points. @default [] */
|
|
25
|
+
connections?: WorldMapConnection[]
|
|
26
|
+
class?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WIDTH = 800
|
|
30
|
+
const HEIGHT = 400
|
|
31
|
+
/** Px spacing between backdrop grid dots. */
|
|
32
|
+
const GRID_GAP = 16
|
|
33
|
+
|
|
34
|
+
interface Point {
|
|
35
|
+
x: number
|
|
36
|
+
y: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Equirectangular projection: lat/lng -> SVG coordinates in the WIDTH x HEIGHT viewBox. */
|
|
40
|
+
function project(point: WorldMapPoint): Point {
|
|
41
|
+
return {
|
|
42
|
+
x: ((point.lng + 180) / 360) * WIDTH,
|
|
43
|
+
y: ((90 - point.lat) / 180) * HEIGHT,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Point at parameter `t` (0..1) along a quadratic Bezier curve. */
|
|
48
|
+
function pointAt(start: Point, control: Point, end: Point, t: number): Point {
|
|
49
|
+
const it = 1 - t
|
|
50
|
+
return {
|
|
51
|
+
x: it * it * start.x + 2 * it * t * control.x + t * t * end.x,
|
|
52
|
+
y: it * it * start.y + 2 * it * t * control.y + t * t * end.y,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Centers of an evenly spaced dot grid covering the WIDTH x HEIGHT viewBox. */
|
|
57
|
+
function gridDots(): Point[] {
|
|
58
|
+
const dots: Point[] = []
|
|
59
|
+
for (let y = GRID_GAP / 2; y < HEIGHT; y += GRID_GAP) {
|
|
60
|
+
for (let x = GRID_GAP / 2; x < WIDTH; x += GRID_GAP) {
|
|
61
|
+
dots.push({ x, y })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return dots
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ConnectionArcProps {
|
|
68
|
+
connection: WorldMapConnection
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One arc: a faint static base stroke plus (unless reduced motion) a traveling gradient segment. */
|
|
72
|
+
function ConnectionArc(props: ConnectionArcProps): JSX.Element {
|
|
73
|
+
const gradientId = `world-map-beam-${createUniqueId()}`
|
|
74
|
+
const start = project(props.connection.from)
|
|
75
|
+
const end = project(props.connection.to)
|
|
76
|
+
const mx = (start.x + end.x) / 2
|
|
77
|
+
const my = (start.y + end.y) / 2
|
|
78
|
+
// Bulge upward (smaller y) regardless of endpoint order.
|
|
79
|
+
const bulge = Math.max(20, Math.hypot(end.x - start.x, end.y - start.y) * 0.25)
|
|
80
|
+
const control = { x: mx, y: my - bulge }
|
|
81
|
+
const d = `M ${start.x} ${start.y} Q ${control.x} ${control.y} ${end.x} ${end.y}`
|
|
82
|
+
const reduced = motionReduced()
|
|
83
|
+
|
|
84
|
+
let gradientEl: SVGLinearGradientElement | undefined
|
|
85
|
+
|
|
86
|
+
if (!reduced) {
|
|
87
|
+
const segment = 0.2
|
|
88
|
+
const controls = animate(0, 1, {
|
|
89
|
+
duration: 3,
|
|
90
|
+
repeat: Infinity,
|
|
91
|
+
ease: 'linear',
|
|
92
|
+
onUpdate: (p: number) => {
|
|
93
|
+
const from = pointAt(start, control, end, Math.max(0, p - segment))
|
|
94
|
+
const to = pointAt(start, control, end, p)
|
|
95
|
+
gradientEl?.setAttribute('x1', String(from.x))
|
|
96
|
+
gradientEl?.setAttribute('y1', String(from.y))
|
|
97
|
+
gradientEl?.setAttribute('x2', String(to.x))
|
|
98
|
+
gradientEl?.setAttribute('y2', String(to.y))
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
onCleanup(() => controls.stop())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<path d={d} stroke="hsl(var(--muted-foreground))" stroke-width="1" stroke-opacity="0.3" fill="none" />
|
|
107
|
+
{!reduced && (
|
|
108
|
+
<>
|
|
109
|
+
<defs>
|
|
110
|
+
<linearGradient
|
|
111
|
+
ref={(el) => {
|
|
112
|
+
gradientEl = el
|
|
113
|
+
}}
|
|
114
|
+
id={gradientId}
|
|
115
|
+
gradientUnits="userSpaceOnUse"
|
|
116
|
+
>
|
|
117
|
+
<stop offset="0%" stop-color="hsl(var(--primary))" stop-opacity="0" />
|
|
118
|
+
<stop offset="50%" stop-color="hsl(var(--primary))" />
|
|
119
|
+
<stop offset="50%" stop-color="hsl(var(--accent))" />
|
|
120
|
+
<stop offset="100%" stop-color="hsl(var(--accent))" stop-opacity="0" />
|
|
121
|
+
</linearGradient>
|
|
122
|
+
</defs>
|
|
123
|
+
<path d={d} stroke={`url(#${gradientId})`} stroke-width="1.5" stroke-linecap="round" fill="none" />
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
<circle
|
|
127
|
+
cx={start.x}
|
|
128
|
+
cy={start.y}
|
|
129
|
+
r="2.5"
|
|
130
|
+
fill="hsl(var(--primary))"
|
|
131
|
+
class={reduced ? undefined : 'animate-pulse'}
|
|
132
|
+
/>
|
|
133
|
+
<circle
|
|
134
|
+
cx={end.x}
|
|
135
|
+
cy={end.y}
|
|
136
|
+
r="2.5"
|
|
137
|
+
fill="hsl(var(--primary))"
|
|
138
|
+
class={reduced ? undefined : 'animate-pulse'}
|
|
139
|
+
/>
|
|
140
|
+
</>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A decorative, dependency-free SVG "world map": an evenly spaced dotted-grid
|
|
146
|
+
* backdrop (a stylized map, not accurate coastlines) with connection arcs
|
|
147
|
+
* projected from lat/lng via equirectangular mapping. Each arc bulges upward
|
|
148
|
+
* and carries a traveling primary→accent gradient segment (reusing
|
|
149
|
+
* {@link AnimatedBeam}'s technique); arcs render static under reduced motion.
|
|
150
|
+
* Responsive — scales to its container via the 2:1 `viewBox`.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```tsx
|
|
154
|
+
* <WorldMap
|
|
155
|
+
* class="w-full"
|
|
156
|
+
* connections={[
|
|
157
|
+
* { from: { lat: 40.7128, lng: -74.006, label: 'New York' }, to: { lat: 51.5074, lng: -0.1278, label: 'London' } },
|
|
158
|
+
* { from: { lat: 51.5074, lng: -0.1278 }, to: { lat: 35.6762, lng: 139.6503, label: 'Tokyo' } },
|
|
159
|
+
* { from: { lat: 40.7128, lng: -74.006 }, to: { lat: -23.5505, lng: -46.6333, label: 'São Paulo' } },
|
|
160
|
+
* ]}
|
|
161
|
+
* />
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export function WorldMap(props: WorldMapProps): JSX.Element {
|
|
165
|
+
const connections = () => props.connections ?? []
|
|
166
|
+
const label = () =>
|
|
167
|
+
connections()
|
|
168
|
+
.map(
|
|
169
|
+
(c) =>
|
|
170
|
+
`${c.from.label ?? `${c.from.lat},${c.from.lng}`} to ${c.to.label ?? `${c.to.lat},${c.to.lng}`}`,
|
|
171
|
+
)
|
|
172
|
+
.join('; ')
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<svg
|
|
176
|
+
role={connections().length > 0 ? 'img' : undefined}
|
|
177
|
+
aria-label={connections().length > 0 ? `World map with connections: ${label()}` : undefined}
|
|
178
|
+
aria-hidden={connections().length > 0 ? undefined : 'true'}
|
|
179
|
+
class={cn('w-full', props.class)}
|
|
180
|
+
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
|
181
|
+
fill="none"
|
|
182
|
+
>
|
|
183
|
+
<For each={gridDots()}>
|
|
184
|
+
{(dot) => (
|
|
185
|
+
<circle cx={dot.x} cy={dot.y} r="0.8" fill="hsl(var(--muted-foreground))" fill-opacity="0.25" />
|
|
186
|
+
)}
|
|
187
|
+
</For>
|
|
188
|
+
<For each={connections()}>{(connection) => <ConnectionArc connection={connection} />}</For>
|
|
189
|
+
</svg>
|
|
190
|
+
)
|
|
191
|
+
}
|