|
|
|
// ___ ___ _ __ / _| (_) __ _ |
|
// / __| / _ \ | '_ \ | |_ | | / _` | |
|
// | (__ | (_) | | | | | | _| | | | (_| | |
|
// \___| \___/ |_| |_| |_| |_| \__, | |
|
// |___/ |
|
|
|
// 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); |
|
}; |
|
}); |