Skip to content

Instantly share code, notes, and snippets.

@philgyford
Last active December 12, 2017 04:14
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save philgyford/4f8c5efd817e515b543e977bbb4ab345 to your computer and use it in GitHub Desktop.
Save philgyford/4f8c5efd817e515b543e977bbb4ab345 to your computer and use it in GitHub Desktop.
Book series years chart
license: cc-by-sa-4.0
height: 1200
scrolling: no
border: yes

A D3.js v4 chart for displaying a series of books, comparing the years in which they are set with the years in which they were published.

You can read more about this project on my website.

[
{
"title": "A Dance to the Music of Time",
"author": "Anthony Powell",
"subtitle": "Source: ‘Invitation to the Dance’ by Hilary Spurling",
"books": [
{
"title": "A Question of Upbringing",
"publish_year": 1951,
"start_year": 1921,
"end_year": 1924
},
{
"title": "A Buyer’s Market",
"publish_year": 1952,
"start_year": 1928,
"end_year": 1928
},
{
"title": "The Acceptance World",
"publish_year": 1955,
"start_year": 1931,
"end_year": 1933
},
{
"title": "At Lady Molly’s",
"publish_year": 1957,
"start_year": 1934,
"end_year": 1934
},
{
"title": "Casanova’s Chinese Restaurant",
"publish_year": 1960,
"start_year": 1933,
"end_year": 1937
},
{
"title": "The Kindly Ones",
"publish_year": 1962,
"start_year": 1938,
"end_year": 1939
},
{
"title": "The Valley of Bones",
"publish_year": 1964,
"start_year": 1940,
"end_year": 1940
},
{
"title": "The Soldier’s Art",
"publish_year": 1966,
"start_year": 1941,
"end_year": 1941
},
{
"title": "The Military Philosophers",
"publish_year": 1968,
"start_year": 1942,
"end_year": 1945
},
{
"title": "Books Do Furnish a Room",
"publish_year": 1971,
"start_year": 1945,
"end_year": 1947
},
{
"title": "Temporary Kings",
"publish_year": 1973,
"start_year": 1958,
"end_year": 1959
},
{
"title": "Hearing Secret Harmonies",
"publish_year": 1975,
"start_year": 1968,
"end_year": 1971
}
]
}
]
[
{
"title": "Narratives of Empire",
"author": "Gore Vidal",
"subtitle": "Source: Wikipedia",
"books": [
{
"title": "Burr",
"publish_year": 1973,
"start_year": 1775,
"end_year": 1840
},
{
"title": "Lincoln",
"publish_year": 1984,
"start_year": 1861,
"end_year": 1867
},
{
"title": "1876",
"publish_year": 1976,
"start_year": 1875,
"end_year": 1877
},
{
"title": "Empire",
"publish_year": 1987,
"start_year": 1898,
"end_year": 1907
},
{
"title": "Hollywood",
"publish_year": 1990,
"start_year": 1917,
"end_year": 1923
},
{
"title": "Washington, D.C.",
"publish_year": 1967,
"start_year": 1937,
"end_year": 1952
},
{
"title": "The Golden Age",
"publish_year": 2000,
"start_year": 1939,
"end_year": 1954
}
]
}
]
[
{
"title": "Strangers and Brothers",
"author": "C. P. Snow",
"subtitle": "Source: Wikipedia",
"books": [
{
"title": "Time of Hope",
"publish_year": 1949,
"start_year": 1914,
"end_year": 1933
},
{
"title": "George Passant",
"publish_year": 1940,
"start_year": 1925,
"end_year": 1933
},
{
"title": "The Conscience of the Rich",
"publish_year": 1958,
"start_year": 1927,
"end_year": 1936
},
{
"title": "The Light and the Dark",
"publish_year": 1947,
"start_year": 1935,
"end_year": 1943
},
{
"title": "The Masters",
"publish_year": 1951,
"start_year": 1937,
"end_year": 1937
},
{
"title": "The New Men",
"publish_year": 1954,
"start_year": 1939,
"end_year": 1946
},
{
"title": "Homecomings",
"publish_year": 1956,
"start_year": 1938,
"end_year": 1950
},
{
"title": "The Affair",
"publish_year": 1960,
"start_year": 1953,
"end_year": 1954
},
{
"title": "Corridors of Power",
"publish_year": 1964,
"start_year": 1955,
"end_year": 1958
},
{
"title": "The Sleep of Reason",
"publish_year": 1968,
"start_year": 1963,
"end_year": 1964
},
{
"title": "Last Things",
"publish_year": 1970,
"start_year": 1964,
"end_year": 1968
}
]
}
]
<!DOCTYPE html>
<html lang="en-gb">
<head>
<meta charset="UTF-8">
<title>Charts showing when books were set and published</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="js-publishchart-strangers publishchart"></div>
<div class="js-publishchart-dance publishchart"></div>
<div class="js-publishchart-narratives publishchart"></div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="publishchart.js"></script>
<script>
'use strict';
(function() {
d3.json('data_strangers_and_brothers.json', function(error, data) {
if (error) {
throw error;
};
var publishchart = charts.publishchart();
d3.select('.js-publishchart-strangers')
.datum(data)
.call(publishchart);
});
d3.json('data_a_dance_to_the_music_of_time.json', function(error, data) {
if (error) {
throw error;
};
var publishchart = charts.publishchart();
d3.select('.js-publishchart-dance')
.datum(data)
.call(publishchart);
});
d3.json('data_narratives_of_empire.json', function(error, data) {
if (error) {
throw error;
};
var publishchart = charts.publishchart();
d3.select('.js-publishchart-narratives')
.datum(data)
.call(publishchart);
});
})();
</script>
</body>
</html>
/**
* 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()
}());
* {
box-sizing: border-box;
}
body {
background: #fff;
color: #000;
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 15px 8px;
}
.publishchart {
width: 100%;
max-width: 1000px;
margin: 0 auto 2em auto;
}
.publishchart__axis path {
stroke: #ccc;
}
.publishchart__title {
font-size: 14px;
}
.publishchart__subtitle {
font-size: 12px;
fill: #666 !important;
}
.publishchart__bar {
opacity: 0.4;
}
.publishchart__bar.hover {
opacity: 0.6;
}
.publishchart__fan {
opacity: 0.05 !important;
}
.publishchart__fan.hover {
opacity: 0.1 !important;
}
.publishchart__barlabel {
font-size: 12px;
fill: #666;
pointer-events: none;
}
.publishchart__date {
opacity: 0.4;
}
.publishchart__date.hover {
opacity: 0.9;
}
.publishchart .tick line {
stroke: #000;
}
.publishchart .tick text {
fill: #000;
}
.publishchart__tooltip {
padding: 0.4em 0.5em;
border: 1px solid #ccc;
font-size: 12px;
line-height: 1.4em;
visibility: hidden;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment