Skip to content

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.

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide

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.

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide
tsx
<RadarChart {...props} niceMaxValue />
vue
<RadarChart :options="{ ...props, niceMaxValue: true }" />
svelte
<div use:radarChart={{ ...props, niceMaxValue: true }}></div>
ts
applyRadarChartProps(this.c.nativeElement, { ...props, niceMaxValue: true });
html
<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>
  • true uses rings (default 4) as the tick count. A number sets the tick count itself, e.g. niceMaxValue: 5 to match a five-tick axis elsewhere on the page.
  • An explicit maxValue always wins: niceMaxValue only 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().maxValue reports 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.

⚗️ Experimental - not yet stable. WebGPU rendering is an opt-in preview. It needs a WebGPU-capable browser (Chrome / Edge, or Safari 26+); everywhere else it falls back to canvas automatically. Axes, labels and tooltips stay on the SVG layer - only the data marks are painted on the GPU.
Heavy-data demo · 12 axes × 4 series… detecting

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.

ts
{ 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.

tsx
const ref = useRef<RadarChartHandle>(null);

<RadarChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()
vue
<RadarChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:radarChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applyRadarChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<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>
  • speedMs sets the pace, loop wraps around, autoplay: true starts on mount, showControl: false hides the built-in bar.
  • Values glide between periods by default (interpolate); tune the motion with tweenMs and easing, or set interpolate: false for hard cuts. Reduced motion always gets the hard cut.
  • The headless controller is always available: chart.timeline() exposes play() / pause() / toggle() / seek(period) / seekIndex(i) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • seek(period) looks for the period first, comparing as text, so seek(2021) and seek("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 period stay visible in every period.
  • timeline wins over progressiveDraw when 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:

tsx
const ref = useRef<RadarChartHandle>(null);

<RadarChart
  ref={ref}
  {...props}
  progressiveDraw={{ durationMs: 2000 }}
/>;
// ref.current?.replay() re-runs the reveal on demand
vue
<RadarChart :options="{ ...props, progressiveDraw: { durationMs: 2000 } }" />
svelte
<div use:radarChart={{ ...props, progressiveDraw: { durationMs: 2000 } }}></div>
ts
applyRadarChartProps(this.c.nativeElement, {
  ...props,
  progressiveDraw: { durationMs: 2000 },
});
html
<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>
  • durationMs and easing ("linear", "easeOutQuad", "easeInOutCubic", or a custom (t) => t function) shape the sweep.
  • autoplay: false renders the chart fully drawn; call replay() (React ref handle, web-component method, or the core instance) to run the reveal on demand. replayOnUpdate: true re-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):

ts
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 ​

tsx
import { RadarChart } from "@michi-vz/react";

export default () => <RadarChart {...props} />; // props = the chart options
vue
<script setup>
import { RadarChart } from "@michi-vz/vue";
</script>

<template>
  <RadarChart :options="props" />
</template>
svelte
<script>
  import { radarChart } from "@michi-vz/svelte";
</script>

<div use:radarChart={props}></div>
ts
// 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);
html
<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>
ts
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.