Polar Arc Tutorial

Arc marks turn Polar positions into closed sector paths. Use count or weighted sum aggregation for proportional donut sectors, or combine categorical theta bands with a quantitative radius for rose charts and radial bars.

Count a category into a donut

Donut chart showing the number of cars from each origin
Count aggregation mapped to annular sector angle.

The shortest donut flow needs a dataset, an arc mark with a nonzero inner radius, a count-based theta encoding, and color:

Start with the Vite project from Getting Started, then place the tutorial dataset in Vite’s public directory:

mkdir -p public
curl --fail --location https://raw.githubusercontent.com/ggaction/ggaction/main/data/cars.json --output public/cars.json

Complete program

import { chart, render } from "ggaction";

const response = await fetch("/cars.json");
if (!response.ok) throw new Error(`Failed to load cars: ${response.status}`);
const cars = await response.json();

const program = chart()
  .createCanvas({
    width: 640,
    height: 500,
    margin: { top: 55, right: 190, bottom: 55, left: 55 }
  })
  .createData({ values: cars })
  .createArcMark({ innerRadius: 0.56, padAngle: 1.5 })
  .encodeTheta({ field: "Origin", aggregate: "count" })
  .encodeColor({ field: "Origin", palette: "tableau10" })
  .createGuides({
    axes: false,
    grid: false,
    legend: { position: "right", title: "Origin" }
  });

render(program, document.querySelector("canvas").getContext("2d"));

aggregate: "count" assigns one sector to each category and makes its angular sweep proportional to the category count. The omitted radius encoding uses the available Polar radius. innerRadius is a fraction of that available radius.

Sum a field into weighted sectors

Donut chart showing 2005 population totals by Gapminder cluster
Quantitative weights summed into proportional annular sectors.

Use aggregate: "sum" when each source row contributes a numeric weight rather than one count. This example filters Gapminder to one year and partitions the donut by the total population in each cluster:

const clusterOrder = [0, 1, 2, 3, 4, 5];
const populationByCluster = chart()
  .createCanvas({
    width: 680,
    height: 520,
    margin: { top: 65, right: 200, bottom: 55, left: 55 }
  })
  .createData({ values: gapminder.filter(row => row.year === 2005) })
  .createArcMark({ innerRadius: 0.5, padAngle: 1.25 })
  .encodeTheta({
    field: "cluster",
    fieldType: "nominal",
    aggregate: "sum",
    weight: "pop",
    scale: { domain: clusterOrder }
  })
  .encodeColor({
    field: "cluster",
    fieldType: "nominal",
    scale: { domain: clusterOrder }
  })
  .createGuides({ axes: false, grid: false, legend: { title: "Cluster" } });

Repeated categories and fractional weights are valid. Every weight must be a non-negative finite number, and the total must be positive. Invalid input fails before semantic state or trace changes; source rows are never expanded.

Rose overlays

Rose chart comparing three mortality causes across twelve months
Equal theta bands with larger-first radial overlays.

A rose chart uses one equal theta band per month and overlays the three causes inside that band. The following fragment continues from an imported chart function and a loaded nightingaleRows array containing one row per month and cause.

const monthOrder = [
  "April", "May", "June", "July", "August", "September",
  "October", "November", "December", "January", "February", "March"
];
const causeOrder = [
  "Zymotic Diseases", "Other Causes", "Wounds & Injuries"
];

const rose = chart()
  .createCanvas({
    width: 780,
    height: 640,
    margin: { top: 80, right: 210, bottom: 80, left: 80 }
  })
  .createData({ values: nightingaleRows })
  .createArcMark({ padAngle: 1, opacity: 0.9, strokeWidth: 0.5 })
  .encodeTheta({
    field: "month",
    fieldType: "ordinal",
    scale: { domain: monthOrder }
  })
  .encodeR({ field: "value", scale: { domain: [0, 6.5], zero: true } })
  .encodeColor({
    field: "cause",
    layout: "overlay",
    scale: {
      domain: causeOrder,
      range: ["#599ad3", "#727272", "#f1595f"]
    }
  })
  .createGuides({
    axes: {
      theta: { title: false },
      radius: {
        ticksAndLabels: { values: [2, 4, 6] },
        title: { text: "Mortality rate", position: "inside" }
      }
    },
    grid: { theta: false, radial: { values: [2, 4, 6] } },
    legend: { position: "right", title: "Cause" }
  });

layout: "overlay" is explicit because multiple rows occupy the same theta band. Larger sectors render first so smaller values remain visible. Values that do not extend beyond the radial baseline produce no placeholder path.

Radial bars

Radial bars comparing life expectancy for twelve countries
Nominal theta bands and quantitative radius from an inner baseline.

Radial bars use the same position pair without an overlay group. This fragment assumes chart, the selected countryRows, and their explicit countryOrder are already available:

const radialBars = chart()
  .createCanvas({
    width: 780,
    height: 640,
    margin: { top: 75, right: 190, bottom: 75, left: 75 }
  })
  .createData({ values: countryRows })
  .createArcMark({ innerRadius: 0.18, padAngle: 2, opacity: 0.94 })
  .encodeTheta({
    field: "country",
    fieldType: "nominal",
    scale: { domain: countryOrder }
  })
  .encodeR({
    field: "life_expect",
    scale: { domain: [45, 85], zero: false }
  })
  .encodeColor({ field: "cluster", fieldType: "nominal", palette: "tableau10" })
  .createGuides({
    axes: {
      theta: { title: { text: "Country" } },
      radius: {
        ticksAndLabels: { values: [50, 60, 70, 80] },
        title: { text: "Life expectancy", position: "inside" }
      }
    },
    grid: { theta: false, radial: { values: [50, 60, 70, 80] } },
    legend: { position: "right", title: "Cluster" }
  });

The resolved radius scale begins at the arc’s inner radius. The radial axis uses that same baseline instead of drawing through the empty center.

Editing an arc

editArcMark updates geometry or appearance and rematerializes every sector:

const tighter = radialBars.editArcMark({
  innerRadius: 0.24,
  padAngle: 1,
  opacity: 0.8
});

Arc mark reference · Position encodings · Polar guides