Supplier Scoring with Radar Graphs

Suppose you’re running an RFP and receive proposals from three suppliers (X, Y, Z). With the award criteria and rubric, along with the evaluation team, you score each supplier on seven key elements:

  1. Price

  2. Quality

  3. Lead Time

  4. Service Level

  5. Finaical Risk

  6. Legal Risk

  7. Sustainability

On a scale of 0-5, with 0 representing no data, 1 being poor, and 5 being excellent, you score each bidder for each criteria. Then you summarize in an ugly spreadsheet table. Sound too familiar?

This radar graph using a polar coordinate system with an azimuthal equidistant projection summarizes suppliers scores in a way that’s a lot more pleasant and readable when comparing a multitude of criteria against a few suppliers. Granted, it does get messy after 3-5 suppliers, so another approach would be recommended.

Plus, this model is super easy to use. All you need a .csv of the criteria, suppliers, and their scores. If it’s already in a spreadsheet, a simple export should suffice. You can view the main source code below and get started with the open source package on your own RFPs!


Plot.plot({
  width: 450,
  projection: {
    type: "azimuthal-equidistant",
    rotate: [0, -90],
    // Adjust the domain for the maximum value of the scale (5) with a bit of margin for labels
    domain: d3.geoCircle().center([0, 90]).radius(5 + 1.7)()
  },
  color: { legend: true },
  marks: [
    // grey discs representing each step on the scale from 1 to 5
    Plot.geo([5, 4, 3, 2, 1], {
      geometry: (r) => d3.geoCircle().center([0, 90]).radius(r)(),
      stroke: "black",
      fill: "black",
      strokeOpacity: 0.3,
      fillOpacity: 0.03,
      strokeWidth: 0.5
    }),

// white axes
Plot.link(longitude.domain(), {
  x1: longitude,
  y1: 90 - 5.5, // This sets the axis lines to extend beyond the 5 scale mark
  x2: 0,
  y2: 90,
  stroke: "white",
  strokeOpacity: 0.5,
  strokeWidth: 2.5
}),

    // tick labels representing each step on the scale from 1 to 5
    Plot.text([1, 2, 3, 4, 5], {
      x: 160,
      y: (d) => 90 - d, // Place the label at the appropriate distance
      dx: 2,
      textAnchor: "start",
      text: (d) => d.toString(),
      fill: "currentColor",
      stroke: "white",
      fontSize: 10
    }),

    // axes labels
    Plot.text(longitude.domain(), {
      x: longitude,
  y: 90 - 6, // Position the labels outside the outermost circle
      text: Plot.identity,
      lineWidth: 5
    }),

    // areas and points should use the actual values instead of scaled percentages
    Plot.area(points, {
      x1: ({ key }) => longitude(key),
      y1: ({ value }) => 90 - value,
      x2: 0,
      y2: 90,
      fill: "name",
      stroke: "name",
      curve: "cardinal-closed"
    }),
    Plot.dot(points, {
      x: ({ key }) => longitude(key),
      y: ({ value }) => 90 - value,
      fill: "name",
      stroke: "white"
    }),
    Plot.text(
      points,
      Plot.pointer({
        x: ({ key }) => longitude(key),
        y: ({ value }) => 90 - value, // Use the actual value for label positioning
        text: (d) => d.value.toString(),
        textAnchor: "start",
        dx: 4,
        fill: "currentColor",
        stroke: "white",
        maxRadius: 10
      })
    ),

    // interactive opacity on the areas
    () =>
      svg`<style>
          g[aria-label=area] path {fill-opacity: 0.1; transition: fill-opacity .2s;}
          g[aria-label=area]:hover path:not(:hover) {fill-opacity: 0.05; transition: fill-opacity .2s;}
          g[aria-label=area] path:hover {fill-opacity: 0.3; transition: fill-opacity .2s;}
      `
  ]
})
Next
Next

Space Supply Chains: The Final Mile Frontier