block by renecnielsen 7a1594c62c8dbf628cf7f573ea20c1f2

VR Renaissance - Linear Scale

Full Screen

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

forked from micahstubbs‘s block: VR Renaissance - Linear Scale

index.html

<!DOCTYPE html>
<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>

annotations.json

[  
  {  
    "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
  }
]

d3-jetpack.js

(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;

}));

data.csv

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,,

swoopy-drag.js

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

}));