Skip to content

Animate a Line

Progressively draw a flight path from New York to London, one coordinate at a time.

gts
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import type { Map } from 'maplibre-gl';
import MapLibreGL from 'ember-maplibre-gl/components/maplibre-gl';

const mapOptions = {
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [-40, 45] as [number, number],
  zoom: 2.5,
};

// Generate a great-circle arc from NYC to London
function generateArc(start: [number, number], end: [number, number], steps = 200): [number, number][] {
  const coords: [number, number][] = [];
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    const lng = start[0] + (end[0] - start[0]) * t;
    const lat = start[1] + (end[1] - start[1]) * t
      + Math.sin(t * Math.PI) * 8; // arc above the straight line
    coords.push([lng, lat]);
  }
  return coords;
}

const fullRoute = generateArc([-74.006, 40.7128], [-0.1276, 51.5074]);

const lineLayer = {
  type: 'line' as const,
  layout: { 'line-cap': 'round' as const, 'line-join': 'round' as const },
  paint: { 'line-color': '#E04E39', 'line-width': 4, 'line-opacity': 0.9 },
};

const dotLayer = {
  type: 'circle' as const,
  paint: { 'circle-radius': 7, 'circle-color': '#E04E39', 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 },
};

export default class AnimateLineDemo extends Component {
  @tracked isPaused = false;
  map: Map | null = null;
  animation: number | null = null;
  currentIndex = 0;

  geojson = {
    type: 'FeatureCollection' as const,
    features: [{
      type: 'Feature' as const,
      geometry: { type: 'LineString' as const, coordinates: [fullRoute[0]] },
    }],
  };

  routeSource = {
    type: 'geojson' as const,
    data: this.geojson,
  };

  dotSource = {
    type: 'geojson' as const,
    data: { type: 'Point' as const, coordinates: fullRoute[0] },
  };

  onMapLoaded = (map: Map) => {
    this.map = map;
    // Defer to next frame so child components (sources, layers) are on the map
    requestAnimationFrame(() => this.tick());
  };

  tick = () => {
    if (this.currentIndex < fullRoute.length) {
      this.geojson.features[0].geometry.coordinates.push(fullRoute[this.currentIndex]);
      this.map!.getSource('route')!.setData(this.geojson);
      this.map!.getSource('dot')!.setData({
        type: 'Point',
        coordinates: fullRoute[this.currentIndex],
      });
      this.currentIndex++;
    } else {
      this.currentIndex = 0;
      this.geojson.features[0].geometry.coordinates = [fullRoute[0]];
    }
    this.animation = requestAnimationFrame(this.tick);
  };

  togglePause = () => {
    this.isPaused = !this.isPaused;
    if (this.isPaused) {
      cancelAnimationFrame(this.animation!);
    } else {
      this.tick();
    }
  };

  willDestroy() {
    super.willDestroy();
    cancelAnimationFrame(this.animation!);
  }

  <template>
    <div style="position: relative;">
      <MapLibreGL
        @initOptions={{mapOptions}}
        @mapLoaded={{this.onMapLoaded}}
        style="height: 500px; width: 100%; border-radius: 8px;"
      as |map|>
        <map.source @sourceId="route" @options={{this.routeSource}} as |source|>
          <source.layer @options={{lineLayer}} />
        </map.source>
        <map.source @sourceId="dot" @options={{this.dotSource}} as |source|>
          <source.layer @options={{dotLayer}} />
        </map.source>
      </MapLibreGL>
      <button
        type="button"
        {{on "click" this.togglePause}}
        style="position: absolute; top: 12px; left: 12px; z-index: 1; padding: 6px 14px; background: #fff; border: 1px solid #ddd; border-radius: 6px; font: 13px/1 system-ui; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.15);"
      >{{if this.isPaused "Play" "Pause"}}</button>
    </div>
  </template>
}

Based on the MapLibre GL JS Animate a line example.