Last active
April 29, 2019 16:17
-
-
Save thomasdullien/b75fb8f3aaedcef4fc0d5ea2f8d5d00e to your computer and use it in GitHub Desktop.
D3 Diagram to illustrate savings.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>Minimal D3 Example</title> | |
<script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script> | |
<style> | |
:root { | |
--savings-color: #A9C3D0F0; | |
--main-color: #888; | |
--main-color-darker: #333; | |
--main-color-lighter: #AAA; | |
} | |
.service_bar_pre { | |
fill: var(--main-color); | |
stroke-width: 2; | |
stroke: var(--main-color-darker); | |
} | |
.service_bar_post_remainder { | |
fill: var(--main-color); | |
stroke-width: 2; | |
stroke: var(--main-color-darker); | |
} | |
.service_bar_post_savings { | |
fill: var(--savings-color); | |
stroke-width: 2; | |
stroke: #fff; | |
} | |
.service_bar_third_remainder { | |
fill: var(--main-color); | |
stroke-width: 2; | |
stroke: var(--main-color-darker); | |
} | |
.service_bar_third_savings { | |
fill: var(--savings-color); | |
stroke-width: 2; | |
stroke: #fff; | |
} | |
.links_pre_post { | |
stroke-width: 1; | |
stroke: #fff; | |
fill: rgba(0,0,0,0.1); | |
} | |
.links_post_third { | |
stroke-width: 0; | |
fill: lightgrey; | |
opacity: 0.4; | |
} | |
.curly_bracket { | |
stroke-width: 1; | |
fill: var(--main-color-darker); | |
stroke: var(--main-color-darker); | |
shape-rendering: geometricPrecision; | |
} | |
div.tooltip { | |
position: absolute; | |
text-align: justify; | |
width: 80px; | |
height: 30px; | |
padding: 5px; | |
font: 12px sans-serif; | |
background: var(--main-color-lighter); | |
border-radius: 4px; | |
} | |
div.savings { | |
position: absolute; | |
text-align: center; | |
width: 80px; | |
height: 30px; | |
padding: 5px; | |
font: 12px sans-serif; | |
background: var(--main-color-lighter); | |
border-radius: 4px; | |
} | |
.bar:hover { | |
fill: orange; | |
} | |
</style> | |
</head> | |
<body> | |
<script type="text/javascript"> | |
var data2 = { | |
"services": [ | |
{ | |
"name" : "Cassandra", | |
"description" : "A large Cassandra cluster.", | |
"percentage" : 32, | |
"savings" : 29, | |
"optimizations" : [ "More sophisticated use of compression" ] | |
}, | |
{ | |
"name" : "ElasticSearch", | |
"description" : "A large ElasticSearch cluster.", | |
"percentage" : 31, | |
"savings" : 31, | |
"optimizations" : [ "Different use of compression" ] | |
}, | |
{ | |
"name" : "Service A", | |
"description" : "An internet-facing data-ingestion service in Python.", | |
"percentage" : 11, | |
"savings" : 90, | |
"optimizations" : [ "Better CPU utilization", "Vectorized loop", | |
"JIT'ed Regular expressions" ] | |
}, | |
{ | |
"name" : "Service B", | |
"description" : "An internet-facing data-ingestion service in Python.", | |
"percentage" : 11, | |
"savings" : 67, | |
"optimizations" : [ "More efficient lock-handling in core datastructure", | |
"Ensuring JIT'ed execution of the entire code" ] | |
}, | |
{ | |
"name" : "Service C", | |
"description" : "An internet-facing data-ingestion service in a mixture of Python and C.", | |
"percentage" : 11, | |
"savings" : 35, | |
"optimizations" : [ "Improved de-duplication and caching of data", | |
"Reduction of initialization costs by implementing a forkserver" ] | |
}, | |
{ | |
"name" : "Misc. services", | |
"description" : "All other services lumped together.", | |
"percentage" : 4, | |
"savings" : 0, | |
"optimizations" : [] | |
} | |
] | |
}; | |
var total_width = 300; | |
var total_height = 500; | |
var bar_width = 40 / (500.0 / total_width); | |
var big_font_size = total_width / 30; | |
var small_font_size = total_width / 40; | |
// Use the margin convention. | |
var margin = {top: 20, right: 40, bottom: 20, left: 10}; | |
var width = total_width - margin.left - margin.right, | |
height = total_height - margin.top - margin.bottom; | |
var svg = d3.select("body").append("svg") | |
.attr("width", width + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom) | |
.append("g") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
// Div for the tooltips. | |
var div = d3.select("body") | |
.append("div") | |
.attr("class", "tooltip") | |
.style("opacity", 0.2); | |
// Div for the savings. | |
var div2 = d3.select("body") | |
.append("div") | |
.attr("class", "savings") | |
.style("opacity", 1.0); | |
data = data2.services; | |
pre_bar_x_position = 0.25*(width / 5) - (bar_width / 2); | |
post_bar_x_position = 1.25*(width / 5) - (bar_width / 2); | |
third_bar_x_position = 2.25*(width / 5) - (bar_width / 2); | |
legend_bar_x_position = 3.25*(width / 5) - (bar_width / 2); | |
legend_bar_width = total_width - legend_bar_x_position; | |
// For each service, we wish to create 5 rectangles: One on the very left | |
// that represents the service prior to our optimization, two in the middle | |
// that show how our optimization split savings off of the original service, | |
// and two in the far right that are just re-arrangements of the blocks in the | |
// middle to group all the "savings" and all the "leftover" bits. | |
// Helper functions for calculating positions. | |
var pre_bar_height = function(data_item) { | |
fractional_height = data_item.percentage / 100.0; | |
return height * fractional_height; | |
} | |
var post_bar_height_savings = function(data_item) { | |
normal_height = pre_bar_height(data_item); | |
savings = data_item.savings / 100.0; | |
return normal_height * savings; | |
} | |
var post_bar_height_remainder = function(data_item) { | |
normal_height = pre_bar_height(data_item); | |
remain = 1.0 - (data_item.savings / 100.0); | |
return normal_height * remain; | |
} | |
// The code is cleanest if we annotate all the data elements with their x and | |
// y positions before we start. I know that this is not the "d3 way", but a | |
// different attempt yielded rather messy code. | |
pre_y_position = 0; | |
total_savings_height = 0; | |
total_remainder_height = 0; | |
for (var i = 0; i < data.length; ++i) { | |
data[i].pre_y_position = pre_y_position; | |
data[i].post_savings_y_position = pre_y_position; | |
data[i].post_remainder_y_position = pre_y_position + post_bar_height_savings(data[i]); | |
pre_y_position += pre_bar_height(data[i]); | |
total_savings_height += post_bar_height_savings(data[i]); | |
total_remainder_height += post_bar_height_remainder(data[i]); | |
} | |
third_savings_y_position = 0; | |
third_remainder_y_position = total_savings_height; | |
for (var i = 0; i < data.length; ++i) { | |
data[i].third_savings_y_position = third_savings_y_position; | |
data[i].third_remainder_y_position = third_remainder_y_position; | |
third_savings_y_position += post_bar_height_savings(data[i]); | |
third_remainder_y_position += post_bar_height_remainder(data[i]); | |
} | |
total_savings_percentage = Math.round(total_savings_height / (total_savings_height + | |
total_remainder_height) * 100.0); | |
// All position data is calculated. Draw the diagram. | |
// Begin by setting the legend div to be the right size. | |
div.style("left", legend_bar_x_position + margin.left + "px" ) | |
.style("top", (total_savings_height + margin.top) + "px") | |
.style("height", (total_remainder_height) + "px") | |
.style("width", legend_bar_width + "px") | |
.style("font-size", small_font_size + "pt"); | |
// The percentage div. | |
percentage_div_height = big_font_size + 10; | |
div2.style("left", legend_bar_x_position + margin.left + "px" ) | |
.style("top", (((total_savings_height) / 2) + margin.top - percentage_div_height/2 ) + "px") | |
.style("height", percentage_div_height + "px") | |
.style("width", legend_bar_width + "px") | |
.style("font-size", big_font_size + "pt") | |
.html("40% savings!"); | |
// Draw sankey-style curves between the first and second bar chart. | |
// SVG unfortunately does not natively support splines that smoothly interpolate | |
// through control points, only Bezier curves (which are more difficult to | |
// handle). To simplify life, the following code takes a series of points, | |
// interpolates a Catmull-Rom spline through them, then converts the results | |
// to arrays of three points for the path to plot. | |
// Code that interpolates a Catmull-Rom spline through a few points, then emits | |
// the control points for Bezier Curves (SVG Path drawing) to draw them. | |
function catmullRom2bezier(points) { | |
var result = []; | |
for (var i = 0; i < points.length - 1; i++) { | |
var p = []; | |
p.push({ | |
x: points[Math.max(i - 1, 0)].x, | |
y: points[Math.max(i - 1, 0)].y | |
}); | |
p.push({ | |
x: points[i].x, | |
y: points[i].y | |
}); | |
p.push({ | |
x: points[i + 1].x, | |
y: points[i + 1].y | |
}); | |
p.push({ | |
x: points[Math.min(i + 2, points.length - 1)].x, | |
y: points[Math.min(i + 2, points.length - 1)].y | |
}); | |
// Catmull-Rom to Cubic Bezier conversion matrix | |
// 0 1 0 0 | |
// -1/6 1 1/6 0 | |
// 0 1/6 1 -1/6 | |
// 0 0 1 0 | |
var bp = []; | |
bp.push({ | |
x: ((-p[0].x + 6 * p[1].x + p[2].x) / 6), | |
y: ((-p[0].y + 6 * p[1].y + p[2].y) / 6) | |
}); | |
bp.push({ | |
x: ((p[1].x + 6 * p[2].x - p[3].x) / 6), | |
y: ((p[1].y + 6 * p[2].y - p[3].y) / 6) | |
}); | |
bp.push({ | |
x: p[2].x, | |
y: p[2].y | |
}); | |
result.push(bp); | |
} | |
return result; | |
} | |
// Given a series of points, generate a path through them. | |
function makePath(points, initialmove) { | |
var result = ""; | |
if (initialmove) { | |
var result = "M" + points[0].x + "," + points[0].y + " "; | |
} | |
var catmull = catmullRom2bezier(points); | |
for (var i = 0; i < catmull.length; i++) { | |
result += "C" + catmull[i][0].x + "," + catmull[i][0].y + " " + catmull[i][1].x + "," + catmull[i][1].y + " " + catmull[i][2].x + "," + catmull[i][2].y + " "; | |
} | |
return result; | |
} | |
function makeLines(points) { | |
//var result = "M" + points[0].x + "," + points[0].y + " "; | |
result = ""; | |
for (var i = 0; i < points.length-1; ++i) { | |
result += "L" + points[i].x + "," + points[i].y + " " + points[i+1].x + "," + | |
points[i+1].y; | |
} | |
return result; | |
} | |
// Constructs a path between two rectangles in a bar chart, showing flow between | |
// them, Sankey-diagram style. | |
function getSanKeyPath(start, end, start2, end2) { | |
var xcurvature = 0.3; | |
var ycurvature = 0.2; | |
// Interpolate two intermediate points: One close to start on the same y as | |
// the start, and one close to the end with the same y as close to the end. | |
intermediate_point_upper_A = { | |
x : d3.interpolateNumber(start.x, end.x)(xcurvature), | |
y : d3.interpolateNumber(start.y, end.y)(ycurvature) }; | |
intermediate_point_upper_B = { | |
x : d3.interpolateNumber(start.x, end.x)(1-xcurvature), | |
y : d3.interpolateNumber(start.y, end.y)(1-ycurvature) }; | |
intermediate_point_lower_A = { | |
x : d3.interpolateNumber(start2.x, end2.x)(xcurvature), | |
y : d3.interpolateNumber(start2.y, end2.y)(ycurvature) }; | |
intermediate_point_lower_B = { | |
x : d3.interpolateNumber(start2.x, end2.x)(1-xcurvature), | |
y : d3.interpolateNumber(start2.y, end2.y)(1-ycurvature) }; | |
upper_path = [ start, intermediate_point_upper_A, intermediate_point_upper_B, | |
end ]; | |
lower_path = [ start2, intermediate_point_lower_A, intermediate_point_lower_B, | |
end2 ]; | |
// Spline from left upper side to right upper side. | |
path = makePath( upper_path, true ) | |
// Linear segment downward. | |
path += "L" + end.x + "," + end.y + " " + start2.x + "," + start2.y; | |
// Spline from lower right side to lower left side. | |
path += makePath( lower_path, false ) | |
return path; | |
} | |
// Only draw the links on a large-enough screen: | |
if (total_width > 250) { | |
svg.selectAll(".links_pre_post") | |
.data(data) | |
.enter() | |
.append("path") | |
.attr({ | |
class: "links_pre_post", | |
d: function(d) { | |
startpoint = { x : pre_bar_x_position + bar_width, | |
y: d.pre_y_position }; | |
endpoint = { x : post_bar_x_position, | |
y : d.post_remainder_y_position }; | |
startpoint2 = { x : post_bar_x_position, | |
y : d.post_remainder_y_position + post_bar_height_remainder(d) }; | |
endpoint2 = { x : pre_bar_x_position + bar_width, | |
y : d.pre_y_position + pre_bar_height(d) }; | |
return getSanKeyPath(startpoint, endpoint, startpoint2, endpoint2); | |
} | |
}); | |
svg.selectAll(".links_post_third") | |
.data(data) | |
.enter() | |
.append("path") | |
.attr({ | |
class: "links_post_third", | |
d: function(d) { | |
startpoint = { x : post_bar_x_position + bar_width, | |
y: d.post_remainder_y_position }; | |
endpoint = { x : third_bar_x_position, | |
y : d.third_remainder_y_position }; | |
startpoint2 = { x : third_bar_x_position, | |
y : d.third_remainder_y_position + post_bar_height_remainder(d) }; | |
endpoint2 = { x : post_bar_x_position + bar_width, | |
y : d.post_remainder_y_position + post_bar_height_remainder(d) }; | |
return getSanKeyPath(startpoint, endpoint, startpoint2, endpoint2); | |
} | |
}); | |
} | |
// The leftmost bar. | |
legend_for_initial_service = function(d) { | |
result = "<h3 style=\"text-align: center\">" + d.name + "</h3>" + | |
"<h4 style=\"text-align: center\">" + d.description + "</h4>" + | |
d.percentage + "% of the total cloud spending " + | |
"was used for this service before the project started."; | |
return result; | |
} | |
legend_for_shrunk_service = function(d) { | |
result = "<h3 style=\"text-align: center\">" + d.name + "</h3>" + | |
"<h4 style=\"text-align: center\">" + d.description + "</h4>" + | |
"The cost of this service was shrunk by " + d.savings + "% through our " + | |
"optimizations.<br>"; | |
if (d.optimizations.length > 0) { | |
result += "The following optimizations were performed: <ul>"; | |
for (i = 0; i < d.optimizations.length; ++i) { | |
result += "<li>" + d.optimizations[i] + "</li>"; | |
} | |
result += "</ul>"; | |
} | |
return result; | |
} | |
legend_for_third_column = function(d) { | |
result = "<h3 style=\"text-align: center\">" + d.name + "</h3>" + | |
"<h4 style=\"text-align: center\">" + d.description + "</h4>" + | |
"After our optimizations, the service used " + (100.0-d.savings) + "% of " + | |
"the original cost."; | |
return result; | |
} | |
legend_for_third_column_savings = function(d) { | |
result = "<h3 style=\"text-align: center\">" + d.name + "</h3>" + | |
"<h4 style=\"text-align: center\">" + d.description + "</h4>" + | |
"Our optimizations reduced the cost of the service by " + d.savings + "%." | |
return result; | |
} | |
svg.selectAll(".service_bar_pre") | |
.data(data) | |
.enter() | |
.append("rect") | |
.attr({ | |
class : "service_bar_pre", | |
width : bar_width, | |
height: pre_bar_height, | |
y : function(d) { return d.pre_y_position; }, | |
x : pre_bar_x_position | |
}) | |
.on("mouseover", function(d) { | |
div.transition() | |
.duration(500) | |
.style("opacity", 0); | |
div.transition() | |
.duration(200) | |
.style("opacity", 1); | |
div.html( | |
legend_for_initial_service(d)); | |
}) | |
.on("mouseout", function(d) { | |
div.transition() | |
.duration(200) | |
.style("opacity", 0.2); | |
div.html("") | |
}); | |
// The middle bar. Draw the savings first. | |
svg.selectAll(".service_bar_post_savings") | |
.data(data) | |
.enter() | |
.append("rect") | |
.attr({ | |
class : "service_bar_post_savings", | |
width : bar_width, | |
height: post_bar_height_savings, | |
y : function(d) { return d.post_savings_y_position; }, | |
x : post_bar_x_position | |
}) | |
.on("mouseover", function(d) { | |
div.transition() | |
.duration(500) | |
.style("opacity", 0); | |
div.transition() | |
.duration(200) | |
.style("opacity", 1); | |
div.html( | |
legend_for_shrunk_service(d)); | |
}) | |
.on("mouseout", function(d) { | |
div.transition() | |
.duration(200) | |
.style("opacity", 0.2); | |
div.html(""); | |
}); | |
// Now draw the remaining costs. | |
svg.selectAll(".service_bar_post_remainder") | |
.data(data) | |
.enter() | |
.append("rect") | |
.attr({ | |
class : "service_bar_post_remainder", | |
width : bar_width, | |
height: post_bar_height_remainder, | |
y : function(d) { return d.post_remainder_y_position; }, | |
x : post_bar_x_position | |
}) | |
.on("mouseover", function(d) { | |
div.transition() | |
.duration(500) | |
.style("opacity", 0); | |
div.transition() | |
.duration(200) | |
.style("opacity", 1); | |
div.html( | |
legend_for_shrunk_service(d)); | |
}) | |
.on("mouseout", function(d) { | |
console.log("mouseout"); | |
div.transition() | |
.duration(200) | |
.style("opacity", 0.2); | |
}); | |
// The rightmost bar. | |
// Draw the savings first. | |
svg.selectAll(".service_bar_third_savings") | |
.data(data2.services) | |
.enter() | |
.append("rect") | |
.attr({ | |
class : "service_bar_third_savings", | |
width : bar_width, | |
height: post_bar_height_savings, | |
y : function(d) { return d.third_savings_y_position; }, | |
x : third_bar_x_position | |
}) | |
.on("mouseover", function(d) { | |
div.transition() | |
.duration(500) | |
.style("opacity", 0); | |
div.transition() | |
.duration(200) | |
.style("opacity", 1); | |
div.html( | |
legend_for_third_column_savings(d)); | |
}) | |
.on("mouseout", function(d) { | |
div.transition() | |
.duration(200) | |
.style("opacity", 0.2); | |
div.html(""); | |
}); | |
// Draw the remainders. | |
svg.selectAll(".service_bar_third_remainder") | |
.data(data2.services) | |
.enter() | |
.append("rect") | |
.attr({ | |
class : "service_bar_third_remainder", | |
width : bar_width, | |
height: post_bar_height_remainder, | |
y : function(d) { return d.third_remainder_y_position; }, | |
x : third_bar_x_position | |
}) | |
.on("mouseover", function(d) { | |
div.transition() | |
.duration(500) | |
.style("opacity", 0); | |
div.transition() | |
.duration(200) | |
.style("opacity", 1); | |
div.html( | |
legend_for_third_column(d)); | |
}) | |
.on("mouseout", function(d) { | |
div.transition() | |
.duration(200) | |
.style("opacity", 0.2); | |
div.html("") | |
}); | |
// Code to draw legends, popups, and so forth. | |
// A function to draw a curly bracket path. Returns a path for | |
// a curly brace between x1,y1 and x2,y2, w pixels wide | |
// and q factor, .5 is normal, higher q = more expressive bracket. | |
function makeCurlyBraceHalf(x1, y1, x2, y2, thickness, curviness) { | |
// The midpoint between the target and the start point is where the linear | |
// segment will live. This will be the "midpoint" of the line (in terms of | |
// thickness. | |
linear_segment_x = d3.interpolateNumber(x1, x2)(0.5); | |
linear_segment_x_right = linear_segment_x + thickness / 2; | |
linear_segment_x_left = linear_segment_x - thickness / 2; | |
// The y position of the start of the linear segment is dictated by the | |
// curviness parameter. | |
linear_segment_start_y = d3.interpolateNumber(y1, y2)(curviness); | |
linear_segment_end_y = d3.interpolateNumber(y1, y2)(1-curviness); | |
linear_segment_right_start = { x : linear_segment_x_right, | |
y : linear_segment_start_y }; | |
linear_segment_right_end = { x : linear_segment_x_right, | |
y : linear_segment_end_y }; | |
linear_segment_left_start = { x : linear_segment_x_left, | |
y: linear_segment_end_y }; | |
linear_segment_left_end = { x : linear_segment_x_left, | |
y: linear_segment_start_y }; | |
// Now interpolating spline points for the angled segments are needed. | |
interp_right = { | |
x : d3.interpolateNumber(x1, linear_segment_x_right)(1-curviness), | |
y : d3.interpolateNumber(y1, linear_segment_start_y)(curviness) } | |
interp_left = { | |
x : d3.interpolateNumber(x1, linear_segment_x_left)(1-curviness), | |
y : d3.interpolateNumber(y1, linear_segment_start_y)(curviness) } | |
start_spline_right = { x : x1, y : y1 } | |
end_spline_right = { x : linear_segment_x_right, y : linear_segment_start_y } | |
start_spline_left = { x : linear_segment_x_left, y : linear_segment_start_y } | |
end_spline_left = start_spline_right; | |
spline_right = makePath( [start_spline_right, interp_right, end_spline_right ], true); | |
spline_left = makePath( [start_spline_left, interp_left, end_spline_left ], false) | |
interp_target_right = { | |
x : d3.interpolateNumber( linear_segment_x_right, x2 )(curviness), | |
y : d3.interpolateNumber( linear_segment_end_y, y2 )(1-curviness), | |
} | |
interp_target_left = { | |
x : d3.interpolateNumber( linear_segment_x_left, x2 )(curviness), | |
y : d3.interpolateNumber( linear_segment_end_y, y2) (1-curviness) | |
} | |
lower_spline_right = makePath( | |
[ linear_segment_right_end , interp_target_right, { x : x2, y: y2 }], false); | |
lower_spline_left = makePath( | |
[{ x: x2, y: y2 }, interp_target_left, linear_segment_left_start ]) | |
linear_segment_right = makeLines( | |
[ linear_segment_right_start, linear_segment_right_end ]); | |
linear_segment_left = makeLines( | |
[ linear_segment_left_start, linear_segment_left_end ] ); | |
result = spline_right + linear_segment_right + lower_spline_right + | |
lower_spline_left + linear_segment_left + spline_left; | |
return result; // spline_right + linear_segment_right + spline_left; | |
} | |
function makeCurlyBrace(x1,y1,x2,y2,target_x, target_y, thickness, curviness) { | |
upper_brace = makeCurlyBraceHalf(x1, y1, target_x, target_y, thickness, curviness); | |
lower_brace = makeCurlyBraceHalf(x2, y2, target_x, target_y, thickness, curviness); | |
return upper_brace + lower_brace; | |
} | |
svg.append("g") | |
.append("path") | |
.attr({ | |
class: "curly_bracket", | |
d: makeCurlyBrace(third_bar_x_position + bar_width + 5, 0, third_bar_x_position | |
+ bar_width + 5, total_savings_height, | |
third_bar_x_position + bar_width + (20 / (500.0/width)), d3.interpolateNumber( | |
0, total_savings_height)(0.5), 3, 0.3) | |
}); | |
/* | |
svg.append("g") | |
.append("text") | |
.attr({ | |
class: "percentage_savings", | |
x : third_bar_x_position + bar_width + (35 / (500.0/width)), | |
y : d3.interpolateNumber(0, total_savings_height)(0.5) + (big_font_size/2)} ) | |
.style("font-size", big_font_size + "pt") | |
.style("font-family", "sans-serif") | |
.text( function(d) { return total_savings_percentage + "% savings!"} ); | |
*/ | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment