Skip to content

Instantly share code, notes, and snippets.

@dan-delaney
Last active May 10, 2016 11:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dan-delaney/a4a5156b73b0e4726673258e106d8fd6 to your computer and use it in GitHub Desktop.
Save dan-delaney/a4a5156b73b0e4726673258e106d8fd6 to your computer and use it in GitHub Desktop.
Multi-Series Line Chart with color banding, tooltip and legend
license: gpl-3.0
border: no

This D3.js multiseries line chart is constructed from a JSON file storing some performance metrics.

  • Red / Amber / Green (RAG) bands are drawn behind the line chart to provide context
  • Grid lines fill the chart for ease of reading
  • Drop shadow effect added to the line.
  • Common problem of straight lines not showing when drop shadow applied fixed.
  • Tooltips and Legend added
<!DOCTYPE html>
<html>
<head>
<script src="//d3js.org/d3.v3.min.js"></script>
<style>
svg {
font: 10px sans-serif;
}
#chartContainer {
top: 0;
left: 0;
width: 960px;
height: 500px;
}
#chartContainer svg {
margin-top:10px;
}
div.tooltip {
position: absolute;
border: 2px solid #333;
border-radius: 8px;
padding: 5px;
background-color: #fff;
z-index: 2000;
font-family: Helvetica;
font-size: 12px;
}
</style>
</head>
<body>
<div id="chartContainer"></div>
<script type="text/javascript">
//*****************************************************************************************************
// 1) d3.js CHART SETUP
//*****************************************************************************************************
{
var graph_options = {
"TickLabel": "%",
"AmberValue": "40.0",
"RedValue": "20.0",
"Operator": "<",
"AlwaysStartAtZero": true,
"AxisBuffer": 5
};
// set margin, width and height for the graph element of the SVG
// fill chartContainer
var margin = { top: 60, right: 60, bottom: 60, left: 60 },
width = d3.select("#chartContainer").node().offsetWidth - margin.left - margin.right,
height = d3.select("#chartContainer").node().offsetHeight - margin.top - margin.bottom;
// set up the scale for the x and y axis
var x = d3.time.scale()
.range([0, width]),
y = d3.scale.linear()
.range([height, 0]);
var color = d3.scale.category10();
// define if "Green" area of the chart should be filled
var showGreenBand = true;
// define legend box size and space
var legendRectHeight = 1;
var legendRectWidth = 30;
var legendSpacing = 10;
// width of lines
var pathStrokeWidth = "2px";
// tickFormat of the x-Axis, and the positioning of the
// (rotated) tick label
var x_tickFormat = d3.time.format("%H:%M");
var ticks = 24;
var y_tick_y = 10;
var y_tick_x = -30;
// scale and format the x-Axis
var xAxis = d3.svg.axis()
.scale(x)
.orient("bottom")
.ticks(ticks)
.innerTickSize(-height) // shows the grid lines
.outerTickSize(0)
.tickFormat(x_tickFormat);
// scale and format the y-Axis
var yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.innerTickSize(-width) // shows the grid lines
.outerTickSize(0)
.tickFormat(function(d) { return d + graph_options.TickLabel });
// define what data the line will represent
var line = d3.svg.line()
.x(function(d) { return x(d.Time); })
.y(function(d) {
// does not draw lines beyond the axis domain (important if adding trend lines to charts that should only go to 100%)
if (d.Value > y.domain()[0]) {
if (d.Value < y.domain()[1]) {
return y(d.Value);
} else return y(y.domain()[1]);
} else return y(y.domain()[0]);
})
.interpolate("linear");
// append the svg element to the chart container (#chartcontainer)
var svg = d3.select("#chartContainer").append("svg")
.attr("id", "chartSVG")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("version", "1.1")
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// append the tooltip DOM element
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.attr("class", "tooltip")
.style("position", "absolute")
.style("opacity", 0);
}
//*****************************************************************************************************
// 2) d3.js DROP SHADOW FILTERING
//*****************************************************************************************************
{
// filters go in defs element
var defs = svg.append("defs");
var stdDeviation = 3;
// create filter with id #drop-shadow
// height=130% so that the shadow is not clipped
// filterUnits=userSpaceOnUse so that straight lines are also visible
var filter = defs.append("filter")
.attr("id", "drop-shadow")
.attr("height", "130%")
.attr("filterUnits", "userSpaceOnUse")
// SourceAlpha refers to opacity of graphic that this filter will be applied to
// convolve that with a Gaussian with stdDeviation (3px) and store result
// in blur
filter.append("feGaussianBlur")
.attr("in", "SourceAlpha")
.attr("stdDeviation", stdDeviation)
.attr("result", "blur");
// translate output of Gaussian blur to the right and downwards with stdDeviation
// store result in offsetBlur
filter.append("feOffset")
.attr("in", "blur")
.attr("dx", stdDeviation)
.attr("dy", stdDeviation)
.attr("result", "offsetBlur");
// overlay original SourceGraphic over translated blurred opacity by using
// feMerge filter. Order of specifying inputs is important!
var feMerge = filter.append("feMerge");
feMerge.append("feMergeNode")
.attr("in", "offsetBlur")
feMerge.append("feMergeNode")
.attr("in", "SourceGraphic");
}
//*****************************************************************************************************
// 3) CHART COLOR BANDING
//*****************************************************************************************************
{
function pathBandData(yStart, yEnd) {
// check to see if band falls outside of the y-Axis scale, if so, return nothing
if (yStart > y.domain()[1] && yEnd > y.domain()[1]) {
return null;
} else // otherwise, draw the banding box. still check we don't go beyond the y-Axis scale at each point.
{
return "M" + x(x.domain()[0]) + "," + (yStart > y.domain()[1] ? y(y.domain()[1]) : y(yStart)) +
"L" + x(x.domain()[0]) + "," + (yEnd > y.domain()[1] ? y(y.domain()[1]) : y(yEnd)) +
"L" + x(x.domain()[1]) + "," + (yEnd > y.domain()[1] ? y(y.domain()[1]) : y(yEnd)) +
"L" + x(x.domain()[1]) + "," + (yStart > y.domain()[1] ? y(y.domain()[1]) : y(yStart));
}
}
function appendColorBands() {
var GreenValue = y.domain()[0];
var EndValue = y.domain()[1];
if (graph_options.Operator == "<") {
// SWAP RED/GREEN START POINTS IF LESS IS WORSE
GreenValue = y.domain()[1];
EndValue = y.domain()[0];
}
if (showGreenBand) {
svg.append("g").append("path") // GREEN
.attr("d", pathBandData(GreenValue, graph_options.AmberValue))
.style("opacity", 0.1)
.style("stroke", "#005e23")
.style("fill", "#005e23");
}
svg.append("g").append("path")
.attr("d", pathBandData(graph_options.AmberValue, graph_options.RedValue))
.style("opacity", 0.1)
.style("stroke", "#f90")
.style("fill", "#f90");
svg.append("g").append("path")
.attr("d", pathBandData(graph_options.RedValue, EndValue))
.style("opacity", 0.1)
.style("stroke", "#c03")
.style("fill", "#c03");
}
}
//*****************************************************************************************************
// 4) GRAPH GENERATION
//*****************************************************************************************************
d3.json("performance.json", function(error, data) {
// ensure Time and Value are properly treated as numbers
data.forEach(function(d) {
d.Time = +d.Time;
d.Value = +d.Value;
});
// nest the results based on the DriveLetter (e.g.; C:)
data = d3.nest().key(function(d) {
return d.DriveLetter;
}).entries(data);
// findDateBoundary sets the X values so the graphs will always end on a full day
// If param t set to 0, returns midnight, 1 set to start of next day
function findDateBoundary(d, t) {
var dS = new Date(d);
var dT = new Date(dS.getFullYear(), dS.getMonth(), dS.getDate(), 0, 0, 0);
if (t) {
dT.setTime(dT.getTime() + 86400000);
}
return dT;
}
// dateFormat for tooltips
var dateFormat = d3.time.format("%d/%m/%Y %H:%M");
function formatDate(d) {
dF = new Date(d);
return dateFormat(dF);
}
x.domain([
findDateBoundary(d3.min(data, function(d) { return d3.min(d.values, function(d) { return d.Time }) }), 0),
findDateBoundary(d3.max(data, function(d) { return d3.max(d.values, function(d) { return d.Time }) }), 1)
]);
var ydomainMin = graph_options.AlwaysStartAtZero ? 0 : d3.min(data, function(d) { return d3.min(d.values, function(d) { return d.Value }) }) - graph_options.AxisBuffer;
y.domain([ydomainMin, d3.max(data, function(d) { return d3.max(d.values, function(d) { return d.Value }) }) + graph_options.AxisBuffer]);
// append the color bands
// this comes before we append the axis so that it is rendered behind the axis lines
appendColorBands();
// append the x-Axis and draw the text labels. Text labels are positioned according to the
// previously calculated x-Axis ticks, and rotated at -45 degrees
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.style("shape-rendering", "crispEdges")
.call(xAxis)
.selectAll("text")
.attr("y", y_tick_y)
.attr("x", y_tick_x)
.attr("dy", ".35em")
.attr("transform", "rotate(-45)")
.style("text-anchor", "start")
.style("text-rendering", "optimiseSpeed");
// append the y-Axis
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
.style("shape-rendering", "crispEdges");
// ensure all CSS formatting is explicitly applied to all axis elements - prevents buggy rendering and svg can be exported neatly
svg.selectAll(".axis path").style("fill", "none").style("stroke", "#000").style("shape-rendering", "crispEdges");
svg.selectAll(".axis line").style("fill", "none").style("stroke", "#000").style("shape-rendering", "crispEdges");
svg.selectAll(".axis .tick line").style("opacity", 0.2);
svg.selectAll(".axis .tick text").style("font-family", "sans-serif").style("font-size", "10px");
// add title to the graph
var title = svg.append("text")
.attr("x", (width / 2))
.attr("y", 0 - (margin.top))
.attr("text-anchor", "middle")
.style("font-size", "13px")
.style("text-decoration", "none");
// first line (eg; server name)
title.append("tspan")
.attr("x", (width / 2))
.attr("dy", 12)
.text("SERVER1");
// second line (description)
title.append("tspan")
.attr("x", (width / 2))
.attr("dy", 20)
.text("Disk Space Available");
// select the data for the line chart, draw the line, and create tooltip hotspots
{
// selects the data as nested by resource
var DriveLetters = svg.selectAll(".DriveLetter")
.data(data, function(d, i) { return d.key; })
.enter().append("g")
.attr("class", "DriveLetter")
.attr("id", function(d, i) { return "chart-entry-" + i });
// draws the actual line for the chart
DriveLetters.append("path")
.attr("class", "line")
.attr("d", function(d) { return line(d.values); })
.style("stroke", function(d, i) { return color(i); })
.style("fill", "none")
.style("stroke-width", pathStrokeWidth)
.style("filter", "url(#drop-shadow)");
// add tooltip hit circles for each data point
// each circle is drawn with 0 opacity, so that the line is not obscured
var dots = DriveLetters.selectAll("circle")
.data(function(d) { return d.values; })
.enter().append("circle")
.attr("cx", function(d, i) { return x(d.Time); })
.attr("cy", function(d, i) {
if (d.Value > y.domain()[0]) {
if (d.Value < y.domain()[1]) {
return y(d.Value);
} else return y(y.domain()[1]);
} else return y(y.domain()[0]);
})
.attr("r", 3)
.style("opacity", 0)
.on("mouseover", function(d) { // onMouseOver() - expand the circle, set and show the tooltip
d3.select(this).style("opacity", 0.7);
d3.select(this).attr("r", 5); /* expand the point circle to be 5 pixels wide,
so slight movement of the mouse doesn't hide it again */
// set the tooltip text
tooltip.html(d.DriveLetter + " &nbsp;<strong>" + d.Value.toFixed(2) + graph_options.TickLabel + "</strong><br/>" + formatDate(d.Time) + " ")
.style("left", (d3.event.pageX - 100) + "px")
.style("top", (d3.event.pageY + 8) + "px");
// fadeIn the tooltip
tooltip.transition()
.duration(100)
.style("opacity", 0.9)
})
.on("mouseout", function(d) { // onMouseOut() - shrink the circle, hide the tooltip
d3.select(this).style("opacity", 0);
d3.select(this).attr("r", 3); // shrink point circle back to 3px
tooltip.transition()
.duration(200)
.style("opacity", 0); // fadeOut the tooltip
tooltip.style("left", "-9999") // move the tooltip off screen so that we don't obscure the graph circles
});
}
// legend generation
data.forEach(function(d, i) {
// adds the text element with the Drive Letter
svg.append("text")
.attr("class", "legend")
.attr("id", "legend-text-" + i)
.attr("x", width - legendSpacing)
.attr("y", ((legendSpacing * i) * 2) - (margin.top / 2))
.style("fill", function() { return color(i) })
.style("font-family", "sans-serif")
.style("font-size", "10px")
.text(d.key);
// adds line in the same color as used in the graph
var thisItem = d3.select("#legend-text-" + i).node();
var bb = thisItem.getBBox();
var bx = bb.x - bb.width - legendRectWidth;
svg.append("path")
.attr("class", "legend")
.attr("data-legend-key", i)
.attr("data-color", function() { return color(i) })
.attr("d", "M" + (bb.x - legendSpacing - legendRectWidth) + "," + (bb.y + bb.height / 2) + " L" + (bb.x - legendSpacing) + "," + (bb.y + bb.height / 2))
.style("stroke", function() { return color(i) })
.style("stroke-width", "2px")
.style("fill", "none")
.attr("height", legendRectHeight)
.attr("width", legendRectWidth)
.style("filter", "url(#drop-shadow)");
});
});
</script>
</body>
</html>
[
{
"DriveLetter": "C:",
"Time": 1457050165000,
"Value": "15"
},{
"DriveLetter": "E:",
"Time": 1457050165000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457053765000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457057365000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457060965000,
"Value": "35"
}, {
"DriveLetter": "E:",
"Time": 1457064565000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457068165000,
"Value": "75"
}, {
"DriveLetter": "E:",
"Time": 1457071765000,
"Value": "65"
}, {
"DriveLetter": "E:",
"Time": 1457075365000,
"Value": "25"
}, {
"DriveLetter": "E:",
"Time": 1457078965000,
"Value": "35"
}, {
"DriveLetter": "E:",
"Time": 1457082565000,
"Value": "65"
}, {
"DriveLetter": "E:",
"Time": 1457086165000,
"Value": "65"
}, {
"DriveLetter": "C:",
"Time": 1457089765000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457093365000,
"Value": "33"
}, {
"DriveLetter": "E:",
"Time": 1457096965000,
"Value": "43"
}, {
"DriveLetter": "E:",
"Time": 1457100565000,
"Value": "55"
}, {
"DriveLetter": "E:",
"Time": 1457104165000,
"Value": "50"
}, {
"DriveLetter": "E:",
"Time": 1457107765000,
"Value": "12"
}, {
"DriveLetter": "E:",
"Time": 1457111365000,
"Value": "44"
}, {
"DriveLetter": "C:",
"Time": 1457114965000,
"Value": "34"
}, {
"DriveLetter": "E:",
"Time": 1457118565000,
"Value": "45"
}, {
"DriveLetter": "E:",
"Time": 1457122165000,
"Value": "50"
}, {
"DriveLetter": "E:",
"Time": 1457125765000,
"Value": "40"
}, {
"DriveLetter": "C:",
"Time": 1457129365000,
"Value": "20"
}, {
"DriveLetter": "C:",
"Time": 1457132965000,
"Value": "60"
}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment