486 lines · 3 files · 14.3 kB
cases/123-active-donut-metric/example.tsx395 lines · entry
cases/123-active-donut-metric/example.tsx
import { useMemo, useRef, useState } from 'react'
import { motion } from '@tanstack/charts/motion'
import { Chart } from '@tanstack/charts/react/core'
import { activeDonutLayout } from './layout'
import { browserRows } from './model'
import type { CSSProperties } from 'react'
import type { ChartPoint } from '@tanstack/charts'
import { defineChart } from '@tanstack/charts'
import { decorative } from '@tanstack/charts/mark/decorative'
import { pie, polar, radialArc, radialText } from '@tanstack/charts/polar'
import { scaleLinear } from '@tanstack/charts/scales/linear'
import { tooltip } from '@tanstack/charts/tooltip'
import { browserTotal } from './model'
import type { ChartTooltipOptions } from '@tanstack/charts'
import type { PieDatum } from '@tanstack/charts/polar'
import type { BrowserRow } from './model'
const palette = ['#7c3aed', '#06b6d4', '#f97316', '#ec4899', '#84cc16']
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
const tau = Math.PI * 2
const gapAngle = (Math.PI / 180) * 2.8
export const donutSpring = {
type: 'spring' as const,
stiffness: 190,
damping: 21,
mass: 0.82,
}
export interface DonutCenterRow {
id: string
angle: number
radius: number
text: string
dy: number
}
export type DonutDatum = PieDatum<BrowserRow>
const tooltipOptions: ChartTooltipOptions<DonutDatum, number, number> = {
anchor: 'point',
placement: ['top', 'right', 'left', 'bottom'],
className: 'active-donut-tooltip',
format: (point) =>
`${point.datum.label} · ${point.datum.visitors.toLocaleString('en-US')} visitors`,
}
export function activeDonutDefinition(
input: ExampleChartInput,
activeId: string,
) {
const rows = browserRows(input.revision)
const selected = rows.find((row) => row.id === activeId) ?? rows[0]!
const { arcs, active } = activeDonutArcs(rows, selected.id)
const centerRows: readonly DonutCenterRow[] = [
{
id: `${selected.id}:value`,
angle: 0,
radius: 0,
text: selected.visitors.toLocaleString('en-US'),
dy: -8,
},
{
id: `${selected.id}:label`,
angle: 0,
radius: 0,
text: selected.label,
dy: input.preview ? 12 : 15,
},
]
return defineChart(
{
marks: [
polar({
id: 'browser-donut',
radiusRatio: input.preview ? 0.76 : 0.74,
marks: [
radialArc(arcs, {
id: 'browser-arcs',
key: 'id',
color: 'id',
innerRadius: ({ radius }) => radius * 0.6,
cornerRadius: 7,
motion: { transition: donutSpring },
}),
],
scales: {
angle: null,
radius: null,
},
}),
decorative(
polar({
id: 'selected-browser',
radiusRatio: input.preview ? 0.84 : 0.83,
marks: [
radialArc(active, {
id: 'selected-browser-wedge',
key: 'id',
color: 'id',
innerRadius: ({ radius }) => radius * 0.59,
cornerRadius: 8,
stroke: 'Canvas',
strokeWidth: input.preview ? 1.5 : 2.5,
motion: { transition: donutSpring },
}),
radialArc(active, {
id: 'selected-browser-ring',
key: 'id',
color: 'id',
innerRadius: ({ radius }) => radius * 0.94,
outerRadius: ({ radius }) => radius,
cornerRadius: 4,
fillOpacity: 0.78,
motion: {
delay: 70,
transition: donutSpring,
},
}),
],
scales: {
angle: null,
radius: null,
},
}),
),
decorative(
polar({
id: 'donut-center',
radiusRatio: 0.8,
scales: {
angle: { scale: scaleLinear().domain([0, tau]) },
radius: { scale: scaleLinear().domain([0, 1]) },
},
marks: [
radialText(centerRows.slice(0, 1), {
id: 'donut-center-value',
angle: 'angle',
radius: 'radius',
key: 'id',
text: 'text',
dy: (row) => row.dy,
fill: 'currentColor',
fontSize: input.preview ? 19 : 26,
fontWeight: 760,
motion: { transition: donutSpring },
}),
radialText(centerRows.slice(1), {
id: 'donut-center-label',
angle: 'angle',
radius: 'radius',
key: 'id',
text: 'text',
dy: (row) => row.dy,
fill: 'color-mix(in srgb, currentColor 56%, transparent)',
fontSize: input.preview ? 8 : 11,
fontWeight: 620,
motion: { transition: donutSpring },
}),
],
}),
),
],
scales: {
x: null,
y: null,
},
color: {
domain: rows.map((row) => row.id),
range: [
'var(--ts-chart-1)',
'var(--ts-chart-2)',
'var(--ts-chart-3)',
'var(--ts-chart-4)',
'var(--ts-chart-5)',
],
},
guides: false,
margin: 0,
motion: { transition: donutSpring },
},
{
focus: 'nearest',
focusRing: false,
keyboard: input.interactive,
tooltip: input.interactive ? { use: tooltip, ...tooltipOptions } : false,
},
)
}
export function activeDonutArcs(rows: readonly BrowserRow[], activeId: string) {
const arcs = pie(rows, { value: 'visitors', gapAngle })
return {
arcs,
active: arcs.filter((row) => row.id === activeId),
}
}
export function donutSummary(input: ExampleChartInput, activeId: string) {
const rows = browserRows(input.revision)
const selected = rows.find((row) => row.id === activeId) ?? rows[0]!
return {
selected,
total: browserTotal(rows),
share: selected.visitors / browserTotal(rows),
}
}
export interface ExampleChartInput {
width: number
height: number
revision: number
preview?: boolean
interactive?: boolean
}
export default function ActiveDonutMetric({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '123-active-donut-metric'
const [activeId, setActiveId] = useState('chrome')
const root = useRef<HTMLElement>(null)
const chartHost = useRef<HTMLElement | null>(null)
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
const focusedId = useRef<string | null>(null)
const renderer = useMemo(() => motion({ initial: !false }), [false])
const definition = useMemo(
() => activeDonutDefinition(input, activeId),
[activeId, input],
)
const rows = browserRows(input.revision)
const summary = donutSummary(input, activeId)
const selectPoint = (
point: ChartPoint<DonutDatum, number, number> | null,
) => {
const row = point && 'visitors' in point.datum ? point.datum : null
focusedId.current = row?.id ?? null
if (row) setActiveId(row.id)
}
const layout = activeDonutLayout(input.width, input.height, rows.length)
const { chartSize, compact } = layout
const cardStyle: CSSProperties & Record<`--${string}`, string> = {
'--ts-chart-1': palette[0]!,
'--ts-chart-2': palette[1]!,
'--ts-chart-3': palette[2]!,
'--ts-chart-4': palette[3]!,
'--ts-chart-5': palette[4]!,
'--ts-chart-tooltip-background':
'color-mix(in srgb, Canvas 94%, transparent)',
'--ts-chart-tooltip-color': 'CanvasText',
'--ts-chart-tooltip-border':
'1px solid color-mix(in srgb, CanvasText 12%, transparent)',
'--ts-chart-tooltip-border-radius': '10px',
'--ts-chart-tooltip-shadow':
'0 12px 34px color-mix(in srgb, CanvasText 14%, transparent)',
'--ts-chart-tooltip-font':
'600 12px/1.35 Inter, ui-sans-serif, system-ui, sans-serif',
boxSizing: 'border-box',
width: input.width,
height: input.height,
overflow: 'hidden',
border: '1px solid color-mix(in srgb, currentColor 12%, transparent)',
borderRadius: 18,
color: 'CanvasText',
background: 'Canvas',
fontFamily:
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
boxShadow: '0 18px 55px color-mix(in srgb, CanvasText 7%, transparent)',
}
return (
<section
ref={root}
data-conformance-view="main"
aria-label="Browser visitors"
style={cardStyle}
>
<header style={{ padding: '18px 20px 0' }}>
<div style={{ fontSize: 14, fontWeight: 680 }}>Browser visitors</div>
<div style={{ marginTop: 3, fontSize: 12, opacity: 0.56 }}>
{summary.total.toLocaleString('en-US')} sessions
</div>
</header>
<div
data-donut-content=""
data-chart-size={chartSize}
style={{
display: 'flex',
alignItems: 'center',
flexDirection: compact ? 'column' : 'row',
gap: layout.contentGap,
padding: compact ? '4px 18px 16px' : '2px 24px 20px 8px',
}}
>
<Chart<DonutDatum, number, number>
idPrefix={idPrefix}
definition={definition}
renderer={renderer}
width={chartSize}
height={chartSize}
ariaLabel="Browser visitor share"
onRender={({ container }) => {
chartHost.current = container
}}
onFocusChange={selectPoint}
/>
<div
data-browser-legend=""
role="group"
aria-label="Select browser"
style={{
display: 'grid',
gridTemplateColumns: compact
? `repeat(${layout.legendColumns}, minmax(0, 1fr))`
: undefined,
width: compact ? '100%' : 190,
gap: 4,
}}
>
{rows.map((row, index) => {
const selected = row.id === activeId
return (
<button
key={row.id}
ref={(element) => {
if (element) buttonRefs.current.set(row.id, element)
else buttonRefs.current.delete(row.id)
}}
type="button"
data-browser={row.id}
aria-pressed={selected}
onClick={() => setActiveId(row.id)}
style={{
display: 'grid',
gridTemplateColumns: '10px 1fr auto',
alignItems: 'center',
gap: 9,
minHeight: 38,
padding: '7px 9px',
border: 0,
borderRadius: 9,
color: 'inherit',
background: selected
? 'color-mix(in srgb, currentColor 7%, Canvas)'
: 'transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: 999,
background: palette[index],
}}
/>
<span style={{ fontSize: 12, fontWeight: 620 }}>
{row.label}
</span>
<span style={{ fontSize: 12, opacity: 0.6 }}>
{Math.round((row.visitors / summary.total) * 100)}%
</span>
</button>
)
})}
</div>
</div>
</section>
)
}cases/123-active-donut-metric/layout.ts66 lines · dependency
cases/123-active-donut-metric/layout.ts
const compactBreakpoint = 560
const compactHorizontalPadding = 36
const compactVerticalPadding = 20
const compactHeaderHeight = 53
const compactContentGap = 8
const compactSafetyInset = 6
const legendItemHeight = 38
const legendGap = 4
export interface ActiveDonutLayout {
compact: boolean
chartSize: number
contentGap: number
legendColumns: number
legendHeight: number
occupiedHeight: number
}
export function activeDonutLayout(
width: number,
height: number,
itemCount = 5,
): ActiveDonutLayout {
const compact = width < compactBreakpoint
if (!compact) {
return {
compact,
chartSize: Math.max(180, Math.min(height - 62, width * 0.55)),
contentGap: 18,
legendColumns: 1,
legendHeight: itemCount * legendItemHeight + (itemCount - 1) * legendGap,
occupiedHeight: height,
}
}
const legendColumns = 2
const legendRows = Math.ceil(itemCount / legendColumns)
const legendHeight =
legendRows * legendItemHeight + Math.max(0, legendRows - 1) * legendGap
const availableChartHeight =
height -
compactHeaderHeight -
compactVerticalPadding -
compactContentGap -
legendHeight -
compactSafetyInset
const chartSize = Math.max(
120,
Math.min(width - compactHorizontalPadding, availableChartHeight),
)
return {
compact,
chartSize,
contentGap: compactContentGap,
legendColumns,
legendHeight,
occupiedHeight:
compactHeaderHeight +
compactVerticalPadding +
chartSize +
compactContentGap +
legendHeight,
}
}cases/123-active-donut-metric/model.ts25 lines · dependency
cases/123-active-donut-metric/model.ts
export interface BrowserRow {
id: string
label: string
visitors: number
}
const baseRows: readonly BrowserRow[] = [
{ id: 'chrome', label: 'Chrome', visitors: 275 },
{ id: 'safari', label: 'Safari', visitors: 200 },
{ id: 'firefox', label: 'Firefox', visitors: 187 },
{ id: 'edge', label: 'Edge', visitors: 173 },
{ id: 'other', label: 'Other', visitors: 90 },
]
export function browserRows(revision: number): readonly BrowserRow[] {
if (revision % 2 === 0) return baseRows
return baseRows.map((row, index) => ({
...row,
visitors: Math.round(row.visitors * (0.93 + ((index * 3) % 5) / 20)),
}))
}
export function browserTotal(rows: readonly BrowserRow[]) {
return rows.reduce((total, row) => total + row.visitors, 0)
}