TanStack
Catalog

Synchronized cursors across views

interaction

468 lines · 4 files · 13.2 kB

cases/87-echarts-synchronized-cursors/colors.ts8 lines · dependency
cases/87-echarts-synchronized-cursors/colors.ts
import type { SynchronizedCursorView } from './model'

export const synchronizedCursorColors: Readonly<
  Record<SynchronizedCursorView, string>
> = {
  current: '#2563eb',
  previous: '#e11d48',
}
cases/87-echarts-synchronized-cursors/example.tsx397 lines · entry
cases/87-echarts-synchronized-cursors/example.tsx
import { useMemo, useRef, useState } from 'react'
import { travelers } from '@tanstack/charts-data/travelers'
import { Chart } from '@tanstack/charts/react'
import { synchronizedCursorColors } from './colors'
import {
  synchronizedCursorDateKey,
  synchronizedCursorDatumAtDate,
} from './model'
import { selectSynchronizedCursorData } from './selection'
import type { ChartScene } from '@tanstack/charts'
import type { ChartInteractionController } from '@tanstack/charts'
import type { TravelersRow } from '@tanstack/charts-data/travelers'
import { defineChart, dot, lineY } from '@tanstack/charts'
import { focusGuideX } from '@tanstack/charts/focus/guide'
import { decorative } from '@tanstack/charts/mark/decorative'
import { tooltip } from '@tanstack/charts/tooltip'
import { viewGrid } from '@tanstack/charts/view'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { synchronizedCursorViews, synchronizedCursorYDomains } from './model'
import type { ChartTooltipOptions } from '@tanstack/charts'
import type { SynchronizedCursorView } from './model'
function SummaryValue({
  color,
  dataAttribute,
  label,
  value,
}: {
  color: string
  dataAttribute: 'date' | 'current' | 'previous'
  label: string
  value: string
}) {
  return (
    <label
      style={{
        display: 'grid',
        gridTemplateColumns: '8px minmax(0, 1fr)',
        gridTemplateRows: 'auto auto',
        columnGap: 6,
        minWidth: 0,
      }}
    >
      <span
        style={{
          gridRow: '1 / 3',
          alignSelf: 'center',
          width: 8,
          height: 8,
          borderRadius: 999,
          background: color,
        }}
      />
      <span
        style={{
          overflow: 'hidden',
          color: 'currentColor',
          fontSize: 10,
          letterSpacing: '0.02em',
          opacity: 0.68,
          textOverflow: 'ellipsis',
          textTransform: 'uppercase',
          whiteSpace: 'nowrap',
        }}
      >
        {label}
      </span>
      <span
        {...{ [`data-conformance-synchronized-${dataAttribute}`]: '' }}
        style={{
          overflow: 'hidden',
          fontWeight: 700,
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap',
        }}
      >
        {value}
      </span>
    </label>
  )
}

function formatDate(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  })
}

export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

export const summaryHeight = 56

const viewGap = 8

const viewMargin = { top: 16, right: 24, bottom: 34, left: 62 } as const

const travelerCountFormat = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  maximumFractionDigits: 1,
})

const month = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  timeZone: 'UTC',
})

const synchronizedCursorTooltip: ChartTooltipOptions<TravelersRow> = {
  sticky: true,
  visibility: 'pinned',
  anchor: 'point',
  placement: ['bottom-right', 'bottom-left', 'right', 'left'],
  offset: 10,
  formatGroup: () => 'Pinned · Press Escape to release',
}

export const synchronizedCursorDefinition = (input: ExampleChartInput) => {
  const rows = selectSynchronizedCursorData(travelers, input.revision)
  const composed = viewGrid({
    id: 'synchronized-cursors',
    rows: synchronizedCursorViews.map((view) => ({ id: view, grow: 1 })),
    columns: [{ id: 'main', grow: 1 }],
    rowGap: input.preview ? 4 : viewGap,
    views: synchronizedCursorViews.map((view) => ({
      id: view,
      row: view,
      column: 'main' as const,
      ...(view === 'previous' ? { share: { x: 'current' as const } } : {}),
      chart: synchronizedCursorViewDefinition(
        rows,
        view,
        input.preview === true,
      ),
    })),
  })

  return defineChart(composed, {
    svgAnimation: false,
    keyboard: true,
    focus: 'group-x',
    focusRing: false,
    maxFocusDistance: Number.POSITIVE_INFINITY,
    tooltip: {
      use: tooltip,
      ...synchronizedCursorTooltip,
    },
  })
}

