Skip to content

Instantly share code, notes, and snippets.

@micahstubbs
Last active Oct 28, 2018
Embed
What would you like to do?
VR Renaissance - Linear Scale
border: no
license: MIT

a friendly, data-driven iteration on the product timeline plot from this tweet

original

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

[
{
"imageFileName":"google-cardboard.png",
"imageWidth": 503,
"imageHeight": 403,
"imageXOffset": 2,
"imageYOffset": -10,
"textOffsetLine0": "",
"textOffsetLine1": " ",
"textOffsetLine2": " ",
"textXOffset": 58,
"textYOffset": 46
},
{
"imageFileName":"samsung-gear-vr.png",
"imageWidth": 561,
"imageHeight": 424,
"imageXOffset": 14,
"imageYOffset": -15,
"textOffsetLine0": "",
"textOffsetLine1": " ",
"textOffsetLine2": " ",
"textXOffset": 310,
"textYOffset": 289
},
{
"imageFileName":"microsoft-hololens.png",
"imageWidth": 445,
"imageHeight": 213,
"imageXOffset": 2,
"imageYOffset": -6,
"textOffsetLine0": "",
"textOffsetLine1": "",
"textOffsetLine2": "",
"textXOffset": 360,
"textYOffset": 435
},
{
"imageFileName":"oculus-rift-cv1.png",
"imageWidth": 466,
"imageHeight": 367,
"imageXOffset": 6,
"imageYOffset": -15,
"textOffsetLine0": "",
"textOffsetLine1": " ",
"textOffsetLine2": "",
"textXOffset": 416,
"textYOffset": 378
},
{
"imageFileName":"htc-vive.png",
"imageWidth": 453,
"imageHeight": 350,
"imageXOffset": 8,
"imageYOffset": -45,
"textOffsetLine0": "",
"textOffsetLine1": "",
"textOffsetLine2": "",
"textXOffset": 578,
"textYOffset": 375
},
{
"imageFileName":"sony-playstation-vr.png",
"imageWidth": 634,
"imageHeight": 548,
"imageXOffset": 10,
"imageYOffset": -5,
"textOffsetLine0": "",
"textOffsetLine1": " ",
"textOffsetLine2": " ",
"textXOffset": 695,
"textYOffset": 286
},
{
"imageFileName":"google-daydream-vr.png",
"imageWidth": 588,
"imageHeight": 514,
"imageXOffset": 20,
"imageYOffset": 0,
"textOffsetLine0": "",
"textOffsetLine1": "",
"textOffsetLine2": "",
"textXOffset": 841,
"textYOffset": 402
}
]
(function(root, factory) {
if (typeof module !== 'undefined' && module.exports) {
module.exports = factory(require('d3'));
} else if (typeof define === 'function' && define.amd) {
define(['d3'], factory);
} else {
root.d3 = factory(root.d3);
}
}(this, function(d3) {
d3.selection.prototype.translate = function(xy) {
return this.attr('transform', function(d,i) {
return 'translate('+[typeof xy == 'function' ? xy.call(this, d,i) : xy]+')';
});
};
d3.transition.prototype.translate = function(xy) {
return this.attr('transform', function(d,i) {
return 'translate('+[typeof xy == 'function' ? xy.call(this, d,i) : xy]+')';
});
};
d3.selection.prototype.tspans = function(lines, lh) {
return this.selectAll('tspan')
.data(lines)
.enter()
.append('tspan')
.attr('class', (d, i) => `line${i}`)
.attr('xml:space', 'preserve')
.text(function(d) { return d; })
.attr('x', 0)
.attr('dy', function(d,i) { return i ? lh || 15 : 0; });
};
d3.selection.prototype.append = function(name) {
var n = d3_parse_attributes(name), s;
name = n.attr ? n.tag : name;
name = d3_selection_creator(name);
s = this.select(function() {
return this.appendChild(name.apply(this, arguments));
});
//attrs not provided by default in v4
for (var name in n.attr) { s.attr(name, n.attr[name]) }
return s;
};
d3.selection.prototype.insert = function(name, before) {
var n = d3_parse_attributes(name), s;
name = n.attr ? n.tag : name;
name = d3_selection_creator(name);
before = d3_selection_selector(before);
s = this.select(function() {
return this.insertBefore(name.apply(this, arguments), before.apply(this, arguments) || null);
});
//attrs not provided by default in v4
for (var name in n.attr) { s.attr(name, n.attr[name]) }
return s;
};
//no selection.enter in v4
if (d3.selection.enter){
d3.selection.enter.prototype.append = d3.selection.prototype.append
d3.selection.enter.prototype.insert = d3.selection.prototype.insert
}
var d3_parse_attributes_regex = /([\.#])/g;
function d3_parse_attributes(name) {
if (typeof name === "string") {
var attr = {},
parts = name.split(d3_parse_attributes_regex), p;
name = parts.shift();
while ((p = parts.shift())) {
if (p == '.') attr['class'] = attr['class'] ? attr['class'] + ' ' + parts.shift() : parts.shift();
else if (p == '#') attr.id = parts.shift();
}
return attr.id || attr['class'] ? { tag: name, attr: attr } : name;
}
return name;
}
function d3_selection_creator(name) {
var qualify = d3.namespace || d3.ns.qualify //v4 API change
return typeof name === "function" ? name : (name = qualify(name)).local ? function() {
return this.ownerDocument.createElementNS(name.space, name.local);
} : function() {
return this.ownerDocument.createElementNS(this.namespaceURI, name);
};
}
function d3_selection_selector(selector) {
return typeof selector === "function" ? selector : function() {
return this.querySelector(selector);
};
}
d3.wordwrap = function(line, maxCharactersPerLine) {
var w = line.split(' '),
lines = [],
words = [],
maxChars = maxCharactersPerLine || 40,
l = 0;
w.forEach(function(d) {
if (l+d.length > maxChars) {
lines.push(words.join(' '));
words.length = 0;
l = 0;
}
l += d.length;
words.push(d);
});
if (words.length) {
lines.push(words.join(' '));
}
return lines;
};
d3.ascendingKey = function(key) {
return typeof key == 'function' ? function (a, b) {
return key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : key(a) >= key(b) ? 0 : NaN;
} : function (a, b) {
return a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : a[key] >= b[key] ? 0 : NaN;
};
};
d3.descendingKey = function(key) {
return typeof key == 'function' ? function (a, b) {
return key(b) < key(a) ? -1 : key(b) > key(a) ? 1 : key(b) >= key(a) ? 0 : NaN;
} : function (a, b) {
return b[key] < a[key] ? -1 : b[key] > a[key] ? 1 : b[key] >= a[key] ? 0 : NaN;
};
};
d3.f = function(){
var functions = arguments;
//convert all string arguments into field accessors
var i = 0, l = functions.length;
while (i < l) {
if (functions[i] in d3.f._) {
functions[i] = (function(f){ return function(d){ return f(d); }; })(d3.f._[functions[i]]);
} else if (typeof(functions[i]) === 'string' || typeof(functions[i]) === 'number'){
functions[i] = (function(str){ return function(d){ return d[str]; }; })(functions[i]);
} else if (typeof(functions[i]) === 'object'){
functions[i] = (function(map){ return function(d){ return map[d]; }; })(functions[i]);
}
i++;
}
//return composition of functions
return function(d) {
var i=0, l = functions.length;
while (i++ < l) d = functions[i-1].call(this, d);
return d;
};
};
// special operator functions
d3.f._ = {
'ƒ.call': function(d) { return d(); },
'ƒ.not': function(d) { return !d; }
};
// store d3.f as convenient unicode character function (alt-f on macs)
if (typeof window !== 'undefined' && !window.hasOwnProperty('ƒ')) window.ƒ = d3.f;
// this tweak allows setting a listener for multiple events, jquery style
var d3_selection_on = d3.selection.prototype.on;
d3.selection.prototype.on = function(type, listener, capture) {
if (typeof type == 'string' && type.indexOf(' ') > -1) {
type = type.split(' ');
for (var i = 0; i<type.length; i++) {
d3_selection_on.apply(this, [type[i], listener, capture]);
}
} else {
d3_selection_on.apply(this, [type, listener, capture]);
}
return this;
};
// for everyone's sake, let's add prop as alias for property
d3.selection.prototype.prop = d3.selection.prototype.property;
// combines data().enter().append()
d3.selection.prototype.appendMany = function(data, name){
return this.selectAll(name).data(data).enter().append(name);
};
d3.round = d3.round || function(n, p) {
return p ? Math.round(n * (p = Math.pow(10, p))) / p : Math.round(n);
};
return d3;
}));
company product launchMonth launchYear unitsShipped unitsPrefix unitsSuffix
Google Cardboard June 2015 10000000 +
Samsung GearVR December 2015 2300000
Microsoft Hololens March 2016 10000 ~
Oculus Rift CV1 April 2016 350000 ~
HTC Vive May 2016 500000 ~
Sony Playstation VR October 2016 2600000
Google Daydream VR November 2016 500000
<!DOCTYPE html>
<head>
<meta charset='utf-8'>
<link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
</head>
<body>
<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 src='./index.js'></script>
</body>
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, 4, 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)');
}
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3));
}(this, function (exports,d3) { 'use strict';
function swoopyDrag(){
var x = function(d){ return d }
var y = function(d){ return d }
var annotations = []
var annotationSel
var draggable = false
var dispatch = d3.dispatch('drag')
var textDrag = d3.drag()
.on('drag', function(d){
var x = d3.event.x
var y = d3.event.y
d.textOffset = [x, y].map(Math.round)
d3.select(this).call(translate, d.textOffset)
dispatch.call('drag')
})
.subject(function(d){ return {x: d.textOffset[0], y: d.textOffset[1]} })
var circleDrag = d3.drag()
.on('drag', function(d){
var x = d3.event.x
var y = d3.event.y
d.pos = [x, y].map(Math.round)
var parentSel = d3.select(this.parentNode)
var path = ''
var points = parentSel.selectAll('circle').data()
if (points[0].type == 'A'){
path = calcCirclePath(points)
} else{
points.forEach(function(d){ path = path + d.type + d.pos })
}
parentSel.select('path').attr('d', path).datum().path = path
d3.select(this).call(translate, d.pos)
dispatch.call('drag')
})
.subject(function(d){ return {x: d.pos[0], y: d.pos[1]} })
var rv = function(sel){
annotationSel = sel.html('').selectAll('g')
.data(annotations).enter()
.append('g')
.call(translate, function(d){ return [x(d), y(d)] })
var textSel = annotationSel.append('text')
.call(translate, ƒ('textOffset'))
.text(ƒ('text'))
annotationSel.append('path')
.attr('d', ƒ('path'))
if (!draggable) return
annotationSel.style('cursor', 'pointer')
textSel.call(textDrag)
annotationSel.selectAll('circle').data(function(d){
var points = []
if (~d.path.indexOf('A')){
//handle arc paths seperatly -- only one circle supported
var pathNode = d3.select(this).select('path').node()
var l = pathNode.getTotalLength()
points = [0, .5, 1].map(function(d){
var p = pathNode.getPointAtLength(d*l)
return {pos: [p.x, p.y], type: 'A'}
})
} else{
var i = 1
var type = 'M'
var commas = 0
for (var j = 1; j < d.path.length; j++){
var curChar = d.path[j]
if (curChar == ',') commas++
if (curChar == 'L' || curChar == 'C' || commas == 2){
points.push({pos: d.path.slice(i, j).split(','), type: type})
type = curChar
i = j + 1
commas = 0
}
}
points.push({pos: d.path.slice(i, j).split(','), type: type})
}
return points
}).enter().append('circle')
.attr('r', 8)
.attr('fill', 'rgba(0,0,0,0)')
.attr('stroke', '#333')
.attr('stroke-dasharray', '2 2')
.call(translate, ƒ('pos'))
.call(circleDrag)
dispatch.call('drag')
}
rv.annotations = function(_x){
if (typeof(_x) == 'undefined') return annotations
annotations = _x
return rv
}
rv.x = function(_x){
if (typeof(_x) == 'undefined') return x
x = _x
return rv
}
rv.y = function(_x){
if (typeof(_x) == 'undefined') return y
y = _x
return rv
}
rv.draggable = function(_x){
if (typeof(_x) == 'undefined') return draggable
draggable = _x
return rv
}
rv.on = function() {
var value = dispatch.on.apply(dispatch, arguments);
return value === dispatch ? rv : value;
}
return rv
//convert 3 points to an Arc Path
function calcCirclePath(points){
var a = points[0].pos
var b = points[2].pos
var c = points[1].pos
var A = dist(b, c)
var B = dist(c, a)
var C = dist(a, b)
var angle = Math.acos((A*A + B*B - C*C)/(2*A*B))
//calc radius of circle
var K = .5*A*B*Math.sin(angle)
var r = A*B*C/4/K
r = Math.round(r*1000)/1000
//large arc flag
var laf = +(Math.PI/2 > angle)
//sweep flag
var saf = +((b[0] - a[0])*(c[1] - a[1]) - (b[1] - a[1])*(c[0] - a[0]) < 0)
return ['M', a, 'A', r, r, 0, laf, saf, b].join(' ')
}
function dist(a, b){
return Math.sqrt(
Math.pow(a[0] - b[0], 2) +
Math.pow(a[1] - b[1], 2))
}
//no jetpack dependency
function translate(sel, pos){
sel.attr('transform', function(d){
var posStr = typeof(pos) == 'function' ? pos(d) : pos
return 'translate(' + posStr + ')'
})
}
function ƒ(str){ return function(d){ return d[str] } }
}
exports.swoopyDrag = swoopyDrag;
Object.defineProperty(exports, '__esModule', { value: true });
}));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment