npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

View the live demo

Marching Ants running story in scout mode

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:

  1. Generate the data — ingest GPX files and output GeoJSON or publish a vector tileset to Mapbox or MapTiler.
  2. 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 .gpx files
  • The host zip/unzip commands 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-ants

The CLI can be run directly from npm without a project installation:

npx marching-ants --zip runs.zip --geojson generated > manifest.json

Step 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.json

GeoJSON

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 writer

Before 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.json

The 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.json

Replace an existing MapTiler document by passing its ID:

zip -r - runs | npx marching-ants --maptiler existing-document-id > manifest.json

The 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, 15min

There 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:

  • decay interpolates from the current clock back to the normalized journey start. It is 0 for a feature at the current time and reaches 1 at time 0.
  • elapsed interpolates from the current clock back by one complete journey span. It is 0 for a feature at the current time and reaches 1 at currentTime - journeySpan.
  • bearing is a generated feature property containing the direction of travel in degrees.
decay   = (currentTime - featureStartTime) / currentTime
elapsed = (currentTime - featureStartTime) / journeySpan

The 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