Skip to content

Instantly share code, notes, and snippets.

@micahstubbs
Last active June 14, 2016 22:47
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save micahstubbs/a772306d6fd49874ec92 to your computer and use it in GitHub Desktop.
radar chart with an interactive legend
license: CC0-1.0

this version adds a legend!

to enable the legend symbol to interact with the areas, when you call RadarChart(), specify the field radarChart.js should use to assign an areaName to each area.

you can also specify the legend position as a parameter, where x and y are pixel coordinates offset from an origin at the top left of the parent svg.

var legendPosition = {x: 25, y: 25}

var radarChartOptions {
  areaName: "device",
  legendPosition: legendPosition
  // ...
};

to get the interaction to work, we add a data-driven class to each area that we can later select in the cellover() function that is called when you mouse over the legend symbol.

blobWrapper
	.append("path")
	.attr("class", function(d) {
		return "radarArea" + " " + d[0][areaName].replace(/\s+/g, '') //Remove spaces from the areaName string to make one valid class name
	})
// ...

the legend is created with the delightful d3-legend component from susielu

an iteration on the bl.ock radar chart with smallest area on top by micahstubbs

a further iteration on the bl.ock Radar Chart Redesign created by nbremer, which is described nicely in this blog post

[
{
"key":"iPhone",
"values":[
{
"reason":"Battery Life",
"device":"iPhone",
"value":0.22
},
{
"reason":"Brand",
"device":"iPhone",
"value":0.28
},
{
"reason":"Contract Cost",
"device":"iPhone",
"value":0.29
},
{
"reason":"Design And Quality",
"device":"iPhone",
"value":0.17
},
{
"reason":"Have Internet Connectivity",
"device":"iPhone",
"value":0.22
},
{
"reason":"Large Screen",
"device":"iPhone",
"value":0.02
},
{
"reason":"Price Of Device",
"device":"iPhone",
"value":0.21
},
{
"reason":"To Be A Smartphone",
"device":"iPhone",
"value":0.50
}
]
},
{
"key":"Samsung",
"values":[
{
"reason":"Battery Life",
"device":"Samsung",
"value":0.27
},
{
"reason":"Brand",
"device":"Samsung",
"value":0.16
},
{
"reason":"Contract Cost",
"device":"Samsung",
"value":0.35
},
{
"reason":"Design And Quality",
"device":"Samsung",
"value":0.13
},
{
"reason":"Have Internet Connectivity",
"device":"Samsung",
"value":0.20
},
{
"reason":"Large Screen",
"device":"Samsung",
"value":0.13
},
{
"reason":"Price Of Device",
"device":"Samsung",
"value":0.35
},
{
"reason":"To Be A Smartphone",
"device":"Samsung",
"value":0.38
}
]
},
{
"key":"Nokia Smartphone",
"values":[
{
"reason":"Battery Life",
"device":"Nokia Smartphone",
"value":0.26
},
{
"reason":"Brand",
"device":"Nokia Smartphone",
"value":0.10
},
{
"reason":"Contract Cost",
"device":"Nokia Smartphone",
"value":0.30
},
{
"reason":"Design And Quality",
"device":"Nokia Smartphone",
"value":0.14
},
{
"reason":"Have Internet Connectivity",
"device":"Nokia Smartphone",
"value":0.22
},
{
"reason":"Large Screen",
"device":"Nokia Smartphone",
"value":0.04
},
{
"reason":"Price Of Device",
"device":"Nokia Smartphone",
"value":0.41
},
{
"reason":"To Be A Smartphone",
"device":"Nokia Smartphone",
"value":0.30
}
]
}
]
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/ >
<title>Smoothed D3.js Radar Chart</title>
<!-- Google fonts -->
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,300' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=Raleway' rel='stylesheet' type='text/css'>
<!-- D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-legend/1.3.0/d3-legend.js" charset="utf-8"></script>
<style>
body {
font-family: 'Open Sans', sans-serif;
font-size: 11px;
font-weight: 300;
fill: #242424;
text-align: center;
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 -1px 0 #fff;
cursor: default;
}
.tooltip {
fill: #333333;
}
</style>
</head>
<body>
<div class="radarChart"></div>
<script src="radarChart.js"></script>
<script>
//////////////////////////////////////////////////////////////
//////////////////////// Set-Up //////////////////////////////
//////////////////////////////////////////////////////////////
var margin = {top: 100, right: 100, bottom: 100, left: 100},
legendPosition = {x: 25, y: 25},
width = Math.min(700, window.innerWidth - 10) - margin.left - margin.right,
height = Math.min(width, window.innerHeight - margin.top - margin.bottom - 20);
//////////////////////////////////////////////////////////////
//////////////////// Draw the Chart //////////////////////////
//////////////////////////////////////////////////////////////
var color = d3.scale.ordinal()
.range(["#EDC951","#CC333F","#00A0B0"]);
var radarChartOptions = {
w: width,
h: height,
margin: margin,
legendPosition: legendPosition,
maxValue: 0.5,
wrapWidth: 60,
levels: 5,
roundStrokes: true,
color: color,
axisName: "reason",
areaName: "device",
value: "value"
};
//Load the data and Call function to draw the Radar chart
d3.json("data.json", function(error, data){
RadarChart(".radarChart", data, radarChartOptions);
});
</script>
</body>
</html>
/////////////////////////////////////////////////////////
/////////////// The Radar Chart Function ////////////////
/////////////// Written by Nadieh Bremer ////////////////
////////////////// VisualCinnamon.com ///////////////////
/////////// Inspired by the code of alangrafu ///////////
/////////////////////////////////////////////////////////
function RadarChart(id, data, options) {
var cfg = {
w: 600, //Width of the circle
h: 600, //Height of the circle
margin: {top: 20, right: 20, bottom: 20, left: 20}, //The margins around the circle
legendPosition: {x: 20, y: 20}, // the position of the legend, from the top-left corner of the svg
levels: 3, //How many levels or inner circles should there be drawn
maxValue: 0, //What is the value that the biggest circle will represent
labelFactor: 1.25, //How much farther than the radius of the outer circle should the labels be placed
wrapWidth: 60, //The number of pixels after which a label needs to be given a new line
opacityArea: 0.35, //The opacity of the area of the blob
dotRadius: 4, //The size of the colored circles of each blog
opacityCircles: 0.1, //The opacity of the circles of each blob
strokeWidth: 2, //The width of the stroke around each blob
roundStrokes: false, //If true the area and stroke will follow a round path (cardinal-closed)
color: d3.scale.category10(), //Color function
axisName: "axis",
areaName:"areaName",
value: "value",
sortAreas: true,
};
//Put all of the options into a variable called cfg
if('undefined' !== typeof options){
for(var i in options){
if('undefined' !== typeof options[i]){ cfg[i] = options[i]; }
}//for i
}//if
//Map the fields specified in the configuration
// to the axis and value variables
var axisName = cfg["axisName"],
areaName = cfg["areaName"],
value = cfg["value"];
//Calculate the average value for each area
data.forEach(function(d){
d[value + "Average"] = d3.mean(d.values, function(e){ return e[value] });
})
//Sort the data for the areas from largest to smallest
//by average value as an approximation of actual blob area
//so that that the smallest area is drawn last
//and therefore appears on top
data = data.sort(function(a, b){
var a = a[value + "Average"],
b = b[value + "Average"];
return b - a;
})
//Convert the nested data passed in
// into an array of values arrays
data = data.map(function(d) { return d.values })
//If the supplied maxValue is smaller than the actual one, replace by the max in the data
var maxValue = Math.max(cfg.maxValue, d3.max(data, function(i){
return d3.max(i.map(
function(o){ return o[value]; }
))
}));
var allAxis = (data[0].map(function(d, i){ return d[axisName] })), //Names of each axis
total = allAxis.length, //The number of different axes
radius = Math.min(cfg.w/2, cfg.h/2), //Radius of the outermost circle
Format = d3.format('%'), //Percentage formatting
angleSlice = Math.PI * 2 / total; //The width in radians of each "slice"
//Scale for the radius
var rScale = d3.scale.linear()
.range([0, radius])
.domain([0, maxValue]);
/////////////////////////////////////////////////////////
//////////// Create the container SVG and g /////////////
/////////////////////////////////////////////////////////
//Remove whatever chart with the same id/class was present before
d3.select(id).select("svg").remove();
//Initiate the radar chart SVG
var svg = d3.select(id).append("svg")
.attr("width", cfg.w + cfg.margin.left + cfg.margin.right)
.attr("height", cfg.h + cfg.margin.top + cfg.margin.bottom)
.attr("class", "radar"+id);
//Append a g element
var g = svg.append("g")
.attr("transform", "translate(" + (cfg.w/2 + cfg.margin.left) + "," + (cfg.h/2 + cfg.margin.top) + ")");
/////////////////////////////////////////////////////////
////////// Glow filter for some extra pizzazz ///////////
/////////////////////////////////////////////////////////
//Filter for the outside glow
var filter = g.append('defs').append('filter').attr('id','glow'),
feGaussianBlur = filter.append('feGaussianBlur').attr('stdDeviation','2.5').attr('result','coloredBlur'),
feMerge = filter.append('feMerge'),
feMergeNode_1 = feMerge.append('feMergeNode').attr('in','coloredBlur'),
feMergeNode_2 = feMerge.append('feMergeNode').attr('in','SourceGraphic');
/////////////////////////////////////////////////////////
/////////////// Draw the Circular grid //////////////////
/////////////////////////////////////////////////////////
//Wrapper for the grid & axes
var axisGrid = g.append("g").attr("class", "axisWrapper");
//Draw the background circles
axisGrid.selectAll(".levels")
.data(d3.range(1,(cfg.levels+1)).reverse())
.enter()
.append("circle")
.attr("class", "gridCircle")
.attr("r", function(d, i){return radius/cfg.levels*d;})
.style("fill", "#CDCDCD")
.style("stroke", "#CDCDCD")
.style("fill-opacity", cfg.opacityCircles)
.style("filter" , "url(#glow)");
//Text indicating at what % each level is
axisGrid.selectAll(".axisLabel")
.data(d3.range(1,(cfg.levels+1)).reverse())
.enter().append("text")
.attr("class", "axisLabel")
.attr("x", 4)
.attr("y", function(d){return -d*radius/cfg.levels;})
.attr("dy", "0.4em")
.style("font-size", "10px")
.attr("fill", "#737373")
.text(function(d,i) { return Format(maxValue * d/cfg.levels); });
/////////////////////////////////////////////////////////
//////////////////// Draw the axes //////////////////////
/////////////////////////////////////////////////////////
//Create the straight lines radiating outward from the center
var axis = axisGrid.selectAll(".axis")
.data(allAxis)
.enter()
.append("g")
.attr("class", "axis");
//Append the lines
axis.append("line")
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", function(d, i){ return rScale(maxValue*1.1) * Math.cos(angleSlice*i - Math.PI/2); })
.attr("y2", function(d, i){ return rScale(maxValue*1.1) * Math.sin(angleSlice*i - Math.PI/2); })
.attr("class", "line")
.style("stroke", "white")
.style("stroke-width", "2px");
//Append the labels at each axis
axis.append("text")
.attr("class", "legend")
.style("font-size", "11px")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("x", function(d, i){ return rScale(maxValue * cfg.labelFactor) * Math.cos(angleSlice*i - Math.PI/2); })
.attr("y", function(d, i){ return rScale(maxValue * cfg.labelFactor) * Math.sin(angleSlice*i - Math.PI/2); })
.text(function(d){return d})
.call(wrap, cfg.wrapWidth);
/////////////////////////////////////////////////////////
///////////// Draw the radar chart blobs ////////////////
/////////////////////////////////////////////////////////
//The radial line function
var radarLine = d3.svg.line.radial()
.interpolate("linear-closed")
.radius(function(d) { return rScale(d[value]); })
.angle(function(d,i) { return i*angleSlice; });
if(cfg.roundStrokes) {
radarLine.interpolate("cardinal-closed");
}
//Create a wrapper for the blobs
var blobWrapper = g.selectAll(".radarWrapper")
.data(data)
.enter().append("g")
.attr("class", "radarWrapper");
//Append the backgrounds
blobWrapper
.append("path")
.attr("class", function(d) {
return "radarArea" + " " + d[0][areaName].replace(/\s+/g, '') //Remove spaces from the areaName string to make one valid class name
})
.attr("d", function(d,i) { return radarLine(d); })
.style("fill", function(d,i) { return cfg.color(i); })
.style("fill-opacity", cfg.opacityArea)
.on('mouseover', function (d,i){
console.log("d", d);
console.log("this", this);
//Dim all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", 0.1);
//Bring back the hovered over blob
d3.select(this)
.transition().duration(200)
.style("fill-opacity", 0.7);
})
.on('mouseout', function(){
//Bring back all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", cfg.opacityArea);
});
//Create the outlines
blobWrapper.append("path")
.attr("class", "radarStroke")
.attr("d", function(d,i) { return radarLine(d); })
.style("stroke-width", cfg.strokeWidth + "px")
.style("stroke", function(d,i) { return cfg.color(i); })
.style("fill", "none")
.style("filter" , "url(#glow)");
//Append the circles
blobWrapper.selectAll(".radarCircle")
.data(function(d,i) { return d; })
.enter().append("circle")
.attr("class", "radarCircle")
.attr("r", cfg.dotRadius)
.attr("cx", function(d,i){ return rScale(d[value]) * Math.cos(angleSlice*i - Math.PI/2); })
.attr("cy", function(d,i){ return rScale(d[value]) * Math.sin(angleSlice*i - Math.PI/2); })
.style("fill", function(d,i,j) { return cfg.color(j); })
.style("fill-opacity", 0.8);
/////////////////////////////////////////////////////////
//////// Append invisible circles for tooltip ///////////
/////////////////////////////////////////////////////////
//Wrapper for the invisible circles on top
var blobCircleWrapper = g.selectAll(".radarCircleWrapper")
.data(data)
.enter().append("g")
.attr("class", "radarCircleWrapper");
//Append a set of invisible circles on top for the mouseover pop-up
blobCircleWrapper.selectAll(".radarInvisibleCircle")
.data(function(d,i) { return d; })
.enter().append("circle")
.attr("class", "radarInvisibleCircle")
.attr("r", cfg.dotRadius*1.5)
.attr("cx", function(d,i){ return rScale(d[value]) * Math.cos(angleSlice*i - Math.PI/2); })
.attr("cy", function(d,i){ return rScale(d[value]) * Math.sin(angleSlice*i - Math.PI/2); })
.style("fill", "none")
.style("pointer-events", "all")
.on("mouseover", function(d,i) {
newX = parseFloat(d3.select(this).attr('cx')) - 10;
newY = parseFloat(d3.select(this).attr('cy')) - 10;
tooltip
.attr('x', newX)
.attr('y', newY)
.text(Format(d[value]))
.transition().duration(200)
.style('opacity', 1);
})
.on("mouseout", function(){
tooltip.transition().duration(200)
.style("opacity", 0);
});
//Set up the small tooltip for when you hover over a circle
var tooltip = g.append("text")
.attr("class", "tooltip")
.style("opacity", 0);
/////////////////////////////////////////////////////////
/////////////////// Helper Functions ////////////////////
/////////////////////////////////////////////////////////
//Taken from http://bl.ocks.org/mbostock/7555321
//Wraps SVG text
function wrap(text, width) {
text.each(function() {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.4, // ems
y = text.attr("y"),
x = text.attr("x"),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", x).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", x).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
}
}
});
}//wrap
// on mouseover for the legend symbol
function cellover(d) {
//Dim all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", 0.1);
//Bring back the hovered over blob
d3.select("." + data[d][0][areaName].replace(/\s+/g, ''))
.transition().duration(200)
.style("fill-opacity", 0.7);
}
// on mouseout for the legend symbol
function cellout() {
//Bring back all blobs
d3.selectAll(".radarArea")
.transition().duration(200)
.style("fill-opacity", cfg.opacityArea);
}
/////////////////////////////////////////////////////////
/////////////////// Draw the Legend /////////////////////
/////////////////////////////////////////////////////////
svg.append("g")
.attr("class", "legendOrdinal")
.attr("transform", "translate(" + cfg["legendPosition"]["x"] + "," + cfg["legendPosition"]["y"] + ")");
var legendOrdinal = d3.legend.color()
//d3 symbol creates a path-string, for example
//"M0,-8.059274488676564L9.306048591020996,
//8.059274488676564 -9.306048591020996,8.059274488676564Z"
.shape("path", d3.svg.symbol().type("triangle-up").size(150)())
.shapePadding(10)
.scale(cfg.color)
.labels(cfg.color.domain().map(function(d){
return data[d][0][areaName];
}))
.on("cellover", function(d){ cellover(d); })
.on("cellout", function(d) { cellout(); });
svg.select(".legendOrdinal")
.call(legendOrdinal);
}//RadarChart
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment