Displaying a 2D vector field with D3

D3 is a JavaScript library priamrily used for data visualisation. The API is neat and coupled with modern browsers’ native support for SVG (Scalable Vector Graphics), we can do some pretty funky stuff.

I was recently following a course on Khan Academy that touched on vector fields. I don’t own a copy of Mathematica/Matlab and was wondering if I could draw one in the browser. TL;DR - yes, and it’s actually easy!

We’ll kick this off by trying to plot the vector field \(\vec{F}(x,y) = [-y, x]^{T}\).

Boilerplate

We’re not using jQuery so we’ll rely on the DOMContentLoaded event to trigger our code. Note we’re using d3 v4 (which has some breaking changes compared to v3).

<!DOCTYPE html>
<html>
  <head>
    <script type="text/javascript" src="d3.v4.min.js"></script>
    <script type="text/javascript">

      document.addEventListener("DOMContentLoaded", function(e) {
        // most of our code goes here
      };
    </script>
  </head>
  <body>
    <p>Hello!</p>
  </body>
</html>

Our ‘canvas’ will be an SVG element which we append on the fly:

var width   = 600,
    height  = 600,
    margin  = 100;

var svgSelection = d3.select("body").append("svg")
      .attr("width", width +2*margin)
      .attr("height", height +2*margin)
      .append("g")
        .attr("translate(" + margin + "," + margin + ")");

Axis

Getting a cartesian plane drawn up is relatively straight-forward. We’ll rely on two linear scales - one for the x-axis and one for the y-axis. We use the translate directive to center those in the middle of screen - otherwise they’d be stuck on the bottom and left-hand-side of the screen.

var yScale = d3.scaleLinear().range([height,0]).domain([-5,5]);
var xScale = d3.scaleLinear().range([0,width]).domain([-5,5]);

svg.append("g")
   .attr("transform", "translate(0," + height/2 + ")")
   .call(d3.axisBottom(xScale));

svg.append("g")
   .attr("transform", "translate(" + width/2 + ",0)")
   .call(d3.axisLeft(yScale));

Results so far:

A single vector

Let’s now try to graph a single vector, say for \((2,-1)\). The vector for this will be \((1,2)\), starting at the origin. To draw it we use the path directive. It’s not D3 specific (which has an API to draw lines) but part of the SVG Standard.

We need to apply a transform to shift it to its ‘correct’ position. The original vector is in green and the transformed one in blue.

Instead of the usual arrow denoting the tip of the vector we’ll use pinheads - it’s not as pretty but a lot simpler to draw and just as informative. This is done by drawing a small circle at the tip of the vector using the circle attribute.

// sample vector
p = {x:2 , y:-1,
     vx:1, vy:2,
     magnitude:Math.sqrt(2*2 + 1*1)};

// un-transformed
svg.append("g")
   .append("path")
   .attr("d", "M" + xScale(0) + " " + yScale(0) + " L" + xScale(p.vx) + " " + yScale(p.vy))
   .attr("stroke", "green")
   .attr("stroke-width", 2)
   .attr("fill", "none");

svg.append("g")
   .append("circle")
   .attr("r",3)
   .attr("cx", xScale(p.vx))
   .attr("cy", yScale(p.vy));

// transformed
svg.append("g")
   .append("path")
   .attr("d", "M" + xScale(0) + " " + yScale(0) + " L" + xScale(p.vx) + " " + yScale(p.vy))
   .attr("stroke", "blue")
   .attr("stroke-width", 2)
   .attr("fill", "none")
   .attr("transform", "translate(" + (xScale(p.x) - xScale(0)) + "," + (yScale(p.x) - yScale(0)) + ")");

svg.append("g")
   .append("circle")
   .attr("r",3)
   .attr("cx", xScale(p.vx))
   .attr("cy", yScale(p.vy))
   .attr("transform", "translate(" + (xScale(p.x) - xScale(0)) + "," + (yScale(p.vy) - yScale(0)) + ")");

Scaling and multiple vectors

Different vectors will have different magnitudes (or length). If we were to display the actual magnitudes we would soon end off-graph. It makes sense to scale each vector down to minimise overlap.

Whatever the spacing between points, the maximum magnitude should be less or equal to that. So if we were plotting every unit (e.g. -5, -4, …) we’d want the magnitude to be less or equal to 1 to avoid any overlap.

Just like we have scales for the x and y axis, we can create another linear scale:

var grid_spacing = 0.5;
var max_magnitude = data.reduce(function (max_, it) {
            return max_ > it.magnitude ? max_ : it.magnitude;
        }, 0);

var vscale = d3.scalePow().domain([0,max_magnitude]).range([0,grid_spacing]);

Where data is an array of points and magnitude represents the length of each individual vector. Note the use of a power scale to scale the length of the vector - this will help us better accentuate the difference between the extremes (though it can just as easily be changed to a linear one with scaleLinear).

Computing for every point on our grid then looks like this:

var vfield = function(d) {
    d.vx = -d.y;
    d.vy = d.x;
    d.magnitude = Math.sqrt(d.vx*d.vx + d.vy*d.vy);
}

var grid_spacing = 0.5
data = [];
for (var i=-5; i <= 10; i+= grid_spacing){
    for (var j=-5; j<=10; j+= grid_spacing) {
        var pt = {x:i, y:j};
        vfield(pt);
        data.push(pt);
    }
}

Which we can plot accordingly:

data.forEach(function(p)
{
    // we first scale down to a unit vector
    p.vx /= p.magnitude;
    p.vy /= p.magnitude;
    // and now scale it to our own scale
    p.vx *= vscale(p.magnitude);
    p.vy *= vscale(p.magnitude);

    // vector
    svg.append("g")
    .append("path")
    .attr("d", "M" + xScale(0) + " " + yScale(0) + " L" + xScale(p.vx) + " " + yScale(p.vy))
    .attr("stroke", "blue"))
    .attr("stroke-width", 1)
    .attr("fill", "none")
    .attr("transform", "translate(" + (xScale(p.x) - xScale(0)) + "," + (yScale(p.y) - yScale(0)) + ")")
    ;
    // pinhead
    svg.append("g")
    .append("circle")
    .attr("r",2)
    .attr("cx", xScale(p.vx))
    .attr("cy", yScale(p.vy))
    .attr("transform", "translate(" + (xScale(p.x) - xScale(0)) + "," + (yScale(p.y) - yScale(0)) + ")")
    ;
    
}

Giving us:

Colour scale

We can do one better though. Instead of using a single colour we can use the magnitude on a colour scale.

var colorScale = d3.scaleSequential(d3.interpolateInferno).domain([0,max_magnitude]);

And changing the stroke attribute to:

.attr("stroke", colorScale(p.magnitude))

Giving us our final result:

Taking it further

You can find the above in a self-contained html file here. I hadn’t really used D3 before so it’s quite likely the code above is ugly at best - and there are better ways to do this. Animating the flow would be great - it often makes vector fields a little easier to follow. So would adding a legend for the colour scale.