Skip to content

Instantly share code, notes, and snippets.

@Kharms
Created February 28, 2017 20:51
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save Kharms/653a6fe8d705a762dbfa586ba1f8f391 to your computer and use it in GitHub Desktop.
Save Kharms/653a6fe8d705a762dbfa586ba1f8f391 to your computer and use it in GitHub Desktop.
d3 propeller chart.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>propeller dev</title>
<script type="text/javascript" src="https://d3js.org/d3.v3.min.js"></script>
<style type="text/css">
.label{
text-anchor: middle;
alignment-baseline: middle;
font-family:sans-serif;
font-size:14px;
fill:#3f3f3f;
}
.label.inner.percent{
font-size:20px;
}
</style>
</head>
<body>
<script type="text/javascript" src="propeller-chart.js"></script>
</body>
</html>
[{"category":"spaceships", "progress": 40, "goal": 230, "percentProgress": 0.17391304347, "pacePercent":".17"},
{"category":"automobiles", "progress": 80, "goal": 321, "percentProgress": 0.2492211838, "pacePercent":".39"},
{"category":"planes", "progress": 595, "goal": 605, "percentProgress": 0.98347107438, "pacePercent":""},
{"category":"other", "progress": 735, "goal": 1100, "percentProgress": 0.66818181818, "pacePercent":".2"},
{"category":"trains", "progress": 800, "goal": 1380, "percentProgress": 0.57971014492, "pacePercent":".53"}
]
// ___ ___ _ __ / _| (_) __ _
// / __| / _ \ | '_ \ | |_ | | / _` |
// | (__ | (_) | | | | | | _| | | | (_| |
// \___| \___/ |_| |_| |_| |_| \__, |
// |___/
// Bostock margin convention https://bl.ocks.org/mbostock/3019563
var margin = {top: 20, right: 10, bottom: 20, left: 10};
// size/padding
var h = 700 - margin.left - margin.right;
var w = 700 - margin.top - margin.bottom;
var goalBuffer = 1.3;
var padding = 30;
var outerRadius = w / 2.5;
var paddedOuterRadius = outerRadius - padding;
var innerRadius = 60;
var anglePadding = 0.01;
var paceMarkSize = 15;
var pacePercentWindow = .025;
// animation config
var progressToDuration = function(d){
return d.data.percentProgress * 1000 + 300;
};
var goalToDuration = function(d){ //datasetSummary is null at creation, but is filled by call time.
return d.data.goal/datasetSummary.maxGoal * 600;
};
var staggeredDelay = function (d,i, data){
return 100*(data);
};
// formatting CONFIG
var goalType = 'dollar'; //
var formatGoal = function(d) {return d;};
if (goalType == 'dollar'){
formatGoal = d3.format('$.3s'); // 3 digit $, like: # $123 $12.3k or 123k or 12.3M
};
var formatPercent = d3.format('.1%');
var color = d3.scale.category10();
// var paceWindow = .025; // +/- window around pace that counts as acceptable.
// _
// ___ ___ | |_ _ _ _ __
// / __| / _ \ | __| | | | | | '_ \
// \__ \ | __/ | |_ | |_| | | |_) |
// |___/ \___| \__| \__,_| | .__/
// |_|
// SVG + dom structure.
var svg = d3.select("body")
.append("svg")
.attr({
width: w + margin.left + margin.right,
height: h + margin.top + margin.bottom
})
.append("g")
.classed("marginConvention", true)
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Parent Nodes, in render order.
var backgroundParent = svg.append("g")
.classed("chartBackground", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")"); //centered in svg.;
var innerLabelParent = svg.append("g")
.classed("label inner", true) // x,y handled manually.
var outerLabelParent = svg.append("g")
.classed("label outer", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")");
var goalParent = svg.append("g")
.classed("goal", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")");
var paceParent = svg.append("g")
.classed("pace", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")");
var progressParent = svg.append("g")
.classed("progress", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")");
var foregroundParent = svg.append("g")
.classed("chartForeground", true)
.attr("transform", "translate(" + w/2+","+ w/2 +")");
var inOutRadiusRatio = innerRadius / paddedOuterRadius;
var scale;
var datasetSummary;
// functions
var arc = d3.svg.arc() // arc drawer:
.innerRadius(innerRadius)
.outerRadius(outerRadius);
var pie = d3.layout.pie() // pie creates equally sized unsorted slices with small buffer between them.
.value(function(){return 1;})
.padAngle(anglePadding)
.sort(null);
// _
// __| | _ __ __ _ __ __
// / _` | | '__| / _` | \ \ /\ / /
// | (_| | | | | (_| | \ V V /
// \__,_| |_| \__,_| \_/\_/
// draw functions: DATA INDEPENDENT
function drawBackground(){
// drawing: background
console.log("drawingBackground");
backgroundParent.append("defs").append("clipPath")
.attr("id", "outerCircleClipPath")
.append("circle")
.attr({
r: paddedOuterRadius // slightly bigger than max goal.
});
backgroundParent.append("circle")
.classed("outer", true)
.attr("id", "background")
.attr({
r: paddedOuterRadius // slightly bigger than max goal.
})
.attr({
'fill': 'steelblue',
'fill-opacity': .2
});
backgroundParent.append("circle") // space for labels, etc.
.classed("inner", true)
.attr({
r: innerRadius
})
.attr({
'fill': 'white',
// 'stroke': 'green',
'fill-opacity': 1
});
};
function drawInnerLabels(){
console.log("drawing inner labels")
innerLabelParent.append("text")
.classed("category", true)
.attr({
x: d3.round(w/2),
y: d3.round(h/2 - innerRadius * .6)
})
innerLabelParent.append("text")
.classed("percent", true)
.attr({
x: d3.round(w/2),
y: d3.round(h/2 - innerRadius * .15)
})
innerLabelParent.append("text")
.classed("progress", true)
.attr({
x: d3.round(w/2),
y: d3.round(h/2 + innerRadius * .27)
})
innerLabelParent.append("text")
.classed("goal", true)
.attr({
x: d3.round(w/2),
y: d3.round(h/2 + innerRadius * .6)
})
};
//draw functions: DATA DEPENDENT
function drawGoals(pieDataset, datasetSummary){
console.log("drawing goals")
// create goal arc elements..
var goalArcs = goalParent.selectAll("g.arc")
.data(pieDataset) //@DD
.enter()
.append("g")
.classed("arc", true)
.attr("opacity",.4);
arc.outerRadius(innerRadius + 2); // initially draws small arcs.
// draw starting arcs.
goalArcs.append("path")
.attr("fill", function(d, i){
return color(i);
})
.attr("d", arc);
// check if done drawing goals:
var goalDrawCount = 0;
var maybeDrawProgress = function(){
goalDrawCount++;
if (goalDrawCount == pieDataset.length){
// console.log('done drawing goals');
drawProgress(pieDataset, datasetSummary);
drawOuterLabels(pieDataset);
}
};
// now grow goals to size..
// outerRadius = w/3.5;
arc.outerRadius(function(d){
return scale(d.data.goal);
});
goalArcs.selectAll("path")
.transition()
.duration(goalToDuration)
.delay(staggeredDelay)
.each("end", maybeDrawProgress)
.ease("linear")
.attr("d", arc);
};
function drawOuterLabels(pieDataset){
// progress labels
console.log("drawing outer labels")
var progressLabels = outerLabelParent.selectAll("text")
.data(pieDataset) //@DD
.enter()
.append("text")
.classed("percent", true)
.attr("opacity",0);
progressLabels.text(function(d){return formatPercent(d.data.percentProgress);})
.attr({
x: function(d){return d3.round(radianXY(d.progressEndAngle, paddedOuterRadius + padding).x)},
y: function(d){return d3.round(radianXY(d.progressEndAngle, paddedOuterRadius + padding).y)}
})
.transition()
.delay(300)
.duration(progressToDuration)
.attr("opacity", .8);
};
function drawForeground(pieDataset){
console.log('drawing foreground')
var sectionLines = foregroundParent.selectAll("g.line.section")
.data(pieDataset) //@DD
.enter()
.append("g")
.classed("sectionDivider", true)
.attr("opacity",1);
sectionLines.append("line")
.attr({
x1: function(d){return radianXY(d.startAngle, innerRadius).x},
y1: function(d){return radianXY(d.startAngle, innerRadius).y},
x2: function(d){return radianXY(d.startAngle, paddedOuterRadius).x},
y2: function(d){return radianXY(d.startAngle, paddedOuterRadius).y}
})
.style("opacity", 1)
.attr("stroke","white");
};
function drawProgressArcs(pieDataset, datasetSummary){
console.log("drawingProgressArcs")
var progessArcs = progressParent.selectAll("g.arc.progress")
.data(pieDataset.map(function(d){ //@DD
d.endAngle = d.startAngle;
return d;
}))
.enter()
.append("g")
.classed("arc", true)
.attr("opacity", .8);
// draw starting (zero-width) arcs.
progessArcs.append("path")
.attr("fill", function(d, i){
return color(i);
})
.attr("d", arc);
// transition to progress-width arcs.
progessArcs.selectAll("path")
.transition()
.duration(progressToDuration)
.attrTween("d", progressArcTween())
.ease("linear");
// mouseover events
d3.selectAll(".progress .arc")
.on("mouseover",function(d){
// console.log(d);
updateInnerLabels(d.data.category, d.data.percentProgress, d.data.progress, d.data.goal);
d3.select(this).attr("opacity", 1);
})
.on("mouseout",function(d){
// @DD
updateInnerLabels("overall", datasetSummary.percentTotal, datasetSummary.progressTotal, datasetSummary.goalTotal);
d3.select(this).attr("opacity", .8);
});
};
function drawProgressLines(pieDataset){
console.log("drawingProgressLines");
var progressLines = progressParent.selectAll("g.line.progress")
.data(pieDataset) //@DD
.enter()
.append("g")
.classed("line", true)
.attr("opacity",1);
progressLines.append("line")
.attr({
x1: function(d){return radianXY(d.progressEndAngle, innerRadius).x},
y1: function(d){return radianXY(d.progressEndAngle, innerRadius).y},
x2: function(d){return radianXY(d.progressEndAngle, paddedOuterRadius).x},
y2: function(d){return radianXY(d.progressEndAngle, paddedOuterRadius).y}
})
.style("opacity", 0)
.style("stroke",function(d,i){
return color(i);
})
.style("stroke-dasharray","5, 5")
.transition()
.delay(300)
.duration(progressToDuration)
.style("opacity", .5);
};
function drawProgress(pieDataset, datasetSummary){
drawProgressArcs(pieDataset, datasetSummary);
drawProgressLines(pieDataset);
drawPaceMarks(pieDataset);
};
function drawPaceMarks(pieDataset){
console.log("drawing pace marks")
paceParent.attr("clip-path", "url(#outerCircleClipPath)")
var paceToFill = function(d){
if (d.data.percentProgress < (+d.data.pacePercent) - pacePercentWindow){
return "red"
} else if (d.data.percentProgress > (+d.data.pacePercent) + pacePercentWindow){
return "green"
} else {
return "orange" }};
var paceMarks = paceParent.selectAll("polygon")
.data(pieDataset) //@DD
.enter()
.append("polygon")
.classed("paceMark", true)
.attr("opacity",0)
.attr("fill", paceToFill);
var paceMarkPolygon = function(d){
var aPoint = [radianXY(d.paceEndAngle-.04, paddedOuterRadius).x, radianXY(d.paceEndAngle-.04, paddedOuterRadius).y].join(",")
var bPoint = [radianXY(d.paceEndAngle+.04, paddedOuterRadius).x, radianXY(d.paceEndAngle+.04, paddedOuterRadius).y].join(",")
var cPoint = [radianXY(d.paceEndAngle, paddedOuterRadius-paceMarkSize).x, radianXY(d.paceEndAngle, paddedOuterRadius-paceMarkSize).y].join(",")
return [aPoint, bPoint, cPoint].join(" "); };
paceMarks.attr("points", paceMarkPolygon)
.transition()
.delay(300)
.duration(progressToDuration)
.attr("opacity",function(d){
if (d.data.pacePercent) {return .6;} else {return 0;}})
};
// _ _
// | |__ ___ | | _ __ ___ _ __
// | '_ \ / _ \ | | | '_ \ / _ \ | '__|
// | | | | | __/ | | | |_) | | __/ | |
// |_| |_| \___| |_| | .__/ \___| |_|
// |_|
// helper functions
function updateInnerLabels(category, percent, progress, goal){
d3.select(".label.inner text.category").text(category);
d3.select(".label.inner text.percent").text(formatPercent(percent));
d3.select(".label.inner text.progress").text(formatGoal(progress));
d3.select(".label.inner text.goal").text("of " + formatGoal(goal));
};
function progressArcTween() {
// interpolates for transition between endAngle (==startAngle) and progressEndAngle, updating endAngle as it goes.
return function(d) {
var newAngle = d.progressEndAngle;
var interpolate = d3.interpolate(d.endAngle, newAngle);
return function(t) {
d.endAngle = interpolate(t);
return arc(d);
};
};
};
function discAreaRadiusScale(outerArea, inOutRadiusRatio, area){
// computes radius that maintains area ratio on disc, given ratio of innerRadius/outerRadius
// use d3.scale.linear to then translate this radius to pixel length.
var rt = inOutRadiusRatio;
var pi = Math.PI;
var innerArea = pi * (outerArea / (pi * (1/Math.pow(rt, 2) - 1)));
var r = Math.sqrt((area + innerArea) / pi);
return r;
};
function rotateXY(x,y, theta){
var newX = Math.cos(theta)*x - Math.sin(theta)*y;
var newY = Math.sin(theta)*x + Math.cos(theta)*y;
return {x: newX, y:newY};
};
function radianXY(radian, radius = 1){ // assumes (0,0) (x,y) center point.
xy = {
x: radius * Math.cos(radian),
y: radius * Math.sin(radian)
};
// console.log(radius)
xy = rotateXY(xy.x, xy.y, Math.PI*1.5); // 0 radians w/d3 is at (0,1) (x,y).
return xy;
};
function summarize(dataset){
var summary = {};
summary.maxGoal = d3.max(dataset, function(d){return d.goal;});
summary.minGoal = d3.min(dataset, function(d){return d.goal;});
summary.progressTotal = d3.sum(dataset, function(d){return d.progress;});
summary.goalTotal = d3.sum(dataset, function(d){return d.goal;});
summary.percentTotal = summary.progressTotal / summary.goalTotal;
return summary;
};
function buildScale(datasetSummary){
var scaledZero = discAreaRadiusScale(datasetSummary.maxGoal, inOutRadiusRatio, 0);
var scaledMaxGoal = discAreaRadiusScale(datasetSummary.maxGoal, inOutRadiusRatio, datasetSummary.maxGoal * goalBuffer);
var linScale = d3.scale.linear()
.domain([scaledZero, scaledMaxGoal])
.range([innerRadius, paddedOuterRadius]);
var scale = function(goal){
var r = discAreaRadiusScale(datasetSummary.maxGoal, inOutRadiusRatio, goal);
r = linScale(r);
return Math.ceil(r); // like range round but better w/ smaller features.
};
return scale;
};
// _ __ _ _ _ __ | |_ (_) _ __ ___ ___
// | '__| | | | | | '_ \ | __| | | | '_ ` _ \ / _ \
// | | | |_| | | | | | | |_ | | | | | | | | | __/
// |_| \__,_| |_| |_| \__| |_| |_| |_| |_| \___|
// data-independent draw function calls.
drawInnerLabels(); // blank labels
drawBackground();
d3.json("progGoal.json", function(error, data) {
if (error) { //If error is not null, something went wrong.
console.log("data load func error", error); //Log the error.
} else {
var pieDataset = pie(data)
.map(function(d){
d.sliceWidth = d.endAngle - d.startAngle;
d.progressEndAngle = d.startAngle + d.sliceWidth * d.data.percentProgress;
d.paceEndAngle = d.startAngle + d.sliceWidth * d.data.pacePercent;
d.sliceEndAngel = d.endAngle;
return d});
datasetSummary = summarize(data);
scale = buildScale(datasetSummary);
drawGoals(pieDataset, datasetSummary); // calls drawProgress when done. which calls drawOuterLabels
updateInnerLabels("overall", datasetSummary.percentTotal, datasetSummary.progressTotal, datasetSummary.goalTotal); //sets to overall.
drawForeground(pieDataset);
};
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment