Mapbox GL JS Tutorial: Part Two

If you haven’t already check out part one to see how we got to this point.

Step Three - Adding interactivity to the map

In our last step we add the basic GeoJSON data to the map but users probably want some information on the trails and trailheads they are seeing on the map and not just the location of points and lines. We will start with the trailheads first by changing the marker for them to a more official looking icon and adding a popup with the name of the trailhead.

First let’s go find a good icon for us to use. I headed over to The Noun Project and downloaded a customized SVG from their wide array of options. You can also download a free uncustomized SVGs and PNGs from them if you want to pick something different. After picking an icon you can add it to your ‘src’ folder under a new assets folder.

If you look back at the webpack config file you’ll see we already were set up for this using the output assetModuleFilename choice and the rules in the modules. We are going to add a marker class to our CSS that will reference this image and control its size.

However, the original way we added the trailheads doesn't allow for us to make edits to the markers to be custom. So we need to use fetch instead of Mapbox’s ‘addSource’.  Once we fetch the data we send the response to a function to create the marker and we will need to add some small CSS changes to cover it.

NOTE FROM MAPBOX

If you have a large GeoJSON file, you may want to load it as an external source rather than adding it inline. You can do so by linking to its URL, if it's hosted remotely, or by using fetch to load locally or from a third-party API.

We will use a for loop to create a div with the class name of ‘marker’ for each of the features on the list (i.e. the GeoJSON of the trailheads). This will be used by the custom CSS we are adding that is going to show a hiker SVG. Then we pass each of those divs into Mapbox’s marker feature and add a popup. We’ve looked at the GeoJSON properties for this file and the ‘LOC_NAME’ is a good option for a quick display of trailhead name in the popup. We will also add some small CSS for the popup.

//index.js after the load function
const createMarker = (featuresList) => {
  for (const feature of featuresList.features) {
    // create a HTML element for each feature
    const el = document.createElement('div');
    el.className = 'marker';
     
    // make a marker for each feature with a popup and add it to the map
    new mapboxgl.Marker(el)
        .setLngLat(feature.geometry.coordinates)
        .setPopup(
        new mapboxgl.Popup({offset: 25})
            .setHTML(
            `<h3>${feature.properties.LOC_NAME}</h3>`
            )
        )
        .addTo(map);
    }
}

fetch('data/GRSM_TRAILHEADS.geojson')
    .then(response => response.json())
    .then(data => createMarker(data))
    .catch(error => console.log(error));

//main.css
.marker {
    background-image: url('/src/assets/hiker.svg');
    background-size: cover;
    width: 25px;
    height: 25px;
    cursor: pointer;
}
.mapboxgl-popup {
    max-width: 200px;
}
  
.mapboxgl-popup-content {
    text-align: center;
    font-family: 'Open Sans', sans-serif;
}

You’ll need to reload/restart webpack since we added a new image file but then you should be able to see your new custom icons on the page. If you click on one a popup will appear.

Next we should make it so users can see the name of the trail for each line.

First let’s add a popup that will say the name of the trail. We can keep our existing data source and layer but will need to add a listener for the click event.  Mapbox has its own listener for the map and we can specify the layer to listen to. We can pass the specific event (e) into the function and then pull any details out of the GeoJSON properties in order to help our user. After checking to make sure the map is zoomed out enough for the popup we can set it on the coordinates of the trail.

//Event listener for clicks on trails layer
map.on('click', 'trails', (e)=>{
        // Get details of the clicked feature
        const coordinates = e.features[0].geometry.coordinates[0];
        const description = e.features[0].properties.TRAILNAME;
        const objectIdentifier = e.features[0].properties.OBJECTID;
        
        // Ensure that if the map is zoomed out such that multiple
        // copies of the feature are visible, the popup appears
        // over the copy being pointed to.
        while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
            coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
        }
        
        //Add a popup
        new mapboxgl.Popup()
        .setLngLat(coordinates)
        .setHTML(description)
        .addTo(map);
})

So that works alright but if you’ll notice the cursor doesn’t change to a click pointer to give the user any indication they might want to click on the trail. Our trailhead icons do not have this issue since we used a CSS attribute to account for that (“cursor: pointer”). Similar to the click event option we have a ‘mouseenter’ and ‘mouseleave’ event we can reference and specify the trails layer again. Using the API’s style cursor option we can easily add code to correct this.

// Listen for the mouse move over trails layer
// Change the cursor to a pointer
map.on('mousemove', 'trails', (e) => {
    map.getCanvas().style.cursor = 'pointer';
    });
     
// Change it back when it leaves
map.on('mouseleave', 'trails', () => {
    map.getCanvas().style.cursor = '';
});

It would also be good if we could give the user some indication of exact what line they clicked so they know for sure where the popup is referencing. To do this we will add a second layer of the trails that is filtered to not show until a click happens. Once it does we will filter that layer to show the entire length of the clicked trail (i.e. all trails with the same name) in a contrasting color.

Note: Most of the trails on this are just one line feature but a few, like the Appalachian Trail, are multi-feature. This ensures those are covered nicely by our new code.

First we add the layer to our map.on(‘load’) function but give it a different ID from the previous one. This layer should have different line-color as well and we add a filter attribute. The filter attribute translates to getting trailnames that equal a value. In this case we put empty quotes to indicate that we aren’t matching anything right now.

Then in the click event listener we add a line to update the filter for that layer using the clicked lines trailname.

map.on('load'), () => {
  ...
map.addLayer({
        id: 'trails_click',
        type: 'line',
        source: 'trails',
        layout: {
            'line-join': 'round',
            'line-cap': 'round'
            },
        paint: {
            'line-color': '#264a2a',
            'line-width': 4
            },
        filter: ['==', ['get','TRAILNAME'],''] //Only show matching filtered objects, '' means display none
    })
});

//click event listener add this line
map.setFilter('trails_click',['==', ['get','TRAILNAME'], description])

Now we can see the entire length of that trail highlighted on our map. Since we’ve got our icons and trails down we should add a legend to ensure our users understand the meaning of our symbology, even though on this simple map it is pretty obvious - it is good practice to do so.

Mapbox doesn’t have a preset legend option so we will need to add an overlay ourselves to the map. In your index.html you’ll need to add a div with two items in it.

<div class='legend'>
    <div>Trails <span class='trailKey'></span></div>
    <div>Trailheads <span class='legendIcon marker'></div>
</div>

Then we will need the corresponding CSS to put the legend in the bottom right area of our map. For our trail we have a class called trailKey which will show a small box the same color as the trails. For our trailheads we already have the marker class which has the marker svg in it but we will need to make sure that it floats left on the legend, so we add a secondary class to that. If we had multiple icons that needed to float left we could use that class over and over again.

.legend {
    position: fixed;
    bottom: 120px;
    right: 0;
    background: #fff;
    margin-right: 20px;
    font-family: 'Open Sans', sans-serif;
    overflow: auto;
    border-radius: 3px;
    padding: 10px;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
    line-height: 25px;
    height: 55px;
    margin-bottom: 40px;
    width: 140px;
}
.trailKey {
    border-radius: 20%;
    width: 15px;
    height: 15px;
    margin-right: 5px;
    background-color: #7e9480;
    float: right;
}

.legendIcon {
    float: right;
}

You should now have a simple legend on your map.

At this point our map is quite usable and provides good information to our user. We could stop here but there some more interactivity features we can add to really polish up this map. If you don’t want to add any more code you can skip to step five for how to publish this to the web using Netlify.

Step Four - Polishing it up

If you’ve been clicking around on the map you may have noticed that the popup for the trails is at the first coordinate pair in the GeoJSON which often is at a convergence of trails. It would look better if it was at the center of that trail segment. We could potential try to pick a random item in the array of coordinates for that line but each line will likely have a different number of coordinates and so it wouldn’t be the midpoint for all the lines. Instead we should calculate a midpoint along that line. Luckily the Turf.js API has such a feature for us to use. First we’ll need to install that package and import it into our index.js file.

If we look at the documentation for the along calculation we’ll see we need to give it a line string (our trail) plus a distance along the line to find the coordinates of. In our case this will be a calculation of half of the distance of that trail length. To get that we will use the length calculation from Turf as well. We will split this midpoint calculation into its own function for ease of readability and then call that function from our click event listener - passing in the clicked lines coordinates. You’ll noticed we send all of them and not just the first in the array. We’ll assign the result of that calculation to a variable and pass that to the popup.

//index.js
import * as turf from '@turf/turf';

//Using turf JS get the midpoint of the line
const getMidPointOfLine = (lineCoordinates) => {
    const trailLine = turf.lineString(lineCoordinates);
    const lengthTrailLine = turf.length(trailLine)
    const midpointCoord = turf.along(trailLine, lengthTrailLine/2);
    return midpointCoord.geometry.coordinates;
};

