Expanding on mbostock's weekday.js and my Weekdays gists by adding adaptive tick mark format and wrapping it all up in a dayselect scale (d3.scale.dayselect
).
View at bl.ocks.org;
Expanding on mbostock's weekday.js and my Weekdays gists by adding adaptive tick mark format and wrapping it all up in a dayselect scale (d3.scale.dayselect
).
View at bl.ocks.org;
date | value | |
---|---|---|
01/5/2014 | 1 | |
02/5/2014 | 2 | |
05/5/2014 | 1 | |
06/5/2014 | 2 | |
08/5/2014 | 2 |
// Expecting a function that can map between non-uniform day scale (weekdays, | |
// for eg.) and a linear scale. The map function needs to return linear values | |
// as output, given a input Date(), respond to .invert() given a value from | |
// linear scale and return a corresponding Date() object, and a .factor | |
// property containing a multplier to convert the linear values to | |
// miliseconds. | |
// | |
// This scale is currently tailored to weekday scale, as the values in | |
// dayselect_time_scaleSteps reflect 5 day weeks. It should be possible to | |
// calculate these values based on the map function, rather than hardcoding | |
// them into the function. | |
// | |
// In theory, it should be possible to use any function that does similar | |
// mapping, for eg. business hours. Let's call it weekhours and each hour of a | |
// business week would be mapped onto a uniform scale just like weekdays. | |
// Performance of this kind of map may be an issue though. | |
d3.scale.dayselect = d3.scale.dayselect = function(mapFunction) { | |
function dayselect_scale(linear, methods, format, mapFunction) { | |
function scale(x) { | |
return linear(x); | |
} | |
function tickMethod(extent, count) { | |
var span = extent[1] - extent[0]; | |
//var target = span / count; | |
var target = span * mapFunction.factor / count; | |
var i = d3.bisect(dayselect_time_scaleSteps, target); | |
/* changing 31536e6 to 22550.4e6, to factor for shorter years */ | |
return i == dayselect_time_scaleSteps.length ? [dayselect_time_scaleLocalMethods.year, dayselect_scale_linearTickRange(extent.map(function(d) { return d / 22550.4e6; }), count)[2]] | |
: !i ? [dayselect_time_scaleMilliseconds, dayselect_scale_linearTickRange(extent, count)[2]] | |
: dayselect_time_scaleLocalMethods[target / dayselect_time_scaleSteps[i - 1] < dayselect_time_scaleSteps[i] / target ? i - 1 : i]; | |
} | |
scale.ticks = function(interval, skip) { | |
var extent = dayselect_scaleExtent(x.domain()); | |
var method = interval == null ? tickMethod(extent, 10) | |
: typeof interval === "number" ? tickMethod(extent, interval) | |
: !interval.range && [{range: interval}, skip]; // assume deprecated range function | |
if (method) interval = method[0], skip = method[1]; | |
//return | |
out = interval.range(mapFunction.invert(extent[0]), mapFunction.invert(+extent[1] + 1), skip < 1 ? 1 : skip); // inclusive upper bound | |
//convert to weekdays | |
return out.map(function(e) { return mapFunction(e); }); | |
} | |
scale.tickFormat = function() { | |
return format; | |
}; | |
scale.copy = function() { | |
return dayselect_scale(linear.copy(), methods, format, mapFunction); | |
}; | |
return d3.rebind(scale, linear, "nice", "domain", "invert", "range", "rangeRound", "interpolate", "clamp"); | |
} | |
/* clean copy from d3, becase we're crossing namespaces */ | |
function dayselect_scale_linearTickRange(domain, m) { | |
if (m == null) m = 10; | |
var extent = dayselect_scaleExtent(domain), | |
span = extent[1] - extent[0], | |
step = Math.pow(10, Math.floor(Math.log(span / m) / Math.LN10)), | |
err = m / span * step; | |
// Filter ticks to get closer to the desired count. | |
if (err <= .15) step *= 10; | |
else if (err <= .35) step *= 5; | |
else if (err <= .75) step *= 2; | |
// Round start and stop values to step interval. | |
extent[0] = Math.ceil(extent[0] / step) * step; | |
extent[1] = Math.floor(extent[1] / step) * step + step * .5; // inclusive | |
extent[2] = step; | |
return extent; | |
} | |
/* clean copy from d3, becase we're crossing namespaces */ | |
function dayselect_scaleExtent(domain) { | |
var start = domain[0], stop = domain[domain.length - 1]; | |
return start < stop ? [start, stop] : [stop, start]; | |
} | |
/* clean copy from d3, becase we're crossing namespaces */ | |
function dayselect_time_scaleDate(t) { | |
return new Date(mapFunction.invert(t)); | |
} | |
var dayselect_time_scaleSteps = [ | |
1e3, // 1-second | |
5e3, // 5-second | |
15e3, // 15-second | |
3e4, // 30-second | |
6e4, // 1-minute | |
3e5, // 5-minute | |
9e5, // 15-minute | |
18e5, // 30-minute | |
36e5, // 1-hour | |
108e5, // 3-hour | |
216e5, // 6-hour | |
432e5, // 12-hour | |
864e5, // 1-day | |
1728e5, // 2-day | |
4320e5, // 1-week // 5 days. original value 6048e5 = 7 days | |
1900.8e6, // 1-month // 22 days is 21 better?. orignal value 2592e6 = 30 days | |
5702.4e6, // 3-month // 66 days. is 63 better?. orignal value 7776e6 = 90 days | |
22550.4e6 // 1-year //261 days. is 260 better?. original value 31536e6 = 365 days | |
]; | |
var dayselect_time_scaleLocalMethods = [ | |
[d3.time.second, 1], | |
[d3.time.second, 5], | |
[d3.time.second, 15], | |
[d3.time.second, 30], | |
[d3.time.minute, 1], | |
[d3.time.minute, 5], | |
[d3.time.minute, 15], | |
[d3.time.minute, 30], | |
[d3.time.hour, 1], | |
[d3.time.hour, 3], | |
[d3.time.hour, 6], | |
[d3.time.hour, 12], | |
[d3.time.day, 1], | |
[d3.time.day, 2], | |
[d3.time.day, 5], //.week, 1 | |
[d3.time.day, 22], //.month, 1 | |
[d3.time.day, 66], //.month, 3 | |
[d3.time.day, 261] //.year, 1 | |
]; | |
function dayselect_time_formatMulti(formats) { | |
var n = formats.length, i = -1; | |
while (++i < n) { | |
formats[i][0] = d3.time.format(formats[i][0]); | |
} | |
return function(date) { | |
date = mapFunction.invert(date); | |
var i = 0, f = formats[i]; | |
while (!f[1](date)) { | |
f = formats[++i]; | |
} | |
return f[0](date); | |
}; | |
} | |
var dayselect_time_scaleLocalFormat = dayselect_time_formatMulti(([ | |
[".%L", function(d) { return d.getMilliseconds(); }], | |
[":%S", function(d) { return d.getSeconds(); }], | |
["%I:%M", function(d) { return d.getMinutes(); }], | |
["%I %p", function(d) { return d.getHours(); }], | |
["%a %d", function(d) { return d.getDay() && d.getDate() != 1; }], | |
["%b %d", function(d) { return d.getDate() != 1; }], | |
["%B", function(d) { return d.getMonth(); }], | |
["%Y", function() { return true; }] | |
])); | |
var dayselect_time_scaleMilliseconds = { | |
range: function(start, stop, step) { return d3.range(Math.ceil(start / step) * step, +stop, step).map(dayselect_time_scaleDate); }, | |
floor: d3.identity, | |
ceil: d3.identity | |
}; | |
dayselect_time_scaleLocalMethods.year = d3.time.year; | |
return dayselect_scale(d3.scale.linear(), dayselect_time_scaleLocalMethods, dayselect_time_scaleLocalFormat, mapFunction); | |
}; |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script src="weekday.js"></script> | |
<script src="dayselect.js"></script> | |
<title>Scale test</title> | |
<style> | |
body { | |
font-family: 'helvetica neue'; | |
font-size: .8em; | |
} | |
.line { | |
fill: none; | |
stroke: black; | |
stroke-width: 1px; | |
} | |
.axis line, | |
.axis path { | |
stroke-width: 1px; | |
stroke: black; | |
fill: none; | |
} | |
.dots circle { | |
fill: #555; | |
fill-opacity: .5; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="chart"></div> | |
<script> | |
var margin = {top: 20, right: 50, bottom: 50, left: 20}, | |
width = 960 - margin.left - margin.right, | |
height = 502 - margin.top - margin.bottom; | |
var parseDate = d3.time.format("%d/%m/%Y").parse; | |
var dayCount = 0; | |
var x = d3.scale.dayselect(weekday) | |
.range([0, width - margin.right]); | |
var y = d3.scale.linear() | |
.range([0, height - margin.left]); | |
var dateFormat = d3.time.format('%a %b %d'); | |
var xAxis = d3.svg.axis() | |
.scale(x) | |
.orient("below"); | |
// .tickFormat(function (d) { return dateFormat(weekday.invert(d)); }); | |
var yAxis = d3.svg.axis() | |
.scale(y) | |
.orient("right"); | |
var line = d3.svg.line() | |
.x(function(d) { return x(d.weekday); }) | |
.y(function(d) { return y(d.value); }); | |
var svg = d3.select("#chart").append("svg") | |
.attr("width", width + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom); | |
d3.csv("data.csv", type, function(error, data) { | |
x.domain(d3.extent(data, function(d) { return d.weekday; })); | |
y.domain(d3.extent(data, function(d) { return parseFloat(d.value); })) | |
svg.append("path") | |
.datum(data) | |
.attr("class", "line") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")") | |
.attr("d", line); | |
svg.append("g") | |
.attr("class", "x axis") | |
.attr("transform", "translate(" + margin.left + "," + (margin.top + height + 20) + ")") | |
.call(xAxis) | |
.selectAll("text") | |
.attr("dy", ".35em"); | |
svg.append("g") | |
.attr("class", "y axis") | |
.attr("transform", "translate(" + (width + 20) + "," + margin.top + ")") | |
.call(yAxis) | |
.selectAll("text") | |
.attr("dy", ".35em"); | |
svg.append("g") | |
.attr("class", "dots") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")") | |
.selectAll("circle") | |
.data(data) | |
.enter() | |
.append("circle") | |
.attr("r", 5) | |
.attr("cx", function(d) { return x(d.weekday); }) | |
.attr("cy", function(d) { return y(d.value); });; | |
}); | |
function type(d) { | |
d.date = parseDate(d.date); | |
d.weekday = weekday(d.date); | |
return d | |
} | |
</script> | |
</body> | |
</html> |
weekday = (function() { | |
cache = {}; | |
// Returns the weekday number for the given date relative to January 1, 1970. | |
function weekday(date) { | |
c = cache[date]; | |
if (c != null) { | |
return c; | |
} | |
var weekdays = weekdayOfYear(date), | |
year = date.getFullYear(); | |
while (--year >= 1970) weekdays += weekdaysInYear(year); | |
cache[date] = weekdays; | |
//if we're looking up a weekend day, make sure we cache the correct weekday | |
if (cache[weekdays] == null) { | |
newDate = new Date(date); | |
offset = newDate.getDay() == 0 ? -2 : newDate.getDay() == 6 ? -1 : 0; | |
if (offset > 0) { | |
date.setDate(date.getDate() + offset); | |
//cache the new date as well | |
cache[newDate] = weekdays; | |
} | |
cache[weekdays] = newDate; | |
} | |
return weekdays; | |
} | |
//multiplier to go from weekday number to miliseconds (javascript timestamp) | |
weekday.factor = 864e5; | |
// Returns the date for the specified weekday number relative to January 1, 1970. | |
weekday.invert = function(weekdays) { | |
c = cache[weekdays]; | |
if (c != null) { | |
return c; | |
} | |
var lookupWeekdays = weekdays; | |
var year = 1970, | |
yearWeekdays; | |
// Compute the year. | |
while ((yearWeekdays = weekdaysInYear(year)) <= weekdays) { | |
++year; | |
weekdays -= yearWeekdays; | |
} | |
// Compute the date from the remaining weekdays. | |
var days = weekdays % 5, | |
day0 = ((new Date(year, 0, 1)).getDay() + 6) % 7; | |
if (day0 + days > 4) days += 2; | |
date = new Date(year, 0, (weekdays / 5 | 0) * 7 + days + 1); | |
cache[date] = lookupWeekdays; | |
cache[lookupWeekdays] = date; | |
return date; | |
}; | |
// Returns the number of weekdays in the specified year. | |
function weekdaysInYear(year) { | |
return weekdayOfYear(new Date(year, 11, 31)) + 1; | |
} | |
// Returns the weekday number for the given date relative to the start of the year. | |
function weekdayOfYear(date) { | |
var days = d3.time.dayOfYear(date), | |
weeks = days / 7 | 0, | |
day0 = (d3.time.year(date).getDay() + 6) % 7, | |
day1 = day0 + days - weeks * 7; | |
return Math.max(0, days - weeks * 2 | |
- (day0 <= 5 && day1 >= 5 || day0 <= 12 && day1 >= 12) // extra saturday | |
- (day0 <= 6 && day1 >= 6 || day0 <= 13 && day1 >= 13)); // extra sunday | |
} | |
return weekday; | |
})(); |