In [1]:
!date
Sat Jan 11 14:34:18 PST 2014

Fisheye Distortion Plugin

mpld3 creater Jake Vanderplas recently developed a plugin mechanism to permit adding custom interactivity to mpld3 plots. I had a little time to give it a try, and put together something I've always wanted: Cartesian fisheye distortion for scatter plots.

In [2]:
%pylab
import matplotlib.pyplot as plt, mpld3, mpld3.plugins
mpld3.enable_notebook(d3_url="/files/d3.v3.js")
Using matplotlib backend: module://IPython.kernel.zmq.pylab.backend_inline
Populating the interactive namespace from numpy and matplotlib

Here is the result:

In [5]:
fig = plt.figure()
xx = round_(randn(100), 2)
yy = round_(randn(100), 2)
scatter = plot(xx, yy, 's', color='k', mec='grey', mew=3)

fig.plugins = [mpld3.plugins.PointLabelTooltip(scatter[0]), FishEye()]

Here is the prototype plugin code:

In [3]:
from mpld3.plugins import PluginBase
import jinja2
import json


class FishEye(PluginBase):
    """A interactive fish eye distortion plugin"""
    HTML = jinja2.Template("""
    <script src="files/fisheye.js"></script>
    """)
    FIG_JS = jinja2.Template("""
    var a = fig.axes[0],
        xFisheye = d3.fisheye.scale(function() {return a.x;}).focus(360),
        yFisheye = d3.fisheye.scale(function() {return a.y;}).focus(90);
      
    fig.canvas.on("mousemove", function() {
        var mouse = d3.mouse(this);
        xFisheye.focus(mouse[0]);
        yFisheye.focus(mouse[1]);
        a.zoomed(true);
      });

    a.xdom = xFisheye;
    a.xmap = xFisheye;
    a.x = xFisheye;
    
    a.ydom = yFisheye;
    a.ymap = yFisheye;
    a.y = yFisheye;
    """)

    def __init__(self):
        self.id = self.generate_unique_id()

    def _fig_js_args(self):
        return dict(id=self.id)

In order to make the scales update properly, I had to tweak the AXIS_CLASS javascript as well. Maybe there is a better way to do this, though.

In [4]:
# monkey patch AXIS_CLASS to take scale from axes even if it has been changed
AXIS_CLASS = """
    function Axis(axes, position, nticks, tickvalues, tickformat){
      this.axes = axes;
      this.position = position;
      this.nticks = nticks;
      this.tickvalues = tickvalues;
      this.tickformat = tickformat;
      if (position == "bottom"){
        this.transform = "translate(0," + this.axes.height + ")";
        this.scale = function() { return this.axes.xdom; };  // changed here, and 3 analogous spots below
        this.class = "x axis";
      }else if (position == "top"){
        this.transform = "translate(0,0)"
        this.scale = function() { return this.axes.xdom; };  // changed
        this.class = "x axis";
      }else if (position == "left"){
        this.transform = "translate(0,0)";
        this.scale = function() { return this.axes.ydom; };  // changed
        this.class = "y axis";
      }else{
        this.transform = "translate(" + this.axes.width + ",0)";
        this.scale = function() { return this.axes.ydom; };  // changed
        this.class = "y axis";
      }
    }

    Axis.prototype.draw = function(){

      this.axis = d3.svg.axis()
                          .scale(this.scale())  // changed here, now scale is a function 
                          .orient(this.position)
                          .ticks(this.nticks)
                          .tickValues(this.tickvalues)
                          .tickFormat(this.tickformat);
      this.elem = this.axes.baseaxes.append('g')
                        .attr("transform", this.transform)
                        .attr("class", this.class)
                        .call(this.axis);
    };

    Axis.prototype.zoomed = function(){
    gFig = this.axis;
    this.axis.scale(this.scale());
      this.elem.call(this.axis);
    };
"""

mpld3._js.ALL_FUNCTIONS[2] = AXIS_CLASS

And to get it to work on nbviewer, I had to edit a few .js urls by hand. I tried to put that in the github gist for my own reference in the future.