//Event listener for clicks on trails layer
map.on('click', 'trails', (e)=>{
  ...
    //Find the midpoint of the line to show the popup from
    let midpoint = getMidPointOfLine(e.features[0].geometry.coordinates);

  //Add a popup
        new mapboxgl.Popup()
        .setLngLat(midpoint) //<- different coordinates than before
        .setHTML(description)
        .addTo(map);
})

Now maybe you don’t want your users to have to click to find out the name of the trail. We could create a hover state for the mouse that will highlight the trail and provide a popup. We can use our original trail layer to adjust the line color based on a feature state of hover. (This is similar to using the layer data to drive the properties of the features. We did not touch on that here but Mapbox has a sample here). So we will replace our line color from a single hex value to an expression that is looking for a feature state. We will need to provide the boolean for the feature state based on the location of the mouse. Remember the mousemove (& mouseleave) function we made to change the cursor to a pointer? We can use that same ones to set the feature state. First we are going to create a global variable for the ID of the trail that is being highlighted. We will set this on mouse enter and the reset it on mouse leave. The using Mapbox’s set feature state we can specify the layer, the id of the feature and hover state to true.

map.on('load', () =>{
  ...
map.addLayer({
        id: 'trails',
        type: 'line',
        source: 'trails',
        layout: {
            'line-join': 'round',
            'line-cap': 'round'
            },
        paint: {
            'line-color': [
                'case',
                ['boolean', ['feature-state', 'hover'], false], //Adjust color based on hover state
                '#fbb03b','#7e9480',
            ],
            'line-width': 4
            },
        
    })
  ...
});
let hoveredStateId = null; //For use on styling hover

// Listen for the mouse move over trails layer
// Change the cursor to a pointer
// Update the feature state for hover effect
map.on('mousemove', 'trails', (e) => {
    map.getCanvas().style.cursor = 'pointer';
    if (e.features.length > 0) {
        hoveredStateId = e.features[0].id;
        map.setFeatureState(
            { source: 'trails', id: hoveredStateId },
            { hover: true }
        );
      }
});
     
//Change it back when it leaves
//Update hover state to false
map.on('mouseleave', 'trails', () => {
    map.getCanvas().style.cursor = '';
    if (hoveredStateId !== null) {
            map.setFeatureState(
            { source: 'trails', id: hoveredStateId },
            { hover: false }
            );
        }
    hoveredStateId = null;
});

Run this code and you’ll notice an occasionally funky thing happening if you keep your pointer over the line. You can get more than one line highlighted and it won’t remove itself until you go back over it. That doesn’t seem like what we want - especially if we want to add a popup as well. What’s happening is the hover state for that item isn’t getting set to false as our mouse movements aren’t triggering the mouse leave event listener.

In order to fix this we will need to check on the hover id status before setting it true. We can add a simple if statement that echos our mouseleave one to ensure only one trail remains highlighted at a time.

map.on('mousemove', 'trails', (e) => {
    map.getCanvas().style.cursor = 'pointer';
    if (e.features.length > 0) {
      //Same a mouse leave call, clear all highlights first
        if (hoveredStateId !== null) {
            map.setFeatureState(
            { source: 'trails', id: hoveredStateId },
            { hover: false }
            );
        }
        hoveredStateId = e.features[0].id;
        map.setFeatureState(
            { source: 'trails', id: hoveredStateId },
            { hover: true }
        );
      }
});

Great! Now let’s add a quick popup to this highlighted trail. I decided to reuse my midpoint code to keep the popup in the middle of the trail. Unlike our click event where we create an unnamed popup using the new mapboxgl.Popup function we’ll want to make this a name one so it can be removed on mouseleave.

Any ideas what would happen if you didn’t do this? Give it a try and see how the popups respond.

// Create a popup for hover state, but don't add it to the map yet.
const hoverpopup = new mapboxgl.Popup({
    closeButton: false,
    closeOnClick: true,
});

// Update the feature state for hover effect and add popup
map.on('mousemove', 'trails', (e) => {
    map.getCanvas().style.cursor = 'pointer';
    if (e.features.length > 0) {
        ...
        let midpoint = getMidPointOfLine(e.features[0].geometry.coordinates);
        const description = e.features[0].properties.TRAILNAME;
        hoverpopup
            .setLngLat(midpoint)
            .setHTML(description)
            .addTo(map);
        }
    });
     
//Change it back when it leaves
//Update hover state to false and remove popup
map.on('mouseleave', 'trails', () => {
    ...
    hoverpopup.remove;
});

