Dynamic maps from GPX

Dynamic maps from GPX

Many #sports and #travel related posts on this site are illustrated with maps. In this post, I show how Gatsby, a static site generator, creates customized maps from plain .gpx files that can be woven into my narratives.

This blog post is aimed at web developers looking to implement a similar file-to-page solution in Gatsby. The solution is lightweight and only depends on the GPXParser.js module. This post consists of four parts. First a small demo of what I have built. Second, how to change the gatsby-config.js file to be able to access GPX files through GraphQL. Third, how to create a page for each GPX file that our graphQL query returns. Finally, we define a React component called MapTemplate that will parse the GPX file and visualize it in Leaflet.

1. Demo

Maps add a captivating visual dimension to stories, but I could not find a solution to generate them automatically. The map above is the dynamic map that I created in Gatsby, and it was embedded in this post using an <iframe> element. It shows the third leg of my cycling adventure to Rome. It is not a static image but a dynamic element. This means that visitors can scroll through the map. It will also render appropriately across different devices.

2. Access GPX files through GraphQL

Gatsby’s strength lies in its ability to generate static pages during the build process. To bring dynamic maps to your Gatsby site, we’ll leverage the gatsby-source-filesystem plugin. This plugin allows us to transform File nodes into other types of data and, in our case incorporate GPX files into our project. Here’s a snippet from gatsby-config.js that resolves files from /content/maps/:

./gatsby-config.js
Copy
module.exports = { plugins: [ { resolve: `gatsby-source-filesystem`, options: { name: `maps`, path: `${__dirname}/content/maps/`, }, }, ], };

This configuration tells Gatsby to treat files in the content/maps/ directory as a source of data, making them accessible through GraphQL. We can then use the following query to access the files. If you are in Gatsby development mode, give it a try through GraphiQL at localhost:8000/___graphql:

➡️ Your GraphiQL graphQL Query:

Copy
{ allFile(filter: { sourceInstanceName: { eq: "maps" }, extension: { eq: "gpx" } }) { nodes { relativePath publicURL } } }

⬅️ What GraphiQL returns:

Copy
{ "data": { "allFile": { "nodes": [ { "relativePath": "example1.gpx", "publicURL": "/static/abcd8413b2ad45308876964d2e941234/example1.gpx" }, { "relativePath": "example2.gpx", "publicURL": "/static/1234ece4d3de02bad452657e8468abcd/example2.gpx" }, ] } }, "extensions": {} }

Great! We now have a place to store our map files and a way to access them!

3. Creating a page for each map

Gatsby uses the createPages function to create pages for, for example, this blog post. With the graphQL query we just constructed, we can additionally create a page for each GPX map file we find. We wrap the graphQL query earlier as follows:

./gatsby-node.js
Copy
exports.createPages = ({ graphql, actions }) => { const { createPage } = actions; const mapPost = path.resolve(`./src/templates/map.tsx`); return graphql(` { allFile(filter: { sourceInstanceName: { eq: "maps" }, extension: { eq: "gpx" } }) { nodes { relativePath publicURL } } } `).then(result => { if (result.errors) { throw result.errors; } const maps = result.data.allFile.nodes; maps.forEach(map => { createPage({ path: `/maps/${map.relativePath.split('.')[0]}`, component: mapPost, context: { gpx_file: map.publicURL, }, }); }); return null; }); };

Note that we import a mapPost element that will use the context we provided to create the actual page. In this context attribute we forward the static location of the actual GPX file. We also specify where these map pages will live, namely as subpages of maartenpoirot.com/maps such as maartenpoirot.com/maps/example1.

4. Parse and display GPX data

Now, the most intricate part involves creating the TypeScript component in ./src/templates/map.tsx that we called above. This component uses the react-leaflet library for mapping and the gpxparser for parsing GPX files. It consists of the MapTemplate, which will receive context from the createPages function above.

We also create a styled component called StyledMapContainer that can be used to further style the map. In this case, we hide the Leaflet banner that can usually be seen in the bottom-right corner.

The cosmetics of the map are primarily set by the url parameter. Check Leaflet-Extras for a collection of map providers.

