Skip to content
Documentation menu
docs / examples / animate-point
Example

Animate a point along a line

A marker that travels a path at a steady speed, frame by frame, from a small GeoJSON source you update with requestAnimationFrame. It stops dead for people who ask their system for reduced motion.

ts
import { createMap, prefersReducedMotion } from "@mapmap/maps";
import type { GeoJSONSource } from "maplibre-gl";

const map = createMap({
  container: "map",
  apiKey: "snk_...",
  attributionPosition: "bottom-left",
  center: [-0.098, 51.506],
  zoom: 13.5,
});
await map.whenReady();

const path: [number, number][] = [
  [-0.1195, 51.5033], [-0.1080, 51.5075], [-0.0990, 51.5080],
  [-0.0870, 51.5070], [-0.0760, 51.5055],
];

// Where along the path a fraction from 0 to 1 lands, by segment length.
const lengths = path.slice(1).map((p, i) => Math.hypot(p[0] - path[i][0], p[1] - path[i][1]));
const total = lengths.reduce((a, b) => a + b, 0);
function pointAt(t: number): [number, number] {
  let d = t * total;
  for (let i = 0; i < lengths.length; i++) {
    if (d <= lengths[i]) {
      const k = d / lengths[i];
      return [
        path[i][0] + (path[i + 1][0] - path[i][0]) * k,
        path[i][1] + (path[i + 1][1] - path[i][1]) * k,
      ];
    }
    d -= lengths[i];
  }
  return path[path.length - 1];
}

const dot = (t: number): GeoJSON.Feature => ({
  type: "Feature",
  properties: {},
  geometry: { type: "Point", coordinates: pointAt(t) },
});

map.map.addSource("path", {
  type: "geojson",
  data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: path } },
});
map.map.addSource("runner", { type: "geojson", data: dot(0) });

// Slots arrive in the SDK 0.17: map.slot("middle") replaces "road-labels".
map.map.addLayer({
  id: "path",
  type: "line",
  source: "path",
  layout: { "line-cap": "round" },
  paint: { "line-color": "#1a6bff", "line-width": 3, "line-opacity": 0.5 },
}, "road-labels");
map.map.addLayer({
  id: "runner",
  type: "circle",
  source: "runner",
  paint: {
    "circle-radius": 7,
    "circle-color": "#1a6bff",
    "circle-stroke-color": "#ffffff",
    "circle-stroke-width": 2,
  },
}, "road-labels");

const lapMs = 12000;
if (!prefersReducedMotion()) {
  const frame = (now: number) => {
    map.map.getSource<GeoJSONSource>("runner")?.setData(dot((now % lapMs) / lapMs));
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
}

Worth knowing

  • setData on a one-feature source is cheap enough for every frame. For many moving features, batch them into one source and use updateData with ids.
  • Distance here is measured in degrees, which is close enough for a city-scale path. For long routes measure in metres (haversine) so the speed stays even north to south.
  • prefersReducedMotion() reads the prefers-reduced-motion media query. Honour it: a looping animation is exactly what that setting asks you to stop.