For our final finishing touch we should add a header to our map page. In our index.html we’ll add a simple div with the class of header. We’ll add some CSS in to make the header fixed to the top of the page. I added a linear gradient to the background header so it looks like it blends right into the map. (https://cssgradient.io/ is helpful for this)

.header {
    height: 4vh;
    width: 100vw;
    text-align: center;
    font-family: Arial, Helvetica, sans-serif;
    font-size: 2rem;
    padding: 30px 0 30px;
    background: rgb(239,233,225);
    background: linear-gradient(0deg, rgba(239,233,225,0) 0%, rgba(239,233,225,0.8715861344537815) 30%, rgba(239,233,225,1) 100%);
    z-index: 3;
    position: absolute;
    
}
@media all and (max-width: 475px) { 
    .header {
        font-size: 1rem;
        height: 1vh;
        padding: 18px;
    }
}

Step Five - Publishing To The Web

After you’ve finished your own polishing touches you should have a pretty functional map, but it’s only local right now- you want to share your creation with others. There are a number of ways to get your code out on the web (for free) and I recommend you look into a number of them but for this tutorial we are going to use Netlify as it’s a very simple process. Hopefully as you’ve been following along you’ve been committing your code and pushing it to your Github repo. This is one of the requirements for publishing to Netlify. (Check out their getting started info.)

We also need to make sure our webpack and package.json configs are in line. One of the most important items is having a build command available. By default Netlify is looking for ‘npm run build’ which we conveniently have in our package.json already. We are also going to minify our code as part of the build process to make it smaller and more efficient for production.

Note: If you are planning on running your code from a public Github repo remember that your Mapbox Access token will be accessible from there. You can, and should, also go to your Mapbox token settings and make it only accessible to a specific website URL. This means you will need to use one token for local development and one for the production code.

We are going to make some updates to our webpack but first we are going to create a dev and prod config file to keep these items separate and have them join to the main config file. (I know this might seem a little overkill for our tiny map project, but sometimes I find doing the overkill items on easy projects make its more digestible for future uses). Create two new files, webpack.dev.js and webpack.prod.js. Rename the original config file webpack.common.js. We’ll be adding a merge package and the Terser plugin to help us handle this.

npm install webpack-merge --save-dev
npm install terser-webpack-plugin --save-dev

Our common file will remain almost the same except we will need to remove the development items into the webpack.dev.js file. The production file will contain our Terser plugin that will minify the code for production.

//webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');

 module.exports = merge(common, {
   mode: 'development',
   devtool: 'inline-source-map',
   devServer: {
    compress: true,
    port: 9000
  },
 });

//webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const TerserPlugin = require("terser-webpack-plugin");

module.exports = merge(common, {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
});

//webpack.common.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
    assetModuleFilename: 'src/assets/images/[name][ext]'
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Mapbox Boilerplate',
     template: 'src/index.html',
    }),
    new WebpackManifestPlugin({
      fileName: 'site.webmanifest'
    }),
    new CopyPlugin({
      patterns: [
        { from: "src/data", to: "data" },
      ],
    }),
  ],
  module: {
    rules: [
        {
            test: /\.css$/i,
            use: ['style-loader', 'css-loader']
        }, 
        {
            test: /\.(png|svg|jpg|jpeg|gif)$/i,
            type: 'asset/resource',
          },
    ]
  }
};

Finally we will update our package.json so that the build function that Netlify is looking for will be pointing at the production config.

//package.json
"scripts": {
        "build": "webpack --config webpack.prod.js",
        "build-dev": "webpack --config webpack.dev.js",
        "start": "webpack serve --open --config webpack.dev.js",
        "watch": "webpack --watch",
        "test": "echo \"Error: no test specified\" && exit 1"
    },

At this point your code should be pretty similar to the main branch of my repo. Go to Netlify and create an account if you haven’t already. Click the Deploy to Netlify button and connect it to your Github account and your updated repository. Click Save and Deploy and wait for the build to finish. To start Netlify will assign a random production URL to your site. For example when I first deployed this it was assigned the url ‘warm-kheer-a987ad’. You can leave as is or you can change it in the Settings for Domain management.
If you do change the site name be sure to add it to the Mapbox Access Token URL list to protect your token.

Great job! Hopefully your deploy worked and you have a functioning site. You can visit the link below to see my working tutorial published via Netlify.

Previous
Previous

OpenLayers Tutorial

Next
Next

Mapbox GL JS Tutorial: Part One