Source

181 chart lines · 3 files · 5.2 kBBenchmark harness excluded · shared/mount.ts

Decathlon results50 records · CSV · 1.3 kB
@charts-poc/demo-data/decathlon

Selection: Complete published snapshot

Country
string
100 Meters
number
Long Jump
number
High Jump
number
100 Meter Hurdles
number

JMP Statistical Discovery sampleobservablehq/plot@356f579b1d94 · revision 356f579b1d94 · test/data/decathlon.csv · Observable Plot repository MIT; upstream source credited · SHA-256 8c016fb7adccPinned snapshot

cases/99-comparative-radar/tanstack.ts99 lines · entry
cases/99-comparative-radar/tanstack.ts
import { defineChart } from '@tanstack/charts'
import {
  angleGrid,
  polar,
  radialArea,
  radialGrid,
} from '@tanstack/charts/polar'
import { decathlon } from '@charts-poc/demo-data/decathlon'
import { scaleLinear, scalePoint } from 'd3-scale'
import { curveLinearClosed } from 'd3-shape'
import { selectRadarProfiles } from './selection'
import {
  comparativeRadarPoints,
  radarCountries,
  radarEvents,
} from './transform'
import { tanstackMount } from '../../shared/mount'
import type { PolarGuideLabelContext } from '@tanstack/charts/polar'
import type { ConformanceInput } from '../../types'

const ringValues = [20, 40, 60, 80, 100] as const
const angleScale = scalePoint<string>().domain(radarEvents)
const radiusScale = scaleLinear().domain([0, 100])
const colors = ['#7c3aed', '#0ea5e9']
const radarProfiles = selectRadarProfiles(decathlon)
const points = comparativeRadarPoints(decathlon, radarProfiles)

function angleLabelIsTopOrBottom(angle: number): boolean {
  return Math.abs(Math.sin(angle)) <= Math.SQRT1_2
}

function angleLabelBaseline({
  angle,
  y,
}: PolarGuideLabelContext): 'auto' | 'middle' | 'hanging' {
  if (!angleLabelIsTopOrBottom(angle)) return 'middle'
  return y > 0 ? 'hanging' : 'auto'
}

function angleLabelDy({ angle, y }: PolarGuideLabelContext): number {
  if (!angleLabelIsTopOrBottom(angle)) return 1.1
  return y > 0 ? -1.1 : 0
}

const definition = (input: ConformanceInput) => {
  const margin =
    input.width < 480
      ? { top: 20, right: 55, bottom: 20, left: 105 }
      : { top: 20, right: 20, bottom: 20, left: 20 }

  return defineChart({
    marks: [
      polar({
        angle: { scale: angleScale, wrap: true },
        radius: { scale: radiusScale },
        inset: 0,
        radiusRatio: 0.78,
        guides: [
          radialGrid({
            values: ringValues,
            shape: 'polygon',
            labels: true,
            labelAngle: Math.PI / 3,
            labelRotate: 60,
            labelBaseline: 'auto',
            labelFill: '#cccccc',
            stroke: '#cbd5e1',
          }),
          angleGrid({
            values: radarEvents,
            labels: true,
            labelOffset: 8,
            labelBaseline: angleLabelBaseline,
            labelDy: angleLabelDy,
            labelFill: '#808080',
            stroke: '#cbd5e1',
          }),
        ],
        marks: [
          radialArea(points, {
            angle: 'event',
            radius: 'relativePerformance',
            color: 'Country',
            className: 'ts-chart__radar',
            curve: curveLinearClosed,
            fillOpacity: 0.18,
          }),
        ],
      }),
    ],
    color: {
      domain: radarCountries,
      range: colors,
    },
    margin,
  })
}

export const mount = tanstackMount(definition, 'Comparative radar chart')
cases/99-comparative-radar/selection.ts14 lines · support
cases/99-comparative-radar/selection.ts
import type { DecathlonRow } from '@charts-poc/demo-data/decathlon'
import type { RadarCountry } from './transform'

export function selectRadarProfiles(
  rows: readonly DecathlonRow[],
): Readonly<Record<RadarCountry, DecathlonRow>> {
  const USA = rows.find((row) => row.Country === 'USA')
  const GBR = rows.find((row) => row.Country === 'GBR')
  if (!USA || !GBR) {
    throw new Error('The decathlon snapshot is missing USA or GBR results')
  }

  return { USA, GBR }
}
cases/99-comparative-radar/transform.ts68 lines · support
cases/99-comparative-radar/transform.ts
import { extent } from 'd3-array'
import type { DecathlonRow } from '@charts-poc/demo-data/decathlon'

export const radarEvents = [
  '100 Meters',
  'Long Jump',
  'High Jump',
  '100 Meter Hurdles',
] as const
export const radarCountries = ['USA', 'GBR'] as const

export type RadarCountry = (typeof radarCountries)[number]
export type RadarEvent = (typeof radarEvents)[number]

export interface ComparativeRadarDatum {
  readonly event: RadarEvent
  readonly USA: number
  readonly GBR: number
}

export interface ComparativeRadarPoint {
  readonly event: RadarEvent
  readonly Country: RadarCountry
  readonly relativePerformance: number
}

const timedEvents = new Set<RadarEvent>(['100 Meters', '100 Meter Hurdles'])
function normalizeResult(
  sourceRows: readonly DecathlonRow[],
  row: DecathlonRow,
  event: RadarEvent,
): number {
  const eventExtent = extent(sourceRows, (sourceRow) => sourceRow[event]) as [
    number,
    number,
  ]
  const [minimum, maximum] = eventExtent ?? [0, 1]
  const proportion = (row[event] - minimum) / (maximum - minimum || 1)
  return (timedEvents.has(event) ? 1 - proportion : proportion) * 100
}

export function comparativeRadarData(
  sourceRows: readonly DecathlonRow[],
  profiles: Readonly<Record<RadarCountry, DecathlonRow>>,
): readonly ComparativeRadarDatum[] {
  return radarEvents.map((event) => ({
    event,
    USA: normalizeResult(sourceRows, profiles.USA, event),
    GBR: normalizeResult(sourceRows, profiles.GBR, event),
  }))
}

export function comparativeRadarPoints(
  sourceRows: readonly DecathlonRow[],
  profiles: Readonly<Record<RadarCountry, DecathlonRow>>,
): readonly ComparativeRadarPoint[] {
  return radarCountries.flatMap((Country) =>
    radarEvents.map((event) => ({
      event,
      Country,
      relativePerformance: normalizeResult(
        sourceRows,
        profiles[Country],
        event,
      ),
    })),
  )
}