304 lines · 2 files · 8.7 kB
cases/126-drillable-sunburst/example.tsx158 lines · entry
cases/126-drillable-sunburst/example.tsx
import { useMemo, useState } from 'react'
import { motion } from '@tanstack/charts/motion'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import {
flareAggregateValue,
flareHasChildren,
flareLabel,
flareParentId,
flarePreviewRootId,
formatFlareValue,
} from './model'
import type { FlareRow } from '@tanstack/charts-data/flare'
import type { ChartPoint } from '@tanstack/charts'
import type { SunburstNode } from '@tanstack/charts/hierarchy/sunburst'
import { defineChart } from '@tanstack/charts'
import { sunburst } from '@tanstack/charts/hierarchy/sunburst'
import { polar } from '@tanstack/charts/polar'
import { tooltip } from '@tanstack/charts/tooltip'
import {
flareNodeColor,
flareRows,
flareVisibleDepth,
flareVisibleRingCount,
} from './model'
type DrillDatum = SunburstNode<FlareRow>
const tau = Math.PI * 2
const ringPadding = 2
export function drillableSunburstDefinition(rootId: string) {
const ringCount = flareVisibleRingCount(rootId)
return defineChart({
marks: [
polar({
radiusRatio: 0.92,
startAngle: Math.PI / 2,
endAngle: Math.PI / 2 - tau,
marks: [
sunburst(flareRows(), {
id: 'drillable-sunburst-arcs',
path: 'name',
delimiter: '.',
value: 'size',
rootId,
visibleDepth: flareVisibleDepth(rootId),
sort: (left, right) =>
right.value - left.value || left.name.localeCompare(right.name),
innerRadius: ({ radius }) => radius * 0.32,
outerRadius: ({ radius }) => {
const innerRadius = radius * 0.32
return (
innerRadius +
((radius - innerRadius) * ringCount) / (ringCount + 1) +
Math.max(0, ringCount - 1) * ringPadding
)
},
ringPadding,
fill: (node) => flareNodeColor(node.id),
stroke: 'Canvas',
strokeOpacity: 0.9,
strokeWidth: 2,
motion(context) {
return {
delay:
context.phase === 'enter'
? Math.min(context.datumIndex * 18, 160)
: 0,
transition:
context.phase === 'exit'
? { type: 'tween', duration: 320, easing: 'ease-out' }
: undefined,
}
},
}),
],
scales: {
angle: null,
radius: null,
},
}),
],
scales: {
x: null,
y: null,
},
motion: {
transition: { type: 'tween', duration: 720, easing: 'ease-in-out' },
},
tooltip: {
use: tooltip,
format: ({ datum }) => `${datum.name} · ${formatFlareValue(datum.value)}`,
},
keyboard: true,
margin: 0,
})
}
export default function Example() {
const [rootId, setRootId] = useState(flarePreviewRootId)
const definition = useMemo(
() => drillableSunburstDefinition(rootId),
[rootId],
)
const renderer = useMemo(() => motion({ initial: 'always' }), [])
const parentId = flareParentId(rootId)
const drill = (point: ChartPoint<DrillDatum> | null) => {
if (point && flareHasChildren(point.datum.id)) setRootId(point.datum.id)
}
return (
<div style={{ position: 'relative', width: '100%', height: 480 }}>
<RendererChart
definition={definition}
renderer={renderer}
height={480}
ariaLabel="Drillable Flare hierarchy"
ariaDescription="Use arrow keys to inspect segments and Enter or Space to drill into a branch. Use the center button to move up."
onSelect={drill}
/>
<button
type="button"
disabled={!parentId}
aria-label={
parentId
? `Back to ${flareLabel(parentId)}`
: `${flareLabel(rootId)}, ${formatFlareValue(flareAggregateValue(rootId))}`
}
onClick={() => parentId && setRootId(parentId)}
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 120,
height: 72,
transform: 'translate(-50%, -50%)',
border: 0,
borderRadius: 999,
background: 'transparent',
color: 'CanvasText',
cursor: parentId ? 'pointer' : 'default',
font: '600 12px/1.35 ui-sans-serif, system-ui, sans-serif',
}}
>
<span style={{ display: 'block', fontWeight: 700 }}>
{flareLabel(rootId)}
</span>
<span style={{ display: 'block', fontSize: 10, opacity: 0.7 }}>
{parentId
? `↑ ${flareLabel(parentId)}`
: formatFlareValue(flareAggregateValue(rootId))}
</span>
</button>
</div>
)
}cases/126-drillable-sunburst/model.ts146 lines · dependency
cases/126-drillable-sunburst/model.ts
import { flare } from '@tanstack/charts-data/flare'
import type { FlareRow } from '@tanstack/charts-data/flare'
export const flareRootId = '/flare'
export const flarePreviewRootId = '/flare/analytics'
export interface FlareSunburstTree {
readonly id: string
readonly name: string
readonly value: number
readonly fill: string
readonly children?: FlareSunburstTree[]
}
const rowsById = new Map(flare.map((row) => [flareId(row.name), row]))
const childrenById = new Map<string, string[]>()
for (const row of flare) {
const id = flareId(row.name)
const parentId = flareParentId(id)
if (!parentId) continue
const children = childrenById.get(parentId)
if (children) children.push(id)
else childrenById.set(parentId, [id])
}
const aggregateValues = new Map<string, number>()
const heights = new Map<string, number>()
export function flareId(path: string): string {
return `/${path.replaceAll('.', '/')}`
}
export function flareLabel(id: string): string {
return rowsById.get(id)?.name.split('.').at(-1) ?? id.split('/').at(-1) ?? id
}
export function flareParentId(id: string): string | null {
const split = id.lastIndexOf('/')
return split > 0 ? id.slice(0, split) : null
}
export function flareHasChildren(id: string): boolean {
return (childrenById.get(id)?.length ?? 0) > 0
}
export function flareAggregateValue(id: string): number {
const cached = aggregateValues.get(id)
if (cached !== undefined) return cached
const own = rowsById.get(id)?.size ?? 0
const value = (childrenById.get(id) ?? []).reduce(
(sum, childId) => sum + flareAggregateValue(childId),
own,
)
aggregateValues.set(id, value)
return value
}
export function flareHeight(id: string): number {
const cached = heights.get(id)
if (cached !== undefined) return cached
const childHeights = (childrenById.get(id) ?? []).map(flareHeight)
const height = childHeights.length ? Math.max(...childHeights) + 1 : 0
heights.set(id, height)
return height
}
export function flareVisibleRingCount(id: string): number {
return Math.min(flareVisibleDepth(id), flareHeight(id))
}
export function flareVisibleDepth(id: string): number {
return id === flareRootId ? 1 : 2
}
export function flareSunburstTree(id: string): FlareSunburstTree {
if (!rowsById.has(id)) throw new TypeError(`Unknown Flare node "${id}"`)
return treeNode(id, 0, flareVisibleDepth(id))
}
export function flareNodeColor(id: string): string {
const branch = id.split('/')[2] ?? 'flare'
const hue = branchHues[branch] ?? 220
const lightness = 48 + (hash(id) % 4) * 5
return `hsl(${hue} 70% ${lightness}%)`
}
export function formatFlareValue(value: number): string {
return `${value.toLocaleString('en-US')} lines`
}
export function flareRows(): readonly FlareRow[] {
return flare
}
function treeNode(
id: string,
depth: number,
visibleDepth: number,
): FlareSunburstTree {
const children =
depth >= visibleDepth
? []
: [...(childrenById.get(id) ?? [])].sort(compareNodes)
return {
id,
name: flareLabel(id),
value: flareAggregateValue(id),
fill: flareNodeColor(id),
...(children.length
? {
children: children.map((childId) =>
treeNode(childId, depth + 1, visibleDepth),
),
}
: {}),
}
}
function compareNodes(left: string, right: string): number {
return (
flareAggregateValue(right) - flareAggregateValue(left) ||
flareLabel(left).localeCompare(flareLabel(right))
)
}
function hash(value: string): number {
let result = 0
for (let index = 0; index < value.length; index += 1) {
result = (result * 31 + value.charCodeAt(index)) >>> 0
}
return result
}
const branchHues: Readonly<Record<string, number>> = {
analytics: 263,
animate: 198,
data: 36,
display: 330,
flex: 152,
physics: 232,
query: 18,
scale: 177,
util: 87,
vis: 355,
}