block by enjalot d0c8b275a5a4fc4d3751

spherical coordinates

Full Screen

Work in progress, trying to understand the relationship between cartesian and spherical coordinates.

parallel coordinates

trackball rotation

spherical coordinates

Built with blockbuilder.org

index.html

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
  <script src="trackball.js"></script>
  <script src="d3.parcoords.js"></script>
  <link rel="stylesheet" type="text/css" href="d3.parcoords.css"></link>

  <style>
    body { margin:0;position:fixed;top:0;right:0;bottom:0;left:0; }
    svg { 
      width: 100%; height: 300px; 
      float: left;
    }
    .parcoords { 
      width: 100%; height: 200px; 
      float:left; clear: left;
    }
    
    path.foreground {
      fill: none;
      stroke: #333;
      stroke-width: 1.5px;
    }
    path.graticule {
      fill: none;
      stroke: #aaa;
      stroke-width: .5px;
    }
  </style>
</head>

<body>
  <svg></svg>
  <div class="parcoords"></div>
  
  <script>
    
    var zero = 1e-6;
    var normals = [
      { x: 0, y: 0, z: 1, i: 0 },
      { x: 0, y: 1, z: 0, i: 1 },
      { x: 1, y: 0, z: 0, i: 2 },
    ]
    
    // Parallel coordinates
    var colorScale = d3.scale.category10();
    var colorScale = d3.scale.ordinal()
    .range(["#f00", "#0f0", "#00f"])
    function color(d) {
      return colorScale(d.i);
    }
    var pc = d3.parcoords()(".parcoords")
      .dimensions(['x', 'y', 'z'])
      .data([
        {x: -1, y: -1, z: -1},
        {x: 1, y: 1, z: 1}
      ])
      .autoscale()
      .color(color)
      .alpha(0.8)
      .createAxes()
      .data(normals)
      .render(); 
    
   // "Globe" -> Unit sphere
    var map_width = 300;
    var map_height = 300;
    var scale = (map_width - 1) / 2 / Math.PI * 2.5
    var projection = d3.geo.orthographic()
      .translate([map_width/2, map_height / 2])
      .scale(scale)
    .rotate([0,0,0])
      .clipAngle(90)

    var path = d3.geo.path()
        .projection(projection);
    
    var graticule = d3.geo.graticule();
    
    var svg = d3.select("svg")
    
    var left = svg.append("g")
      .attr("transform", "translate(100, 0)")
    
    left.selectAll("line.normal")
    .data(normals)
    .enter().append("line").classed("normal", true)
    .attr({
      stroke: color,
      x1: map_width/2,
      y1: map_height/2,
      x2: getX,
      y2: getY
    })
    
    left.append("path")
        .datum(graticule)
        .attr("class", "graticule")
        .attr("d", path);
    
    left.selectAll("circle.normal")
    .data(normals)
    .enter().append("circle").classed("normal", true)
    .attr({
      r: 5,
      fill: color,
      cx: getX,
      cy: getY
    })
    
    d3.behavior.trackball(svg).on("rotate", function(rot) {
			//update the rotation in our projection
      projection.rotate(rot);
      //redraw our visualization with the updated projection
			left.selectAll("path.graticule")
        .attr("d", path)
      left.selectAll("circle.normal")
        .attr({
          cx: getX,
          cy: getY
        })
      left.selectAll("line.normal")
        .attr({
          x2: getX,
          y2: getY
        })

      //update the parallel coordinates
      var rotated = normals.map(function(n) {
        var ll = unit2latlon(n);
        ll[0] += rot[0];
        ll[1] += rot[1];
        var r = latlon2unit(ll)
        r.i = n.i;
        return r;
      })
      pc.data(rotated)
      .render();

     
    })

    function getX(d) {
      var ll = unit2latlon(d);
      return projection(ll)[0]
    }
    function getY(d) {
      var ll = unit2latlon(d);
      return projection(ll)[1]
    }
    // convert our unit vectors into lat/lon
    function unit2latlon(v) {
      ////stackoverflow.com/questions/5674149/3d-coordinates-on-a-sphere-to-latitude-and-longitude
     
     var r = 1; //Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z)
     //var theta = Math.acos(v.z/r);
     //var phi = Math.atan(v.x/(v.y ? v.y : zero));
     // we switch the given formulation with z -> x and y <-> z
     // so that our axes match the traditional x is right, y is up and z is out
     var theta = Math.acos(v.x/r);
     var phi = Math.atan(v.y/(v.z ? v.z : zero));
     // lat, lon
     return [90 - rad2deg(theta), rad2deg(phi)]
   }
   function rad2deg(r) {
     return r * 180/Math.PI;
   }
   function deg2rad(d) {
     return d * Math.PI/180;
   }
    
   function latlon2unit(latlon) {
     var lat = latlon[0];
     var lon = latlon[1];
     if(!lat) lat = zero;
     if(!lon) lon = zero;
     lat = deg2rad(lat);
     lon = deg2rad(lon);
     
     // we switch the given formulation with z -> x and y <-> z
     var z = Math.cos(lat) * Math.cos(lon);
     var y = Math.cos(lat) * Math.sin(lon);
     var x = Math.sin(lat)
     return {x: x, y:y, z: z}
   }
    // on rotate
  </script>
</body>

d3.parcoords.css

.parcoords > svg, .parcoords > canvas { 
  font: 14px sans-serif;
  position: absolute;
}
.parcoords > canvas {
  pointer-events: none;
}
.parcoords rect.background {
  fill: transparent;
}
.parcoords rect.background:hover {
  fill: rgba(120,120,120,0.2);
}
.parcoords .resize rect {
  fill: rgba(0,0,0,0.1);
}
.parcoords rect.extent {
  fill: rgba(255,255,255,0.25);
  stroke: rgba(0,0,0,0.6);
}
.parcoords .axis line, .parcoords .axis path {
  fill: none;
  stroke: #222;
  shape-rendering: crispEdges;
}
.parcoords canvas {
  opacity: 1;
  -moz-transition: opacity 0.3s;
  -webkit-transition: opacity 0.3s;
  -o-transition: opacity 0.3s;
}
.parcoords canvas.faded {
  opacity: 0.25;
}

d3.parcoords.js

d3.parcoords = function(config) {
  var __ = {
    data: [],
    dimensions: [],
    types: {},
    brushed: false,
    mode: "default",
    rate: 10,
    width: 600,
    height: 300,
    margin: { top: 24, right: 0, bottom: 12, left: 0 },
    color: "#069",
    composite: "source-over",
    alpha: "0.7"
  };

  extend(__, config);

  var pc = function(selection) {
    selection = pc.selection = d3.select(selection);

    __.width = selection[0][0].clientWidth;
    __.height = selection[0][0].clientHeight;

    // canvas data layers
    ["extents", "shadows", "marks", "foreground", "highlight"].forEach(function(layer) {
      canvas[layer] = selection
        .append("canvas")
        .attr("class", layer)[0][0];
      ctx[layer] = canvas[layer].getContext("2d");
    });

    // svg tick and brush layers
    pc.svg = selection
      .append("svg")
        .attr("width", __.width)
        .attr("height", __.height)
      .append("svg:g")
        .attr("transform", "translate(" + __.margin.left + "," + __.margin.top + ")");

    return pc;
  };

  var events = d3.dispatch.apply(this,["render", "resize", "highlight", "brush"].concat(d3.keys(__))),
      w = function() { return __.width - __.margin.right - __.margin.left; },
      h = function() { return __.height - __.margin.top - __.margin.bottom },
      flags = {
        brushable: false,
        reorderable: false,
        axes: false,
        interactive: false,
        shadows: false,
        debug: false
      },
      xscale = d3.scale.ordinal(),
      yscale = {},
      dragging = {},
      line = d3.svg.line(),
      axis = d3.svg.axis().orient("left").ticks(5),
      g, // groups for axes, brushes
      ctx = {},
      canvas = {};

  // side effects for setters
  var side_effects = d3.dispatch.apply(this,d3.keys(__))
    .on("composite", function(d) { ctx.foreground.globalCompositeOperation = d.value; })
    .on("alpha", function(d) { ctx.foreground.globalAlpha = d.value; })
    .on("width", function(d) { pc.resize(); })
    .on("height", function(d) { pc.resize(); })
    .on("margin", function(d) { pc.resize(); })
    .on("rate", function(d) { rqueue.rate(d.value); })
    .on("data", function(d) { 
      if (flags.shadows) paths(__.data, ctx.shadows);
    })
    .on("dimensions", function(d) {
      xscale.domain(__.dimensions);
      if (flags.interactive) pc.render().updateAxes();
    });

  pc.toString = function() { return "Parallel Coordinates: " + __.dimensions.length + " dimensions (" + d3.keys(__.data[0]).length + " total) , " + __.data.length + " rows"; };

  // expose the state of the chart
  pc.state = __;
  pc.flags = flags;

  // create getter/setters
  getset(pc, __, events);

  // expose events
  d3.rebind(pc, events, "on");

  // tick formatting
  d3.rebind(pc, axis, "ticks", "orient", "tickValues", "tickSubdivide", "tickSize", "tickPadding", "tickFormat");

  pc.autoscale = function() {
    // xscale
    xscale.rangePoints([0, w()], 1);

    // yscale
    __.dimensions.forEach(function(k) {
      yscale[k] = d3.scale.linear()
        .domain(d3.extent(__.data, function(d) { return +d[k]; }))
        .range([h()+1, 1])
    });

    // canvas sizes 
    pc.selection.selectAll("canvas")
        .style("margin-top", __.margin.top + "px") 
        .style("margin-left", __.margin.left + "px") 
        .attr("width", w()+2)
        .attr("height", h()+2)

    // default styles, needs to be set when canvas width changes
    ctx.foreground.strokeStyle = __.color;
    ctx.foreground.lineWidth = 1.4;
    ctx.foreground.globalCompositeOperation = __.composite;
    ctx.foreground.globalAlpha = __.alpha;
    ctx.highlight.lineWidth = 3;
    ctx.shadows.strokeStyle = "#dadada";
    ctx.extents.strokeStyle = "rgba(140,140,140,0.25)";
    ctx.extents.fillStyle = "rgba(255,255,255,0.4)";

    return this;
  };

  pc.detectDimensions = function() {
    pc.types(d3.parcoords.detectDimensionTypes(__.data));
    pc.dimensions(d3.parcoords.quantitative(__.data));
    return this;
  };

  var rqueue = d3.renderQueue(path_foreground)
    .rate(50)
    .clear(function() { pc.clear('foreground'); });

  pc.render = function() {
    // try to autodetect dimensions and create scales
    if (!__.dimensions.length) pc.detectDimensions();
    if (!(__.dimensions[0] in yscale)) pc.autoscale();

    pc.render[__.mode]();

    events.render.call(this);
    return this;
  };

  pc.render.default = function() {
    pc.clear('foreground');
    if (__.brushed) {
      __.brushed.forEach(path_foreground);
    } else {
      __.data.forEach(path_foreground);
    }
  };

  pc.render.queue = function() {
    if (__.brushed) {
      rqueue(__.brushed);
    } else {
      rqueue(__.data);
    }
  };

  pc.shadows = function() {
    flags.shadows = true;
    if (__.data.length > 0) paths(__.data, ctx.shadows);
    return this;
  };

  pc.axisDots = function() {
    var ctx = pc.ctx.marks;
    ctx.globalAlpha = d3.min([1/Math.pow(data.length, 1/2), 1]);
    __.data.forEach(function(d) {
      __.dimensions.map(function(p,i) {
        ctx.fillRect(position(p)-0.75,yscale[p](d[p])-0.75,1.5,1.5);
      });
    });
    return this;
  };

  pc.clear = function(layer) {
    ctx[layer].clearRect(0,0,w()+2,h()+2);
    return this;
  };

  pc.createAxes = function() {
    if (g) pc.removeAxes(); 

    // Add a group element for each dimension.
    g = pc.svg.selectAll(".dimension")
        .data(__.dimensions, function(d) { return d; })
      .enter().append("svg:g")
        .attr("class", "dimension")
        .attr("transform", function(d) { return "translate(" + xscale(d) + ")"; })

    // Add an axis and title.
    g.append("svg:g")
        .attr("class", "axis")
        .attr("transform", "translate(0,0)")
        .each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
      .append("svg:text")
        .attr({
          "text-anchor": "middle",
          "y": 0,
          "transform": "translate(0,-12)",
          "x": 0,
          "class": "label"
        })
        .text(String)

    flags.axes= true;
    return this;
  };

  pc.removeAxes = function() {
    g.remove();
    return this;
  };

  pc.updateAxes = function() {
    var g_data = pc.svg.selectAll(".dimension")
        .data(__.dimensions, function(d) { return d; })

    g_data.enter().append("svg:g")
        .attr("class", "dimension")
        .attr("transform", function(p) { return "translate(" + position(p) + ")"; })
        .style("opacity", 0)
        .append("svg:g")
        .attr("class", "axis")
        .attr("transform", "translate(0,0)")
        .each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
      .append("svg:text")
        .attr({
          "text-anchor": "middle",
          "y": 0,
          "transform": "translate(0,-12)",
          "x": 0,
          "class": "label"
        })
        .text(String);

    g_data.exit().remove();

    g = pc.svg.selectAll(".dimension");

    g.transition().duration(1100)
      .attr("transform", function(p) { return "translate(" + position(p) + ")"; })
      .style("opacity", 1)
   if (flags.shadows) paths(__.data, ctx.shadows);
    return this;
  };

  pc.brushable = function() {
    if (!g) pc.createAxes(); 

    // Add and store a brush for each axis.
    g.append("svg:g")
        .attr("class", "brush")
        .each(function(d) {
          d3.select(this).call(
            yscale[d].brush = d3.svg.brush()
              .y(yscale[d])
              .on("brush", pc.brush)
          );
        })
      .selectAll("rect")
        .style("visibility", null)
        .attr("x", -15)
        .attr("width", 30)
    flags.brushable = true;
    return this;
  };

  // Jason Davies, http://bl.ocks.org/1341281
  pc.reorderable = function() {
    if (!g) pc.createAxes(); 

    g.style("cursor", "move")
      .call(d3.behavior.drag()
        .on("dragstart", function(d) {
          dragging[d] = this.__origin__ = xscale(d);
        })
        .on("drag", function(d) {
          dragging[d] = Math.min(w(), Math.max(0, this.__origin__ += d3.event.dx));
          __.dimensions.sort(function(a, b) { return position(a) - position(b); });
          xscale.domain(__.dimensions);
          pc.render();
          g.attr("transform", function(d) { return "translate(" + position(d) + ")"; })
        })
        .on("dragend", function(d) {
          delete this.__origin__;
          delete dragging[d];
          d3.select(this).transition().attr("transform", "translate(" + xscale(d) + ")");
          pc.render();
        }));
    flags.reorderable = true;
    return this;
  };

  pc.interactive = function() {
    flags.interactive = true;
    return this;
  };

  // Get data within brushes
  pc.brush = function() {
    __.brushed = selected();  
    pc.render();
    //extent_area();
    events.brush.call(pc,__.brushed);
  };

  // expose a few objects
  pc.xscale = xscale;
  pc.yscale = yscale;
  pc.ctx = ctx;
  pc.canvas = canvas;
  pc.g = function() { return g; };

  // TODO
  pc.brushReset = function(dimension) {
    yscale[dimension].brush.clear()(
      pc.g()
        .filter(function(p) {
          return dimension == p;
        })
    )
    return this;
  };

  // rescale for height, width and margins
  pc.resize = function() {
    // selection size
    pc.selection.select("svg") 
      .attr("width", __.width)
      .attr("height", __.height)
    pc.svg.attr("transform", "translate(" + __.margin.left + "," + __.margin.top + ")");

    // scales
    pc.autoscale();

    // axes
    if (g) {
      g.attr("transform", function(d) { return "translate(" + xscale(d) + ")"; })
      g.selectAll("g.axis").each(function(d) { d3.select(this).call(axis.scale(yscale[d])); })
    };
 
    pc.render();
    events.resize.call(this, {width: __.width, height: __.height, margin: __.margin});
    return this;
  };

  // highlight an array of data
  pc.highlight = function(data) {
    pc.clear("highlight");
    d3.select(canvas.foreground).classed("faded", true);
    data.forEach(path_highlight);
    events.highlight.call(this,data);
    return this;
  };

  // clear highlighting
  pc.unhighlight = function(data) {
    pc.clear("highlight");
    d3.select(canvas.foreground).classed("faded", false);
    return this;
  };

  // draw single polyline
  function color_path(d, ctx) {
    ctx.strokeStyle = d3.functor(__.color)(d);
    ctx.beginPath();
    __.dimensions.map(function(p,i) {
      if (i == 0) {
        ctx.moveTo(position(p),yscale[p](d[p]));
      } else { 
        ctx.lineTo(position(p),yscale[p](d[p]));
      }
    });
    ctx.stroke();
  };

  // draw many polylines of the same color
  function paths(data, ctx) {
    ctx.clearRect(-1,-1,w()+2,h()+2);
    ctx.beginPath();
    data.forEach(function(d) {
      __.dimensions.map(function(p,i) {
        if (i == 0) {
          ctx.moveTo(position(p),yscale[p](d[p]));
        } else { 
          ctx.lineTo(position(p),yscale[p](d[p]));
        }
      });
    });
    ctx.stroke();
  };

  function extent_area() {
    pc.clear('extents');

    // no active brushes
    var actives = __.dimensions.filter(is_brushed);
    if (actives.length == 0) return;

    // create envelope
    var ctx = pc.ctx.extents;
    ctx.beginPath();
    __.dimensions.map(function(p,i) {
      if (i == 0) {
        ctx.moveTo(xscale(p), brush_max(p));
      } else { 
        ctx.lineTo(xscale(p), brush_max(p));
      }
    });
    __.dimensions.reverse().map(function(p,i) {
      ctx.lineTo(xscale(p), brush_min(p));
    });
    ctx.fill();
    ctx.stroke();
  };

  function is_brushed(p) { 
    return !yscale[p].brush.empty();
  };

  function brush_max(p) {
    return is_brushed(p) ? yscale[p](yscale[p].brush.extent()[1]) : 0;
  };

  function brush_min(p) {
    return is_brushed(p) ? yscale[p](yscale[p].brush.extent()[0]) : h();
  };

  function position(d) {
    var v = dragging[d];
    return v == null ? xscale(d) : v;
  }

  // data within extents
  function selected() {
    var actives = __.dimensions.filter(is_brushed),
        extents = actives.map(function(p) { return yscale[p].brush.extent(); });

    return __.data
      .filter(function(d) {
        return actives.every(function(p, dimension) {
          return extents[dimension][0] <= d[p] && d[p] <= extents[dimension][1];
        });
      });
  };

  function path_foreground(d) {
    return color_path(d, ctx.foreground);
  };

  function path_highlight(d) {
    return color_path(d, ctx.highlight);
  };

  // getter/setter with event firing
  function getset(obj,state,events)  {
    d3.keys(state).forEach(function(key) {   
      obj[key] = function(x) {
        if (!arguments.length) return state[key];
        var old = state[key];
        state[key] = x;
        side_effects[key].call(pc,{"value": x, "previous": old});
        events[key].call(pc,{"value": x, "previous": old});
        return obj;
      };
    });
  };

  function extend(target, source) {
    for (key in source) {
      target[key] = source[key];
    }
    return target;
  };

  return pc;
};

