a-to-b-map-flyover.tsx
import * as turf from '@turf/turf';
import maplibregl, {type Map} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Easing,
Interactive,
Sequence,
interpolate,
type InteractiveBaseProps,
type InteractiveTransformProps,
type InteractivitySchema,
type SequenceControls,
useBufferState,
useCurrentFrame,
useDelayRender,
useVideoConfig,
} from 'remotion';
type MapFlyoverLayerProps = InteractiveBaseProps &
InteractiveTransformProps & {
readonly destination?: readonly [number, number];
readonly destinationLabel?: string;
readonly lineWidth?: number;
readonly origin?: readonly [number, number];
readonly originLabel?: string;
readonly routeColor?: string;
};
const mapFlyoverSchema = {
...Interactive.baseSchema,
origin: {
type: 'array',
item: {type: 'number', step: 0.0001},
default: [-0.1276, 51.5072],
minLength: 2,
maxLength: 2,
newItemDefault: 0,
description: 'Origin [longitude, latitude]',
},
destination: {
type: 'array',
item: {type: 'number', step: 0.0001},
default: [139.6917, 35.6895],
minLength: 2,
maxLength: 2,
newItemDefault: 0,
description: 'Destination [longitude, latitude]',
},
originLabel: {
type: 'text-content',
default: 'London',
description: 'Origin label',
},
destinationLabel: {
type: 'text-content',
default: 'Tokyo',
description: 'Destination label',
},
routeColor: {
type: 'color',
default: '#ff5c4d',
description: 'Route color',
},
lineWidth: {
type: 'number',
min: 2,
max: 24,
step: 1,
default: 8,
description: 'Route width',
hiddenFromList: false,
},
...Interactive.transformSchema,
} as const satisfies InteractivitySchema;
const unwrapLongitude = (longitude: number, reference: number) => {
let unwrapped = longitude;
while (unwrapped - reference > 180) {
unwrapped -= 360;
}
while (unwrapped - reference < -180) {
unwrapped += 360;
}
return unwrapped;
};
const createFlyoverRoute = (start: [number, number], end: [number, number]) => {
if (
Math.abs(start[0] - end[0]) < 0.000001 &&
Math.abs(start[1] - end[1]) < 0.000001
) {
return turf.lineString([start, [start[0] + 0.0001, start[1]]]);
}
const greatCircle = turf.greatCircle(start, end, {npoints: 200});
const splitCoordinates =
greatCircle.geometry.type === 'LineString'
? greatCircle.geometry.coordinates
: greatCircle.geometry.coordinates.flat();
const coordinates: [number, number][] = [];
for (const coordinate of splitCoordinates) {
const previousLongitude = coordinates.at(-1)?.[0] ?? coordinate[0];
coordinates.push([
unwrapLongitude(coordinate[0], previousLongitude),
coordinate[1],
]);
}
const startMercator = maplibregl.MercatorCoordinate.fromLngLat({
lng: coordinates[0][0],
lat: coordinates[0][1],
});
const lastCoordinate = coordinates[coordinates.length - 1];
const endMercator = maplibregl.MercatorCoordinate.fromLngLat({
lng: lastCoordinate[0],
lat: lastCoordinate[1],
});
return turf.lineString(
coordinates.map((coordinate, index) => {
const progress = index / (coordinates.length - 1);
const greatCirclePoint = maplibregl.MercatorCoordinate.fromLngLat({
lng: coordinate[0],
lat: coordinate[1],
});
const straightX =
startMercator.x + (endMercator.x - startMercator.x) * progress;
const straightY =
startMercator.y + (endMercator.y - startMercator.y) * progress;
const reducedBend = new maplibregl.MercatorCoordinate(
straightX + (greatCirclePoint.x - straightX) * 0.5,
straightY + (greatCirclePoint.y - straightY) * 0.5,
).toLngLat();
return [reducedBend.lng, reducedBend.lat];
}),
);
};
const calculateMapPlate = ({
height,
route,
routeDistance,
width,
}: {
height: number;
route: ReturnType<typeof createFlyoverRoute>;
routeDistance: number;
width: number;
}) => {
const mercatorPoints = route.geometry.coordinates.map((coordinate) =>
maplibregl.MercatorCoordinate.fromLngLat({
lng: coordinate[0],
lat: coordinate[1],
}),
);
const minX = Math.min(...mercatorPoints.map((point) => point.x));
const maxX = Math.max(...mercatorPoints.map((point) => point.x));
const minY = Math.min(...mercatorPoints.map((point) => point.y));
const maxY = Math.max(...mercatorPoints.map((point) => point.y));
const spanX = Math.max(0.000001, maxX - minX);
const spanY = Math.max(0.000001, maxY - minY);
const center = new maplibregl.MercatorCoordinate(
(minX + maxX) / 2,
(minY + maxY) / 2,
).toLngLat();
const plateMultiplier = Math.min(4096 / width, 4096 / height);
const plateWidth = Math.floor(width * plateMultiplier);
const plateHeight = Math.floor(height * plateMultiplier);
const maximumOverviewZoom = interpolate(routeDistance, [100, 10000], [7, 3], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const overviewZoom = Math.min(
Math.log2((width * 0.82) / (512 * spanX)),
Math.log2((height * 0.72) / (512 * spanY)),
maximumOverviewZoom,
);
const maximumPlateZoom = Math.min(
Math.log2(Math.max(1, plateWidth - width) / (512 * spanX)),
Math.log2(Math.max(1, plateHeight - height) / (512 * spanY)),
);
const maximumCoveredZoom =
overviewZoom +
Math.log2(Math.min(plateWidth / width, plateHeight / height));
const zoom = Math.max(
overviewZoom,
Math.min(overviewZoom + 1.25, maximumPlateZoom, maximumCoveredZoom),
);
return {
center,
height: plateHeight,
width: plateWidth,
zoom,
};
};
const projectToMapPlate = (
coordinate: readonly [number, number],
mapPlate: ReturnType<typeof calculateMapPlate>,
) => {
const center = maplibregl.MercatorCoordinate.fromLngLat(mapPlate.center);
const mercator = maplibregl.MercatorCoordinate.fromLngLat({
lng: coordinate[0],
lat: coordinate[1],
});
const worldSize = 512 * 2 ** mapPlate.zoom;
return {
x: (mercator.x - center.x) * worldSize + mapPlate.width / 2,
y: (mercator.y - center.y) * worldSize + mapPlate.height / 2,
};
};
const projectFlyoverRoute = (
route: ReturnType<typeof createFlyoverRoute>,
mapPlate: ReturnType<typeof calculateMapPlate>,
) => {
let routeLength = 0;
let previousProjected: {x: number; y: number} | null = null;
const points = route.geometry.coordinates.map((coordinate) => {
const projected = projectToMapPlate(
coordinate as [number, number],
mapPlate,
);
if (previousProjected) {
routeLength += Math.hypot(
projected.x - previousProjected.x,
projected.y - previousProjected.y,
);
}
previousProjected = projected;
return {distance: routeLength, ...projected};
});
return {
end: points[points.length - 1],
pointAtProgress: (progress: number) => {
const targetLength = routeLength * progress;
const foundPointIndex = points.findIndex(
(point) => point.distance >= targetLength,
);
const nextPointIndex =
foundPointIndex === -1
? points.length - 1
: Math.max(1, foundPointIndex);
const nextPoint = points[nextPointIndex];
const previousPoint = points[nextPointIndex - 1];
const segmentProgress =
nextPoint.distance === previousPoint.distance
? 0
: (targetLength - previousPoint.distance) /
(nextPoint.distance - previousPoint.distance);
return {
x: interpolate(segmentProgress, [0, 1], [previousPoint.x, nextPoint.x]),
y: interpolate(segmentProgress, [0, 1], [previousPoint.y, nextPoint.y]),
};
},
routePath: points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' '),
start: points[0],
};
};
const MapFlyoverLayerInner = forwardRef<
HTMLDivElement,
MapFlyoverLayerProps & {readonly controls: SequenceControls | undefined}
>(
(
{
controls,
destination = [139.6917, 35.6895],
destinationLabel = 'Tokyo',
durationInFrames: sequenceDurationInFrames,
freeze,
from,
hidden,
lineWidth = 8,
name,
origin = [-0.1276, 51.5072],
originLabel = 'London',
routeColor = '#ff5c4d',
showInTimeline,
style,
trimBefore,
},
ref,
) => {
const frame = useCurrentFrame();
const {height, width} = useVideoConfig();
const {continueRender, delayRender} = useDelayRender();
const {delayPlayback} = useBufferState();
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
const outlineRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<Map | null>(null);
const [loadingHandle] = useState(() =>
delayRender('Loading MapLibre flyover'),
);
useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []);
const startCoordinates = useMemo(
() => [origin[0], origin[1]] as [number, number],
[origin],
);
const endCoordinates = useMemo(
() => [destination[0], destination[1]] as [number, number],
[destination],
);
const route = useMemo(
() => createFlyoverRoute(startCoordinates, endCoordinates),
[endCoordinates, startCoordinates],
);
const routeDistance = useMemo(() => turf.length(route), [route]);
const mapPlate = useMemo(
() => calculateMapPlate({height, route, routeDistance, width}),
[height, route, routeDistance, width],
);
const projectedOverlay = useMemo(
() => projectFlyoverRoute(route, mapPlate),
[mapPlate, route],
);
const travelStart = 0;
const travelEnd = 205;
const travelProgress = interpolate(
frame,
[travelStart, travelEnd],
[0, 1],
{
easing: Easing.inOut(Easing.quad),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const originLabelOpacity = interpolate(
frame,
[travelStart, travelStart + 14],
[1, 0],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const markerRadius = Math.max(14, lineWidth * 0.8);
const markerCenterRadius = markerRadius * 0.27;
const destinationMarkerScale = interpolate(
frame,
[travelEnd + 5, travelEnd + 17],
[lineWidth / 2 / markerRadius, 1],
{
easing: Easing.inOut(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const destinationMarkerCenterScale = interpolate(
frame,
[travelEnd + 19, travelEnd + 29],
[0, 1],
{
easing: Easing.inOut(Easing.cubic),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const destinationLabelOpacity = interpolate(
frame,
[travelEnd + 27, travelEnd + 35],
[0, 1],
{
easing: Easing.inOut(Easing.quad),
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
},
);
const currentPoint = projectedOverlay.pointAtProgress(travelProgress);
const plateTransform = `translate(${width / 2 - currentPoint.x}px, ${
height / 2 - currentPoint.y
}px)`;
useLayoutEffect(() => {
if (map) {
return;
}
return delayPlayback().unblock;
}, [delayPlayback, map]);
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) {
return;
}
const mapInstance = new maplibregl.Map({
container: mapContainerRef.current,
style: {
version: 8,
sources: {
'nasa-blue-marble': {
type: 'raster',
tiles: [
'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/BlueMarble_NextGeneration/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpeg',
],
tileSize: 256,
maxzoom: 8,
attribution: 'NASA EOSDIS / GIBS',
},
},
layers: [
{
id: 'nasa-blue-marble',
type: 'raster',
source: 'nasa-blue-marble',
paint: {
'raster-fade-duration': 0,
},
},
],
},
center: mapPlate.center,
zoom: mapPlate.zoom,
interactive: false,
attributionControl: false,
fadeDuration: 0,
pixelRatio: 1,
renderWorldCopies: true,
canvasContextAttributes: {
preserveDrawingBuffer: true,
},
});
mapRef.current = mapInstance;
mapInstance.on('load', () => {
mapInstance.jumpTo({
bearing: 0,
center: mapPlate.center,
pitch: 0,
zoom: mapPlate.zoom,
});
mapInstance.once('idle', () => {
setMap(mapInstance);
continueRender(loadingHandle);
});
mapInstance.triggerRepaint();
});
}, [continueRender, loadingHandle, mapPlate]);
useEffect(() => {
if (!map) {
return;
}
const mapPlateHandle = delayRender('Reframing MapLibre map plate');
map.resize();
map.jumpTo({
bearing: 0,
center: mapPlate.center,
pitch: 0,
zoom: mapPlate.zoom,
});
map.once('idle', () => continueRender(mapPlateHandle));
map.triggerRepaint();
}, [continueRender, delayRender, map, mapPlate.center, mapPlate.zoom]);
return (
<Sequence
layout="none"
from={from ?? 0}
trimBefore={trimBefore}
durationInFrames={sequenceDurationInFrames ?? Infinity}
freeze={freeze}
hidden={hidden}
name={name ?? '<MapFlyover>'}
showInTimeline={showInTimeline ?? true}
controls={controls}
outlineRef={outlineRef}
>
<div
ref={outlineRef}
style={{
backgroundColor: '#dbe4e8',
height,
overflow: 'hidden',
position: 'absolute',
width,
...style,
}}
>
<div
ref={mapContainerRef}
style={{
height: mapPlate.height,
opacity: map ? 1 : 0,
position: 'absolute',
transform: plateTransform,
transformOrigin: '0 0',
width: mapPlate.width,
willChange: 'transform',
}}
/>
<svg
viewBox={`0 0 ${mapPlate.width} ${mapPlate.height}`}
style={{
height: mapPlate.height,
opacity: map ? 1 : 0,
overflow: 'visible',
pointerEvents: 'none',
position: 'absolute',
transform: plateTransform,
transformOrigin: '0 0',
width: mapPlate.width,
willChange: 'transform',
}}
>
<path
d={projectedOverlay.routePath}
fill="none"
pathLength={1}
stroke={routeColor}
strokeDasharray={1}
strokeDashoffset={1 - travelProgress}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={lineWidth}
/>
<g
transform={`translate(${projectedOverlay.start.x} ${projectedOverlay.start.y})`}
>
<circle fill={routeColor} r={markerRadius} />
</g>
<text
x={projectedOverlay.start.x}
y={projectedOverlay.start.y + markerRadius + 26}
fill="#ffffff"
fontFamily="sans-serif"
fontSize={26}
fontWeight={600}
opacity={originLabelOpacity}
style={{
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.9))',
}}
textAnchor="middle"
>
{originLabel}
</text>
{frame >= travelEnd + 5 ? (
<g
transform={`translate(${projectedOverlay.end.x} ${projectedOverlay.end.y})`}
>
<circle
fill={routeColor}
r={markerRadius}
transform={`scale(${destinationMarkerScale})`}
/>
<circle
fill="#ffffff"
r={markerCenterRadius}
transform={`scale(${destinationMarkerCenterScale})`}
/>
</g>
) : null}
<text
x={projectedOverlay.end.x}
y={projectedOverlay.end.y + markerRadius + 26}
fill="#ffffff"
fontFamily="sans-serif"
fontSize={26}
fontWeight={600}
opacity={destinationLabelOpacity}
style={{
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.9))',
}}
textAnchor="middle"
>
{destinationLabel}
</text>
</svg>
<div
style={{
background:
'radial-gradient(circle, transparent 45%, rgba(10, 20, 28, 0.22) 130%)',
inset: 0,
pointerEvents: 'none',
position: 'absolute',
}}
/>
<div
style={{
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4,
bottom: 8,
color: '#111827',
fontFamily: 'sans-serif',
fontSize: 12,
padding: '3px 6px',
position: 'absolute',
right: 8,
}}
>
NASA EOSDIS / GIBS
</div>
</div>
</Sequence>
);
},
);
const InteractiveMapFlyoverLayer = Interactive.withSchema({
Component: MapFlyoverLayerInner,
componentName: '<MapFlyover>',
componentIdentity: null,
schema: mapFlyoverSchema,
supportsEffects: false,
}) as React.FC<MapFlyoverLayerProps>;
export const MapFlyover: React.FC<MapFlyoverLayerProps> = (props) => {
return (
<InteractiveMapFlyoverLayer
name="A-to-B Map Flyover"
origin={[-0.1276, 51.5072]}
destination={[139.6917, 35.6895]}
originLabel="London"
destinationLabel="Tokyo"
routeColor={'#ff5c4d'}
lineWidth={24}
{...props}
/>
);
};