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/decathlonSelection: 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,
),
})),
)
}