I'm creating a chart similar to Mike Bostock's zoomable area chart.
For my specific project, I have a bunch of sensors which are recording values every 30 seconds (temperature, light, humidity and sound). I have the zoom implementation working, however when I zoom out to the scale of, say, a year, the density of the chart slows downs the browser and the graphics don't read as well.
How can I edit the script so that the density of the line graph changes relative to the amount of zoom? In other words, the x domain is controlling the number of points on the valueline. I'd like to have full density (a recording every 30 seconds) when I zoom in to a time range of one hour, and I'd like much lower density (a recording every day) when I zoom out. Any ideas? An implementation with the script from the link above would be helpful.
Thanks!
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<script type="text/javascript" src="d3/d3.js"></script>
<script type="text/javascript" src="d3/d3.csv.js"></script>
<script type="text/javascript" src="d3/d3.time.js"></script>
<link type="text/css" rel="stylesheet" href="style.css"/>
<style type="text/css">
svg {
font-size: 10px;
}
.axis {
shape-rendering: crispEdges;
}
.axis path, .axis line {
fill: none;
stroke-width: .5px;
}
.x.axis path {
stroke: #000;
}
.x.axis line {
stroke: #fff;
stroke-opacity: .5;
}
.y.axis line {
stroke: #ddd;
}
path.line {
fill: none;
stroke: #000;
stroke-width: .5px;
}
rect.pane {
cursor: move;
fill: none;
pointer-events: all;
}
</style>
</head>
<body>
<div id="body">
<div id="footer">
<span>…</span>
<div class="hint">mousewheel to zoom, drag to pan</div>
</div>
</div>
<script type="text/javascript">
var m = [79, 80, 160, 79],
w = 1280 - m[1] - m[3],
h = 800 - m[0] - m[2],
parse = d3.time.format("%Y-%m-%d").parse,
format = d3.time.format("%Y");
// Scales. Note the inverted domain for the y-scale: bigger is up!
var x = d3.time.scale().range([0, w]),
y = d3.scale.linear().range([h, 0]),
xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(-h, 0).tickPadding(6),
yAxis = d3.svg.axis().scale(y).orient("right").tickSize(-w).tickPadding(6);
// An area generator.
var area = d3.svg.area()
.interpolate("step-after")
.x(function(d) { return x(d.date); })
.y0(y(0))
.y1(function(d) { return y(d.value); });
// A line generator.
var line = d3.svg.line()
.interpolate("step-after")
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.value); });
var svg = d3.select("body").append("svg:svg")
.attr("width", w + m[1] + m[3])
.attr("height", h + m[0] + m[2])
.append("svg:g")
.attr("transform", "translate(" + m[3] + "," + m[0] + ")");
var gradient = svg.append("svg:defs").append("svg:linearGradient")
.attr("id", "gradient")
.attr("x2", "0%")
.attr("y2", "100%");
gradient.append("svg:stop")
.attr("offset", "0%")
.attr("stop-color", "#fff")
.attr("stop-opacity", .5);
gradient.append("svg:stop")
.attr("offset", "100%")
.attr("stop-color", "#999")
.attr("stop-opacity", 1);
svg.append("svg:clipPath")
.attr("id", "clip")
.append("svg:rect")
.attr("x", x(0))
.attr("y", y(1))
.attr("width", x(1) - x(0))
.attr("height", y(0) - y(1));
svg.append("svg:g")
.attr("class", "y axis")
.attr("transform", "translate(" + w + ",0)");
svg.append("svg:path")
.attr("class", "area")
.attr("clip-path", "url(#clip)")
.style("fill", "url(#gradient)");
svg.append("svg:g")
.attr("class", "x axis")
.attr("transform", "translate(0," + h + ")");
svg.append("svg:path")
.attr("class", "line")
.attr("clip-path", "url(#clip)");
svg.append("svg:rect")
.attr("class", "pane")
.attr("width", w)
.attr("height", h)
.call(d3.behavior.zoom().on("zoom", zoom));
d3.csv("flights-departed.csv", function(data) {
// Parse dates and numbers.
data.forEach(function(d) {
d.date = parse(d.date);
d.value = +d.value;
});
// Compute the maximum price.
x.domain([new Date(1999, 0, 1), new Date(2003, 0, 0)]);
y.domain([0, d3.max(data, function(d) { return d.value; })]);
// Bind the data to our path elements.
svg.select("path.area").data([data]);
svg.select("path.line").data([data]);
draw();
});
function draw() {
svg.select("g.x.axis").call(xAxis);
svg.select("g.y.axis").call(yAxis);
svg.select("path.area").attr("d", area);
svg.select("path.line").attr("d", line);
d3.select("#footer span").text("U.S. Commercial Flights, " + x.domain().map(format).join("-"));
}
function zoom() {
d3.event.transform(x); // TODO d3.behavior.zoom should support extents
draw();
}
</script>
</body>
</html>
Lars and Ari were correct, this was definitely not a trivial problem. But I thought it was an important one, of use to many people (including probably myself in the future), and therefore worth the time to figure out.
So you can follow along, here's my adaption of Mike Bostock's daily flight count graphic, such that it graphs weekly / monthly / annual average daily flight counts when zoomed out (instead of the individual days), and only graphs the subset of data that can be shown at any zoom level:
https://jsfiddle.net/ncy5J/2/
Here's the step-by-step breakdown of what I had to do:
Get the very large csv data table working as an embedded variable in the JSFiddle script. I'm assuming you won't be doing it this way, but I mention it because it was a hassle. Had to add a \n\
at the end of every line before I could run d3.csv.parse()
on the string.
Create alternate data arrays for weeks, months and years, and calculate the average daily values for these time periods:
Use d3.nest
with a key function that uses d3's interval
.floor()
functions to get all the dates from the same year, month, etc. to nest together;
Use Array.forEach
on the nested arrays with a custom function to access the array of nested objects, calculate the mean of their values, and then replace the object created by nest()
with an object that matches the format of the original data (code below).
Move the data-binding step from initialization to the re-draw function, and change that function to accept a data array as a parameter.
Update the d3.behavior.zoom
methods to match D3 version 3 API (the original example used v2.4, which had different methods for linking a zoom behaviour to a scale).
Change the zoom
function that is called by the zoom behaviour to
Access the visible data domain from the x scale (which is automatically updated by the zoom behavior);
Calculate the span of time covered by that domain;
Select one of my four data arrays that will have appropriate level of precision for that span;
Scan through the array to find the section of elements that are in the visible domain (as I mention in the code comments, this will be a little slow when you're zoomed all the way in; you could also use date-time math to figure out the right section of the array since the time interval between successive elements is always the same).
Call the re-draw function with the appropriate slice of the array as the passed-in data.
Here's the custom nesting/averaging routine from step 2:
AllData.yearly = d3.nest().key(function(d){
return d3.time.year.floor(d.date);
})
.entries(data);
AllData.yearly.forEach(meanDaily);
function meanDaily(nestedObject, i, array){
//This function is passed to the Array.forEach() method
//which passes in:
// (1) the element in the array
// (2) the element's index, and
// (3) the array as a whole
//It replaces the element in the array
//(an object with properties key and values, where
// values is an array of nested objects)
//with a single object that has the nesting key
//value parsed back into a date,
//and the mean of the nested values' value
//as the value. It makes sense, I swear.
array[i] = {date:new Date(nestedObject.key),
value:d3.mean(nestedObject.values,
function(d) {return d.value;}
)};
}
The zoom method is just basic javascript, the key part is that you can access the visible domain directly from the x-scale, and then use that to figure out which data points to pass to the draw function.
P.S. It is interesting to look at the data at the different average scales. The sharp drop in flights after September 11 stands out on daily, weekly, and monthly charts, but disappears from the annual averages. Instead, the annual averages show that 2002 as a whole had lower average daily flights than 2001, a reminder that many people were afraid to fly long after the flight ban was lifted.
Here's an updated solution for D3 v6, running in ObservableHQ (and building on the extremely helpful work in AmeliaBR's earlier answer):
See the ObservableHQ notebook for all the code and explanations. A few key parts that may be useful…
To pick the best data density for the current zoom level, you can find out how wide a single day is (in screen pixels) on the zoomed x scale, and then choose the largest density where a single time unit (day/month/week/year) will stay narrower than a particular pixel width:
// Return largest of "daily", "weekly", "monthly" or "yearly"
// where a single time unit stays narrower than `thresholdPixels`
// on d3 time scale `x`
function bestDensityForScale(x, thresholdPixels=5) {
const dayWidth = x(Date.UTC(2000, 0, 2)) - x(Date.UTC(2000, 0, 1));
const chooseDensity = d3.scaleThreshold()
.domain([dayWidth * 7, dayWidth * 30, dayWidth * 365])
.range(["daily", "weekly", "monthly", "yearly"]);
return chooseDensity(thresholdPixels);
}
You may want to adjust thresholdPixels
to get the desired effect. Think of it as the maximum width of a "flat" section in a low density plot before it gets replaced with higher density data. E.g., if a single month would span more than 5 pixels at current zoom, then switch to weekly data. And if a single week would span 5 pixels, switch to daily.
To compute average daily data for larger time periods, d3.rollups
is helpful:
const weekly = d3.rollups(
data,
v => d3.mean(v, d => d.value),
d => d3.timeWeek.floor(d.date)
).map(([date, value]) => ({date, value}));
(The map
at the end changes the arrays returned by rollups
—['2010-01-01', 197062]
—back to the same object format as the daily data—{date: '2010-01-01', value: 197062}
.)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With