Skip to content

Arc Layer — Flight Routes

Visualize connections between cities using animated curved arcs. Each arc is a bezier curve drawn between an origin and destination, colored by direction and animated with a flowing dash effect.

How It Works

Each arc is a quadratic bezier curve computed in JavaScript between two [lng, lat] coordinates. The curve is added as a GeoJSON LineString source, then rendered with a line layer. A flowing animation is achieved by cycling through line-dasharray values each frame using requestAnimationFrame.

Step 1 — Generate bezier arc coordinates

javascript
function bezierArc(from, to, steps = 80) {
  const coords = [];
  const midLng = (from[0] + to[0]) / 2;
  const midLat = (from[1] + to[1]) / 2;
  const dist = Math.sqrt(Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2));
  const ctrl = [midLng, midLat + dist * 0.25]; // control point lifted upward

  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    const lng = (1-t)*(1-t)*from[0] + 2*(1-t)*t*ctrl[0] + t*t*to[0];
    const lat = (1-t)*(1-t)*from[1] + 2*(1-t)*t*ctrl[1] + t*t*to[1];
    coords.push([lng, lat]);
  }
  return coords;
}

Step 2 — Add source and layer

javascript
map.addSource('arc-0', {
  type: 'geojson',
  data: {
    type: 'Feature',
    geometry: { type: 'LineString', coordinates: bezierArc(from, to) }
  }
});

map.addLayer({
  id: 'arc-line-0',
  type: 'line',
  source: 'arc-0',
  paint: {
    'line-color': '#ef4444',
    'line-width': 2,
    'line-opacity': 0.9,
    'line-dasharray': [0, 4, 3],
  }
});

Step 3 — Animate the dash

javascript
const dashArraySequence = [
  [0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], /* ... */
];
let step = 0;

