Making 3D Hexplots With Maplibre

One of the curses I've found about being a software developer is knowing there has to be a way (or a better way) to do something, and then nerd sniping yourself until you figure out how to do the thing.

Nerd Sniping - XKCD 356

Cue me, over Christmas break, wanting to make a map. I knew I could do some 3d visualization based on some of the Maplibre examples, but I had a very specific idea in my brain of what I wanted to make.

I'd done a lot of work with Bridge.Watch using D3 for 2D data visualizations, but I really wanted to see what I could add as 3d data on a real map, rather than just a country/state/county svg from the TopoJSON U.S. Atlas.

To further complicate things, I really liked the visualization I'd done with hexbinning for the national bridge overview.

I wanted a 3D hexbin, and after a bit of mucking around with some hacky scripts, I finally have it.

3D Hexbin Map

To utilize the d3-hexbin library rather than rewriting my own hex functions, I had to transform the data into the projected coordinates of a 2d visualization, and then transform them back out to latitudes and longitude coordinates that could be stored as polygonal features in a geojson.

Projection and Hexbin functions

const width = 975;
const height = 610;
const scaleValue = 1300;
const radius = 6;

const projection = d3
  .geoAlbersUsa()
  .scale(scaleValue)
  .translate([width * 0.5, height * 0.5]);

const customHexbin = hexbin()
  .extent([
    [0, 0],
    [width, height],
  ])
  .radius(radius);

I used the same width/height/scaling I'd used in a few other maps, and played with the radius a bit to come up with the right balance of bins to data.

The data I wanted to plot was all the plants and factories contained within the EC3 Environmental Product Declaration Database. The data set wasn't too large, and I knew it would have a fairly sparse distribution, with a lot of the EPDs coming from plants in metropolitan areas and ports. I ran the material data through the projection function and filtered out any EPDs/Plants that were missing latitudes and longitudes before running it through the customHexbin function.

let hexBins = [];
if (Array.isArray(materials)) {
  const projectedData = materials
    .map((d) => projection([d.longitude, d.latitude]))
    .filter((d) => d !== null);
  hexBins = customHexbin(projectedData);
}

The hexbin API is pretty limited. The most I could figure out for actually getting the points was to run the hexbin.hexagon([radius]) function, obtain the svg path, and then extracting the points from the path data using a very complicated regular expression. I did this for a single baseHex centered at the origin, and then offset all the other points based on the center points of each hex, which I could extract via hexbin.centers().

function svgPathToCoordinates(pathData) {
  const coordinates = [];
  const pathCommands =
    pathData.match(
      /[a-df-z]?\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?,?\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?/g
    ) || [];

  let currentX = 0;
  let currentY = 0;

  let startingPoint = [];
  pathCommands.forEach((command) => {
    const cmd = command[0];
    const values = command
      .slice(1)
      .split(/[,\s]+/)
      .map(parseFloat);

    switch (cmd) {
      case "m":
        currentX = values[0];
        currentY = values[1];
        coordinates.push([currentX, currentY]);
        startingPoint = [currentX, currentY];
        break;
      case "l":
        currentX = currentX + values[0];
        currentY = currentY + values[1];
        coordinates.push([currentX, currentY]);
        break;
    }
  });

  coordinates.push(startingPoint);

  return coordinates;
}

const baseHex = svgPathToCoordinates(customHexbin.hexagon(radius));

Once I had the base coordinates for the properly scaled hexagon, I simply mapped all the hexbins to their projected coordinates (so within the 975 x 610 box), and inverted the projection to obtain the latitudes and longitudes of the hexes, and then made the properly shaped polygon feature with a height attribute of the length of the hexbin (how many items fall within the boundaries).

const hexFeatures = hexBins.map((hex, i) => {
  const newCoordinates = baseHex.map((point) => [
    point[0] + hex.x,
    point[1] + hex.y,
  ]);
  const latLong = newCoordinates.map((point) => projection.invert(point));
  return {
    type: "Feature",
    geometry: {
      type: "Polygon",
      coordinates: [latLong],
    },
    properties: {
      count: hex.length,
      height: hex.length,
      base_height: 0,
      color: "green",
    },
    id: `h_${i}`,
  };
});

I wrote all the hexes as a FeatureCollection to a JSON file, and then loaded the static file into an existing Map component:

map.on("load", function () {
      map.resize();

      map.addSource("hexes", {
        type: "geojson",
        data: hexbins,
      });

      map.addLayer({
        id: "boundary",
        type: "fill-extrusion",
        source: "hexes",
        paint: {
          // See the MapLibre Style Specification for details on data expressions.
          // https://maplibre.org/maplibre-style-spec/expressions/

          // Get the fill-extrusion-color from the source 'color' property.
          "fill-extrusion-color": ["get", "color"],

          // Get fill-extrusion-height from the source 'height' property.
          "fill-extrusion-height": ["*", ["get", "height"], 100],

          // Get fill-extrusion-base from the source 'base_height' property.
          "fill-extrusion-base": ["get", "base_height"],

          // Make extrusions slightly opaque for see through indoor walls.
          "fill-extrusion-opacity": 0.8,
        },
      });
    });

I'm sure with a little work, I could skip the writing/loading step, and even make the hex sizing interactive on the UI, but for now, this is enough to keep me out of the road.

Zoomable Map