///////////////////////////////////////////////////////// /////////////// The Radar Chart Function //////////////// /// mthh - 2017 ///////////////////////////////////////// // Inspired by the code of alangrafu and Nadieh Bremer // // (VisualCinnamon.com) and modified for d3 v4 ////////// ///////////////////////////////////////////////////////// const max = Math.max; const sin = Math.sin; const cos = Math.cos; const HALF_PI = Math.PI / 2; const RadarChart = function RadarChart(parent_selector, data, options) { //Wraps SVG text - Taken from http://bl.ocks.org/mbostock/7555321 const 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 const cfg = { w: 600, //Width of the circle h: 600, //Height of the circle margin: {top: 20, right: 20, bottom: 20, left: 20}, //The margins 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.scaleOrdinal(d3.schemeCategory10), //Color function, format: '.2%', unit: '', legend: false }; //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 //If the supplied maxValue is smaller than the actual one, replace by the max in the data // var maxValue = max(cfg.maxValue, d3.max(data, function(i){return d3.max(i.map(function(o){return o.value;}))})); let maxValue = 0; for (let j=0; j < data.length; j++) { for (let i = 0; i < data[j].axes.length; i++) { data[j].axes[i]['id'] = data[j].name; if (data[j].axes[i]['value'] > maxValue) { maxValue = data[j].axes[i]['value']; } } } maxValue = max(cfg.maxValue, maxValue); const allAxis = data[0].axes.map((i, j) => i.axis), //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(cfg.format), //Formatting angleSlice = Math.PI * 2 / total; //The width in radians of each "slice" //Scale for the radius const rScale = d3.scaleLinear() .range([0, radius]) .domain([0, maxValue]); ///////////////////////////////////////////////////////// //////////// Create the container SVG and g ///////////// ///////////////////////////////////////////////////////// const parent = d3.select(parent_selector); //Remove whatever chart with the same id/class was present before parent.select("svg").remove(); //Initiate the radar chart SVG let svg = parent.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"); //Append a g element let 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 let 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 let 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", d => 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", d => -d * radius / cfg.levels) .attr("dy", "0.4em") .style("font-size", "10px") .attr("fill", "#737373") .text(d => Format(maxValue * d / cfg.levels) + cfg.unit); ///////////////////////////////////////////////////////// //////////////////// 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", (d, i) => rScale(maxValue *1.1) * cos(angleSlice * i - HALF_PI)) .attr("y2", (d, i) => rScale(maxValue* 1.1) * sin(angleSlice * i - HALF_PI)) .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", (d,i) => rScale(maxValue * cfg.labelFactor) * cos(angleSlice * i - HALF_PI)) .attr("y", (d,i) => rScale(maxValue * cfg.labelFactor) * sin(angleSlice * i - HALF_PI)) .text(d => d) .call(wrap, cfg.wrapWidth); ///////////////////////////////////////////////////////// ///////////// Draw the radar chart blobs //////////////// ///////////////////////////////////////////////////////// //The radial line function const radarLine = d3.radialLine() .curve(d3.curveLinearClosed) .radius(d => rScale(d.value)) .angle((d,i) => i * angleSlice); if(cfg.roundStrokes) { radarLine.curve(d3.curveCardinalClosed) } //Create a wrapper for the blobs const blobWrapper = g.selectAll(".radarWrapper") .data(data) .enter().append("g") .attr("class", "radarWrapper"); //Append the backgrounds blobWrapper .append("path") .attr("class", "radarArea") .attr("d", d => radarLine(d.axes)) .style("fill", (d,i) => cfg.color(i)) .style("fill-opacity", cfg.opacityArea) .on('mouseover', function(d, i) { //Dim all blobs parent.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', () => { //Bring back all blobs parent.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.axes); }) .style("stroke-width", cfg.strokeWidth + "px") .style("stroke", (d,i) => cfg.color(i)) .style("fill", "none") .style("filter" , "url(#glow)"); // const circles = blobWrapper.selectAll(".radarCircle").data(d => d.axes); // console.log(circles); /* const simulation = d3.forceSimulation(circles) .force("charge", d3.forceManyBody().strength(2)) .force("collision", d3.forceCollide().radius(cfg.dotRadius)) .force('x', d3.forceX().x(function(d,i){ return rScale(d.value) * cos(angleSlice * i - HALF_PI); })) .force('y', d3.forceY().y(function(d,i){ return rScale(d.value) * sin(angleSlice * i - HALF_PI); })) .on('tick', ticked); function ticked { // const circles = blobWrapper.selectAll(".radarCircle").data(d => d.axes) //Append the circles circles.enter() .append("circle") .attr("class", "radarCircle") .attr("r", cfg.dotRadius) .attr("cx", (d,i) => rScale(d.value) * cos(angleSlice * i - HALF_PI)) .attr("cy", (d,i) => rScale(d.value) * sin(angleSlice * i - HALF_PI)) .style("fill", (d) => cfg.color(d.id)) .style("fill-opacity", 0.8) .merge(circles) .attr('cx', function(d) { return d.x }) .attr('cy', function(d) { return d.y }); } // console.log(circles) */ //Append the circles blobWrapper.selectAll(".radarCircle") .data(d => d.axes) .enter() .append("circle") .attr("class", "radarCircle") .attr("r", function(d,i,n) { // console.log(d.axis) // console.log(i) // console.log(n); return cfg.dotRadius }) .attr("cx", (d,i) => rScale(d.value) * cos(angleSlice * i - HALF_PI)) .attr("cy", (d,i) => rScale(d.value) * sin(angleSlice * i - HALF_PI)) .style("fill", (d) => cfg.color(d.id)) .style("fill-opacity", 0.8); // EXPERIMENTAL CODE - NOT YET WORKING /* const simulation = d3.forceSimulation(d => d.axes) .force("charge", d3.forceManyBody().strength(2)) .force("collision", d3.forceCollide().radius(cfg.dotRadius)) .force('x', d3.forceX().x(function(d,i){ return rScale(d.value) * cos(angleSlice * i - HALF_PI); })) .force('y', d3.forceY().y(function(d,i){ return rScale(d.value) * sin(angleSlice * i - HALF_PI); })) .on('tick', ticked); function ticked() { //Append the circles blobWrapper.selectAll(".radarCircle") .data(d => d.axes) .enter() .append("circle") .attr("class", "radarCircle") .attr("r", cfg.dotRadius) .style("fill", (d) => cfg.color(d.id)) .style("fill-opacity", 0.8) .merge(d => d.axes) .attr('cx', function(d) { return d.x }) .attr('cy', function(d) { return d.y }); } */ ///////////////////////////////////////////////////////// //////// Append invisible circles for tooltip /////////// ///////////////////////////////////////////////////////// //Wrapper for the invisible circles on top const 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(d => d.axes) .enter().append("circle") .attr("class", "radarInvisibleCircle") .attr("r", cfg.dotRadius * 1.5) .attr("cx", (d,i) => rScale(d.value) * cos(angleSlice*i - HALF_PI)) .attr("cy", (d,i) => rScale(d.value) * sin(angleSlice*i - HALF_PI)) .style("fill", "none") .style("pointer-events", "all") .on("mouseover", function(d,i) { tooltip .attr('x', this.cx.baseVal.value - 10) .attr('y', this.cy.baseVal.value - 10) .transition() .style('display', 'block') .text(Format(d.value) + cfg.unit); }) .on("mouseout", function(){ tooltip.transition() .style('display', 'none').text(''); }); const tooltip = g.append("text") .attr("class", "tooltip") .attr('x', 0) .attr('y', 0) .style("font-size", "12px") .style('display', 'none') .attr("text-anchor", "middle") .attr("dy", "0.35em"); if (cfg.legend !== false && typeof cfg.legend === "object") { let legendZone = svg.append('g'); let names = data.map(el => el.name); if (cfg.legend.title) { let title = legendZone.append("text") .attr("class", "title") .attr('transform', `translate(${cfg.legend.translateX},${cfg.legend.translateY})`) .attr("x", cfg.w - 70) .attr("y", 10) .attr("font-size", "12px") .attr("fill", "#404040") .text(cfg.legend.title); } let legend = legendZone.append("g") .attr("class", "legend") .attr("height", 100) .attr("width", 200) .attr('transform', `translate(${cfg.legend.translateX},${cfg.legend.translateY + 20})`); // Create rectangles markers legend.selectAll('rect') .data(names) .enter() .append("rect") .attr("x", cfg.w - 65) .attr("y", (d,i) => i * 20) .attr("width", 10) .attr("height", 10) .style("fill", (d,i) => cfg.color(i)); // Create labels legend.selectAll('text') .data(names) .enter() .append("text") .attr("x", cfg.w - 52) .attr("y", (d,i) => i * 20 + 9) .attr("font-size", "11px") .attr("fill", "#737373") .text(d => d); } return svg; }