Radar Chart

import { DOM } from "/components/DOM.js";
import * as d3 from "npm:d3";
globalThis.d3 = d3;

A radar chart can show three or more quantitative variables on axes starting from the same point. They are also known as spider chart or star plot. They can be used to plot data among similar groups, people, or objects.

Usage

You need to have an array of data objects, each having an attribute (plotted along each axis) and a numerical value, to create a radar chart. The unique values in the attribute should be 3 or more. If you are making a plot with only one or two unique attributes, it makes more sense with bar charts.

Example:

    ```js
    data = [
      { team: "Marketing", budget: 30000},
      { team: "Sales", budget: 25000},
      { team: "Administration", budget: 40000},
      { team: "R&D", budget: 70000},
      { team: "Human Resources", budget: 20000},
    ]
    ```

Once you have data ready, import radarChart function into your notebook:

      ```js
      import {radarChart} from "@adb/radar-chart"
      ```

In another cell, call the function with data, and provide keys for attribute, and value data points:

      ```js
      radarChart(data, {
        attribute: "team",
        value: "budget",
      })
      ```

If everything went well, you should see the radar chart!

For more advanced options, see the radarChart function code.

Example: Multi-group data

Access to facilities in households of Province 1 and Province 2, Nepal, 2011. Data source.

const sampleChart = radarChart(sampleData, {
  group: "Province",
  attribute: "Attribute",
  value: "Penetration",

  tickFormat: d3.format(".0%"),
  radarCurve: curve
});
display(sampleChart)
/// viewof curve = Inputs.radio(
const curve = view(Inputs.radio(
  new Map([
    ["curveLinearClosed (default)", d3.curveLinearClosed],
    ["curveCardinalClosed", d3.curveCardinalClosed],
    ["curveCatmullRomClosed", d3.curveCatmullRomClosed]
  ]),
  { label: "Curve styles", value: d3.curveLinearClosed }
))
sampleData

The example radar chart is equivalent to this set of bar charts.

display(Plot.plot({
  marginBottom: 120,
  facet: {
    data: sampleData,
    x: "Province"
  },
  color: {
    range: schemeBuReGnGr
  },
  fx: { label: null },
  fy: { label: null },
  x: { tickRotate: -30, label: null },
  y: { grid: true, tickFormat: ".0%", label: null },
  marks: [
    Plot.barY(sampleData, {
      x: "Attribute",
      y: "Penetration",
      fill: "Attribute",
      title: d => `${d.Attribute}\n\$${d.Penetration}`
    })
  ]
}))

Example: Single group data

Access to facilities in households of Province 1, Nepal, 2011. Data source.

display(radarChart(sampleSingleGroupData, {
  attribute: "Attribute",
  value: "Penetration",

  tickFormat: d3.format(".0%"),
  radarCurve: curve
}))
const sampleSingleGroupData = (() => {
  const group = sampleData[0].Province;

  return sampleData.filter((p) => p.Province === group);
})();
display(sampleSingleGroupData)

Implementation

function radarChart(
  data,
  {
    // Required
    attribute = "attribute",
    value = "value",
    group,

    // Optionals
    maxValue = undefined, // will calculate it
    scheme = schemeBuReGnGr,

    width = 600,
    height = width,
    margin,
    marginTop,
    marginRight,
    marginBottom,
    marginLeft,

    fontFamily = "var(--sans-serif, sans-serif)",

    angleOffset = -Math.PI / 2,
    ticks = 3,

    radarStrokeWidth = 2.5,
    radarDotRadius = radarStrokeWidth * 1.25,
    radarFillOpacity = 0.1,
    radarCurve = d3.curveLinearClosed,

    sortGroups,
    sortAttributes,

    gridStroke = main.grey["300"],
    gridStrokeWidth = 1.5,
    tickFontSize = "0.75rem",
    tickFill = main.grey["700"],
    tickFormat = formatTick,

    axisLabelFill = main.grey["1000"],
    axisLabelFontSize = "0.8rem",
    axisLabelFontWeight = "normal",
    axisLabelMaxWidth = 120,

    showLegend = true
  } = {}
) {
  // Access data
  const getGroup = group === null ? null : generateAccessor(group);
  const getValue = generateAccessor(value);
  const getAttribute = generateAccessor(attribute);

  // Compute values
  let G = getGroup ? [...new Set(data.map(getGroup))] : null;
  let A = [...new Set(data.map(getAttribute))];

  G = typeof sortGroups === "function" ? G.slice().sort(sortGroups) : G;
  A = typeof sortAttributes === "function" ? A.slice().sort(sortGroups) : A;

  let V = [];
  G.forEach((g, i) => {
    V[i] = [];
    A.forEach((a, j) => {
      const obj = data.find((d) => g === getGroup(d) && a === getAttribute(d));
      const v = getValue(obj);
      V[i][j] = v == null ? NaN : +v;
    });
  });

  // Construct scales
  marginTop = margin ?? marginTop ?? 0;
  marginRight = margin ?? marginRight ?? 0;
  marginBottom = margin ?? marginBottom ?? 0;
  marginLeft = margin ?? marginLeft ?? 0;

  const w = width - marginLeft - marginRight;
  const h = height - marginTop - marginBottom;
  const maxR = (Math.min(w, h) * 0.5 * 2) / 3;
  maxValue = maxValue || d3.max(data, getValue);
  const radialGridStrokeWidth = Math.max(1.5, gridStrokeWidth / 2);

  const radius = d3.scaleLinear().domain([0, maxValue]).range([0, maxR]);
  const angle = d3
    .scaleBand()
    .domain(A)
    .range([0 + angleOffset, Math.PI * 2 + angleOffset]);

  const color = d3.scaleOrdinal().domain(G).range(scheme);

  // Construct generator
  const radarLine = d3
    .lineRadial()
    .curve(radarCurve)
    .radius((d) => radius(d))
    .angle((_, i) => angle(A[i]) - angleOffset);

  // Draw canvas
  const svg = DOM.svg(width, height);
  const canvas = d3
    .select(svg)
    .style("background", "white")
    .append("g")
    .attr("transform", `translate(${width / 2},${height / 2})`);

  const peripherals = canvas.append("g").attr("class", "peripherals");

  // Add axes
  peripherals
    .selectAll(".axis")
    .data(A)
    .join("line")
    .attr("class", "axis")
    .attr("stroke", gridStroke)
    .attr("stroke-width", gridStrokeWidth)
    .each(function (d, i) {
      const theta = angle(d);
      const [x, y] = getCoordinatesForAngle(
        theta,
        maxR + radialGridStrokeWidth / 2
      );
      d3.select(this).attr("x2", x).attr("y2", y);
    });

  // Add axis labels
  setTimeout(() => {
    // Running within timeout since wrap(), to wrap labels needs to be in DOM to measure width
    peripherals
      .selectAll(".axis-label")
      .data(A)
      .join("text")
      .attr("class", "axis-label")
      .text((d) => d)
      .attr("dominant-baseline", "middle")
      .attr("fill", axisLabelFill)
      .attr("fill-opacity", 1)
      .attr("font-size", axisLabelFontSize)
      .attr("font-weight", axisLabelFontWeight)
      .style("font-family", fontFamily)
      .each(function (d, i) {
        const theta = angle(d);
        const [x, y] = getCoordinatesForAngle(theta, maxR * 1.125);
        d3.select(this)
          .attr("x", x)
          .attr("y", y)
          .style(
            "text-anchor",
            Math.abs(x) < 5 ? "middle" : x > 0 ? "start" : "end"
          );
      })
      .attr("dy", "0em")
      .call(wrap, axisLabelMaxWidth);
  });

  // Add radial ticks
  const radialTicks = radius.ticks(ticks);
  peripherals
    .selectAll(".radial-grid")
    .data(radialTicks)
    .join("circle")
    .attr("class", "radial-grid")
    .attr("r", radius)
    .attr("fill", "none")
    .attr("stroke", gridStroke)
    .attr("stroke-width", radialGridStrokeWidth)
    .attr(
      "stroke-dasharray",
      `${radialGridStrokeWidth} ${radialGridStrokeWidth * 2}`
    );

  peripherals
    .selectAll(".radial-tick")
    .data(radialTicks.slice(1)) // Ignore the zero tick
    .join("text")
    .attr("class", "radial-tick")
    .style("font-size", tickFontSize)
    .style("fill", tickFill)
    .style("font-family", fontFamily)
    .attr("dy", "16px")
    .attr("dx", "4px")
    .attr("y", (d) => -radius(d))
    .text((d) => tickFormat(d));

  // Draw data
  const plot = canvas.append("g").attr("class", "plot");
  const groups = plot
    .selectAll(".group")
    .data(G)
    .join("g")
    .attr("class", "group");

  groups.each(function (group, groupIndex) {
    const groupData = V[groupIndex];
    const positions = groupData.map((value, attrIndex) => {
      const theta = angle(A[attrIndex]);
      return getCoordinatesForAngle(theta, radius(value));
    });

    const g = d3.select(this);

    const radarTitle = `${group ? `${group}\n\n` : ""}${groupData
      .map((v, i) => `${A[i]}: ${tickFormat(v)}`)
      .join("\n")}`;

    // Add radarLine
    g.selectAll(".radar")
      .data([group])
      .join("path")
      .attr("class", "radar")
      .attr("fill", () => color(group))
      .attr("fill-opacity", radarFillOpacity)
      .attr("stroke", () => color(group))
      .attr("d", radarLine(groupData))
      .append("title")
      .text(radarTitle);

    // Add dots
    g.selectAll(".dot")
      .data(positions)
      .join("circle")
      .attr("class", "dot")
      .attr("r", radarDotRadius)
      .attr("fill", () => color(group))
      .attr("cx", (d) => d[0])
      .attr("cy", (d) => d[1])
      .append("title")
      .text(
        (d, i) =>
          `${group ? `${group}\n` : ""}${A[i]}: ${tickFormat(groupData[i])}`
      );
  });

  // Set up interactions
  const radars = plot
    .selectAll(".radar")
    .on("mouseover", function (d, i) {
      d3.selectAll(".radar")
        .transition()
        .duration(200)
        .attr("stroke-opacity", 1 / 2)
        .attr("fill-opacity", 1 / 50);

      d3.select(this)
        .transition()
        .duration(200)
        .attr("stroke-opacity", 1)
        .attr("fill-opacity", 1 / 3);
    })
    .on("mouseout", function (d, u) {
      d3.selectAll(".radar")
        .transition()
        .duration(200)
        .attr("stroke-opacity", 1)
        .attr("fill-opacity", radarFillOpacity);
    });

  if (showLegend && G.length > 1) {
    const key = swatch(color);
    d3.select(svg)
      .append("g")
      .attr("transform", `translate(${marginLeft},${marginTop})`)
      .node()
      .appendChild(key);
  }

  return svg;
}
const formatTick = d3.format(".2s")
function generateAccessor(accessor) {
  return function (obj) {
    if (typeof accessor === "function") return accessor(obj);
    return obj[accessor];
  };
}
function getCoordinatesForAngle(angle, r = 1, offset = 1) {
  return [Math.cos(angle) * r * offset, Math.sin(angle) * r * offset];
}
const sampleData = byProvince
  .flatMap((p) => {
    const selectKeys = [
      "Radio",
      "Television",
      "Computer",
      "Internet",
      "Mobile Phone"
    ];

    const { Province, Total } = p;

    return selectKeys.map((Attribute) => ({
      Province,
      Attribute,
      Penetration: p[Attribute] / Total
    }));
  })
  .filter((p) => ["Province 1", "Province 2"].includes(p.Province));
display(sampleData)

Imports

// To apply base styles when the notebook is downloaded/exported
//substratum({invalidation})
//import {substratum} from "@adb/substratum"
//import { schemeBuReGnGr, main } from "@adb/data-vis-style-guide"
import { schemeBuReGnGr, main } from "/components/data-vis-style-guide.js"
display(schemeBuReGnGr);
display(main);
//import { swatch, wrap } from "@adb/color-legend"
import { swatch, wrap } from "/components/color-legend.js"
display(swatch);
display(wrap);
//import {byProvince} from "@adb/nepal-cbs-2011-census-household-facilities"
import {byProvince} from "/components/nepal-cbs-2011-census-household-facilities.js";
display(byProvince)

Credits and Attributions

By Saneef H. Ansari. Compare implementations from Radar Chart by Rayraegah and Radar Chart by Saneef.