/**
* For drawing a chart showing when a series of books are set, compared to
* the dates on which they were published.
*
* Requires d3.js v4.
*
* Usage:
*
*
*
*
*
*
*
*
* You can also customise a few things when instantiating the chart. e.g.:
*
* var publishchart = charts.publishchart();
* .barHeight(10)
* .barPadding(5)
* .margin({top:5, bottom:40, left:5, right:5})
*
* A data.json file should be of this form:
*
* [
* {
* "title": "A Dance to the Music of Time",
* "author": "Anthony Powell",
* "subtitle": "[Optional extra text here]",
* "books": [
* {
* "title": "A Question of Upbringing",
* "publish_year": 1951,
* "start_year": 1921,
* "end_year": 1924
* },
* # etc...
* ]
* }
* ]
*
* The books should be in chronological reading order.
*
*
* A note on bar lengths: In a few places we add +1 to the length of a bar. This
* is because by default a bar would go from the start of its start_year to the
* start of its end_year. But we want to count in entire years. e.g. if a book
* starts and ends in 1928, that would result in a bar of 0 length by default.
* So we add 1 to each length to draw in the end_year in full.
*/
;(function() {
'use strict';
window.charts = window.charts || {};
charts.publishchart = function module() {
// Default values that can be overridden:
// Height in pixels of one of the books' bars:
var barHeight = 20;
// Number of pixels between the bars vertically:
var barPadding = 2;
var margin = {top: 20, bottom: 40, left: 10, right: 10};
function chart(selection) {
// Space above the bars for the publish dots.
// The space will be publishDotsSpace * barHeight in pixels.
var publishDotsSpace = 3;
var tooltipFormat = function(d,i) {
var s = '' + d.title + ' (pub. ' + d.publish_year + ')';
s += '
Set in ';
if (d.start_year === d.end_year) {
s += d.start_year;
} else {
s += d.start_year + '–' + d.end_year;
};
return s;
};
selection.each(function(data) {
// SET UP VARIABLES.
var booksData = data[0].books;
// Width/height of svg area:
var totalW;
var totalH;
// Width/height of inner chart area:
var chartW;
var chartH;
// Will be the pixel width/height of a single unit on each axis:
var oneX; // Width of one year.
// Will store the widths of all the bar labels:
var labelWidths = [];
var startYear = d3.min(booksData, function(d) { return d.start_year; });
// See note re bar widths:
var endYear = d3.max([
d3.max(booksData, function(d) { return d.publish_year + 1; }),
d3.max(booksData, function(d) { return d.end_year + 1; })
]);
var mouseOver = function(d,i) {
tooltip.html( tooltipFormat(d) );
tooltip.style('visibility', 'visible');
// Add .hover to all the other elements related to this book.
inner.selectAll(
'.publishchart__bar--'+i+', .publishchart__date--'+i+', .publishchart__fan--'+i+', .publishchart__barlabel--'+i
).classed('hover', true);
};
// What happens when the cursor moves.
var mouseMove = function(d,i) {
var tooltipRect = tooltip
.node().getBoundingClientRect();
var chartRect = d3.select('.publishchart')
.node().getBoundingClientRect();
// Default x position relative to cursor - slightly to the right:
var leftOffset = 15;
if ((event.pageX + tooltipRect.width) > chartRect.right) {
// If there isn't room to position the tooltip to the right,
// position it to the left of the cursor:
leftOffset = - (tooltipRect.width + 10);
};
tooltip
.style('top', (event.pageY+5)+'px')
.style('left',(event.pageX+leftOffset)+'px');
};
// What happens when the cursor leaves.
var mouseOut = function(d,i) {
tooltip.style('visibility', 'hidden');
inner.selectAll(
'.publishchart__bar, .publishchart__date, .publishchart__fan, .publishchart__barlabel'
).classed('hover', false);
};
// CREATE CONTAINER, SCALES AND AXES.
// The tooltip that appears when hovering over a bar or span.
var tooltip = d3.select('body')
.append('div')
.classed('publishchart__tooltip', true)
.style('position', 'absolute')
.style('background', '#fff');
var container = d3.select(this);
var svg = container.append('svg');
// Axes and chart area will be within this.
var inner = svg.append('g')
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.classed('publishchart__inner', true);
// Set up scales.
var xScale = d3.scaleLinear();
var yScale = d3.scaleLinear();
xScale.domain([startYear, endYear]);
yScale.domain([booksData.length, -(publishDotsSpace)]);
// Make axes.
var xAxis = inner.append('g')
.classed('publishchart__axis publishchart__axis--x', true);
render();
window.addEventListener('resize', render);
function render() {
setDimensions();
renderStructure();
renderAxes();
renderTitle();
renderBars();
renderLabels();
renderFans();
renderPublishDates();
};
/**
* Work out how big the entire chart area is.
*/
function setDimensions() {
var rect = container.node().getBoundingClientRect();
var totalBarHeight = barHeight + barPadding;
// Height for the chart area:
// Space for the bars, plus the publish date dots above:
chartH = (booksData.length * totalBarHeight) + (publishDotsSpace * totalBarHeight);
// Total size of the whole thing:
totalW = parseInt(rect.width, 10);
totalH = chartH + margin.top + margin.bottom;
// Width of the chart area:
chartW = totalW - margin.left - margin.right;
// The width of a single year on the x-axis:
oneX = chartW / (endYear - startYear);
};
/**
* Draw chart to correct dimensions.
*/
function renderStructure() {
svg.transition().attr('width', totalW)
.attr('height', totalH);
};
function renderAxes() {
renderXAxes();
renderYAxis();
};
function renderXAxes() {
xScale.range([0, chartW]);
// The x-axis we use to position things has no ticks:
xAxis.call(
d3.axisTop(xScale)
.tickFormat(d3.format("d"))
.tickSize(0)
);
// Move the x-axis labels to the right by half-a-year,
// so that they're in the middle of the year.
xAxis.selectAll('text')
.attr('transform', 'translate(' + oneX / 2 + ',0)');
};
/**
* There's no visible y-axis, but we still need to set the range etc.
*/
function renderYAxis() {
yScale.range([chartH, 0]);
};
/**
* Title and (optional) subtitle at bottom of chart.
* We're assuming there's enough bottom margin for them to fit.
*/
function renderTitle() {
var title = inner.selectAll('.publishchart__title')
.data(data);
title.enter()
.append('text')
.classed('publishchart__title', true)
.attr('x', (totalW / 2))
.attr('y', totalH - 42)
.attr('text-anchor', 'middle')
.text("‘" + data[0].title + "’ by " + data[0].author)
.style('fill', '#000');
title.exit().remove();
title.transition()
.attr('x', (totalW / 2));
if ('subtitle' in data[0] && data[0].subtitle !== '') {
var subtitle = inner.selectAll('.publishchart__subtitle')
.data(data);
subtitle.enter()
.append('text')
.classed('publishchart__subtitle', true)
.attr('x', (totalW / 2))
.attr('y', totalH - 25)
.attr('text-anchor', 'middle')
.text(data[0].subtitle)
.style('fill', '#000');
subtitle.exit().remove();
subtitle.transition()
.attr('x', (totalW / 2));
};
};
/**
* Draw the horizontal bars from a start year to an end year.
*/
function renderBars() {
var bars = inner.selectAll('.publishchart__bar')
.data(booksData);
var barX = function(d,i) { return xScale(d['start_year']); };
var barY = function(d,i) { return yScale(i); };
var barW = function(d,i) {
// See note re bar widths:
return xScale(d.end_year + 1) - xScale(d.start_year);
};
bars.enter()
.append('rect')
.attr('class', function(d,i) {
return 'publishchart__bar publishchart__bar--'+i;
})
.attr('x', barX)
.attr('y', barY)
.attr('width', barW)
.attr('height', barHeight)
.style('fill', '#000')
.on('mouseover', mouseOver)
.on('mousemove', mouseMove)
.on('mouseout', mouseOut);
// Remove un-wanted bars.
bars.exit().remove();
// Update bar position and width.
bars.transition()
.attr('x', barX)
.attr('y', barY)
.attr('width', barW)
};
/**
* Draw the labels next to the bars.
*/
function renderLabels() {
// Padding to the left/right of a label:
var labelPadding = 4;
// Get the width of this label, from the pre-calculated widths.
var labelW = function(d,i) {
return labelWidths[i];
};
var labelH = barHeight;
var labelX = function(d,i) {
// By default position with its right end before the left of a bar.
var x = xScale(d.start_year) - labelWidths[i] - labelPadding;
if (x < xScale(startYear)) {
// But if this puts it off the left of the chart, place it so its
// left end is to the right of the bar.
x = xScale(d.end_year + 1) + labelPadding;
};
return x;
};
var labelY = function(d,i) { return yScale(i + 1); }
var labelText = function(d,i) { return d.title; };
var labels = inner.selectAll('.publishchart__barlabel')
.data(booksData);
if (labelWidths.length === 0) {
// Add, and then remove, labels, purely to calculate their widths
// based on the size of their text.
// Add the widths to the labelWidths array.
// We only need to do this the first time round, not on resize.
labels.enter()
.append('text')
.classed('publishchart__barlabel', true)
.text(labelText)
.each(function(d,i) {
var w = this.getComputedTextLength();
labelWidths.push(w);
this.remove();
});
};
labels.enter()
.append('text')
.attr('class', function(d,i) {
return 'publishchart__barlabel publishchart__barlabel--'+i;
})
.attr('width', labelW)
.attr('height', labelH)
.attr('x', labelX)
.attr('y', labelY)
.attr('dy', - Math.round(labelH / 2.6))
.attr('text-anchor', 'start')
.text(labelText);
// Remove un-wanted label.
labels.exit().remove();
// Update label position, width and height.
labels.transition()
.attr('x', labelX);
};
/**
* Draw the circles located at the publish dates.
*/
function renderPublishDates() {
var dateX = function(d,i) {
// Default position:
var x = xScale(d.publish_year);
// Shift right by one unit so they line up with our centered labels:
return x + (oneX / 2);
};
var dates = inner.selectAll('.publishchart__date')
.data(booksData);
dates.enter()
.append('circle')
.attr('class', function(d,i) {
return 'publishchart__date publishchart__date--'+i;
})
.attr('cx', dateX)
.attr('cy', yScale(-(publishDotsSpace-1)))
.attr('r', 3)
.style('fill', '#000')
.on('mouseover', mouseOver)
.on('mousemove', mouseMove)
.on('mouseout', mouseOut);
dates.exit().remove();
dates.transition()
.attr('cx', dateX);
};
/**
* Draw the triangles that join the publish dots with the bars.
*/
function renderFans() {
var dateX = function(d,i) {
// Default position:
var x = xScale(d.publish_year);
// Shift right by one unit so they line up with our centered labels:
return x + (oneX / 2);
};
var fanPoints = function(d,i) {
var points = [];
// Position of the bar's left end.
points.push([
xScale(d['start_year']),
yScale(i)
]);
// Position of the publish dot.
points.push([
dateX(d,i),
yScale(-(publishDotsSpace-1))
]);
// Position of the bar's right end.
points.push([
// See note re bar widths:
xScale(d['end_year'] + 1),
yScale(i)
]);
return points.join(',');
};
var fans = inner.selectAll('.publishchart__fan')
.data(booksData);
fans.enter()
.append('polyline')
.attr('class', function(d,i) {
return 'publishchart__fan publishchart__fan--'+i;
})
.attr('points', fanPoints)
.style('fill', '#000')
.style('opacity', '0.1')
.on('mouseover', mouseOver)
.on('mousemove', mouseMove)
.on('mouseout', mouseOut);
fans.exit().remove();
fans.transition()
.attr('points', fanPoints);
};
}); // end selection.each()
}; // end chart()
chart.barHeight = function(_) {
if (!arguments.length) return barHeight;
barHeight = _;
return chart;
};
chart.barPadding = function(_) {
if (!arguments.length) return barPadding;
barPadding = _;
return chart;
};
chart.margin = function(_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
return chart;
}; // end charts.publishchart()
}());