A while ago, I wanted to plot a complex function that was defined on a sphere [i.e. $f \mapsto r(\vartheta, \varphi) e^{i\phi(\vartheta, \varphi)}$], and do it in such a way that both the magnitude and phase ($r$ and $\phi$, respectively) of the function would be made visible. Without resorting to complicated three-dimensional plotting methods, the most straightforward way of doing that is to use a colormap to display the phase, while the magnitude is used to set the transparency. (This makes it harder to read off the numerical value of the magnitude, but we’ll have to stomach that for now.)

While it’s fairly straightforward to achieve this using Matplotlib (see this StackExchange post for instance), I wanted to use a way that I could use to create an interactive interface in a website. As we’ll explore here, this can be done using the d3 plotting library which allows us to draw svg-based figures right into a browser. I also used the extensions d3-geo-voronoi for generating polygons that we can fill with a color, and d3-geo-projection to access a nonstandard geographical projection. For now we’ll focus on the plotting; the interactivity can come later.

Let’s see how to go about this.

Perhaps superfluous, but we’ll start things off by defining an <svg> element,

<div id = 'container1'>
  <svg id = 'svg1'></svg>
</div>

where we draw the graticule of the sphere we want to plot on according to the Hammer projection using the following piece of Javascript:

let width = parseInt(d3.select('#container1').style('width'));
let height = 400;
let svg = d3.select('#svg1')
  .attr('width', width)
  .attr('height', height);
drawGraticule(svg);

function drawGraticule(svg) {
  let projection = d3.geoHammer();
  /* Move the projection origin to the center,
  960 is d3's default projection width. */
  projection.translate([width/2, height/2])
    .scale(width/960 * projection.scale());
  let path = d3.geoPath().projection(projection);

  svg.append('path')
    .attr('d', path(d3.geoGraticule10()))
    .attr('stroke', '#aaa')
    .attr('stroke-width', 0.5)
    .attr('fill', 'none');
  svg.append('path').datum({type: 'Sphere'})
    .attr('d', path)
    .attr('stroke', '#aaa')
    .attr('stroke-width', 0.5)
    .attr('fill', 'none');
}

To plot a function on this sphere we’ll define a grid of points where we evaluate the function. Then we’ll color in a small region around our points according to the function’s value. In a Cartesian coordinate system we could simply draw a box around each point, but on a sphere things are more complicated (looking at the graticule drawn above this becomes apparent: an element $\Delta\vartheta\Delta\varphi$ doesn’t have the same surface area everywhere). Fortunately Voronoi diagrams (the collection of polygons that is constructed by drawing equidistant lines between pairs of points) provide a valuable tool to solve this problem, and they’re baked right into d3 for this very reason.

Let’s define an array of points in a JSON format that can be used by d3-geo,

let sampling = 20;
let points = generatePoints(sampling);  

function generatePoints() {
  let xcoords = d3.range(-180, 180, sampling);
  let ycoords = d3.range(-90, 90 + sampling, sampling);
  let pointarray = [];
  for (let m = 0; m < ycoords.length; m++) {
    for (let n = 0; n < xcoords.length; n++) {
      /* Add small random offset to prevent aliasing at dense sampling. */
      pointarray.push( [xcoords[n], ycoords[m] + Math.random() * 0.001] );
      }
  } 
  let points = {
    type: 'FeatureCollection',
    features: pointarray.map(function(d) {
      return {
        type: 'Point',
        coordinates: [ d[0], d[1] ]
        }
    })
  }
  return points
}

and use this to draw the Voronoi diagram (where we use random polygon colors):

let vor = drawVoronoi(svg, points, path);

function drawVoronoi(svg, points, path) {
  let vor = d3.geoVoronoi(points);
  svg.append('g')
    .selectAll('path')
    .data(vor.polygons().features)
    .enter()
      .append('path')
      .attr('d', path)
      .attr('fill', () => d3.interpolateTurbo(Math.random()));
  svg.append('g')
    .selectAll('path')
    .data(points.features)
    .enter()
      .append('path')
      .attr('d', path)
      .attr('opacity', 0.5);
  return vor
}

Pretty funky! Now the only thing that remains to be done is to link the color and opacity of the Voronoi cells to a function. Let’s plot the spherical harmonic

\[Y_1^{1}(\vartheta, \varphi) = -\frac{1}{2} e^{i\phi} \sin\left(\vartheta\right).\]

We’ll refine the mesh a little more to increase the resolution, and we’ll use the custom colormap icefire which I’ve defined elsewhere.

sampling = 3;
points = generatePoints(sampling);
plotFunction(svg, points, path, f);

function f(phi, theta) {
  return math.multiply( -math.exp( math.multiply(math.complex(0, 1), 2*math.pi*phi/360 )), math.sin(math.pi*theta/360))
}

function plotFunction(svg, points, path, f) {
  let vor = d3.geoVoronoi(points);
  svg.append('g')
    .selectAll('path')
    .data(vor.polygons().features)
    .enter()
      .append('path')
      .attr('d', path)
      .attr('fill', d => icefire( 
        (math.pi + f(d.properties.sitecoordinates[0], d.properties.sitecoordinates[1] ).arg())/(2*math.pi)
      ))
      .attr('opacity', d => f( d.properties.sitecoordinates[0], d.properties.sitecoordinates[1] ).abs()/0.5);
}