function synchronizedCursorViewDefinition(
  rows: readonly TravelersRow[],
  view: SynchronizedCursorView,
  preview: boolean,
) {
  const group = () => view
  return defineChart({
    marks: [
      decorative(
        lineY(rows, {
          id: `${view}-line`,
          key: (row) => synchronizedCursorDateKey(row.date),
          x: 'date',
          y: view,
          z: group,
          stroke: synchronizedCursorColors[view],
          strokeWidth: 2,
        }),
      ),
      dot(rows, {
        id: `${view}-points`,
        key: (row) => synchronizedCursorDateKey(row.date),
        x: 'date',
        y: view,
        z: group,
        fill: synchronizedCursorColors[view],
        r: 3,
        stroke: '#ffffff',
        strokeWidth: 1,
      }),
      focusGuideX(rows, {
        id: `${view}-guide`,
        key: (row) => synchronizedCursorDateKey(row.date),
        x: 'date',
        y: view,
        z: group,
        match: 'x',
        xRule: {
          stroke: '#64748b',
          strokeWidth: 1,
          strokeDasharray: '4 4',
        },
        marker: {
          radius: 5,
          fill: '#ffffff',
          stroke: '#334155',
          strokeWidth: 2,
        },
      }),
    ],
    scales: {
      x: {
        scale: scaleUtc,
        axis: preview
          ? false
          : { ticks: { format: (value) => month.format(value) } },
      },
      y: {
        scale: scaleLinear().domain(synchronizedCursorYDomains[view]),
        grid: !preview,
        axis: preview
          ? false
          : {
              ticks: { count: 4, format: travelerCountFormat.format },
              label: view === 'current' ? '2020 travelers' : '2019 travelers',
            },
      },
    },

    margin: preview ? 0 : viewMargin,
  })
}

export function chartHeight(input: ExampleChartInput) {
  return Math.max(280, input.height - summaryHeight)
}

export interface ExampleChartInput {
  width: number
  height: number
  revision: number
  preview?: boolean
  interactive?: boolean
}

