Building a Vite GPX plugin to enable rich maps with one import
May 23, 2023
I love posting adventures on my blog. When I go on a hike, I track the route on my watch and upload the resulting GPX file to Strava. That’s fine, but what I really want is to display the route on my own website alongside photos and a full trip report. In this post, I will walk through how I achieved this on my SvelteKit site using a custom Vite plugin. The concepts are easily applicable to any site using Rollup or Vite, and hopefully are helpful for anybody hoping to deal with GPX file inputs on the web.
Spoiler alert: here’s the end result! By simply adding a GPX file to the routes key in the frontmatter of a Markdown blog post, I can see an interactive map and elevation profile. Here is the example for the Top Hope Hut adventure.
The first step for tackling this problem was to identify the features I wanted from this functionality. Here are some features I want to replicate from Strava:
View the route on an interactive map
View an elevation profile
See metadata:
Elapsed time
Total distance
Gross elevation gain
And here are some features I desire beyond Strava’s functionality:
Ability for anybody to download original GPX file
View overnight stops along the route (most of my blogged adventures are multi-day hikes and the stops usually correspond to notable huts or campsites)
Setting up the Vite plugin
Before tackling most of these features, a Vite plugin needs to be set up.
Create a minimal plugin
A Vite plugin is simple in that it’s a function that is directly referenced by config. That is great for type-safety and ease of getting started. To create one, I simply created the following stub in a src/plugins/vite-plugin-gpx/index.ts file:
I find it awesome that Vite supports type-safety all the way from the config file. If gpxPlugin didn’t properly implement Plugin, we would see errors in the IDE well before we built any output. This code “works” great but doesn’t achieve anything!
Enabling importing of GPX files
Let’s go over what this Vite plugin will let us achieve. Specifically, we want to be able to import the GPX file directly. The return value of the import will be an object with the data we’re interested in.
Initially, we want the output object to just have a fileName property, just to prove that we can get some output from the plugin. We’ll make the output more useful in subsequent steps.
The load function is called whenever import is invoked, with the absolute path of the imported file present in the id parameter. In this example, we do a few things:
If the file does not end with .gpx, ignore it!
Get the name of the imported file
Wrap the name with an object matching the output interface defined as ViteGpxPluginOutput
Return a plugin output object. The code key is set to a string containing JavaScript code which exports the output object, in string form.
The last part is particularly wild to me. We are effectively transforming our GPX file into a JavaScript file! Importing a GPX file will “trick” the compiler into thinking it has imported whatever JavaScript file contents this function returns.
Enabling type-safe imports
Running the import example above should work well now. But there’s a problem:
The imports fail with the error Cannot find module ‘./my-route.gpx’ or its corresponding type declarations. even though the file does exist. What’s possibly even worse is that the return type of the import is any, as demonstrated by asyncRouteData having an any type.
This can be easily fixed with a TypeScript module declaration. In my case, I put it in src/plugins/vite-plugin-gpx/ambient.d.ts. The main thing is to ensure the file matches an include rule from tsconfig.json.
With this file in place, import errors are gone and we see beautiful, beautiful autocomplete on the imported variable! Even import paths will autocomplete .gpx files now.
Now the plugin is fully set up and type-safe. By adding to our pluginOutput, we can return more data about the file. Time to incorporate the first feature.
Enabling downloading of the original file
The first and most trivial feature for the plugin is to enable the download of the original GPX file. Frustratingly, this feature is withheld by Strava; I personally love the idea of letting people download GPX files as it can help with navigation for their own travels.
The main aim here is to instruct Vite to either render or serve the file then obtain the hosted path for the file. I was inspired by Jonas Kruckenberg’s work on the imagetools library and implemented a simplified version of imagetools’ Vite plugin definition. I’ll place the code below and then walk through it.
There’s a lot going on here. But at its core you should still able to see the return statement within the load function. Here’s what is happening:
When Vite is started and “sees” the plugin, configResolved is called. The cfg argument contains a bunch of configuration and context about Vite. In this case, we use it to get base path information and store it for later use.
Whenever we import a file, the load function is called, just like before. It has a bit more functionality now, though. First, it reads the file using Node’s readFile. It then needs to ensure this GPX data is hosted somewhere before returning the path at which it is hosted. This is achieved quite differently depending on whether Vite is running with a dev server or as a build command:
In build mode, the file contents are registered with Vite using the this.emitFile function. The output path looks like __VITE_ASSET_{uniqueid}__. Behind the scenes, Vite will replace this magic path with the path at which it has stored the emitted file. (I can’t find much documentation on this process—reach out if you know more).
In dev server mode, a path is generated for each loaded gpx file with the form /@gpx/{filename}. The plugin also registers each loaded gpx file in the gpxPaths map. To ensure the files actually exist in this location, the configureServer function is used to register a sever middleware which intercepts requests to /@gpx/* paths. If the request is for a loaded GPX file, the file contents are read and returned.
Now, when a GPX file is imported, there is easy access to the file path—perfect for a download link or button.
Extracting data from the GPX file
It’s time to delve into the file contents and extract some data. It might be time to refactor the output into its own function:
The rest of the code in this section will be within the scope of this gpxDataToOutput function, unless specified otherwise.
Understanding GPX
A GPX file is an XML file with a schema. The most useful thing to do with it in this plugin is to parse it into an GeoJSON format. This makes it easier to work with in JavaScript. Besides, most mapping libraries will accept GeoJSON as input. The xmldom and @mapbox/togeojson libraries work well here.
It’s worth noting that the @mapbox/togeojson library, while reliable, is quite old and has no type definitions. Thankfully, the GeoJSON output from the library is externally standardised so we can simply use a separate library for typing and apply a module declaration. I achieved this by creating a src/@mapbox/ambient.d.ts file with the following declaration which uses the geojson library to provide typing:
That’s a GeoJSON! Each co-ordinate is displayed as [latitude, longitude, elevation].
Data compression
It might be tempting to surface our path and elevation data at this point by simply returning:
return {
filePath: path,
geoJson: gj
}
Before doing that, consider file size! Everything that we return here will have to travel across the wire at some point, and GPX files can be huge—often tens of megabytes for long trips. That includes position, heart rate and timestamp data taken every second or two. Without intervention, most of this data will end up in our GeoJSON file as metadata. Compressed server responses will help to some extent, but we have a prime candidate for optimisation here. Let’s see how this can be achieved for path and elevation data.
Path data
First, the plugin calculates the target number of datapoints for the route. The input count is raised to the power of 0.7 (a number I settled on after much experimentation) to obtain the desired number of coordinates in the output data. This means that longer trips are compressed more aggressively. A short route with 1,000 points will be downsampled to about 125 points, while a 30,000-point adventure will result in around 1,361 points in the output.
Downsampling occurs by only taking values spaced pathSamplingPeriod apart:
const downSampleArray = <T>(input: T[], period: number): T[] => {
if (period < 1 || period % 1 != 0) {
thrownewTypeError('Period must be an integer greater than or equal to 1')
}
if (period === 1) {
// Return a copy of inputreturn [...input]
}
constoutput: T[] = []
for (let i = 0; i < input.length; i += period) {
output.push(input[i])
}
return output
}
After downsampling the coordinates, we can exclude elevation data from the GeoJSON as this will be handled separately. Removing this data will decrease the filesize further by a third.
I used a similar method for elevation data, but with a different approach to obtaining a count target. Here I want the target to be at least 900, as this is approximately the number of pixels my elevation graph takes up at its maximum width.
const targetElevationDataCount = 900const elevationSamplingPeriod =
coordinatesDataCount < targetElevationDataCount
? 1// Use all the datapoints if there are fewer
: Math.floor(coordinatesDataCount / targetElevationDataCount) // Downsampleconst downSampledGeometryForElevation = downSampleArray(
feature.geometry.coordinates,
elevationSamplingPeriod
)
The final step is to extract just a list of elevations from the GeoJSON:
We could downsample elevation at the same rate as position data but generally speaking there are fewer elevation points required, so this saves space overall.
Bringing it together
With some changes to the ouput interface, we can set the ouput of gpxDataToOutput function to look a bit like the following:
buildGeoJSONFromGeometry simply reconstructs a minimal GeoJSON to wrap geometry. In this case it’s used to strip out any of the extra bloat that may have been in the original GeoJSON.
It’s coming together! This is enough information to show path data and basic elevation data.
Computing metadata
Distance
There are two purposes for computing the distance of the path:
Display the total distance of the route
Display an accurate elevation graph
The first item is probably obvious, but the interaction between distance and elevation graph is particularly interesting. You see, an elevation graph typically has distance as the x-axis, not data point index. Currently we only have a list of elevations and no distance data for correlation.
To provide this distance data, a list of cumulative distances is required. This enables us to map an index to a distance.
/**
* Compute cumulative distances along a path
* @param input List of coordinate arrays representing a path
* @returns Array of distances. Each distance is the distance along the path from the start of the path to that point.
*/const computeCumulutiveDistanceMetres = (input: Position[]): number[] => {
let distanceMetres = 0const distancesMetres = [0]
for (let i = 1; i < input.length; i++) {
distanceMetres += haversineDistanceMetres(
input[i] as [number, number],
input[i - 1] as [number, number]
)
distancesMetres.push(distanceMetres)
}
return distancesMetres
}
The distance between each successive pair of co-ordinates is computed using the Haversine formula then added to the previous distance and appended to the array.
The total distance is simple the last item in the cumulative distance array.
Elevation gain
Gross elevation gain is a useful metric for assessing how hilly a route was. Conceptually it’s the total cumulative upwards travel. In practice, it can be a bit nuanced to compute given that elevation data can be imprecise and noisy at times. I implemented a fairly simple version which includes some downsampling and a threshold for elevation gain. I don’t think it’s perfect, but it gives a fairly good estimate.
const computeCumulativeElevationGainMetres = (input: Position[]): number => {
let vertGain = 0const gainThreshold = 2let currentBaseline = input[0][2]
for (let i = 0; i < input.length; i += 3) {
const alt = input[i][2]
const climb = alt - currentBaseline
if (climb >= gainThreshold) {
// We have gone up an appreciable amount
currentBaseline = alt
vertGain += climb
} elseif (climb <= -gainThreshold) {
// We have gone down an appreciable amount
currentBaseline = alt
}
}
return vertGain
}
Time
There are two key times to capture from an activity: duration and start time.
Start time is easy. Most of the time the GeoJSON feature will contain a “time” which can be used. Just in case, I fall back to the first coordinate time.
Times in GeoJSON are represented in ISO format, so liberal use of parseISO from date-fns is appropriate here.
import { parseISO } from'date-fns'/**
* Calculate the start time from a feature
* @param input Feature to use for calculating start time
* @returns Date object representing the feature's `time` property, or the first `coordTime` if there is no such property
*/const computeStartTime = (input: Feature): Date | null => {
const startTime = input.properties?.timeif (typeof startTime === 'string') {
returnparseISO(startTime)
}
const times = input.properties?.coordTimesif (Array.isArray(times)) {
returnparseISO(times[0])
}
returnnull
}
Elapsed duration can be calculated by taking the difference between the first and last coordinate time. The date-fns library has some very helpful methods and types to assist with this. The timestamps appear in the properties key of the GeoJSON. I’m unsure whether this is standard or arbitrary behaviour in @mapbox/togeojson.
import { intervalToDuration, parseISO } from'date-fns'/**
* Calculate the duration from the `coordTime`s stored in a feature's property
* @param input Feature to use for calculating duration
* @returns Duration between first and last `coordTime` in the feature
*/const computeDuration = (input: Feature): Duration | null => {
const times = input.properties?.coordTimesif (times == null || !Array.isArray(times)) {
returnnull
}
const start = parseISO(times[0])
const end = parseISO(times[times.length - 1])
returnintervalToDuration({ start, end })
}
Breaks
Many of the trips I track are multi-day hikes (or “tramps” as they’re lovingly known by New Zealanders). It is nice to know where the huts and campsites were along the route. Manually entering the locations of these spots is probably the most foolproof way of doing this, but I opted for an alternative, lazy, method. I simply extract the locations where there are no GPX entries for over four hours. This tends to be a good indicator of where I paused my tracking device overnight—and a great proxy for hut and campsite locations.
import { differenceInHours, parseISO } from'date-fns'/**
* Compute the locations of breaks exceeding four hours within a feature
* @param input Feature with coordTimes data used to evaluate breaks
* @returns Array of indexes where each index corresponds to a coordinate after which there were no coordinates recorded for at least four hours
*/const computeBreakIndices = (input: Feature): number[] => {
// Self-contained sampling rate to accelerate calcs// Can ususally set this high unless there are lots of stopsconst samplingRate = 30const times = input.properties?.coordTimesif (!Array.isArray(times)) {
return []
}
// Get the date objects for a subsampled set of timestampsconst parsedTimes = times.filter((_, i) => i % samplingRate === 0).map((t) =>parseISO(t))
const indices = []
for (let i = 1; i < parsedTimes.length; i++) {
if (differenceInHours(parsedTimes[i], parsedTimes[i - 1]) > 4) {
indices.push((i - 1) * samplingRate)
}
}
return indices
}
Displaying data
Now that all the data and metadata has been computed, it’s time to put this to use! Let’s import a gpx file like before and see what we’re dealing with:
We can see all the geo information is stored there as we expect! Elevation data has a sparser sampling rate than point data and both sampling rates are present. Plus, the object gives us a gpxFilePath which can be used to download the original file. Here the file starts with /@gpx/ because the example output is from a local server.
Displaying the file is now a matter of writing some code that takes this data and renders something with it. It could be plain old JavaScript, a React component, or just about anything. For my Sveltekit blog, I created a <MapGroup> Svelte component that renders an elevation graph using SVG and a map using MapBox. The details are a bit beyond this article, but suffice it to say that I can use the component with the following code snippet:
<script>
import MapGroup from '$lib/components/MapGroup.svelte'
import geoData from '../top-hope-hut/Top_Hope_Hope_Kiwi_.gpx'
</script>
<figure>
<MapGroup geo={geoData} />
<figcaption>Example of GPX-derived data being loaded via map component</figcaption>
</figure>
For my blog, I check the blog post for the routes key, then import every GPX file reference and insert a component similar to the above. See how it works on GitHub.
Graceful degradation in the absence of JavaScript
An early version of my site’s GPX integration used lazy loading for the entire map group, consisting of elevation graph, metadata display and map. But it’s always worth considering how much can be rendered without JavaScript present in the browser at all. In my case, I could easily pre-render or server-side-render the SVG elevation graph, as well as the metadata block.
<script lang="ts">
import type { ViteGpxPluginOutput } from '../../plugins/vite-plugin-gpx/types'
import type { ComponentType, SvelteComponentTyped } from 'svelte'
import { onMount } from 'svelte'
import ElevationGraph from '$lib/components/ElevationGraph.svelte'
import MapMetadata from '$lib/components/MapMetadata.svelte'
import MapLoading from '$lib/components/MapLoading.svelte'
export let geo: ViteGpxPluginOutput
let hoveredIndex: number | undefined = undefined
let mapComponent: ComponentType<SvelteComponentTyped> | undefined
onMount(async () => {
mapComponent = (await import('$lib/components/Map.svelte')).default
})
</script>
<svelte:head>
<noscript>
<style>
.maploading {
display: none;
}
</style>
</noscript>
</svelte:head>
<ElevationGraph {...elevationGraphPropsGoHere} />
<MapMetadata {...metadataPropsGoHere} />
{#if mapComponent == null}
<div class="maploading">
<MapLoading />
</div>
{:else}
<svelte:component this={mapComponent} {...otherMapPropsGoHere} />
{/if}
Here, the map component itself is lazy-loaded (and will be hidden if there is no JavaScript on the client) but elevation graph and metadata will be present regardless of whether the code is running on server or client. The next level would be to fall back to Mapbox’s image API to render an image instead of an interactive map in the absence of JavaScript!
Conclusion
In building a Vite GPX plugin, I set out to move a small slice of Strava to my personal site. A Vite plugin achieves this in a fairly elegant way, abstracting the messy work away from application code which simply imports it. With creative downsampling it is possible to minimise file sizes while retaining the full GPX file for download.
The next step might be to make some improvements to the performance of the loader, given that it can take 2-3 minutes to build the entire site. There are also plenty of options for enabling configuration which I will explore if I ever publish vite-plugin-gpx as a standalone library.