TanStack
shadcn/ui Charts

shadcn Pie Chart - Interactive

pie

450 lines · 2 files · 11.7 kB

cases/164-shadcn-pie-interactive/example.tsx265 lines · entry
cases/164-shadcn-pie-interactive/example.tsx
import { useMemo, useState } from 'react'
import { defineChart, type ChartPoint } from '@tanstack/charts'
import {
  focusGroupAngle,
  pie,
  polar,
  radialArc,
  radialText,
} from '@tanstack/charts/polar'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import { motion } from '@tanstack/charts/motion'
import { scaleLinear } from 'd3-scale'
import {
  shadcnColors,
  type ShadcnBrowserDatum,
} from '@tanstack/charts-data/shadcn'
import './styles.css'
const interactivePieRows: readonly ShadcnBrowserDatum[] = [
  { browser: 'january', visitors: 186 },
  { browser: 'february', visitors: 305 },
  { browser: 'march', visitors: 237 },
  { browser: 'april', visitors: 173 },
  { browser: 'may', visitors: 209 },
]
export function createExampleChart(activeMonth = 'january') {
  const arcs = pie(interactivePieRows, {
    value: 'visitors',
    startAngle: Math.PI / 2,
    endAngle: (-Math.PI * 3) / 2,
  })
  const activeIndex = Math.max(
    0,
    interactivePieRows.findIndex((row) => row.browser === activeMonth),
  )
  const activeRow = interactivePieRows[activeIndex]!
  const active = arcs.filter((row) => row.browser === activeRow.browser)
  const activeColor = shadcnColors[activeIndex]!
  return defineChart(
    {
      marks: [
        polar({
          radiusRatio: 0.78,
          scales: {
            angle: { scale: scaleLinear().domain([0, Math.PI * 2]) },
            radius: { scale: scaleLinear().domain([0, 1]) },
          },

          marks: [
            radialArc(arcs, {
              id: 'month-slices',
              key: 'browser',
              innerRadius: 60,
              color: 'browser',
              stroke: 'var(--background)',
              strokeWidth: 1,
            }),
            radialArc(active, {
              id: 'active-month',
              key: 'browser',
              innerRadius: 60,
              outerRadius: ({ radius }) => radius + 10,
              fill: activeColor,
              stroke: 'var(--background)',
              strokeWidth: 5,
            }),
            radialArc(active, {
              id: 'active-month-ring',
              key: 'browser',
              innerRadius: ({ radius }) => radius + 12,
              outerRadius: ({ radius }) => radius + 25,
              fill: activeColor,
              stroke: 'var(--background)',
              strokeWidth: 3,
            }),
            radialText(
              [
                {
                  id: 'total',
                  angle: 0,
                  radius: 0,
                  text: String(activeRow.visitors),
                },
              ],
              {
                id: 'active-total',
                angle: 'angle',
                radius: 'radius',
                key: 'id',
                text: 'text',
                dy: -5,
                fill: 'var(--foreground)',
                fontSize: 30,
                fontWeight: 700,
              },
            ),
            radialText(
              [{ id: 'label', angle: 0, radius: 0, text: 'Visitors' }],
              {
                id: 'active-label',
                angle: 'angle',
                radius: 'radius',
                key: 'id',
                text: 'text',
                dy: 20,
                fill: 'var(--muted-foreground)',
                fontSize: 12,
              },
            ),
          ],
        }),
      ],
      scales: {
        x: null,
        y: null,
      },
      color: {
        domain: interactivePieRows.map((row) => row.browser),
        range: shadcnColors,
      },
      margin: 0,
    },
    {
      svgAnimation: false,
      focus: focusGroupAngle,
      tooltip: {
        use: tooltip,
        className: 'sc-chart-tooltip',
        anchor: 'group-center',
        placement: 'auto',
        sort: 'color-domain',
        content: (points) => shadcnTooltipContent(points),
      },
    },
  )
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
  const point = points.find((candidate) => browserMetric(candidate.datum))
  const metric = point && browserMetric(point.datum)
  return metric
    ? {
        title: titleCase(metric.browser),
        rows: [
          {
            label: 'Visitors',
            value: metric.visitors.toLocaleString('en-US'),
            color: point.color,
          },
        ],
      }
    : { rows: [] }
}
function browserMetric(datum: unknown) {
  if (!datum || typeof datum !== 'object') return undefined
  const browser = Reflect.get(datum, 'browser')
  const visitors = Reflect.get(datum, 'visitors')
  return typeof browser === 'string' &&
    typeof visitors === 'number' &&
    Number.isFinite(visitors)
    ? { browser, visitors }
    : undefined
}
function titleCase(value: string) {
  return value.charAt(0).toUpperCase() + value.slice(1)
}
export const definition = createExampleChart()
const renderer = motion({
  initial: 'always',
  transition: { type: 'spring', stiffness: 170, damping: 18, mass: 1 },
})
export interface ExampleProps {
  width?: number
  height?: number
}
export default function Example({ width = 640, height = 600 }: ExampleProps) {
  const [activeMonth, setActiveMonth] = useState('january')
  const chartDefinition = useMemo(
    () => createExampleChart(activeMonth),
    [activeMonth],
  )
  const contentWidth = Math.max(1, width - 50)
  const chartWidth = Math.min(300, contentWidth)
  const chartHeight = chartWidth
  return (
    <div className="sc-example" style={{ width, height }}>
      <article className="sc-card sc-interactive-pie" style={{ width }}>
        <header className="sc-card-header">
          <div className="sc-card-heading">
            <h2>Pie Chart - Interactive</h2>
            <p>January - June 2024</p>
          </div>
          <div className="sc-card-action">
            <SelectControl
              value={activeMonth}
              onChange={setActiveMonth}
              options={interactivePieRows.map((row, index) => ({
                value: row.browser,
                label: titleCase(row.browser),
                swatch: shadcnColors[index],
              }))}
            />
          </div>
        </header>
        <div className="sc-card-content sc-centered">
          <div
            className="sc-chart"
            style={{ width: chartWidth, height: chartHeight }}
          >
            <RendererChart
              definition={chartDefinition}
              renderer={renderer}
              initialWidth={chartWidth}
              height={chartHeight}
              ariaLabel="Pie Chart - Interactive"
            />
          </div>
        </div>
      </article>
    </div>
  )
}
function SelectControl({
  value,
  options,
  onChange,
}: {
  value: string
  options: readonly {
    value: string
    label: string
    swatch?: string
  }[]
  onChange: (value: string) => void
}) {
  const selected = options.find((option) => option.value === value)
  return (
    <label className="sc-select-display">
      {selected?.swatch ? (
        <span
          className="sc-select-swatch"
          style={{ background: selected.swatch }}
        />
      ) : null}
      <select
        aria-label="Select a value"
        value={value}
        onChange={(event) => onChange(event.currentTarget.value)}
      >
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <path
          d="m6 9 6 6 6-6"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
        />
      </svg>
    </label>
  )
}
cases/164-shadcn-pie-interactive/styles.css185 lines · dependency
cases/164-shadcn-pie-interactive/styles.css
.sc-example {
  --background: oklch(1 0 0);
  --foreground: oklch(0 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --chart-1: oklch(0.809 0.105 251.813);
  --chart-2: oklch(0.623 0.214 259.815);
  --chart-3: oklch(0.546 0.245 262.881);
  --chart-4: oklch(0.488 0.243 264.376);
  --chart-5: oklch(0.424 0.199 265.638);
  display: flex;
  justify-content: center;
  align-items: flex-start;
  overflow: hidden;
  color: var(--foreground);
  background: var(--background);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  text-rendering: geometricPrecision;
}
:root[data-theme='dark'] .sc-example {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border: oklch(1 0 0 / 10%);
  --chart-1: oklch(0.809 0.105 251.813);
  --chart-2: oklch(0.623 0.214 259.815);
  --chart-3: oklch(0.546 0.245 262.881);
  --chart-4: oklch(0.488 0.243 264.376);
  --chart-5: oklch(0.424 0.199 265.638);
}
.sc-example,
.sc-example * {
  box-sizing: border-box;
}
.sc-card {
  display: flex;
  flex-direction: column;
  gap: 24px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--card);
  color: var(--card-foreground);
  padding: 24px 0;
}
.sc-card-header {
  display: grid;
  gap: 8px;
  padding: 0 24px;
}
.sc-card-heading {
  display: grid;
  gap: 8px;
}
.sc-card-header h2 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.01em;
}
.sc-card-header p {
  margin: 0;
  color: var(--muted-foreground);
  font-size: 14px;
  line-height: 20px;
}
.sc-card-content {
  display: flex;
  min-height: 0;
  flex-direction: column;
  padding: 0 24px;
}
.sc-chart {
  position: relative;
  flex: none;
}
.sc-chart > * {
  display: block;
}
.sc-centered {
  justify-content: center;
  align-items: center;
  text-align: center;
}
.sc-example .ts-chart {
  color: var(--muted-foreground);
}
.sc-example .ts-chart__grid line,
.sc-example .ts-chart__polar-grid path,
.sc-example .ts-chart__polar-grid line {
  stroke: var(--border);
}
.sc-example .ts-chart__axes text,
.sc-example .ts-chart__polar-grid text {
  fill: var(--muted-foreground);
  font-size: 12px;
}
.sc-example .ts-chart-tooltip {
  min-width: 128px;
  padding: 7px 10px !important;
  border: 1px solid var(--border) !important;
  border-radius: 8px !important;
  background: var(--card) !important;
  color: var(--card-foreground) !important;
  box-shadow: 0 2px 6px rgb(0 0 0 / 8%);
  font-size: 12px;
}
.sc-interactive-pie .sc-card-header {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  padding-bottom: 0;
}
.sc-interactive-pie .sc-card-heading {
  flex: 1;
  gap: 4px;
}
.sc-interactive-pie .sc-card-content {
  padding-bottom: 44px;
}
.sc-interactive-pie .sc-chart {
  transform: translateY(55px);
}
.sc-interactive-pie .sc-card-action {
  transform: translateY(48px);
}
.sc-card-action {
  margin-left: auto;
}
.sc-select-display {
  position: relative;
  display: flex;
  min-width: 130px;
  height: 36px;
  align-items: center;
  gap: 8px;
  padding: 0 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--background);
  color: var(--foreground);
  font-size: 14px;
  box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.sc-select-display:focus-within {
  outline: 2px solid var(--foreground);
  outline-offset: 2px;
}
.sc-select-display select {
  min-width: 0;
  flex: 1;
  appearance: none;
  border: 0;
  outline: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.sc-select-display svg {
  width: 14px;
  height: 14px;
  flex: none;
  color: var(--muted-foreground);
  pointer-events: none;
}
.sc-select-swatch {
  width: 12px;
  height: 12px;
  flex: none;
  border-radius: 3px;
}