d3.parcoords.version = "0.1.6";

// quantitative dimensions based on numerical or null values in the first row
d3.parcoords.quantitative = function(data) {
  return d3.keys(data[0])
    .filter(function(col) {
      var v = data[0][col];
      return (parseFloat(v) == v) && (v != null);
    });
};

// a better "typeof" from this post: http://stackoverflow.com/questions/7390426/better-way-to-get-type-of-a-javascript-variable
d3.parcoords.toType = function(v) {
  return ({}).toString.call(v).match(/\s([a-zA-Z]+)/)[1].toLowerCase()
};

// try to coerce to number before returning type
d3.parcoords.toTypeCoerceNumbers = function(v) {
  if ((parseFloat(v) == v) && (v != null)) return "number";
  return d3.parcoords.toType(v);
};

// attempt to determine types of each dimension based on first row of data
d3.parcoords.detectDimensionTypes = function(data) {
  var types = {}
  d3.keys(data[0])
    .forEach(function(col) {
      types[col] = d3.parcoords.toTypeCoerceNumbers(data[0][col]);
    });
  return types;
};

// pairs of adjacent dimensions
d3.parcoords.adjacent_pairs = function(arr) {
  var ret = [];
  for (var i = 0; i < arr.length-1; i++) {
    ret.push([arr[i],arr[i+1]]);
  };
  return ret;
};

