Radar Chart
Comparison
Which option wins, and where? Lay a few candidates over the same set of criteria and each one becomes a polygon you can read in a glance - the spikes show every strength, the dents show every weakness, and the overlaps show exactly where they trade places.
The chart above is the same engine in every framework - only the integration code below differs.
When to reach for it
- Scorecards. Vendor evaluations, candidate assessments, product benchmarks: a few options over the same criteria, each a polygon whose spikes and dents are its strengths and weaknesses.
- Balance vs specialisation. A rounder polygon is the all-rounder; a spiky one bets everything on two axes. That shape story is what tables cannot tell.
- Keep it to a few entities and 5-12 axes. For a precise comparison on one criterion, a Comparable bar reads exact values; the radar reads profiles.
Round the outer ring to a nice value
Without maxValue, the outer ring sits at the largest value in the data, so a data max of 73 labels the rings 18, 37, 55 and 73. Set niceMaxValue and the outer ring rounds up to the next "nice" tick instead, on the same 1, 2 and 5 × 10ⁿ steps a d3 axis uses, so the rings read 20, 40, 60 and 80.
<RadarChart {...props} niceMaxValue /><RadarChart :options="{ ...props, niceMaxValue: true }" /><div use:radarChart={{ ...props, niceMaxValue: true }}></div>applyRadarChartProps(this.c.nativeElement, { ...props, niceMaxValue: true });<michi-vz-radar-chart id="c"></michi-vz-radar-chart>
<script>
// a property, not an attribute: it takes true or a tick count
document.getElementById("c").niceMaxValue = true;
</script>trueusesrings(default 4) as the tick count. A number sets the tick count itself, e.g.niceMaxValue: 5to match a five-tick axis elsewhere on the page.- An explicit
maxValuealways wins:niceMaxValueonly rounds the outer ring the chart would otherwise take from the data. A max already on a tick stays put (100 stays 100), and all-zero or negative data keeps the default outer ring of 1. - Only the outer ring is guaranteed to be round. The inner rings are equal fractions of it, so they are round when the nice max divides evenly by
rings: 80 over 4 rings gives 20, 40, 60, 80, while 50 over 4 rings gives 12.5, 25, 37.5, 50. getContext().maxValuereports the rounded value. The rounding is also exported as a pure helper,niceRadarMax(dataMax, tickCount)from@michi-vz/core.
Heavy data on WebGPU Experimental
RadarChart has an opt-in renderer="webgpu" that paints the polygon fills and pole markers as GPU-instanced marks while axes, labels and tooltips stay on the SVG layer. It is capability-gated: on a browser without WebGPU it downgrades to canvas automatically, and getContext().renderer reports whichever actually painted.
Play through the years
RadarChart already uses date for the legacy per-axis { date, value } shape, so the timeline tag is named period instead. Tag every series row with a period and flip on timeline: a year's snapshot is the rows sharing that period, and each polygon tweens between years. Off by default - nothing changes until a chart opts in. This is interactive year-by-year stepping, not the one-shot entrance further down.
{ label: "Vienna", period: "2021", values: [72, 65, 40, 88 /* … */] }Press the play button under the chart: it steps through the years, one snapshot at a time. Drag the scrubber to jump to any year.
const ref = useRef<RadarChartHandle>(null);
<RadarChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()<RadarChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" /><div use:radarChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>applyRadarChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });<michi-vz-radar-chart id="c"></michi-vz-radar-chart>
<script>
const el = document.getElementById("c");
el.timeline = { speedMs: 1000, loop: true };
// el.getTimeline() -> play() / pause() / seek(year) / seekIndex(i)
</script>speedMssets the pace,loopwraps around,autoplay: truestarts on mount,showControl: falsehides the built-in bar.- Values glide between periods by default (
interpolate); tune the motion withtweenMsandeasing, or setinterpolate: falsefor hard cuts. Reduced motion always gets the hard cut. - The headless controller is always available:
chart.timeline()exposesplay() / pause() / toggle() / seek(period) / seekIndex(i) / stepForward() / stepBack(), plusonStepandformatPeriodin the config for custom UI. seek(period)looks for the period first, comparing as text, soseek(2021)andseek("2021")both land on 2021 whether your dates are numbers or strings. A number counts as a position (0 = first) only when no period matches, and a string that matches none does nothing.seekIndex(i)always goes by position, as the built-in scrubber does.- Series without a
periodstay visible in every period. timelinewins overprogressiveDrawwhen both are set - the reveal animation further down stays off while the timeline is in control.
Reveal animation
The chart wipes in from left to right on mount, revealing its marks in sequence before settling into place. Off by default - a chart opts in with the progressiveDraw prop.
The marks wipe in from left to right; axes and titles stay put. With reduced motion enabled, the chart renders fully drawn instantly.
progressiveDraw: true enables the defaults (1200 ms, easeInOutCubic). A config object tunes it:
const ref = useRef<RadarChartHandle>(null);
<RadarChart
ref={ref}
{...props}
progressiveDraw={{ durationMs: 2000 }}
/>;
// ref.current?.replay() re-runs the reveal on demand<RadarChart :options="{ ...props, progressiveDraw: { durationMs: 2000 } }" /><div use:radarChart={{ ...props, progressiveDraw: { durationMs: 2000 } }}></div>applyRadarChartProps(this.c.nativeElement, {
...props,
progressiveDraw: { durationMs: 2000 },
});<michi-vz-radar-chart id="c"></michi-vz-radar-chart>
<script>
const el = document.getElementById("c");
el.progressiveDraw = { durationMs: 2000 };
// el.replay() re-runs the reveal
</script>durationMsandeasing("linear", "easeOutQuad", "easeInOutCubic", or a custom(t) => tfunction) shape the sweep.autoplay: falserenders the chart fully drawn; callreplay()(React ref handle, web-component method, or the core instance) to run the reveal on demand.replayOnUpdate: truere-runs it on every data change.- Respects
prefers-reduced-motion: the chart renders fully drawn instantly. - Reveal animation is a one-shot entrance; play through the years above steps through data year by year instead.
Hover and tooltips
Hovering a pole calls tooltipFormatter with the pole's data item plus date - the hovered pole's axis label - and this is now consistent across every renderer (svg, canvas, webgpu):
tooltipFormatter: (item) => `${item.date}: ${item.value}`,The default tooltip markup (no tooltipFormatter passed) is unchanged. Hover follows the cursor as it moves across a polygon rather than firing once on entering it, and highlightItems dims every other series - dimmed series are not hit-tested, so only the active series responds to hover, matching canvas and webgpu. disabledItems is a different mechanism: it removes a series from the chart entirely rather than dimming it, so a disabled series was never in the render model to hit-test in the first place. Clicking now pins the tooltip only when the click resolves an actual pole; a click that misses (empty space, or a dimmed series, which is not hit-tested) no longer pins, where previously on svg a click anywhere on a polygon - dimmed included - would.
On svg, onHighlightItem now fires on every mousemove that resolves a hit, and with [] on every move that does not - it is not deduped or throttled. If your onHighlightItem handler writes the highlighted label into a store or other state that feeds back into the chart's props, debounce or dedupe it yourself, since every dispatch that reaches props triggers a re-render. This is not new for the library - canvas and webgpu radar hover already fired at this rate, and so do other charts (e.g. Area, Pie) - svg radar hover now simply matches them.
Usage
import { RadarChart } from "@michi-vz/react";
export default () => <RadarChart {...props} />; // props = the chart options<script setup>
import { RadarChart } from "@michi-vz/vue";
</script>
<template>
<RadarChart :options="props" />
</template><script>
import { radarChart } from "@michi-vz/svelte";
</script>
<div use:radarChart={props}></div>// main.ts - register the elements once
import "@michi-vz/angular";
import { applyRadarChartProps } from "@michi-vz/angular";
// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-radar-chart #c></michi-vz-radar-chart>
applyRadarChartProps(this.c.nativeElement, props);<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>
<michi-vz-radar-chart id="c"></michi-vz-radar-chart>
<script>
Object.assign(document.getElementById("c"), props); // dataSet/series, title, …
</script>import { mountRadarChart } from "@michi-vz/core";
const chart = mountRadarChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();API
Props are typed as RadarChartProps in @michi-vz/core. Shared across all charts: width, height, margin, colors / colorsMapping, renderer ("svg", "canvas", or experimental "webgpu"), highlightItems, disabledItems, and the on* callbacks. onChartDataProcessed / getContext() return the renderer-agnostic ChartContext.
