@hanzo/dashboard 0.2.0 → 0.2.2

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 (39) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +245 -0
  3. package/package.json +38 -50
  4. package/src/analytics/index.ts +20 -0
  5. package/src/analytics/parts.tsx +91 -0
  6. package/src/analytics/product.tsx +64 -0
  7. package/src/analytics/source.ts +141 -0
  8. package/src/analytics/web.tsx +83 -0
  9. package/src/charts/Charts.tsx +470 -0
  10. package/src/dashboard.css +157 -0
  11. package/src/index.ts +119 -0
  12. package/src/landing/Hero.tsx +115 -0
  13. package/src/landing/Landing.tsx +47 -0
  14. package/src/landing/Metrics.tsx +134 -0
  15. package/src/landing/Rail.tsx +61 -0
  16. package/src/landing/Samples.tsx +78 -0
  17. package/src/landing/logic.ts +47 -0
  18. package/src/landing/parts.tsx +116 -0
  19. package/src/landing/types.ts +98 -0
  20. package/src/motion/hooks.ts +98 -0
  21. package/src/motion/motion.ts +76 -0
  22. package/src/overview/Overview.tsx +203 -0
  23. package/src/overview/config.ts +177 -0
  24. package/src/overview/logic.ts +204 -0
  25. package/src/overview/primitives.tsx +306 -0
  26. package/src/overview/superadmin.test.ts +36 -0
  27. package/src/overview/tiles.tsx +223 -0
  28. package/src/pipeline/Pipeline.tsx +178 -0
  29. package/src/pipeline/stages.ts +156 -0
  30. package/src/types.ts +12 -0
  31. package/readme.md +0 -57
  32. package/src/lib/api.ts +0 -155
  33. package/src/lib/components/ConfirmDialog.tsx +0 -53
  34. package/src/lib/components/TenantRow.tsx +0 -39
  35. package/src/lib/components/TenantStatusBadge.tsx +0 -33
  36. package/src/lib/index.ts +0 -30
  37. package/src/lib/nav.ts +0 -12
  38. package/src/lib/screens/NewTenantScreen.tsx +0 -137
  39. package/src/lib/screens/TenantsScreen.tsx +0 -229
