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.