Notice that I chose to draw two <Polyline/> objects. By setting different weight parameters, the first serves as a light-colored edge around the second dark-colored line.

./src/templates/map.tsx
Copy
import React, { useEffect, useState } from 'react'; import { MapContainer, TileLayer, Polyline } from 'react-leaflet'; import gpxParser from 'gpxparser'; import '/src/theme/styles.css'; import styled from 'styled-components'; const StyledMapContainer = styled(MapContainer)` .leaflet-control-attribution.leaflet-control { display: none; } `; const MapTemplate = ({ pageContext }) => { // Unpack the GPX file from the page context const { gpx_file } = pageContext; // Create a useState to collect GPX track coordinates const [trackPoints, setTrackPoints] = useState([]); // Fetch the GPX file and extract track points useEffect(() => { const fetchData = async () => { // Fetch the GPX file from the publicURL const response = await fetch(gpx_file); // Extract text data from the fetched response const gpxData = await response.text(); // Create a new parser instance and parse the text data to coordinates const gpx = new gpxParser(); gpx.parse(gpxData); // Assuming we have a single track in your GPX file const track = gpx.tracks[0]; // Extracting the track points (latitudes and longitudes) const trackPoints = track.points.map((point) => [point.lat, point.lon]); // Update the our collection of track coordinates setTrackPoints(trackPoints); }; // Call the fetchData function when the component mounts fetchData(); }, [gpx_file]); // Read the zoom level from the GPX publicURL const zoomLevel = readZoomLevel(gpx_file); // Calculate the center of the track points const centerCoordinate = calculateCenter(trackPoints); // Specify the source of our map tiles const tileUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; // Created the map return trackPoints.length > 0 ? ( <StyledMapContainer style={{ height: '100vh' }} center={centerCoordinate} zoom={zoomLevel} zoomControl={false} // Disable the default zoom control > <TileLayer url={tileUrl} /> {trackPoints.length > 0 && ( <> <Polyline positions={trackPoints} color="#f2efe9" weight={10} /> <Polyline positions={trackPoints} color="#292929" weight={5} /> </> )} </StyledMapContainer> ) : null; }; export default MapTemplate;

This component fetches the GPX file, parses it, extracts track points, and renders an interactive map using Leaflet. But it still requires two more functions, calculateCenter which calculates the center of the window from the GPX coordinates, and readZoomLevel which reads the specified zoom level directly from the file name suffix. It assumes that files are formatted with the suffix _z[zoomlevel:int] such as mapname_z11.gpx, or it defaults to zoom level 6. I have chosen this implementation because I wanted an efficient way to manually set a static default zoom level for each map.

./src/templates/map.tsx
Copy
type TrackPoint = [number, number]; // [latitude, longitude] const calculateCenter = (trackPoints: TrackPoint[]) => { const lats = trackPoints.map((point) => point[0]); const lons = trackPoints.map((point) => point[1]); const minLat = Math.min(...lats); const maxLat = Math.max(...lats); const minLon = Math.min(...lons); const maxLon = Math.max(...lons); return [(minLat + maxLat) / 2, (minLon + maxLon) / 2]; }; const readZoomLevel = (filename: string) => { const zoomSubstring = filename.match(/_z(\d+)/); if (zoomSubstring) { return parseFloat(zoomSubstring[1]) } else { return 6; // Default zoom level if no _zoom-* substring found } };

Finally, if you would like to use a map with a different background I can recommend Stadia Maps. After creating an account and linking your domain name to an API key, make sure to add your API key to .env.production. It is essential to start the environment variable name with GATSBY_ to be able to access it in the browser, more about that here. It is not necessary to add the API key to the .env.development file since local development is supported by default, see the Stadia Maps documentation.

The code block below shows how to retrieve the environment variable en embed it in the tile layer URL:

./src/templates/map.tsx
Copy
const apiKey = process.env.GATSBY_STADIA_MAPS_API_KEY const query = apiKey ? `?api_key=${apiKey}` : ``; const tileUrl = `https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png` + query;

Congratulations! You’ve unlocked the potential for dynamic maps in your Gatsby project. Now, as you weave your narratives, your readers can explore the geographical landscapes that enrich your stories. Enjoy the journey of creating more engaging and interactive content!