// calculate 2d intersection of line a->b with line c->d
// points are objects with x and y properties
d3.parcoords.intersection =  function(a, b, c, d) {
  return {
    x: ((a.x * b.y - a.y * b.x) * (c.x - d.x) - (a.x - b.x) * (c.x * d.y - c.y * d.x)) / ((a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x)),
    y: ((a.x * b.y - a.y * b.x) * (c.y - d.y) - (a.y - b.y) * (c.x * d.y - c.y * d.x)) / ((a.x - b.x) * (c.y - d.y) - (a.y - b.y) * (c.x - d.x))
  };
};

d3.renderQueue = (function(func) {
  var _queue = [],                  // data to be rendered
      _rate = 10,                 // number of calls per frame
      _invalidate = function() {},  // invalidate last render queue
      _clear = function() {};       // clearing function

  var rq = function(data) {
    if (data) rq.data(data);
    _invalidate();
    _clear();
    rq.render();
  };

  rq.render = function() {
    var valid = true;
    _invalidate = rq.invalidate = function() {
      valid = false;
    };

    function doFrame() {
      if (!valid) return true;
      if (!_queue.length) return true;
      var chunk = _queue.splice(0,_rate);
      chunk.map(func);
      timer_frame(doFrame);
    }

    doFrame();
  };

  rq.data = function(data) {
    _invalidate();
    _queue = data.slice(0);
    return rq;
  };

  rq.add = function(data) {
    _queue = _queue.concat(data);
  };

  rq.rate = function(value) {
    if (!arguments.length) return _rate;
    _rate = value;
    return rq;
  };

  rq.remaining = function() {
    return _queue.length;
  };

  // clear the canvas
  rq.clear = function(func) {
    if (!arguments.length) {
      _clear();
      return rq;
    }
    _clear = func;
    return rq;
  };

  rq.invalidate = _invalidate;

  var timer_frame = window.requestAnimationFrame
    || window.webkitRequestAnimationFrame
    || window.mozRequestAnimationFrame
    || window.oRequestAnimationFrame
    || window.msRequestAnimationFrame
    || function(callback) { setTimeout(callback, 17); };

  return rq;
});

