All Articles

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
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
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

Published Jan 27, 2024

Striving to stay curious.