function animate(timestamp) {
  const newStep = Math.floor((timestamp / 80) % dashArraySequence.length);
  if (newStep !== step) {
    step = newStep;
    map.setPaintProperty('arc-line-0', 'line-dasharray', dashArraySequence[step]);
  }
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Complete Example

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link href="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.css" rel="stylesheet" />
  <script src="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.js"></script>
  <style>
    body { margin: 0; }
    #map { height: 100vh; width: 100%; }
    #controls { position: absolute; top: 10px; left: 10px; z-index: 1; display: flex; gap: 8px; align-items: center; }
    button { padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
    #info { background: rgba(0,0,0,0.6); color: white; padding: 6px 12px; border-radius: 6px; font-size: 13px; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="controls">
    <button id="btn-toggle">⏸ Pause</button>
    <label style="display:flex;align-items:center;gap:6px;color:white;font-size:13px;">
      Width
      <input id="width-slider" type="range" min="1" max="10" value="2" step="0.5" style="width:100px;cursor:pointer;" />
      <span id="width-value">2px</span>
    </label>
    <span id="info"></span>
  </div>
  <script>
    const routes = [
      { from: [-73.9857, 40.7484], to: [-0.1276,   51.5074],  color: '#ef4444', label: 'NYC → London'    },
      { from: [-73.9857, 40.7484], to: [2.3490,    48.8530],  color: '#f97316', label: 'NYC → Paris'     },
      { from: [-73.9857, 40.7484], to: [13.4050,   52.5200],  color: '#eab308', label: 'NYC → Berlin'    },
      { from: [-73.9857, 40.7484], to: [37.6173,   55.7558],  color: '#22c55e', label: 'NYC → Moscow'    },
      { from: [-73.9857, 40.7484], to: [139.6917,  35.6895],  color: '#06b6d4', label: 'NYC → Tokyo'     },
      { from: [-73.9857, 40.7484], to: [103.8198,   1.3521],  color: '#8b5cf6', label: 'NYC → Singapore' },
      { from: [-73.9857, 40.7484], to: [151.2093, -33.8688],  color: '#ec4899', label: 'NYC → Sydney'    },
      { from: [-73.9857, 40.7484], to: [-43.1729, -22.9068],  color: '#14b8a6', label: 'NYC → Rio'       },
      { from: [-73.9857, 40.7484], to: [18.4241,  -33.9249],  color: '#a855f7', label: 'NYC → Cape Town' },
      { from: [-73.9857, 40.7484], to: [72.8777,   19.0760],  color: '#f59e0b', label: 'NYC → Mumbai'    },
    ];

    function bezierArc(from, to, steps = 80) {
      const coords = [];
      const midLng = (from[0] + to[0]) / 2;
      const midLat = (from[1] + to[1]) / 2;
      const dist = Math.sqrt(Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2));
      const ctrl = [midLng, midLat + dist * 0.25];
      for (let i = 0; i <= steps; i++) {
        const t = i / steps;
        coords.push([
          (1-t)*(1-t)*from[0] + 2*(1-t)*t*ctrl[0] + t*t*to[0],
          (1-t)*(1-t)*from[1] + 2*(1-t)*t*ctrl[1] + t*t*to[1],
        ]);
      }
      return coords;
    }

    const map = new mapmetricsgl.Map({
      container: 'map',
      style: 'YOUR_STYLE_URL_WITH_TOKEN',
      center: [-30, 30],
      zoom: 1.5,
    });

    map.addControl(new mapmetricsgl.NavigationControl(), 'top-right');
    document.getElementById('info').textContent = `${routes.length} routes from New York`;

    map.on('load', () => {
      routes.forEach((route, i) => {
        map.addSource(`arc-${i}`, {
          type: 'geojson',
          data: { type: 'Feature', geometry: { type: 'LineString', coordinates: bezierArc(route.from, route.to) } }
        });

        // Glow effect
        map.addLayer({
          id: `arc-glow-${i}`,
          type: 'line',
          source: `arc-${i}`,
          paint: { 'line-color': route.color, 'line-width': 6, 'line-opacity': 0.15, 'line-blur': 4 }
        });

        // Animated dashed line
        map.addLayer({
          id: `arc-line-${i}`,
          type: 'line',
          source: `arc-${i}`,
          paint: { 'line-color': route.color, 'line-width': 2, 'line-opacity': 0.9, 'line-dasharray': [0, 4, 3] }
        });
      });

      // Origin marker
      new mapmetricsgl.Marker({ color: '#ffffff', scale: 1.2 })
        .setLngLat([-73.9857, 40.7484])
        .setPopup(new mapmetricsgl.Popup({ offset: 20 }).setHTML('<strong>New York City</strong><br>Origin hub'))
        .addTo(map);

      // Destination markers
      routes.forEach(route => {
        new mapmetricsgl.Marker({ color: route.color, scale: 0.7 })
          .setLngLat(route.to)
          .setPopup(new mapmetricsgl.Popup({ offset: 15 }).setHTML(`<strong>${route.label}</strong>`))
          .addTo(map);
      });

      // Dash animation
      const dashArraySequence = [
        [0,4,3],[0.5,4,2.5],[1,4,2],[1.5,4,1.5],[2,4,1],[2.5,4,0.5],[3,4,0],
        [0,0.5,3,3.5],[0,1,3,3],[0,1.5,3,2.5],[0,2,3,2],[0,2.5,3,1.5],
        [0,3,3,1],[0,3.5,3,0.5],[0,4,3,0],
      ];
      let step = 0, running = true;

      function animate(timestamp) {
        if (!running) return;
        const newStep = Math.floor((timestamp / 80) % dashArraySequence.length);
        if (newStep !== step) {
          step = newStep;
          routes.forEach((_, i) => {
            map.setPaintProperty(`arc-line-${i}`, 'line-dasharray', dashArraySequence[step]);
          });
        }
        requestAnimationFrame(animate);
      }
      requestAnimationFrame(animate);

      document.getElementById('btn-toggle').addEventListener('click', function() {
        running = !running;
        this.textContent = running ? '⏸ Pause' : '▶ Play';
        if (running) requestAnimationFrame(animate);
      });

      const slider = document.getElementById('width-slider');
      const widthLabel = document.getElementById('width-value');
      slider.addEventListener('input', () => {
        const w = parseFloat(slider.value);
        widthLabel.textContent = `${w}px`;
        routes.forEach((_, i) => {
          map.setPaintProperty(`arc-line-${i}`, 'line-width', w);
          map.setPaintProperty(`arc-glow-${i}`, 'line-width', w * 3);
        });
      });
    });
  </script>
</body>
</html>
jsx
import React, { useEffect, useRef, useState } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';

const routes = [
  { from: [-73.9857, 40.7484], to: [-0.1276,   51.5074],  color: '#ef4444', label: 'NYC → London'    },
  { from: [-73.9857, 40.7484], to: [2.3490,    48.8530],  color: '#f97316', label: 'NYC → Paris'     },
  { from: [-73.9857, 40.7484], to: [13.4050,   52.5200],  color: '#eab308', label: 'NYC → Berlin'    },
  { from: [-73.9857, 40.7484], to: [37.6173,   55.7558],  color: '#22c55e', label: 'NYC → Moscow'    },
  { from: [-73.9857, 40.7484], to: [139.6917,  35.6895],  color: '#06b6d4', label: 'NYC → Tokyo'     },
  { from: [-73.9857, 40.7484], to: [103.8198,   1.3521],  color: '#8b5cf6', label: 'NYC → Singapore' },
  { from: [-73.9857, 40.7484], to: [151.2093, -33.8688],  color: '#ec4899', label: 'NYC → Sydney'    },
  { from: [-73.9857, 40.7484], to: [-43.1729, -22.9068],  color: '#14b8a6', label: 'NYC → Rio'       },
  { from: [-73.9857, 40.7484], to: [18.4241,  -33.9249],  color: '#a855f7', label: 'NYC → Cape Town' },
  { from: [-73.9857, 40.7484], to: [72.8777,   19.0760],  color: '#f59e0b', label: 'NYC → Mumbai'    },
];

function bezierArc(from, to, steps = 80) {
  const coords = [];
  const midLng = (from[0] + to[0]) / 2;
  const midLat = (from[1] + to[1]) / 2;
  const dist = Math.sqrt(Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2));
  const ctrl = [midLng, midLat + dist * 0.25];
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    coords.push([
      (1-t)*(1-t)*from[0] + 2*(1-t)*t*ctrl[0] + t*t*to[0],
      (1-t)*(1-t)*from[1] + 2*(1-t)*t*ctrl[1] + t*t*to[1],
    ]);
  }
  return coords;
}

