|
/** |
|
* 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: |
|
* |
|
* <div class="chart"></div> |
|
* |
|
* <script src="https://d3js.org/d3.v4.min.js"></script> |
|
* <script src="publishchart.js"></script> |
|
* |
|
* <script> |
|
* (function() { |
|
* |
|
* d3.json('data.json', function(error, data) { |
|
* if (error) { |
|
* throw error; |
|
* }; |
|
* |
|
* var publishchart = charts.publishchart(); |
|
* |
|
* d3.select('.chart') |
|
* .datum(data) |
|
* .call(publishchart); |
|
* }); |
|
* |
|
* })(); |
|
* </script> |
|
* |
|
* 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 = '<strong>' + d.title + '</strong> (pub. ' + d.publish_year + ')'; |
|
s += '<br>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() |
|
|
|
}()); |