Skip to content

Instantly share code, notes, and snippets.

@rkirsling
Last active October 23, 2019 11:01
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 8 You must be signed in to fork a gist
  • Save rkirsling/33a9e350516da54a5d4f to your computer and use it in GitHub Desktop.
Save rkirsling/33a9e350516da54a5d4f to your computer and use it in GitHub Desktop.
Trend Chart (Area + Line)

This "trend chart" shows loading times across releases of a hypothetical web application.

It is a mixture of a line chart and an area chart, with the daily median loading time indicated by the line and percentile ranges indicated by the surrounding areas.

The "lollipops" mark releases of (different parts of) the application.

@import url(//fonts.googleapis.com/css?family=Open+Sans:400,700);
svg {
font: 14px 'Open Sans';
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.axis text {
fill: #000;
}
.axis .tick line {
stroke: rgba(0, 0, 0, 0.1);
}
.area {
stroke-width: 1;
}
.area.outer,
.legend .outer {
fill: rgba(230, 230, 255, 0.8);
stroke: rgba(216, 216, 255, 0.8);
}
.area.inner,
.legend .inner {
fill: rgba(127, 127, 255, 0.8);
stroke: rgba(96, 96, 255, 0.8);
}
.median-line,
.legend .median-line {
fill: none;
stroke: #000;
stroke-width: 3;
}
.legend .legend-bg {
fill: rgba(0, 0, 0, 0.5);
stroke: rgba(0, 0, 0, 0.5);
}
.marker.client .marker-bg,
.marker.client path {
fill: rgba(255, 127, 0, 0.8);
stroke: rgba(255, 127, 0, 0.8);
stroke-width: 3;
}
.marker.server .marker-bg,
.marker.server path {
fill: rgba(0, 153, 51, 0.8);
stroke: rgba(0, 153, 51, 0.8);
stroke-width: 3;
}
.marker path {
fill: none;
}
.legend text,
.marker text {
fill: #fff;
font-weight: bold;
}
.marker text {
text-anchor: middle;
}
function addAxesAndLegend (svg, xAxis, yAxis, margin, chartWidth, chartHeight) {
var legendWidth = 200,
legendHeight = 100;
// clipping to make sure nothing appears behind legend
svg.append('clipPath')
.attr('id', 'axes-clip')
.append('polygon')
.attr('points', (-margin.left) + ',' + (-margin.top) + ' ' +
(chartWidth - legendWidth - 1) + ',' + (-margin.top) + ' ' +
(chartWidth - legendWidth - 1) + ',' + legendHeight + ' ' +
(chartWidth + margin.right) + ',' + legendHeight + ' ' +
(chartWidth + margin.right) + ',' + (chartHeight + margin.bottom) + ' ' +
(-margin.left) + ',' + (chartHeight + margin.bottom));
var axes = svg.append('g')
.attr('clip-path', 'url(#axes-clip)');
axes.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + chartHeight + ')')
.call(xAxis);
axes.append('g')
.attr('class', 'y axis')
.call(yAxis)
.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', 6)
.attr('dy', '.71em')
.style('text-anchor', 'end')
.text('Time (s)');
var legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', 'translate(' + (chartWidth - legendWidth) + ', 0)');
legend.append('rect')
.attr('class', 'legend-bg')
.attr('width', legendWidth)
.attr('height', legendHeight);
legend.append('rect')
.attr('class', 'outer')
.attr('width', 75)
.attr('height', 20)
.attr('x', 10)
.attr('y', 10);
legend.append('text')
.attr('x', 115)
.attr('y', 25)
.text('5% - 95%');
legend.append('rect')
.attr('class', 'inner')
.attr('width', 75)
.attr('height', 20)
.attr('x', 10)
.attr('y', 40);
legend.append('text')
.attr('x', 115)
.attr('y', 55)
.text('25% - 75%');
legend.append('path')
.attr('class', 'median-line')
.attr('d', 'M10,80L85,80');
legend.append('text')
.attr('x', 115)
.attr('y', 85)
.text('Median');
}
function drawPaths (svg, data, x, y) {
var upperOuterArea = d3.svg.area()
.interpolate('basis')
.x (function (d) { return x(d.date) || 1; })
.y0(function (d) { return y(d.pct95); })
.y1(function (d) { return y(d.pct75); });
var upperInnerArea = d3.svg.area()
.interpolate('basis')
.x (function (d) { return x(d.date) || 1; })
.y0(function (d) { return y(d.pct75); })
.y1(function (d) { return y(d.pct50); });
var medianLine = d3.svg.line()
.interpolate('basis')
.x(function (d) { return x(d.date); })
.y(function (d) { return y(d.pct50); });
var lowerInnerArea = d3.svg.area()
.interpolate('basis')
.x (function (d) { return x(d.date) || 1; })
.y0(function (d) { return y(d.pct50); })
.y1(function (d) { return y(d.pct25); });
var lowerOuterArea = d3.svg.area()
.interpolate('basis')
.x (function (d) { return x(d.date) || 1; })
.y0(function (d) { return y(d.pct25); })
.y1(function (d) { return y(d.pct05); });
svg.datum(data);
svg.append('path')
.attr('class', 'area upper outer')
.attr('d', upperOuterArea)
.attr('clip-path', 'url(#rect-clip)');
svg.append('path')
.attr('class', 'area lower outer')
.attr('d', lowerOuterArea)
.attr('clip-path', 'url(#rect-clip)');
svg.append('path')
.attr('class', 'area upper inner')
.attr('d', upperInnerArea)
.attr('clip-path', 'url(#rect-clip)');
svg.append('path')
.attr('class', 'area lower inner')
.attr('d', lowerInnerArea)
.attr('clip-path', 'url(#rect-clip)');
svg.append('path')
.attr('class', 'median-line')
.attr('d', medianLine)
.attr('clip-path', 'url(#rect-clip)');
}
function addMarker (marker, svg, chartHeight, x) {
var radius = 32,
xPos = x(marker.date) - radius - 3,
yPosStart = chartHeight - radius - 3,
yPosEnd = (marker.type === 'Client' ? 80 : 160) + radius - 3;
var markerG = svg.append('g')
.attr('class', 'marker '+marker.type.toLowerCase())
.attr('transform', 'translate(' + xPos + ', ' + yPosStart + ')')
.attr('opacity', 0);
markerG.transition()
.duration(1000)
.attr('transform', 'translate(' + xPos + ', ' + yPosEnd + ')')
.attr('opacity', 1);
markerG.append('path')
.attr('d', 'M' + radius + ',' + (chartHeight-yPosStart) + 'L' + radius + ',' + (chartHeight-yPosStart))
.transition()
.duration(1000)
.attr('d', 'M' + radius + ',' + (chartHeight-yPosEnd) + 'L' + radius + ',' + (radius*2));
markerG.append('circle')
.attr('class', 'marker-bg')
.attr('cx', radius)
.attr('cy', radius)
.attr('r', radius);
markerG.append('text')
.attr('x', radius)
.attr('y', radius*0.9)
.text(marker.type);
markerG.append('text')
.attr('x', radius)
.attr('y', radius*1.5)
.text(marker.version);
}
function startTransitions (svg, chartWidth, chartHeight, rectClip, markers, x) {
rectClip.transition()
.duration(1000*markers.length)
.attr('width', chartWidth);
markers.forEach(function (marker, i) {
setTimeout(function () {
addMarker(marker, svg, chartHeight, x);
}, 1000 + 500*i);
});
}
function makeChart (data, markers) {
var svgWidth = 960,
svgHeight = 500,
margin = { top: 20, right: 20, bottom: 40, left: 40 },
chartWidth = svgWidth - margin.left - margin.right,
chartHeight = svgHeight - margin.top - margin.bottom;
var x = d3.time.scale().range([0, chartWidth])
.domain(d3.extent(data, function (d) { return d.date; })),
y = d3.scale.linear().range([chartHeight, 0])
.domain([0, d3.max(data, function (d) { return d.pct95; })]);
var xAxis = d3.svg.axis().scale(x).orient('bottom')
.innerTickSize(-chartHeight).outerTickSize(0).tickPadding(10),
yAxis = d3.svg.axis().scale(y).orient('left')
.innerTickSize(-chartWidth).outerTickSize(0).tickPadding(10);
var svg = d3.select('body').append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// clipping to start chart hidden and slide it in later
var rectClip = svg.append('clipPath')
.attr('id', 'rect-clip')
.append('rect')
.attr('width', 0)
.attr('height', chartHeight);
addAxesAndLegend(svg, xAxis, yAxis, margin, chartWidth, chartHeight);
drawPaths(svg, data, x, y);
startTransitions(svg, chartWidth, chartHeight, rectClip, markers, x);
}
var parseDate = d3.time.format('%Y-%m-%d').parse;
d3.json('data.json', function (error, rawData) {
if (error) {
console.error(error);
return;
}
var data = rawData.map(function (d) {
return {
date: parseDate(d.date),
pct05: d.pct05 / 1000,
pct25: d.pct25 / 1000,
pct50: d.pct50 / 1000,
pct75: d.pct75 / 1000,
pct95: d.pct95 / 1000
};
});
d3.json('markers.json', function (error, markerData) {
if (error) {
console.error(error);
return;
}
var markers = markerData.map(function (marker) {
return {
date: parseDate(marker.date),
type: marker.type,
version: marker.version
};
});
makeChart(data, markers);
});
});
[
{
"date": "2014-08-01",
"pct05": 5350,
"pct25": 6756,
"pct50": 7819,
"pct75": 9284,
"pct95": 13835
},
{
"date": "2014-08-02",
"pct05": 4439,
"pct25": 5584,
"pct50": 6554,
"pct75": 8016,
"pct95": 12765
},
{
"date": "2014-08-03",
"pct05": 4247,
"pct25": 5419,
"pct50": 6332,
"pct75": 7754,
"pct95": 12236
},
{
"date": "2014-08-04",
"pct05": 3293,
"pct25": 4414,
"pct50": 5191,
"pct75": 6491,
"pct95": 10325
},
{
"date": "2014-08-05",
"pct05": 3942,
"pct25": 5134,
"pct50": 6069,
"pct75": 7501,
"pct95": 11685
},
{
"date": "2014-08-06",
"pct05": 2744,
"pct25": 5508,
"pct50": 6879,
"pct75": 9221,
"pct95": 17239
},
{
"date": "2014-08-07",
"pct05": 1807,
"pct25": 3019,
"pct50": 4119,
"pct75": 5656,
"pct95": 8851
},
{
"date": "2014-08-08",
"pct05": 1855,
"pct25": 3386,
"pct50": 4473,
"pct75": 5915,
"pct95": 10580
},
{
"date": "2014-08-09",
"pct05": 1830,
"pct25": 3202,
"pct50": 4233,
"pct75": 5559,
"pct95": 8930
},
{
"date": "2014-08-10",
"pct05": 1828,
"pct25": 3195,
"pct50": 4304,
"pct75": 5482,
"pct95": 9189
},
{
"date": "2014-08-11",
"pct05": 2246,
"pct25": 3929,
"pct50": 5326,
"pct75": 7077,
"pct95": 11648
},
{
"date": "2014-08-12",
"pct05": 2051,
"pct25": 3662,
"pct50": 4849,
"pct75": 6194,
"pct95": 10078
},
{
"date": "2014-08-13",
"pct05": 1700,
"pct25": 3075,
"pct50": 4068,
"pct75": 5259,
"pct95": 9789
},
{
"date": "2014-08-14",
"pct05": 2161,
"pct25": 3891,
"pct50": 5262,
"pct75": 6924,
"pct95": 11612
},
{
"date": "2014-08-15",
"pct05": 1765,
"pct25": 3190,
"pct50": 4388,
"pct75": 5822,
"pct95": 9433
},
{
"date": "2014-08-16",
"pct05": 2036,
"pct25": 3756,
"pct50": 4775,
"pct75": 6158,
"pct95": 9999
},
{
"date": "2014-08-17",
"pct05": 2079,
"pct25": 3561,
"pct50": 4753,
"pct75": 6124,
"pct95": 9807
},
{
"date": "2014-08-18",
"pct05": 2108,
"pct25": 3576,
"pct50": 4818,
"pct75": 6344,
"pct95": 10235
},
{
"date": "2014-08-19",
"pct05": 2143,
"pct25": 3792,
"pct50": 5073,
"pct75": 6772,
"pct95": 11338
},
{
"date": "2014-08-20",
"pct05": 2086,
"pct25": 3801,
"pct50": 5073,
"pct75": 6688,
"pct95": 12394
},
{
"date": "2014-08-21",
"pct05": 1767,
"pct25": 3253,
"pct50": 4282,
"pct75": 5563,
"pct95": 9167
},
{
"date": "2014-08-22",
"pct05": 1756,
"pct25": 3047,
"pct50": 3950,
"pct75": 5006,
"pct95": 7948
},
{
"date": "2014-08-23",
"pct05": 2123,
"pct25": 3755,
"pct50": 5173,
"pct75": 7243,
"pct95": 12338
},
{
"date": "2014-08-24",
"pct05": 1967,
"pct25": 3404,
"pct50": 4529,
"pct75": 5970,
"pct95": 9897
},
{
"date": "2014-08-25",
"pct05": 1537,
"pct25": 2612,
"pct50": 3394,
"pct75": 4279,
"pct95": 7104
},
{
"date": "2014-08-26",
"pct05": 2182,
"pct25": 3958,
"pct50": 5505,
"pct75": 7642,
"pct95": 12707
},
{
"date": "2014-08-27",
"pct05": 1932,
"pct25": 3366,
"pct50": 4526,
"pct75": 6086,
"pct95": 9930
},
{
"date": "2014-08-28",
"pct05": 1268,
"pct25": 2344,
"pct50": 3256,
"pct75": 4215,
"pct95": 6673
},
{
"date": "2014-08-29",
"pct05": 1225,
"pct25": 2239,
"pct50": 3033,
"pct75": 4111,
"pct95": 7601
},
{
"date": "2014-08-30",
"pct05": 1393,
"pct25": 2432,
"pct50": 3417,
"pct75": 4710,
"pct95": 8798
},
{
"date": "2014-08-31",
"pct05": 1175,
"pct25": 2020,
"pct50": 2768,
"pct75": 3889,
"pct95": 7744
},
{
"date": "2014-09-01",
"pct05": 989,
"pct25": 1655,
"pct50": 2218,
"pct75": 3167,
"pct95": 6018
},
{
"date": "2014-09-02",
"pct05": 1249,
"pct25": 2069,
"pct50": 2738,
"pct75": 3938,
"pct95": 7574
},
{
"date": "2014-09-03",
"pct05": 936,
"pct25": 1510,
"pct50": 1968,
"pct75": 2700,
"pct95": 5215
},
{
"date": "2014-09-04",
"pct05": 1264,
"pct25": 2039,
"pct50": 2657,
"pct75": 3646,
"pct95": 7042
},
{
"date": "2014-09-05",
"pct05": 1305,
"pct25": 2106,
"pct50": 2745,
"pct75": 3766,
"pct95": 7273
},
{
"date": "2014-09-06",
"pct05": 798,
"pct25": 1288,
"pct50": 1678,
"pct75": 2303,
"pct95": 4448
},
{
"date": "2014-09-07",
"pct05": 1314,
"pct25": 2120,
"pct50": 2763,
"pct75": 3791,
"pct95": 7321
},
{
"date": "2014-09-08",
"pct05": 1042,
"pct25": 1681,
"pct50": 2191,
"pct75": 3007,
"pct95": 5806
}
]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Trend Chart (Area + Line)</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
</body>
<script src="http://d3js.org/d3.v3.js"></script>
<script src="app.js"></script>
</html>
Copyright (c) 2014 Ross Kirsling
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
[
{
"date": "2014-08-06",
"type": "Client",
"version": "2.0"
},
{
"date": "2014-08-20",
"type": "Client",
"version": "2.1"
},
{
"date": "2014-08-27",
"type": "Server",
"version": "3.5"
},
{
"date": "2014-09-03",
"type": "Client",
"version": "2.2"
}
]
@evanjfraser
Copy link

Hi there,
First off, thanks for writing this, it was supremely useful!

Secondly, I found a bug in the MakeChart function within app.js (line 196). Basically returning d.pct95 without using parseInt, means that it does a string comparison of numerical values.

This causes problems on graphs that have values with different numbers of digits within them (eg it thinks 964 is bigger than 3800). I forked your gist and edited it as an example here:
https://gist.github.com/evanjfraser/f2fc91ac78f183f8d50a

Thanks again!

@rkirsling
Copy link
Author

Glad you liked it, @evanjfraser!

What you're describing is not a bug in my code though:

  1. Unlike what you've suggested, d3.max doesn't coerce numbers into strings before comparing. You can easily confirm this by trying d3.max([964, 3800], function (d) { return d; });.
  2. In my example data.json, the percentile values are all integers, and d.pct95 in particular gets turned into a float on line 235 of app.js. These values are not turned into strings at any point in app.js.

Sounds like there may be an issue with the data you're using? Otherwise perhaps you've made some logical changes with unintentional effects. Either way, best of luck to you. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment