Files
Flight-Ops/components/Map.tsx
T

128 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { pitchForZoom } from '@/lib/camera';
const EUROPE_CENTER: [number, number] = [10, 52];
const INITIAL_ZOOM = 4;
// Useful gameplay range: continent overview → city detail (10–100 m/px). The
// zoom→pitch camera transition completes at z12; street-level zoom has no value.
export const MIN_ZOOM = 3;
export const MAX_ZOOM = 14;
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const TERRAIN_TILES = ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'];
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export default function Map() {
const containerRef = useRef<HTMLDivElement>(null);
const [airportCount, setAirportCount] = useState<number | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const terrainEnabled =
new URLSearchParams(window.location.search).get('terrain') !== '0' &&
process.env.NEXT_PUBLIC_TERRAIN !== '0';
// Bundlers (Turbopack) break MapLibre's derived worker URL, so point it at a
// route that serves the worker script (and its relative imports) from node_modules.
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
const map = new maplibregl.Map({
container: containerRef.current,
style: OPENFREEMAP_STYLE,
center: EUROPE_CENTER,
zoom: INITIAL_ZOOM,
pitch: 0,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
});
// Dev/e2e hook so tests can project coordinates to screen space.
(window as unknown as Record<string, unknown>).__flightMap = map;
map.on('move', () => {
const target = pitchForZoom(map.getZoom());
if (Math.abs(map.getPitch() - target) > 0.05) {
map.setPitch(target);
}
});
// Airports are GeoJSON, so they don't depend on basemap tiles: initialize as soon
// as the style is applied ('styledata') rather than waiting for 'load', which also
// requires all tile sources to finish.
let initialized = false;
const init = async () => {
if (initialized) return;
initialized = true;
try {
if (terrainEnabled) {
map.addSource('dem', {
type: 'raster-dem',
tiles: TERRAIN_TILES,
encoding: 'terrarium',
tileSize: 256,
});
map.setTerrain({ source: 'dem', exaggeration: 1.4 });
}
const res = await fetch('/api/airports');
if (!res.ok) return;
const data = (await res.json()) as { features: unknown[] };
setAirportCount(data.features.length);
map.addSource('airports', { type: 'geojson', data });
map.addLayer({
id: 'airports-circles',
type: 'circle',
source: 'airports',
paint: {
'circle-radius': 3,
'circle-color': '#e11d48',
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
},
});
map.on('click', 'airports-circles', (e) => {
const f = e.features?.[0];
if (!f) return;
const p = f.properties as { name: string; icao: string };
new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number])
.setHTML(
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
)
.addTo(map);
});
} catch (err) {
console.error('failed to initialize map layers', err);
}
};
if (map.isStyleLoaded()) void init();
else map.on('styledata', () => void init());
return () => {
delete (window as unknown as Record<string, unknown>).__flightMap;
map.remove();
};
}, []);
return (
<div className="map-wrap">
<header data-testid="app-header" className="app-header">
<h1>Flight Ops</h1>
<span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`}
</span>
</header>
<div ref={containerRef} data-testid="map" className="map-container" />
</div>
);
}