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.
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.
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/
:
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:
{
allFile(filter: { sourceInstanceName: { eq: "maps" }, extension: { eq: "gpx" } }) {
nodes {
relativePath
publicURL
}
}
}
⬅️ What GraphiQL returns:
{
"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!
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:
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
.
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.
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.
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:
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!