/** * 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() }());