TanStack
Catalog

Drillable Flare sunburst

hierarchy

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,
}