trackball.js

// this isn't structured like a proper behavior. something
// that would probably be useful, for now I just want to encapsulate it
// all the code below comes from http://bl.ocks.org/patricksurry/5721459

d3.behavior.trackball = function(svg) {
  
svg
  .on("mousedown", mousedown)
  .on("mousemove", mousemove)
  .on("mouseup", mouseup);
  
function trackballAngles(pt) {
  // based on http://www.opengl.org/wiki/Trackball  
  // given a click at (x,y) in canvas coords on the globe (trackball),
  // calculate the spherical coordianates for the point as a rotation around
  // the vertical and horizontal axes
  
  var r = projection.scale();
  var c = projection.translate();
  var x = pt[0] - c[0], y = - (pt[1] - c[1]), ss = x*x + y*y;


  var z = r*r > 2 * ss ? Math.sqrt(r*r - ss) : r*r / 2 / Math.sqrt(ss);  

  var lambda = Math.atan2(x, z) * 180 / Math.PI; 
  var phi = Math.atan2(y, z) * 180 / Math.PI
  return [lambda, phi];
}

/*
This is the cartesian equivalent of the rotation matrix, 
which is the product of the following rotations (in numbered order):
1. longitude: λ around the y axis (which points up in the canvas)
2. latitude: -ϕ around the x axis (which points right in the canvas)
3. yaw:       γ around the z axis (which points out of the screen)

NB.  If you measure rotations in a positive direction according to the right-hand rule 
(point your right thumb in the positive direction of the rotation axis, and rotate in the
direction of your curled fingers), then the latitude rotation is negative.

R(λ, ϕ, γ) = 
[[ sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ), −sin(γ)cos(ϕ), −sin(γ)sin(ϕ)cos(λ)+sin(λ)cos(γ)],
 [ −sin(λ)sin(ϕ)cos(γ)+sin(γ)cos(λ), cos(γ)cos(ϕ), sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ)],
 [ −sin(λ)cos(ϕ),                    −sin(ϕ),       cos(λ)cos(ϕ)]]

If you then apply a "trackball rotation" of δλ around the y axis, and -δϕ around the 
x axis, you get this horrible composite matrix:

R2(λ, ϕ, γ, δλ, δϕ) = 
[[−sin(δλ)sin(λ)cos(ϕ)+(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))cos(δλ),
        −sin(γ)cos(δλ)cos(ϕ)−sin(δλ)sin(ϕ),
                sin(δλ)cos(λ)cos(ϕ)−(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))cos(δλ)],
 [−sin(δϕ)sin(λ)cos(δλ)cos(ϕ)−(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))sin(δλ)sin(δϕ)−(sin(λ)sin(ϕ)cos(γ)−sin(γ)cos(λ))cos(δϕ),
        sin(δλ)sin(δϕ)sin(γ)cos(ϕ)−sin(δϕ)sin(ϕ)cos(δλ)+cos(δϕ)cos(γ)cos(ϕ),
                sin(δϕ)cos(δλ)cos(λ)cos(ϕ)+(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))sin(δλ)sin(δϕ)+(sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ))cos(δϕ)],
 [−sin(λ)cos(δλ)cos(δϕ)cos(ϕ)−(sin(γ)sin(λ)sin(ϕ)+cos(γ)cos(λ))sin(δλ)cos(δϕ)+(sin(λ)sin(ϕ)cos(γ)−sin(γ)cos(λ))sin(δϕ),
        sin(δλ)sin(γ)cos(δϕ)cos(ϕ)−sin(δϕ)cos(γ)cos(ϕ)−sin(ϕ)cos(δλ)cos(δϕ),
                cos(δλ)cos(δϕ)cos(λ)cos(ϕ)+(sin(γ)sin(ϕ)cos(λ)−sin(λ)cos(γ))sin(δλ)cos(δϕ)−(sin(ϕ)cos(γ)cos(λ)+sin(γ)sin(λ))sin(δϕ)]]
                
by equating components of the matrics 
(label them [[a00, a01, a02], [a10, a11, a12], [a20, a21, a22]])
we can find an equivalent rotation R(λ', ϕ', γ') == RC(λ, ϕ, γ, δλ, δϕ) :
 
if cos(ϕ') != 0:
 γ' = atan2(-RC01, RC11)
 ϕ' = atan2(-RC21, γ' == 0 ? RC11 / cos(γ') : - RC01 / sin(γ'))
 λ' = atan2(-RC20, RC22)
else:
 // when cos(ϕ') == 0, RC21 == - sin(ϕ') == +/- 1
 // the solution is degenerate, requiring just that
 //    γ' - λ' = atan2(RC00, RC10) if RC21 == -1 (ϕ' = π/2)
 // or γ' + λ' = atan2(RC00, RC10) if RC21 == 1 (ϕ' = -π/2)
 // so choose:
 γ' = atan2(RC10, RC00) - RC21 * λ
 ϕ' = - RC21 * π/2
 λ' = λ

*/

function composedRotation(λ, ϕ, γ, δλ, δϕ) {
    λ = Math.PI / 180 * λ;
    ϕ = Math.PI / 180 * ϕ;
    γ = Math.PI / 180 * γ;
    δλ = Math.PI / 180 * δλ;
    δϕ = Math.PI / 180 * δϕ;
    
    var sλ = Math.sin(λ), sϕ = Math.sin(ϕ), sγ = Math.sin(γ), 
        sδλ = Math.sin(δλ), sδϕ = Math.sin(δϕ),
        cλ = Math.cos(λ), cϕ = Math.cos(ϕ), cγ = Math.cos(γ), 
        cδλ = Math.cos(δλ), cδϕ = Math.cos(δϕ);

    var m00 = -sδλ * sλ * cϕ + (sγ * sλ * sϕ + cγ * cλ) * cδλ,
            m01 = -sγ * cδλ * cϕ - sδλ * sϕ,
                m02 = sδλ * cλ * cϕ - (sγ * sϕ * cλ - sλ * cγ) * cδλ,
        m10 = - sδϕ * sλ * cδλ * cϕ - (sγ * sλ * sϕ + cγ * cλ) * sδλ * sδϕ - (sλ * sϕ * cγ - sγ * cλ) * cδϕ,
            m11 = sδλ * sδϕ * sγ * cϕ - sδϕ * sϕ * cδλ + cδϕ * cγ * cϕ,
                 m12 = sδϕ * cδλ * cλ * cϕ + (sγ * sϕ * cλ - sλ * cγ) * sδλ * sδϕ + (sϕ * cγ * cλ + sγ * sλ) * cδϕ,
        m20 = - sλ * cδλ * cδϕ * cϕ - (sγ * sλ * sϕ + cγ * cλ) * sδλ * cδϕ + (sλ * sϕ * cγ - sγ * cλ) * sδϕ,
            m21 = sδλ * sγ * cδϕ * cϕ - sδϕ * cγ * cϕ - sϕ * cδλ * cδϕ,
                 m22 = cδλ * cδϕ * cλ * cϕ + (sγ * sϕ * cλ - sλ * cγ) * sδλ * cδϕ - (sϕ * cγ * cλ + sγ * sλ) * sδϕ;
                 
    if (m01 != 0 || m11 != 0) {
         γ_ = Math.atan2(-m01, m11);
         ϕ_ = Math.atan2(-m21, Math.sin(γ_) == 0 ? m11 / Math.cos(γ_) : - m01 / Math.sin(γ_));
         λ_ = Math.atan2(-m20, m22);
    } else {
         γ_ = Math.atan2(m10, m00) - m21 * λ;
         ϕ_ = - m21 * Math.PI / 2;
         λ_ = λ;       
    }
    
    return([λ_ * 180 / Math.PI, ϕ_ * 180 / Math.PI, γ_ * 180 / Math.PI]);
}
    
var m0 = null,
    o0;

var dispatch = d3.dispatch("rotate")
function mousedown() {  // remember where the mouse was pressed, in canvas coords
  m0 = trackballAngles(d3.mouse(svg[0][0]));
  o0 = projection.rotate();
  d3.event.preventDefault();
}

function mousemove() {
  if (m0) {  // if mousedown
    var m1 = trackballAngles(d3.mouse(svg[0][0]));
    // we want to find rotate the current projection so that the point at m0 rotates to m1
    // along the great circle arc between them.
    // when the current projection is at rotation(0,0), with the north pole aligned
    // to the vertical canvas axis, and the equator aligned to the horizontal canvas
    // axis, this is easy to do, since D3's longitude rotation corresponds to trackball
    // rotation around the vertical axis, and then the subsequent latitude rotation 
    // corresponds to the trackball rotation around the horizontal axis.
    // But if the current projection is already rotated, it's harder.  
    // We need to find a new rotation equivalent to the composition of both
    
    // Choose one of these three update schemes:
    
    // Best behavior
    o1 = composedRotation(o0[0], o0[1], o0[2], m1[0] - m0[0], m1[1] - m0[1])
    
    // Improved behavior over original example
    //o1 = [o0[0] + (m1[0] - m0[0]), o0[1] + (m1[1] - m0[1])];
    
    // Original example from http://mbostock.github.io/d3/talk/20111018/azimuthal.html
    // o1 = [o0[0] - (m0[0] - m1[0]) / 8, o0[1] - (m1[1] - m0[1]) / 8];

    // move to the updated rotation
    dispatch.rotate(o1);
    //projection.rotate(o1);
    
    // We can optionally update the "origin state" at each step.  This has the 
    // advantage that each 'trackball movement' is small, but the disadvantage of
    // potentially accumulating many small drifts (you often see a twist creeping in
    // if you keep rolling the globe around with the mouse button down)    
//    o0 = o1;
//    m0 = m1;

    //svg.selectAll("path").attr("d", path); 
  }
}

function mouseup() {
  if (m0) {
    mousemove();
    m0 = null;
  }
}
  
  return dispatch;
}