@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.
- package/LICENSE.md +21 -0
- package/README.md +245 -0
- package/package.json +38 -50
- package/src/analytics/index.ts +20 -0
- package/src/analytics/parts.tsx +91 -0
- package/src/analytics/product.tsx +64 -0
- package/src/analytics/source.ts +141 -0
- package/src/analytics/web.tsx +83 -0
- package/src/charts/Charts.tsx +470 -0
- package/src/dashboard.css +157 -0
- package/src/index.ts +119 -0
- package/src/landing/Hero.tsx +115 -0
- package/src/landing/Landing.tsx +47 -0
- package/src/landing/Metrics.tsx +134 -0
- package/src/landing/Rail.tsx +61 -0
- package/src/landing/Samples.tsx +78 -0
- package/src/landing/logic.ts +47 -0
- package/src/landing/parts.tsx +116 -0
- package/src/landing/types.ts +98 -0
- package/src/motion/hooks.ts +98 -0
- package/src/motion/motion.ts +76 -0
- package/src/overview/Overview.tsx +203 -0
- package/src/overview/config.ts +177 -0
- package/src/overview/logic.ts +204 -0
- package/src/overview/primitives.tsx +306 -0
- package/src/overview/superadmin.test.ts +36 -0
- package/src/overview/tiles.tsx +223 -0
- package/src/pipeline/Pipeline.tsx +178 -0
- package/src/pipeline/stages.ts +156 -0
- package/src/types.ts +12 -0
- package/readme.md +0 -57
- package/src/lib/api.ts +0 -155
- package/src/lib/components/ConfirmDialog.tsx +0 -53
- package/src/lib/components/TenantRow.tsx +0 -39
- package/src/lib/components/TenantStatusBadge.tsx +0 -33
- package/src/lib/index.ts +0 -30
- package/src/lib/nav.ts +0 -12
- package/src/lib/screens/NewTenantScreen.tsx +0 -137
- 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
|
+
}
|