a friendly, data-driven iteration on the product timeline plot from this tweet
this iteration uses actual linear units shipped
data for the y-height
of the bars and actual linear time
data for the x-position
of the bars
click and drag any of the images or text annotations to change their position and experiment with a new annotation layout
click the background to see axis lines and ticks
for the curious, there is a detailed commit history at the vr-renaissance companion repo
compare this with the VR Renaissance - Log Scale iteration
forked from micahstubbs's block: VR Renaissance - Linear Scale
xxxxxxxxxx
<meta charset='utf-8'>
<link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
<style>
</style>
<svg width='960' height='500'></svg>
<script src='https://d3js.org/d3.v4.js'></script>
<script src='https://d3js.org/d3-queue.v3.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.19.0/babel.min.js'></script>
<script src='swoopy-drag.js'></script>
<script src='d3-jetpack.js'></script>
<script lang='babel' type='text/babel'>
const svg = d3.select('svg');
const margin = {top: 150, right: 60, bottom: 30, left: 60};
const width = +svg.attr('width') - margin.left - margin.right;
const height = +svg.attr('height') - margin.top - margin.bottom;
const x = d3.scaleTime()
.domain([new Date(2015, 12, 1), new Date(2016, 12, 31)])
.range([0, width]);
const y = d3.scaleLinear()
.rangeRound([height, 0]);
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const parseDate = d3.utcParse('%Y%B');
d3.queue()
.defer(d3.csv, 'data.csv')
.defer(d3.json, 'annotations.json')
.awaitAll(render);
function render(err, response) {
console.log('response', response);
const data = response[0];
const annotationsData = response[1];
// format input data
data.forEach(d => {
d.unitsShipped = +d.unitsShipped;
d.launchDate = parseDate(`${d.launchYear}${d.launchMonth}`);
d.imageXOffset = +d.imageXOffset;
d.imageYOffset = +d.imageYOffset;
})
annotationsData.forEach(d => {
d.imageWidth = +d.imageWidth;
d.imageHeight = +d.imageHeight;
d.imageXOffset = +d.imageXOffset;
d.imageYOffset = +d.imageYOffset;
})
// x.domain(data.map(d => d.letter));
y.domain([0, d3.max(data, d => d.unitsShipped)]);
const xAxis = d3.axisBottom()
.scale(x)
// .ticks(d3.timeMonths)
// .tickSize(16, 0)
.tickSizeOuter(0)
.tickFormat(d3.timeFormat('%B %Y'));
const yAxis = d3.axisLeft()
.scale(y)
.ticks(10, ',.0f');
g.append('g')
.attr('class', 'axis axis--x')
.attr('transform', `translate(0,${height})`)
.call(xAxis);
g.append('g')
.attr('class', 'axis axis--y')
.call(yAxis)
.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', 6)
.attr('dy', '0.71em')
.attr('text-anchor', 'end')
.text('Frequency');
// style the x-axis path
d3.select('.axis--x path')
.style('stroke-opacity', 0);
const defs = svg.append('defs');
// draw arrow for x-axis baseline
defs.append('marker')
.attr('id', 'arrow')
.attr('markerWidth', '6')
.attr('markerHeight', '6')
.attr('viewbox', '-3 -3 6 6')
.attr('refX', '-1')
.attr('refY', '0')
.attr('markerUnits', 'strokeWidth')
.attr('orient', 'auto')
.attr('overflow', 'visible')
.append('polygon')
.attr('points', '-1,0 -2,2 2,0 -2,-2')
.attr('fill', 'black');
// draw the baseline with an arrow
svg
.append('line')
.attr('x1', margin.left)
.attr('y1', height + margin.top)
.attr('x2', width + margin.left)
.attr('y2', height + margin.top)
.style('stroke-width', 4)
.style('stroke-opacity', 1.0)
.style('stroke', 'black')
.attr('transform', 'translate(0,3)')
.attr('marker-end', 'url(#arrow)');
// draw the bars
g.selectAll('.bar')
.data(data)
.enter().append('rect')
.attr('class', 'bar')
.attr('x', d => x(d.launchDate))
// + 1px y so that bar does not protrude
// above the dot
.attr('y', d => y(d.unitsShipped) + 1)
.attr('width', 4)
.attr('height', d => height - y(d.unitsShipped))
.style('fill', 'black')
.on('mouseover', d => console.log(d));
// draw the circles at the top of the bars
g.selectAll('.circle')
.data(data)
.enter().append('circle')
.attr('class', 'point')
.attr('cx', d => x(d.launchDate) + 2)
.attr('cy', d => y(d.unitsShipped) + 6)
.attr('r', 6)
.style('fill', 'black')
.style('fill-opacity', 1.0)
.on('mouseover', d => console.log(d));
//
// draw head-mounted-display images
//
const imageScaleFactor = 8;
const images = svg.selectAll('image')
.data(data)
.enter()
.append('svg:image')
.attr('xlink:href', (d, i) => annotationsData[i].imageFileName)
.attr('x', (d, i) => (x(d.launchDate) + margin.left + annotationsData[i].imageXOffset - (annotationsData[i].imageWidth / imageScaleFactor) / 2))
.attr('y', (d, i) => (y(d.unitsShipped) + margin.top + annotationsData[i].imageYOffset - (annotationsData[i].imageHeight / imageScaleFactor)))
.attr('width', (d, i) => annotationsData[i].imageWidth / imageScaleFactor)
.attr('height', (d, i) => annotationsData[i].imageHeight / imageScaleFactor)
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
function dragstarted(d) {
d3.select(this)
.raise()
.classed('active', true);
}
function dragged(d) {
d3.select(this)
.attr('x', d.x = d3.event.x)
.attr('y', d.y = d3.event.y);
}
function dragended(d) {
d3.select(this)
.classed('active', false);
}
// start with the axes hidden
let axesVisible = false;
d3.selectAll('.axis')
.style('opacity', 0);
d3.select('body')
.on('click', click);
function click() {
if (axesVisible) {
d3.selectAll('.axis')
.style('opacity', 0);
axesVisible = false;
} else {
d3.selectAll('.axis')
.style('opacity', 1);
axesVisible = true;
}
}
//
// add labels
//
const format = d3.format('.2s')
// collect annotations data
// generate data-driven annotation positions
// pad strings to achieve text alignment
const annotations = [];
data.forEach((d, i) => {
let textXOffset = x(d.launchDate) + margin.left - 40;
let textYOffset = y(d.unitsShipped) + margin.top - 100;
if (typeof annotationsData[i].textXOffset !== 'undefined') {
textXOffset = annotationsData[i].textXOffset;
}
if (typeof annotationsData[i].textYOffset !== 'undefined') {
textYOffset = annotationsData[i].textYOffset;
}
if (typeof d.unitsSuffix === 'undefined') {
d.unitsSuffix = '';
}
annotations.push({
'path': 'M 610,143 A 81.322 81.322 0 0 1 564,221',
'text': [
`${annotationsData[i].textOffsetLine0}${d.company} ${d.product}`,
`${annotationsData[i].textOffsetLine1}${d.launchMonth} ${d.launchYear}`,
`${annotationsData[i].textOffsetLine2}${d.unitsPrefix}${format(d.unitsShipped)}${d.unitsSuffix} shipped`
],
'textOffset': [
textXOffset,
textYOffset
]
})
})
// draw the annotation layer
const swoopy = d3.swoopyDrag()
.x(d => 0)
.y(d => 0)
.draggable(1)
.annotations(annotations)
const swoopySel = svg
.append('g.swoopy')
.call(swoopy)
// no circles for now
swoopySel.selectAll('circle')
.remove();
// no paths or arrowheads for now
swoopySel.selectAll('path')
.remove();
// .attr('marker-end', 'url(#arrow)')
// svg.append('marker')
// .attr('id', 'arrow')
// .attr('viewBox', '-10 -10 20 20')
// .attr('markerWidth', 20)
// .attr('markerHeight', 20)
// .attr('orient', 'auto')
// .append('path')
// .attr('d', 'M-6.75,-6.75 L 0,0 L -6.75,6.75')
swoopySel.selectAll('text')
.each(function(d){
d3.select(this)
.text('')
.tspans(d.text) // d3.wordwrap(d.text, 22)
})
swoopySel.selectAll('text')
.style('font-size', 12)
.style('font-family', 'Roboto');
// d3.select('g.swoopy').selectAll('g')
// .attr('transform', 'translate(0,-20)');
};
</script>
https://d3js.org/d3.v4.js
https://d3js.org/d3-queue.v3.min.js
https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.19.0/babel.min.js