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:
Price
Quality
Lead Time
Service Level
Finaical Risk
Legal Risk
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;} ` ] })