TanStack
shadcn/ui Charts

shadcn Line Chart - Interactive

line

387 lines · 2 files · 9.9 kB

cases/156-shadcn-line-interactive/example.tsx214 lines · entry
cases/156-shadcn-line-interactive/example.tsx
import { useMemo, useState } from 'react'
import { d3Curve, defineChart, lineY, type ChartPoint } from '@tanstack/charts'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import { motion } from '@tanstack/charts/motion'
import { scaleLinear, scalePoint } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import {
  shadcnColors,
  type ShadcnMonthDatum,
} from '@tanstack/charts-data/shadcn'
import interactiveAreaData from '@tanstack/charts-data/shadcn-area-interactive-data'
import './styles.css'
type InteractiveSeries = 'desktop' | 'mobile'
const twoSeries = ['desktop', 'mobile'] as const
const interactiveAreaRows = interactiveAreaData as readonly {
  date: string
  desktop: number
  mobile: number
}[]
const interactiveBarRows: readonly ShadcnMonthDatum[] = interactiveAreaRows.map(
  (row) => ({
    month: row.date,
    desktop: row.desktop,
    mobile: row.mobile,
    tablet: 0,
  }),
)
export function createExampleChart(
  activeSeries: InteractiveSeries = 'desktop',
) {
  return defineChart(
    ({ width }) => ({
      marks: [
        lineY(interactiveBarRows, {
          id: 'daily-line',
          x: 'month',
          y: (row) => row[activeSeries],
          z: () => activeSeries,
          key: 'month',
          curve: d3Curve(curveMonotoneX),
          stroke:
            activeSeries === 'desktop' ? shadcnColors[0] : shadcnColors[1],
          strokeWidth: 2,
        }),
      ],
      scales: {
        x: {
          scale: scalePoint,
          axis: {
            line: false,
            ticks: {
              values: [
                '2024-04-10',
                '2024-04-21',
                '2024-05-02',
                '2024-05-13',
                '2024-05-25',
                '2024-06-05',
                '2024-06-16',
                '2024-06-29',
              ],
              size: 0,
              padding: 10,
              format: formatMonthDay,
            },
          },
        },
        y: {
          scale: scaleLinear().domain([0, 600]),
          grid: true,
          axis: {
            line: false,
            ticks: { values: [0, 150, 300, 450, 600], size: 0 },
            tickLabels: false,
          },
        },
      },

      color: { domain: twoSeries, range: shadcnColors.slice(0, 2) },
      margin: {
        top: 5,
        right: width < 400 ? 24 : 12,
        bottom: 35,
        left: 12,
      },
      theme: shadcnTheme(),
    }),
    {
      svgAnimation: false,
      focus: 'group-x',
      tooltip: {
        use: tooltip,
        className: 'sc-chart-tooltip',
        anchor: 'group-center',
        placement: 'auto',
        sort: 'color-domain',
        content: (points) => shadcnTooltipContent(points),
      },
    },
  )
}
function formatMonthDay(value: string) {
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    timeZone: 'UTC',
  }).format(new Date(value))
}
function shadcnTheme() {
  return {
    foreground: 'var(--muted-foreground, var(--muted))',
    grid: 'var(--border)',
    background: 'transparent',
  }
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
  return {
    title: String(points[0]?.xValue ?? ''),
    rows: points.map((point) => ({
      label: titleCase(
        String(
          point.group ??
            point.markId.replace(
              /-?(bars|lines|areas|slices|values|radar)$/u,
              '',
            ),
        ),
      ),
      value: Number(point.yValue ?? point.xValue ?? 0).toLocaleString('en-US'),
      color: point.color,
    })),
  }
}
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 [activeSeries, setActiveSeries] = useState<InteractiveSeries>('desktop')
  const chartDefinition = useMemo(
    () => createExampleChart(activeSeries),
    [activeSeries],
  )
  const contentWidth = Math.max(1, width - 50)
  const chartWidth = contentWidth
  const chartHeight = 250
  return (
    <div className="sc-example" style={{ width, height }}>
      <article className="sc-card sc-interactive-line" style={{ width }}>
        <header className="sc-card-header">
          <div className="sc-card-heading">
            <h2>Line Chart - Interactive</h2>
            <p>Showing total visitors for the last 3 months</p>
          </div>
          <div className="sc-card-action">
            <BarMetrics active={activeSeries} onChange={setActiveSeries} />
          </div>
        </header>
        <div className="sc-card-content">
          <div
            className="sc-chart"
            style={{ width: chartWidth, height: chartHeight }}
          >
            <RendererChart
              definition={chartDefinition}
              renderer={renderer}
              initialWidth={chartWidth}
              height={chartHeight}
              ariaLabel="Line Chart - Interactive"
            />
          </div>
        </div>
      </article>
    </div>
  )
}
function BarMetrics({
  active,
  onChange,
}: {
  active: InteractiveSeries
  onChange: (series: InteractiveSeries) => void
}) {
  const totals = {
    desktop: interactiveBarRows.reduce((sum, row) => sum + row.desktop, 0),
    mobile: interactiveBarRows.reduce((sum, row) => sum + row.mobile, 0),
  }
  return (
    <>
      {twoSeries.map((series) => (
        <button
          key={series}
          type="button"
          className="sc-bar-metric"
          data-active={active === series}
          aria-pressed={active === series}
          onClick={() => onChange(series)}
        >
          <span>{titleCase(series)}</span>
          <strong>{totals[series].toLocaleString('en-US')}</strong>
        </button>
      ))}
    </>
  )
}
cases/156-shadcn-line-interactive/styles.css173 lines · dependency
cases/156-shadcn-line-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-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-line {
  gap: 0;
  padding: 0;
}
.sc-interactive-line .sc-card-header {
  display: flex;
  min-height: 99px;
  align-items: stretch;
  gap: 0;
  padding: 0;
  border-bottom: 1px solid var(--border);
}
.sc-interactive-line .sc-card-heading {
  flex: 1;
  align-content: center;
  gap: 4px;
  padding: 16px 24px;
}
.sc-interactive-line .sc-card-action {
  display: flex;
  margin: 0;
}
.sc-interactive-line .sc-card-content {
  padding: 43px 24px 0;
}
.sc-card-action {
  margin-left: auto;
}
.sc-bar-metric {
  display: grid;
  width: 168px;
  align-content: center;
  gap: 6px;
  padding: 16px 32px;
  border: 0;
  border-left: 1px solid var(--border);
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.sc-bar-metric[data-active='true'] {
  background: var(--muted);
}
.sc-bar-metric:focus-visible {
  outline: 2px solid var(--foreground);
  outline-offset: -3px;
}
.sc-bar-metric span {
  color: var(--muted-foreground);
  font-size: 12px;
}
.sc-bar-metric strong {
  font-size: 30px;
  line-height: 1;
  letter-spacing: -0.03em;
}