marching-ants
v0.2.4
Published
Animated marching-ants layers for Mapbox GL
Readme
marching-ants
Animate a collection of GPX tracks as time-controlled marching points on Mapbox GL.

I built marching-ants to tell the story of my running. A static map can draw every route I have taken, but it flattens all of those runs into one moment. It does not show which run came first, how the journey accumulated, or how I returned to the same places over time.
I wanted the map to unfold like the history itself. marching-ants takes a collection of runs as GPX files, preserves their sequence on a continuous timeline, and converts them into time-aware GeoJSON features or a hosted vector tileset. The frontend plugin can then move through that timeline and replay the running story on the map.
The workflow has two steps:
- Generate the data — ingest GPX files and output GeoJSON or publish a vector tileset to Mapbox or MapTiler.
- Add the frontend plugin — connect the GeoJSON or vector source to a clock-driven marching layer. The generated manifest is available as configuration reference.
Requirements
- Node.js 24 or newer
- A ZIP archive containing one or more
.gpxfiles - The host
zip/unzipcommands for the CLI examples - Mapbox GL JS or MapLibre GL JS in the browser
Install the package when using the JavaScript API:
npm install marching-antsThe CLI can be run directly from npm without a project installation:
npx marching-ants --zip runs.zip --geojson generated > manifest.jsonStep 1: generate or publish the data
The CLI recursively discovers GPX files inside a ZIP archive. One GPX file is treated as one run, and every <trkseg> is treated as one track. Points without valid timestamps are discarded.
Zip input can also be piped through standard input:
zip -r - path/to/gpx-directory | npx marching-ants [output option]Or supplied as an existing ZIP file:
npx marching-ants --zip runs.zip [output option]The manifest is always printed to standard output. You may redirect it to a file:
zip -r - runs | npx marching-ants --geojson generated > manifest.jsonGeoJSON
Write one GeoJSON FeatureCollection per temporal resolution:
zip -r - runs | npx marching-ants --geojson public/data > public/data/manifest.json--output is accepted as an alias of --geojson.
The output directory contains:
15min.geojson
5min.geojson
1min.geojson
15s.geojson
5s.geojson
manifest.json # created by shell redirection, not by the writerBefore writing, existing .geojson files from that directory are removed while leaving every other file untouched.
The manifest includes:
{
"globalStartTime": 0,
"globalEndTime": 66013,
"bounds": {
"sw": { "lat": 1.2, "lon": 103.7 },
"ne": { "lat": 1.4, "lon": 103.9 }
},
"oneTileFitZoom": 10,
"runs": [
{
"filename": "morning-run.gpx",
"trackCount": 1,
"earliestTime": 0,
"latestTime": 3600
}
],
"runCount": 1,
"trackCount": 1,
"baseZoom": 10,
"baseInterval": 15,
"sourceCount": 1,
"segmentCount": 1,
"resolutions": [
{ "id": "5s", "intervalSeconds": 5, "minzoom": 12, "maxzoom": 16 },
{ "id": "15s", "intervalSeconds": 15, "minzoom": 10, "maxzoom": 11 },
{ "id": "1min", "intervalSeconds": 60, "minzoom": 8, "maxzoom": 9 },
{ "id": "5min", "intervalSeconds": 300, "minzoom": 6, "maxzoom": 7 },
{ "id": "15min", "intervalSeconds": 900, "minzoom": 0, "maxzoom": 5 }
],
"geojson": { "outputDir": "/absolute/path/to/public/data" }
}Relative timeline values are measured in seconds. Source timestamps in run summaries and feature properties are Unix epoch milliseconds.
Mapbox
Set a Mapbox token, then pass the complete username.tileset ID:
export MAPBOX_ACCESS_TOKEN="..."
zip -r - runs | npx marching-ants --mapbox username.running-story > manifest.jsonThe CLI uploads the generated source, creates or updates the tileset recipe, publishes it, and waits for the processing job to succeed. The manifest contains a mapbox result:
{
mapbox: {
tilesetId,
sourceId,
action, // "created" or "updated"
publish,
job
}
}Use the published source in Mapbox GL JS:
map.addSource("running-story", {
type: "vector",
url: "mapbox://username.running-story",
});MapTiler
MapTiler's direct GeoJSON upload can omit feature attributes during tileset generation, so this package builds an MBTiles archive locally and uploads that archive instead.
Set a service token and create a new vector tileset:
export MAPTILER_SERVICE_TOKEN="..."
zip -r - runs | npx marching-ants --maptiler > manifest.jsonReplace an existing MapTiler document by passing its ID:
zip -r - runs | npx marching-ants --maptiler existing-document-id > manifest.jsonThe CLI waits for processing to complete. The manifest contains:
{
maptiler: {
documentId,
uploadId,
tileCount,
size,
state
}
}Add the resulting TileJSON endpoint to MapLibre or Mapbox:
map.addSource("running-story", {
type: "vector",
url: `https://api.maptiler.com/tiles/${documentId}/tiles.json?key=${browserKey}`,
});The service token is only for uploading. Use a browser-safe MapTiler API key in frontend code.
CLI options
| Option | Default | Meaning |
| --- | ---: | --- |
| --zip <path> | stdin | Read an existing ZIP instead of piped input |
| --geojson <directory> | — | Write resolution GeoJSON files |
| --output <directory> | — | Alias of --geojson |
| --mapbox <username.tileset> | — | Create or update a Mapbox tileset |
| --maptiler | — | Create a MapTiler document |
| --maptiler <document-id> | — | Replace a MapTiler document |
| --max-gap <seconds> | 600 | Maximum real-world gap retained between tracks |
| --zoom <number> | 10 | Zoom anchor for the base resolution |
| --interval <seconds> | 15 | Base interval; it must exist in the resolution set |
Output mode precedence is MapTiler, Mapbox, then GeoJSON.
Resolution derivation
The temporal intervals default to:
5s, 15s, 1min, 5min, 15minThere is no fixed zoom assigned to any individual resolution. --interval selects the base interval and anchors it at --zoom. The uploader derives every other inclusive zoom range from those two values and writes the result into manifest.resolutions. Mapbox, MapTiler, and the frontend plugin all consume those derived ranges.
Using the default base interval of 15 seconds and base zoom of 10 produces this default zoom set:
| Source layer | Interval | Inclusive tile zooms |
| --- | ---: | ---: |
| 15min | 900 seconds | 0–5 |
| 5min | 300 seconds | 6–7 |
| 1min | 60 seconds | 8–9 |
| 15s | 15 seconds | 10–11 |
| 5s | 5 seconds | 12–16 |
The coarser intervals keep the frontend from loading every five-second point at low zoom. Feature IDs are unique across all resolutions generated in one run.
Step 2: animate the map source
Import the browser API:
import {
createClock,
createMarchingLayer,
createScoutLayer,
} from "marching-ants";For a no-build demo, an ESM CDN also works:
import {
createClock,
createMarchingLayer,
createScoutLayer,
} from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";Optional: initialize the map from the manifest
The manifest is reference metadata, not a frontend runtime dependency. The plugin does not load it or require it. You can load it as a convenient source for the timeline, bounds, initial zoom, and resolution ranges, or hardcode those values directly in your application.
To derive the map configuration from the manifest:
const manifest = await fetch("/data/manifest.json").then((response) =>
response.json(),
);
const { sw, ne } = manifest.bounds;
const center = [(sw.lon + ne.lon) / 2, (sw.lat + ne.lat) / 2];
const lonBuffer = ne.lon - sw.lon;
const latBuffer = ne.lat - sw.lat;
const multiZoom = Object.fromEntries(
manifest.resolutions.map(({ id, minzoom, maxzoom }) => [
id,
[minzoom, maxzoom],
]),
);
const map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center,
zoom: manifest.oneTileFitZoom,
minZoom: Math.min(...manifest.resolutions.map(({ minzoom }) => minzoom)),
maxZoom: 22,
maxBounds: [
[sw.lon - lonBuffer, sw.lat - latBuffer],
[ne.lon + lonBuffer, ne.lat + latBuffer],
],
});Add the source before mounting a plugin layer. For one generated GeoJSON resolution:
map.addSource("running-story", {
type: "geojson",
data: "/data/5s.geojson",
});For a hosted Mapbox or MapTiler tileset:
map.addSource("running-story", {
type: "vector",
url: "mapbox://username.running-story",
});Mount the layer after the source exists:
map.on("load", () => {
marchingLayer.addTo(map);
});Create and drive a clock
The clock is a small synchronous store whose value is the current normalized journey time in seconds:
const clock = createClock(manifest.globalStartTime);
clock.get();
clock.set(120);
clock.update((currentTime) => currentTime + 1);get returns the current time. set replaces it. update receives the current time and returns the next time.
A scroll-driven story can map page progress directly onto the manifest timeline:
window.addEventListener("scroll", () => {
const maxScroll = document.documentElement.scrollHeight - innerHeight;
const progress = Math.min(1, Math.max(0, scrollY / maxScroll));
const { globalStartTime: start, globalEndTime: end } = manifest;
clock.set(start + (end - start) * progress);
});Marching, scout, and lagging
The animation works with only a marching layer. It is the main effect: points enter the repeating marching pattern as the clock advances.
The plugin keeps each point at a fixed coordinate and divides the repeating ant loop into phase segments. Only the active segment is visible at a given clock time. Toggling successive segments on and off creates a good-enough marching illusion without moving individual points.
segmentCount controls ant spacing. The default is 12: increasing it produces wider spacing and fewer visible ants, while decreasing it produces a denser animation with more ants on screen.
A scout is optional. It selects the point at the front of the current time window so a consumer can style that leading point differently—for example, as an arrow or runner icon while the rest of the history remains circles.
When a scout and marching layer are used together, set lagging: true on the marching layer. This delays a point's entry into the marching pattern by one complete segmentCount ring, giving the scout time to represent the leading point before that point joins the trail. Without lagging, the scout and marching layer can animate the same current point at the same time.
Marching layer
Pass a normal Mapbox/MapLibre circle or symbol layer style. In multi-zoom mode, the plugin generates one decorated style per manifest resolution and overwrites id, source-layer, minzoom, and maxzoom for each one.
const marchingLayer = createMarchingLayer(
{
id: "running-march",
type: "circle",
source: "running-story",
"source-layer": "15s", // used as the original style; overwritten per resolution
paint: {
"circle-radius": 4,
"circle-color": "#ff5a5f",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
},
},
clock,
{
globalRelativeStart: manifest.globalStartTime,
globalRelativeEnd: manifest.globalEndTime,
segmentCount: 12,
multiZoom,
},
);
marchingLayer.addTo(map);The plugin controls circle-opacity and circle-stroke-opacity through feature state. For symbol styles, it controls icon-opacity; all other paint and layout properties remain yours.
The generated features and runtime expose three especially useful styling values:
decayinterpolates from the current clock back to the normalized journey start. It is0for a feature at the current time and reaches1at time0.elapsedinterpolates from the current clock back by one complete journey span. It is0for a feature at the current time and reaches1atcurrentTime - journeySpan.bearingis a generated feature property containing the direction of travel in degrees.
decay = (currentTime - featureStartTime) / currentTime
elapsed = (currentTime - featureStartTime) / journeySpanThe difference is the denominator. decay stretches the entire history from the current clock to the journey start across 0…1. elapsed uses a fixed journey-sized window that moves with the current clock.
Read elapsed and decay with feature-state, and read bearing as a normal feature property:
const style = {
id: "running-march",
type: "symbol",
source: "running-story",
"source-layer": "15s",
paint: {
"icon-color": [
"interpolate",
["linear"],
["coalesce", ["feature-state", "elapsed"], 0],
0,
"#ff5a5f",
1,
"#406cff",
],
},
layout: {
"icon-image": "runner-arrow",
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
},
};decay can be used the same way for radius, color, blur, or other data-driven paint expressions.
The lagging form is intended for a marching trail paired with a scout:
const trailLayer = createMarchingLayer(style, clock, {
globalRelativeStart: manifest.globalStartTime,
globalRelativeEnd: manifest.globalEndTime,
segmentCount: 12,
lagging: true,
multiZoom,
});Scout layer
A scout selects the feature whose time window currently contains the clock. Use it only when the leading point needs a separate visual treatment:
const scoutLayer = createScoutLayer(
{
id: "running-scout",
type: "symbol",
source: "running-story",
"source-layer": "15s",
layout: {
"icon-image": "runner-arrow",
"icon-allow-overlap": true,
"icon-rotate": ["get", "bearing"],
},
paint: {
"icon-color": "#406cff",
},
},
clock,
{
globalRelativeStart: manifest.globalStartTime,
globalRelativeEnd: manifest.globalEndTime,
multiZoom,
},
);
scoutLayer.addTo(map);The plugin controls isScout feature state and the corresponding circle or icon opacity. A feature retained as the scout across consecutive clock updates does not emit redundant visibility hooks.
Hooks
Both layer factories accept registration and visibility hooks:
const hooks = {
onFeatureRegistered(feature) {
console.log("loaded", feature.id, feature.sourceLayer);
},
onVisibilityToggled({ feature, visible }) {
console.log(feature.id, visible ? "visible" : "hidden");
},
};
const layer = createMarchingLayer(style, clock, {
multiZoom,
hooks,
});onFeatureRegistered runs once when a queried source feature is stored. onVisibilityToggled runs only for actual hidden/visible transitions.
Single-resolution sources
Omit multiZoom when animating one resolution. Source-layer configuration follows the source type.
For a GeoJSON source, omit source-layer:
const layer = createMarchingLayer(
{
id: "single-resolution",
type: "circle",
source: "running-story",
paint: { "circle-radius": 4, "circle-color": "#ff5a5f" },
},
clock,
);For a hosted vector tileset, select one of its resolutions:
const layer = createMarchingLayer(
{
id: "single-resolution",
type: "circle",
source: "running-story",
"source-layer": "15s",
paint: { "circle-radius": 4, "circle-color": "#ff5a5f" },
},
clock,
);Set minzoom and maxzoom on either style as needed.
Layer lifecycle
Mount and remove a layer with:
layer.addTo(map);
layer.remove(map);The source must exist before addTo(map) is called. A layer can be added to more than one map. Pass a map to remove it from one map, or call remove() without arguments to remove it from every map.
Browser API reference
createClock(initialValue = 0)
Returns a clock with:
get(): number;
set(nextValue: number): number;
update(updateValue: (currentTime: number) => number): number;createMarchingLayer(style, clock, options?)
Options:
{
globalRelativeStart?: number;
globalRelativeEnd?: number;
segmentCount?: number; // default: 12; higher means wider ant spacing
lagging?: boolean; // default: false
multiZoom?: false | Record<string, readonly [number, number]>;
hooks?: {
onFeatureRegistered?(feature): void;
onVisibilityToggled?({ feature, visible }): void;
};
}createScoutLayer(style, clock, options?)
Accepts the same timeline, multiZoom, and hook options as the marching layer, without segmentCount or lagging.
The root TypeScript declarations export the authoring types MarchingLayerStyle, MarchingLayerOptions, and ScoutLayerOptions.
License
MIT