@@ -0,0 +1,141 @@
1
+ // The data every analytics view reads, from ONE place: analytics.hanzo.ai's
2
+ // /v1/websites API, called with the viewer's IAM bearer. A website is a cloud
3
+ // project, so the standalone app and a project page in the platform read the same
4
+ // numbers for the same site.
5
+
6
+ export type Range = '24h' | '7d' | '30d'
7
+ export const RANGES: readonly Range[] = ['24h', '7d', '30d']
8
+
9
+ /** The window a view reads, in the reporting zone (HIP-1190 §6.1). */
10
+ export const ZONE = 'America/Los_Angeles'
11
+
12
+ export interface Stats {
13
+ pageviews: number
14
+ visitors: number
15
+ visits: number
16
+ bounces: number
17
+ totaltime: number
18
+ comparison?: Omit<Stats, 'comparison'>
19
+ }
20
+
21
+ export interface Point {
22
+ x: string
23
+ y: number
24
+ }
25
+
26
+ export interface Series {
27
+ pageviews: Point[]
28
+ sessions: Point[]
29
+ }
30
+
31
+ /** A top-list dimension, named as the API names it. */
32
+ export type Dimension =
33
+ | 'path'
34
+ | 'referrer'
35
+ | 'country'
36
+ | 'browser'
37
+ | 'os'
38
+ | 'device'
39
+ | 'event'
40
+ | 'hostname'
41
+ | 'title'
42
+
43
+ export interface Step {
44
+ type: 'path' | 'event'
45
+ value: string
46
+ }
47
+
48
+ export interface FunnelStep extends Step {
49
+ visitors: number
50
+ previous: number
51
+ dropped: number
52
+ dropoff: number
53
+ remaining: number
54
+ }
55
+
56
+ /** A project's website: its id in analytics and the project it projects. */
57
+ export interface Website {
58
+ websiteId: string
59
+ org: string
60
+ slug: string
61
+ name: string
62
+ liveUrl: string
63
+ pixel: string | null
64
+ }
65
+
66
+ export interface Source {
67
+ stats(website: string, range: Range): Promise<Stats>
68
+ series(website: string, range: Range): Promise<Series>
69
+ top(website: string, dimension: Dimension, range: Range, limit?: number): Promise<Point[]>
70
+ funnel(website: string, steps: Step[], range: Range): Promise<FunnelStep[]>
71
+ /** The viewer's projects, each with its website (analytics syncs them from cloud). */
72
+ websites(): Promise<Website[]>
73
+ }
74
+
75
+ export class SourceError extends Error {
76
+ constructor(
77
+ message: string,
78
+ readonly status: number,
79
+ ) {
80
+ super(message)
81
+ }
82
+ }
83
+
84
+ /** The window a range names: start and end in ms, and the bucket its series uses. */
85
+ export function windowOf(range: Range, now = Date.now()) {
86
+ const hour = 3_600_000
87
+ const span = range === '24h' ? 24 * hour : range === '7d' ? 7 * 24 * hour : 30 * 24 * hour
88
+ return { startAt: now - span, endAt: now, unit: range === '24h' ? 'hour' : 'day' }
89
+ }
90
+
91
+ /**
92
+ * The source over analytics.hanzo.ai. `base` is its origin ('' on the app itself)
93
+ * and `token` answers the viewer's IAM access token at call time.
94
+ */
95
+ export function analyticsSource({ base = 'https://analytics.hanzo.ai', token }: { base?: string; token: () => string | null }): Source {
96
+ const call = async <T>(path: string, init?: RequestInit): Promise<T> => {
97
+ const bearer = token()
98
+ const res = await fetch(`${base}/v1${path}`, {
99
+ ...init,
100
+ headers: {
101
+ Accept: 'application/json',
102
+ ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
103
+ ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
104
+ },
105
+ })
106
+ if (!res.ok) {
107
+ throw new SourceError(res.status === 401 ? 'Sign in to see this project’s analytics.' : `analytics answered ${res.status}`, res.status)
108
+ }
109
+ return (await res.json()) as T
110
+ }
111
+ const query = (range: Range, extra: Record<string, string | number> = {}) => {
112
+ const w = windowOf(range)
113
+ const q = new URLSearchParams({ startAt: String(w.startAt), endAt: String(w.endAt), unit: w.unit, timezone: ZONE })
114
+ for (const [k, v] of Object.entries(extra)) q.set(k, String(v))
115
+ return q.toString()
116
+ }
117
+ return {
118
+ stats: (website, range) => call<Stats>(`/websites/${website}/stats?${query(range)}`),
119
+ series: (website, range) => call<Series>(`/websites/${website}/pageviews?${query(range)}`),
120
+ top: (website, dimension, range, limit = 10) =>
121
+ call<Point[]>(`/websites/${website}/metrics?${query(range, { type: dimension, limit })}`),
122
+ funnel: (website, steps, range) => {
123
+ const w = windowOf(range)
124
+ return call<FunnelStep[]>('/reports/funnel', {
125
+ method: 'POST',
126
+ body: JSON.stringify({
127
+ websiteId: website,
128
+ type: 'funnel',
129
+ filters: {},
130
+ parameters: {
131
+ startDate: new Date(w.startAt).toISOString(),
132
+ endDate: new Date(w.endAt).toISOString(),
133
+ window: 60 * 24 * 7,
134
+ steps,
135
+ },
136
+ }),
137
+ })
138
+ },
139
+ websites: () => call<Website[]>('/projects'),
140
+ }
141
+ }
@@ -0,0 +1,83 @@
1
+ import { useState } from 'react'
2
+ import { Text, XStack, YStack } from '@hanzo/gui'
3
+ import { Line, type ChartPoint } from '../charts/Charts'
4
+ import { Kpi } from '../overview/primitives'
5
+ import { Loaded, RangeSwitch, TopList, useLoad } from './parts'
6
+ import type { Dimension, Range, Series, Source, Stats } from './source'
7
+
8
+ const TOPS: { dimension: Dimension; title: string }[] = [
9
+ { dimension: 'path', title: 'Pages' },
10
+ { dimension: 'referrer', title: 'Referrers' },
11
+ { dimension: 'country', title: 'Countries' },
12
+ { dimension: 'device', title: 'Devices' },
13
+ { dimension: 'browser', title: 'Browsers' },
14
+ { dimension: 'os', title: 'Operating systems' },
15
+ ]
16
+
17
+ const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 1000) / 10 : 0)
18
+ const delta = (now: number, was?: number) => (was ? Math.round(((now - was) / was) * 1000) / 10 : null)
19
+
20
+ function points(series: Series, range: Range): ChartPoint[] {
21
+ return series.pageviews.map((p) => {
22
+ const d = new Date(p.x.replace(' ', 'T'))
23
+ const label = Number.isNaN(d.getTime())
24
+ ? p.x
25
+ : range === '24h'
26
+ ? d.toLocaleTimeString([], { hour: 'numeric' })
27
+ : d.toLocaleDateString([], { month: 'short', day: 'numeric' })
28
+ return { label, value: p.y }
29
+ })
30
+ }
31
+
32
+ function Tiles({ s }: { s: Stats }) {
33
+ const c = s.comparison
34
+ const avg = s.visits > 0 ? Math.round(s.totaltime / s.visits) : 0
35
+ return (
36
+ <XStack gap="$3" flexWrap="wrap">
37
+ <Kpi label="Visitors" value={s.visitors} deltaPct={delta(s.visitors, c?.visitors)} />
38
+ <Kpi label="Visits" value={s.visits} deltaPct={delta(s.visits, c?.visits)} />
39
+ <Kpi label="Pageviews" value={s.pageviews} deltaPct={delta(s.pageviews, c?.pageviews)} />
40
+ <Kpi label="Bounce rate" value={pct(s.bounces, s.visits)} unit="pct" />
41
+ <Kpi label="Visit duration" value={avg * 1000} unit="ms" />
42
+ </XStack>
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Web analytics for one website: visitors, visits, pageviews, bounce and duration,
48
+ * the traffic series, and where it came from and on what. The same view on
49
+ * analytics.hanzo.ai and on a project's page in the platform.
50
+ */
51
+ export function WebAnalytics({ source, website, initial = '7d' }: { source: Source; website: string; initial?: Range }) {
52
+ const [range, setRange] = useState<Range>(initial)
53
+ const stats = useLoad(() => source.stats(website, range), [source, website, range])
54
+ const series = useLoad(() => source.series(website, range), [source, website, range])
55
+ return (
56
+ <YStack gap="$4" width="100%">
57
+ <XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
58
+ <Text fontSize="$6" fontWeight="600" color="$color12">
59
+ Web analytics
60
+ </Text>
61
+ <RangeSwitch range={range} onRange={setRange} />
62
+ </XStack>
63
+ {stats.state === 'ok' ? <Tiles s={stats.value} /> : null}
64
+ <Loaded title="Pageviews" load={series} empty={(v) => !v.pageviews?.length}>
65
+ {(v) => <Line data={points(v, range)} formatValue={(n) => n.toLocaleString()} />}
66
+ </Loaded>
67
+ <XStack gap="$3" flexWrap="wrap">
68
+ {TOPS.map((t) => (
69
+ <Top key={t.dimension} source={source} website={website} range={range} {...t} />
70
+ ))}
71
+ </XStack>
72
+ </YStack>
73
+ )
74
+ }
75
+
76
+ function Top({ source, website, range, dimension, title }: { source: Source; website: string; range: Range; dimension: Dimension; title: string }) {
77
+ const load = useLoad(() => source.top(website, dimension, range), [source, website, range, dimension])
78
+ return (
79
+ <Loaded title={title} load={load} empty={(v) => !v.length}>
80
+ {(rows) => <TopList rows={rows} />}
81
+ </Loaded>
82
+ )
83
+ }
@@ -0,0 +1,470 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Charts — the ONE way to draw a trend, a series, a share, or a distribution.
5
+ * Dependency-free: inline SVG for the data-dense temporal charts (Sparkline /
6
+ * Line / Columns) and @hanzo/gui primitives for the token-native ones
7
+ * (Bars, and the Donut's optional legend), so everything themes to the shell.
8
+ *
9
+ * Color is data-driven, not a mode flag: a `Slice` is drawn in its own `color`
10
+ * when it has one (categorical/polychrome — e.g. spend-by-model) and in a
11
+ * monochrome opacity ramp off the foreground token when it doesn't (on-brand —
12
+ * e.g. collections-by-model). One Donut serves both; a consumer that needs a
13
+ * richer legend (amounts, click targets) leaves `legend` off and composes its own.
14
+ *
15
+ * Honest by construction: a Sparkline with fewer than two points renders nothing
16
+ * (no invented trend); Donut / Bars with no positive value render an em-dash.
17
+ * Callers pass REAL series — these never fabricate data.
18
+ *
19
+ * SVG marks can't read Tamagui tokens, so concrete colors for SVG come from the
20
+ * live theme (`useTheme().<token>.get()` → `var(--token)` on web, stays reactive).
21
+ */
22
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
23
+ import { Text, XStack, YStack, useTheme } from '@hanzo/gui'
24
+
25
+ /** Distinct hues for categorical series (e.g. spend-by-model donut), dark-tuned. */
26
+ export const CHART_PALETTE = [
27
+ '#7c5cff',
28
+ '#23c562',
29
+ '#ff9f45',
30
+ '#3aa0ff',
31
+ '#ff5d8f',
32
+ '#16c0c8',
33
+ '#c084fc',
34
+ '#f4c245',
35
+ ]
36
+ export const CHART_OTHER = '#64748b'
37
+
38
+ const ACCENT = CHART_PALETTE[0]
39
+ const GRID = 'rgba(148,163,184,0.16)'
40
+ const AXIS = 'rgba(148,163,184,0.85)'
41
+
42
+ /** A point on a temporal/sequential axis (Line / Columns). */
43
+ export type ChartPoint = { label: string; value: number }
44
+
45
+ /** A labelled magnitude in a categorical chart (Donut / Bars). */
46
+ export type Slice = { label: string; value: number; color?: string; hint?: string }
47
+
48
+ type Themed = ReturnType<typeof useTheme>
49
+
50
+ /** Read a concrete color for a theme token (for SVG), with a safe fallback. */
51
+ const tone = (theme: Themed, key: string, fallback: string): string => {
52
+ const v = (theme as unknown as Record<string, { get?: () => unknown } | undefined>)[key]
53
+ const got = v?.get?.()
54
+ return typeof got === 'string' ? got : fallback
55
+ }
56
+
57
+ /** Opacity ramp so N monochrome slices/areas stay visually distinct. */
58
+ const rampOpacity = (i: number, n: number): number => (n <= 1 ? 0.9 : 0.95 - (i / (n - 1)) * 0.62)
59
+
60
+ export function useContainerWidth() {
61
+ const ref = useRef<HTMLDivElement>(null)
62
+ const [w, setW] = useState(0)
63
+ useEffect(() => {
64
+ const el = ref.current
65
+ if (!el) return
66
+ const measure = () => setW(el.clientWidth)
67
+ measure()
68
+ const ro = new ResizeObserver(measure)
69
+ ro.observe(el)
70
+ return () => ro.disconnect()
71
+ }, [])
72
+ return { ref, w }
73
+ }
74
+
75
+ // ── Sparkline (fixed size, for metric cards) ─────────────────────────────────
76
+
77
+ export function Sparkline({
78
+ values,
79
+ width = 120,
80
+ height = 36,
81
+ color,
82
+ }: {
83
+ values: number[]
84
+ width?: number
85
+ height?: number
86
+ color?: string
87
+ }) {
88
+ const theme = useTheme()
89
+ const clean = (values ?? []).filter((v) => Number.isFinite(v))
90
+ if (clean.length < 2) return null
91
+ const stroke = color ?? tone(theme, 'color11', 'currentColor')
92
+ const max = Math.max(...clean)
93
+ const min = Math.min(...clean)
94
+ const span = max - min || 1
95
+ const n = clean.length
96
+ const px = (i: number) => (i / (n - 1)) * (width - 2) + 1
97
+ const py = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
98
+ const line = clean.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ')
99
+ const area = `${line} L${(width - 1).toFixed(1)},${height} L1,${height} Z`
100
+ return (
101
+ <svg width={width} height={height} aria-hidden style={{ display: 'block' }}>
102
+ <path d={area} fill={stroke} fillOpacity={0.12} />
103
+ <path d={line} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
104
+ </svg>
105
+ )
106
+ }
107
+
108
+ // ── Shared hover read-out (Line / Columns) ─────────────────────────────
109
+
110
+ function Tooltip({ x, height, label, value }: { x: number; height: number; label: string; value: string }) {
111
+ // Keep the box on-screen near the guide line.
112
+ const left = Math.max(0, x - 60)
113
+ return (
114
+ <div
115
+ style={{
116
+ position: 'absolute',
117
+ left,
118
+ top: 0,
119
+ height,
120
+ pointerEvents: 'none',
121
+ display: 'flex',
122
+ alignItems: 'flex-start',
123
+ }}
124
+ >
125
+ <div
126
+ style={{
127
+ background: 'rgba(15,17,21,0.94)',
128
+ border: '1px solid rgba(148,163,184,0.25)',
129
+ borderRadius: 8,
130
+ padding: '6px 9px',
131
+ fontSize: 11,
132
+ lineHeight: 1.35,
133
+ color: '#e2e8f0',
134
+ whiteSpace: 'nowrap',
135
+ boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
136
+ }}
137
+ >
138
+ <div style={{ color: AXIS }}>{label}</div>
139
+ <div style={{ fontWeight: 700 }}>{value}</div>
140
+ </div>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ function nearestIndex(clientX: number, rect: DOMRect, padL: number, innerW: number, n: number): number {
146
+ if (n <= 1) return 0
147
+ const mx = clientX - rect.left - padL
148
+ const i = Math.round((mx / (innerW || 1)) * (n - 1))
149
+ return Math.max(0, Math.min(n - 1, i))
150
+ }
151
+
152
+ /** Pick a few evenly-spaced x labels (first/mid/last) to avoid crowding. */
153
+ function axisTicks(data: ChartPoint[]): { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] {
154
+ const n = data.length
155
+ if (n === 0) return []
156
+ if (n === 1) return [{ i: 0, label: data[0].label, anchor: 'middle' }]
157
+ const mid = Math.floor((n - 1) / 2)
158
+ const out: { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] = [
159
+ { i: 0, label: data[0].label, anchor: 'start' },
160
+ { i: n - 1, label: data[n - 1].label, anchor: 'end' },
161
+ ]
162
+ if (n >= 5) out.splice(1, 0, { i: mid, label: data[mid].label, anchor: 'middle' })
163
+ return out
164
+ }
165
+
166
+ // ── Line chart (a value over time) ───────────────────────────────────────────
167
+
168
+ export function Line({
169
+ data,
170
+ height = 210,
171
+ color = ACCENT,
172
+ formatValue = (v) => String(v),
173
+ }: {
174
+ data: ChartPoint[]
175
+ height?: number
176
+ color?: string
177
+ formatValue?: (v: number) => string
178
+ }) {
179
+ const { ref, w } = useContainerWidth()
180
+ const [hover, setHover] = useState<number | null>(null)
181
+ const padL = 10
182
+ const padR = 10
183
+ const padT = 12
184
+ const padB = 24
185
+ const n = data.length
186
+ const innerW = Math.max(1, w - padL - padR)
187
+ const innerH = Math.max(1, height - padT - padB)
188
+ const max = Math.max(...data.map((d) => d.value), 1)
189
+ const x = (i: number) => padL + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW)
190
+ const y = (v: number) => padT + innerH - (v / max) * innerH
191
+
192
+ const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.value).toFixed(1)}`).join(' ')
193
+ const area = n >= 2 ? `${line} L${x(n - 1).toFixed(1)},${(padT + innerH).toFixed(1)} L${x(0).toFixed(1)},${(padT + innerH).toFixed(1)} Z` : ''
194
+ const ticks = axisTicks(data)
195
+
196
+ return (
197
+ <div ref={ref} style={{ position: 'relative', width: '100%' }}>
198
+ {w > 0 ? (
199
+ <svg
200
+ width={w}
201
+ height={height}
202
+ style={{ display: 'block' }}
203
+ onMouseMove={(e) => setHover(nearestIndex(e.clientX, e.currentTarget.getBoundingClientRect(), padL, innerW, n))}
204
+ onMouseLeave={() => setHover(null)}
205
+ >
206
+ {[0, 0.5, 1].map((g) => (
207
+ <line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
208
+ ))}
209
+ {area ? <path d={area} fill={color} fillOpacity={0.1} /> : null}
210
+ {n >= 2 ? <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}
211
+ {hover != null && data[hover] ? (
212
+ <>
213
+ <line x1={x(hover)} x2={x(hover)} y1={padT} y2={padT + innerH} stroke={AXIS} strokeWidth={1} strokeDasharray="3 3" />
214
+ <circle cx={x(hover)} cy={y(data[hover].value)} r={4} fill={color} stroke="#0f1115" strokeWidth={2} />
215
+ </>
216
+ ) : null}
217
+ {ticks.map((t) => (
218
+ <text key={t.i} x={x(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor={t.anchor}>
219
+ {t.label}
220
+ </text>
221
+ ))}
222
+ </svg>
223
+ ) : null}
224
+ {hover != null && data[hover] ? (
225
+ <Tooltip x={x(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
226
+ ) : null}
227
+ </div>
228
+ )
229
+ }
230
+
231
+ // ── Columns (a value over time, vertical bars) ───────────────────────────────
232
+
233
+ export function Columns({
234
+ data,
235
+ height = 210,
236
+ color = ACCENT,
237
+ formatValue = (v) => String(v),
238
+ }: {
239
+ data: ChartPoint[]
240
+ height?: number
241
+ color?: string
242
+ formatValue?: (v: number) => string
243
+ }) {
244
+ const { ref, w } = useContainerWidth()
245
+ const [hover, setHover] = useState<number | null>(null)
246
+ const padL = 10
247
+ const padR = 10
248
+ const padT = 12
249
+ const padB = 24
250
+ const n = data.length
251
+ const innerW = Math.max(1, w - padL - padR)
252
+ const innerH = Math.max(1, height - padT - padB)
253
+ const max = Math.max(...data.map((d) => d.value), 1)
254
+ const slot = innerW / Math.max(1, n)
255
+ const barW = Math.max(1, Math.min(slot * 0.62, 36))
256
+ const cx = (i: number) => padL + slot * i + slot / 2
257
+ const ticks = axisTicks(data)
258
+
259
+ return (
260
+ <div ref={ref} style={{ position: 'relative', width: '100%' }}>
261
+ {w > 0 ? (
262
+ <svg
263
+ width={w}
264
+ height={height}
265
+ style={{ display: 'block' }}
266
+ onMouseMove={(e) => {
267
+ const rect = e.currentTarget.getBoundingClientRect()
268
+ const i = Math.floor((e.clientX - rect.left - padL) / (slot || 1))
269
+ setHover(Math.max(0, Math.min(n - 1, i)))
270
+ }}
271
+ onMouseLeave={() => setHover(null)}
272
+ >
273
+ {[0, 0.5, 1].map((g) => (
274
+ <line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
275
+ ))}
276
+ {data.map((d, i) => {
277
+ const h = (d.value / max) * innerH
278
+ return (
279
+ <rect
280
+ key={i}
281
+ x={cx(i) - barW / 2}
282
+ y={padT + innerH - h}
283
+ width={barW}
284
+ height={Math.max(0, h)}
285
+ rx={Math.min(3, barW / 2)}
286
+ fill={color}
287
+ fillOpacity={hover === i ? 1 : 0.78}
288
+ />
289
+ )
290
+ })}
291
+ {ticks.map((t) => (
292
+ <text key={t.i} x={cx(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor="middle">
293
+ {t.label}
294
+ </text>
295
+ ))}
296
+ </svg>
297
+ ) : null}
298
+ {hover != null && data[hover] ? (
299
+ <Tooltip x={cx(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
300
+ ) : null}
301
+ </div>
302
+ )
303
+ }
304
+
305
+ // ── Donut (categorical share) ────────────────────────────────────────────────
306
+
307
+ export function Donut({
308
+ slices,
309
+ size = 160,
310
+ thickness = 22,
311
+ center,
312
+ legend = false,
313
+ }: {
314
+ slices: Slice[]
315
+ size?: number
316
+ thickness?: number
317
+ /** Rendered, centered, inside the ring (a number, a label+sub, anything). */
318
+ center?: ReactNode
319
+ /**
320
+ * Render a built-in label/percentage legend beside the ring. Off by default so
321
+ * a consumer can compose a richer legend (amounts, click targets) itself.
322
+ */
323
+ legend?: boolean
324
+ }) {
325
+ const theme = useTheme()
326
+ const fg = tone(theme, 'color12', 'currentColor')
327
+ const positive = slices.filter((s) => s.value > 0)
328
+ const total = positive.reduce((sum, s) => sum + s.value, 0)
329
+ const r = size / 2
330
+ const rInner = r - thickness
331
+ const mid = (r + rInner) / 2
332
+
333
+ if (total <= 0) {
334
+ return (
335
+ <XStack items="center" justify="center" height={size}>
336
+ <Text fontSize="$6" color="$color10">
337
+ —
338
+ </Text>
339
+ </XStack>
340
+ )
341
+ }
342
+
343
+ const ring = (
344
+ <div style={{ position: 'relative', width: size, height: size }}>
345
+ <svg width={size} height={size} style={{ display: 'block' }}>
346
+ {positive.length === 1 ? (
347
+ <circle
348
+ cx={r}
349
+ cy={r}
350
+ r={mid}
351
+ fill="none"
352
+ stroke={positive[0].color ?? fg}
353
+ strokeOpacity={positive[0].color ? 1 : 0.9}
354
+ strokeWidth={thickness}
355
+ />
356
+ ) : (
357
+ renderArcs(positive, total, r, rInner, fg)
358
+ )}
359
+ </svg>
360
+ {center ? (
361
+ <div
362
+ style={{
363
+ position: 'absolute',
364
+ inset: 0,
365
+ display: 'flex',
366
+ flexDirection: 'column',
367
+ alignItems: 'center',
368
+ justifyContent: 'center',
369
+ textAlign: 'center',
370
+ pointerEvents: 'none',
371
+ }}
372
+ >
373
+ {center}
374
+ </div>
375
+ ) : null}
376
+ </div>
377
+ )
378
+
379
+ if (!legend) return ring
380
+
381
+ return (
382
+ <XStack gap="$4" items="center" flexWrap="wrap">
383
+ {ring}
384
+ <YStack gap="$1.5" flex={1} minW={150}>
385
+ {positive.map((s, i) => (
386
+ <XStack key={s.label} gap="$2" items="center">
387
+ <div
388
+ style={{
389
+ width: 10,
390
+ height: 10,
391
+ borderRadius: 2,
392
+ backgroundColor: s.color ?? AXIS,
393
+ opacity: s.color ? 1 : rampOpacity(i, positive.length),
394
+ }}
395
+ />
396
+ <Text fontSize="$2" color="$color11" flex={1} numberOfLines={1}>
397
+ {s.label}
398
+ </Text>
399
+ <Text fontSize="$2" color="$color12" fontWeight="600">
400
+ {Math.round((s.value / total) * 100)}%
401
+ </Text>
402
+ </XStack>
403
+ ))}
404
+ </YStack>
405
+ </XStack>
406
+ )
407
+ }
408
+
409
+ function renderArcs(slices: Slice[], total: number, rOuter: number, rInner: number, fg: string): ReactNode {
410
+ let a0 = -Math.PI / 2
411
+ const paths: ReactNode[] = []
412
+ slices.forEach((s, idx) => {
413
+ const frac = Math.max(0, s.value) / total
414
+ if (frac <= 0) return
415
+ const a1 = a0 + frac * Math.PI * 2
416
+ paths.push(
417
+ <path
418
+ key={s.label || idx}
419
+ d={annularArc(rOuter, rOuter, rOuter, rInner, a0, Math.min(a1, a0 + Math.PI * 1.9999))}
420
+ fill={s.color ?? fg}
421
+ fillOpacity={s.color ? 1 : rampOpacity(idx, slices.length)}
422
+ />,
423
+ )
424
+ a0 = a1
425
+ })
426
+ return <>{paths}</>
427
+ }
428
+
429
+ function polar(cx: number, cy: number, rad: number, a: number): [number, number] {
430
+ return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]
431
+ }
432
+
433
+ function annularArc(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
434
+ const large = a1 - a0 > Math.PI ? 1 : 0
435
+ const [x0, y0] = polar(cx, cy, rOuter, a0)
436
+ const [x1, y1] = polar(cx, cy, rOuter, a1)
437
+ const [x2, y2] = polar(cx, cy, rInner, a1)
438
+ const [x3, y3] = polar(cx, cy, rInner, a0)
439
+ return `M${x0},${y0} A${rOuter},${rOuter} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rInner},${rInner} 0 ${large} 0 ${x3},${y3} Z`
440
+ }
441
+
442
+ // ── Bars (a ranked distribution, horizontal, token-native) ───────────────────
443
+
444
+ export function Bars({ bars }: { bars: Slice[] }) {
445
+ const max = Math.max(0, ...bars.map((b) => b.value))
446
+ if (max <= 0) {
447
+ return (
448
+ <Text fontSize="$3" color="$color10">
449
+ —
450
+ </Text>
451
+ )
452
+ }
453
+ return (
454
+ <YStack gap="$2.5">
455
+ {bars.map((b) => (
456
+ <XStack key={b.label} gap="$3" items="center">
457
+ <Text width={64} fontSize="$2" color="$color11" text="right">
458
+ {b.label}
459
+ </Text>
460
+ <YStack flex={1} height={10} bg="$color3" rounded="$2" overflow="hidden">
461
+ <YStack height={10} width={`${Math.max(2, (b.value / max) * 100)}%`} bg="$color9" rounded="$2" />
462
+ </YStack>
463
+ <Text width={56} fontSize="$2" color="$color12" fontWeight="600">
464
+ {b.value.toLocaleString()}
465
+ </Text>
466
+ </XStack>
467
+ ))}
468
+ </YStack>
469
+ )
470
+ }