const dashArraySequence = [
  [0,4,3],[0.5,4,2.5],[1,4,2],[1.5,4,1.5],[2,4,1],[2.5,4,0.5],[3,4,0],
  [0,0.5,3,3.5],[0,1,3,3],[0,1.5,3,2.5],[0,2,3,2],[0,2.5,3,1.5],
  [0,3,3,1],[0,3.5,3,0.5],[0,4,3,0],
];

const ArcLayer = () => {
  const mapContainer = useRef(null);
  const map = useRef(null);
  const rafId = useRef(null);
  const [running, setRunning] = useState(true);
  const runningRef = useRef(true);
  const [lineWidth, setLineWidth] = useState(2);

  useEffect(() => {
    if (map.current) return;

    map.current = new mapmetricsgl.Map({
      container: mapContainer.current,
      style: 'YOUR_STYLE_URL_WITH_TOKEN',
      center: [-30, 30],
      zoom: 1.5,
    });

    map.current.addControl(new mapmetricsgl.NavigationControl(), 'top-right');

    map.current.on('load', () => {
      routes.forEach((route, i) => {
        map.current.addSource(`arc-${i}`, {
          type: 'geojson',
          data: { type: 'Feature', geometry: { type: 'LineString', coordinates: bezierArc(route.from, route.to) } }
        });
        map.current.addLayer({
          id: `arc-glow-${i}`, type: 'line', source: `arc-${i}`,
          paint: { 'line-color': route.color, 'line-width': 6, 'line-opacity': 0.15, 'line-blur': 4 }
        });
        map.current.addLayer({
          id: `arc-line-${i}`, type: 'line', source: `arc-${i}`,
          paint: { 'line-color': route.color, 'line-width': 2, 'line-opacity': 0.9, 'line-dasharray': [0, 4, 3] }
        });
      });

      new mapmetricsgl.Marker({ color: '#ffffff', scale: 1.2 })
        .setLngLat([-73.9857, 40.7484])
        .setPopup(new mapmetricsgl.Popup({ offset: 20 }).setHTML('<strong>New York City</strong><br>Origin hub'))
        .addTo(map.current);

      routes.forEach(route => {
        new mapmetricsgl.Marker({ color: route.color, scale: 0.7 })
          .setLngLat(route.to)
          .setPopup(new mapmetricsgl.Popup({ offset: 15 }).setHTML(`<strong>${route.label}</strong>`))
          .addTo(map.current);
      });

      let step = 0;
      const animate = (timestamp) => {
        if (!runningRef.current) return;
        const newStep = Math.floor((timestamp / 80) % dashArraySequence.length);
        if (newStep !== step) {
          step = newStep;
          routes.forEach((_, i) => {
            map.current?.setPaintProperty(`arc-line-${i}`, 'line-dasharray', dashArraySequence[step]);
          });
        }
        rafId.current = requestAnimationFrame(animate);
      };
      rafId.current = requestAnimationFrame(animate);
    });

    return () => {
      if (rafId.current) cancelAnimationFrame(rafId.current);
      map.current?.remove();
      map.current = null;
    };
  }, []);

  const toggle = () => {
    runningRef.current = !runningRef.current;
    setRunning(runningRef.current);
    if (runningRef.current) rafId.current = requestAnimationFrame(() => {});
  };

  const handleWidthChange = (e) => {
    const w = parseFloat(e.target.value);
    setLineWidth(w);
    routes.forEach((_, i) => {
      map.current?.setPaintProperty(`arc-line-${i}`, 'line-width', w);
      map.current?.setPaintProperty(`arc-glow-${i}`, 'line-width', w * 3);
    });
  };

  return (
    <div>
      <div ref={mapContainer} style={{ width: '100%', height: '500px' }} />
      <div style={{ marginTop: '10px', display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
        <button onClick={toggle} style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
          {running ? '⏸ Pause' : '▶ Play'}
        </button>
        <label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#6b7280' }}>
          Line width
          <input type="range" min="1" max="10" value={lineWidth} step="0.5" onChange={handleWidthChange} style={{ width: '120px', cursor: 'pointer' }} />
          <span style={{ minWidth: '28px' }}>{lineWidth}px</span>
        </label>
        <span style={{ fontSize: '13px', color: '#6b7280' }}>{routes.length} routes from New York</span>
      </div>
    </div>
  );
};

export default ArcLayer;

For more information, visit the MapMetrics GitHub repository.