export default function SynchronizedCursorsExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '87-echarts-synchronized-cursors'
  const chartFrameRef = useRef<HTMLDivElement>(null)

  const sceneRef = useRef<ChartScene<TravelersRow, Date, number>>(null)

  const interactionRef = useRef<
    ChartInteractionController<TravelersRow, Date, number> | undefined
  >(undefined)

  const inputRef = useRef(input)

  const previousInputRef = useRef(input)

  const restoreDateRef = useRef<Date | null | undefined>(undefined)

  const stateRef = useRef<{ date: Date | null; pinned: boolean }>({
    date: null,
    pinned: false,
  })

  const [focusedDate, setFocusedDate] = useState<Date | null>(null)

  const [pinned, setPinned] = useState(false)

  if (previousInputRef.current !== input) {
    restoreDateRef.current = stateRef.current.date
    previousInputRef.current = input
  }

  inputRef.current = input

  stateRef.current = { date: focusedDate, pinned }

  const definition = useMemo(() => synchronizedCursorDefinition(input), [input])

  const rows = useMemo(
    () => selectSynchronizedCursorData(travelers, input.revision),
    [input.revision],
  )

  const row = focusedDate
    ? synchronizedCursorDatumAtDate(rows, focusedDate)
    : undefined

  return (
    <div
      onMouseLeave={() => {
        if (stateRef.current.pinned) return
        interactionRef.current?.setControlledFocus(null)
        stateRef.current = { date: null, pinned: false }
        setFocusedDate(null)
        setPinned(false)
      }}
      style={{
        display: 'grid',
        gridTemplateRows: `${summaryHeight}px minmax(0, 1fr)`,
        width: input.width,
        height: input.height,
      }}
    >
      <div
        data-conformance-synchronized-summary=""
        data-date={
          focusedDate ? synchronizedCursorDateKey(focusedDate) : undefined
        }
        data-pinned={String(pinned)}
        role="status"
        aria-live="polite"
        aria-atomic="true"
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
          alignItems: 'center',
          gap: 8,
          minHeight: summaryHeight,
          padding: '6px 12px',
          boxSizing: 'border-box',
          borderBottom:
            '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
          background: 'color-mix(in srgb, Canvas 95%, CanvasText 5%)',
          color: 'CanvasText',
          font: '500 12px/1.25 system-ui, sans-serif',
        }}
      >
        <SummaryValue
          dataAttribute="date"
          label="Linked date"
          color="currentColor"
          value={
            focusedDate
              ? `${formatDate(focusedDate)}${pinned ? ' · pinned' : ''}`
              : 'Focus either chart'
          }
        />
        <SummaryValue
          dataAttribute="current"
          label="2020 travelers"
          color={synchronizedCursorColors.current}
          value={row?.current.toLocaleString() ?? '—'}
        />
        <SummaryValue
          dataAttribute="previous"
          label="2019 travelers"
          color={synchronizedCursorColors.previous}
          value={row?.previous.toLocaleString() ?? '—'}
        />
      </div>
      <div
        ref={chartFrameRef}
        style={{ minHeight: 0, width: input.width, height: chartHeight(input) }}
      >
        <Chart
          idPrefix={idPrefix}
          definition={definition}
          width={input.width}
          height={chartHeight(input)}
          ariaLabel="Linked 2020 and 2019 airport traveler time series"
          ariaDescription="Move across either view or use the arrow keys to compare both years at the same date. Select a point to pin the cursor."
          onFocusGroupChange={(points) => {
            const date = points[0]?.datum.date ?? null
            setFocusedDate(date)
            if (!date) setPinned(false)
          }}
          onSelect={(point) => {
            if (!point) return
            setFocusedDate(point.datum.date)
            setPinned((value) => !value)
          }}
          onRender={({ interaction, scene }) => {
            sceneRef.current = scene
            interactionRef.current = interaction
            const restoreDate = restoreDateRef.current
            restoreDateRef.current = undefined
            if (!restoreDate) return
            const timestamp = restoreDate.getTime()
            const point = scene.points.find(
              (candidate) =>
                candidate.markId.endsWith(':current:current-points') &&
                candidate.datum.date.getTime() === timestamp,
            )
            if (point) {
              interaction.setControlledFocus(point, {
                source: 'programmatic',
                pinned: stateRef.current.pinned,
              })
            }
          }}
        />
      </div>
    </div>
  )
}
cases/87-echarts-synchronized-cursors/model.ts48 lines · dependency
cases/87-echarts-synchronized-cursors/model.ts
import type { TravelersRow } from '@tanstack/charts-data/travelers'

export type SynchronizedCursorView = 'current' | 'previous'

export const synchronizedCursorViews: readonly SynchronizedCursorView[] = [
  'current',
  'previous',
]

export const synchronizedCursorYDomains: Readonly<
  Record<SynchronizedCursorView, readonly [number, number]>
> = {
  current: [0, 1_200_000],
  previous: [0, 3_000_000],
}

export function synchronizedCursorDateKey(date: Date) {
  return date.toISOString().slice(0, 10)
}

export function synchronizedCursorAnchorDate(anchor: string) {
  const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
  const timestamp = Date.parse(key.includes('T') ? key : `${key}T00:00:00.000Z`)
  if (!Number.isFinite(timestamp)) return null
  return new Date(timestamp)
}

export function synchronizedCursorDatumAtDate(
  rows: readonly TravelersRow[],
  date: Date,
) {
  const timestamp = date.getTime()
  return rows.find((datum) => datum.date.getTime() === timestamp) ?? null
}

export function synchronizedCursorNearestDatum(
  rows: readonly TravelersRow[],
  date: Date,
) {
  const timestamp = date.getTime()
  return rows.reduce<TravelersRow | undefined>((nearest, datum) => {
    if (!nearest) return datum
    return Math.abs(datum.date.getTime() - timestamp) <
      Math.abs(nearest.date.getTime() - timestamp)
      ? datum
      : nearest
  }, undefined)
}
cases/87-echarts-synchronized-cursors/selection.ts15 lines · dependency
cases/87-echarts-synchronized-cursors/selection.ts
import type { TravelersRow } from '@tanstack/charts-data/travelers'

export function selectSynchronizedCursorData(
  rows: readonly TravelersRow[],
  revision = 0,
): readonly TravelersRow[] {
  const advanced = Math.abs(Math.trunc(revision)) % 2
  return rows.slice(8 - advanced, 16 - advanced).reverse()
}

export function synchronizedCursorDates(
  rows: readonly TravelersRow[],
): readonly Date[] {
  return rows.map((row) => row.date)
}