Source
142 chart lines · 3 files · 4.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/75-radar/tanstack.ts95 lines · entry
cases/75-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 { selectRadarAthlete } from './selection'
import { radarEvents, radarProfile } 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 radarAthlete = selectRadarAthlete(decathlon)
const angleScale = scalePoint<string>().domain(radarEvents)
const radiusScale = scaleLinear().domain([0, 100])
const profile = radarProfile(decathlon, radarAthlete)
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.8,
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(profile, {
angle: 'event',
radius: 'relativePerformance',
className: 'ts-chart__radar',
curve: curveLinearClosed,
fill: '#8884d8',
fillOpacity: 0.6,
stroke: '#8884d8',
strokeWidth: 2,
}),
],
}),
],
margin,
})
}
export const mount = tanstackMount(definition, 'Decathlon radar chart', {
format: ({ datum }) =>
`${datum.Country} · ${datum.event} · ${datum.relativePerformance.toFixed(1)} / 100`,
})cases/75-radar/selection.ts7 lines · support
cases/75-radar/selection.ts
import type { DecathlonRow } from '@charts-poc/demo-data/decathlon'
export function selectRadarAthlete(rows: readonly DecathlonRow[]) {
const firstAthlete = rows[0]
if (!firstAthlete) throw new Error('The decathlon snapshot is empty')
return firstAthlete
}cases/75-radar/transform.ts40 lines · support
cases/75-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 type RadarEvent = (typeof radarEvents)[number]
export interface RadarPoint {
readonly Country: string
readonly event: RadarEvent
readonly relativePerformance: number
}
const timedEvents = new Set<RadarEvent>(['100 Meters', '100 Meter Hurdles'])
export function radarProfile(
sourceRows: readonly DecathlonRow[],
row: DecathlonRow,
): readonly RadarPoint[] {
const extents = new Map(
radarEvents.map((event) => [
event,
extent(sourceRows, (sourceRow) => sourceRow[event]) as [number, number],
]),
)
return radarEvents.map((event) => {
const [minimum, maximum] = extents.get(event) ?? [0, 1]
const proportion = (row[event] - minimum) / (maximum - minimum || 1)
return {
Country: row.Country,
event,
relativePerformance:
(timedEvents.has(event) ? 1 - proportion : proportion) * 100,
}
})
}