import React, { useState, useEffect, useRef } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import * as turf from '@turf/turf'; import apiService from './services/api'; import './styles/App.css'; const APP = () => { const mapContainerRef = useRef(null); const mapRef = useRef(null); const startMarkerRef = useRef(null); const cityMarkersRef = useRef([]); const animationRef = useRef(null); const popupRef = useRef(null); // Refs mirror state so stale closures (e.g. style.load) always read the current value const mapProjectionRef = useRef('globe'); const selectedPointRef = useRef({ lat: 51.5074, lon: -0.1278 }); const directionRef = useRef(90); const lineOfSightDataRef = useRef(null); const flightProgressRef = useRef(0); const seekRef = useRef(null); const currentCityIndexRef = useRef(-1); const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 }); const [direction, setDirection] = useState(90); const [lineOfSightData, setLineOfSightData] = useState(null); const [loading, setLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [flightSpeed, setFlightSpeed] = useState(1.0); const [mapStyle, setMapStyle] = useState('basic'); const [mapProjection, setMapProjection] = useState('globe'); const [tolerance, setTolerance] = useState(50); const [selectedCity, setSelectedCity] = useState(null); const [isLocked, setIsLocked] = useState(false); const [currentCityIndex, setCurrentCityIndex] = useState(-1); // Keep refs in sync with state so stale closures always have current values useEffect(() => { mapProjectionRef.current = mapProjection; }, [mapProjection]); useEffect(() => { selectedPointRef.current = selectedPoint; }, [selectedPoint]); useEffect(() => { directionRef.current = direction; }, [direction]); useEffect(() => { lineOfSightDataRef.current = lineOfSightData; }, [lineOfSightData]); // Auto-scroll sidebar to current city during flight useEffect(() => { if (isPlaying && currentCityIndex >= 0) { const row = document.getElementById(`city-row-${currentCityIndex}`); if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }, [currentCityIndex, isPlaying]); // --- Helpers --- // Build GeoJSON for the line, splitting at the antimeridian for flat projections const buildLineGeoJSON = (lineCoords) => { const coords = lineCoords.map(c => [c.lon, c.lat]); // Split segments wherever consecutive points cross the antimeridian (±180°) const segments = []; let current = [coords[0]]; for (let i = 1; i < coords.length; i++) { const diff = coords[i][0] - coords[i - 1][0]; if (Math.abs(diff) > 180) { segments.push(current); current = [coords[i]]; } else { current.push(coords[i]); } } segments.push(current); if (segments.length === 1) { return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: segments[0] } }; } return { type: 'Feature', properties: {}, geometry: { type: 'MultiLineString', coordinates: segments } }; }; // Register a subtle chevron arrow image for the symbol layer const addArrowImage = (map) => { const size = 20; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.strokeStyle = 'rgba(255, 107, 107, 0.75)'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); // Chevron pointing right; MapLibre rotates it to follow line direction ctx.moveTo(size * 0.2, size * 0.22); ctx.lineTo(size * 0.78, size * 0.5); ctx.lineTo(size * 0.2, size * 0.78); ctx.stroke(); // MapLibre v5 requires ImageData, not a raw canvas element map.addImage('arrow-icon', ctx.getImageData(0, 0, size, size)); }; // --- Map initialisation (runs once) --- useEffect(() => { mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: getMapStyle(mapStyle), center: [-0.1278, 51.5074], zoom: 2, pitch: 0, projection: { type: 'globe' } }); mapRef.current.on('style.load', () => { setupMapLayers(); }); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const setupMapLayers = () => { const map = mapRef.current; if (!map) return; const isGlobe = mapProjectionRef.current === 'globe'; // 1. Register arrow image (cleared on style reload) addArrowImage(map); // 2. Sky and terrain — globe only if (isGlobe) { map.setSky({ 'sky-color': '#199EF3', 'sky-horizon-blend': 0.5, 'horizon-color': '#ffffff', 'horizon-fog-blend': 0.5, 'fog-color': '#add8e6', 'fog-ground-blend': 0.5 }); if (!map.getSource('terrain')) { map.addSource('terrain', { type: 'raster-dem', tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], encoding: 'terrarium', tileSize: 256, maxzoom: 15 }); } map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); } // Read current values from refs to avoid stale closure problems const currentPoint = selectedPointRef.current; const currentDirection = directionRef.current; const currentLineData = lineOfSightDataRef.current; // 3. Start marker if (startMarkerRef.current) startMarkerRef.current.remove(); startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' }) .setLngLat([currentPoint.lon, currentPoint.lat]) .addTo(map); // 4. Preview line source and layer if (!map.getSource('preview-line')) { map.addSource('preview-line', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); map.addLayer({ id: 'preview-line', type: 'line', source: 'preview-line', paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] } }); } // 5. Restore results line and markers if they existed if (currentLineData) { renderLineOnMap(currentLineData); if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'none'); } } else { updatePreviewLine(currentPoint, currentDirection); } }; // --- Projection change effect --- useEffect(() => { const map = mapRef.current; if (!map) return; const applyProjection = () => { map.setProjection({ type: mapProjection }); if (mapProjection === 'globe') { map.setSky({ 'sky-color': '#199EF3', 'sky-horizon-blend': 0.5, 'horizon-color': '#ffffff', 'horizon-fog-blend': 0.5, 'fog-color': '#add8e6', 'fog-ground-blend': 0.5 }); if (map.getSource('terrain')) { map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); } } else { map.setTerrain(null); } // Re-render line with correct antimeridian handling for this projection if (lineOfSightData && map.getSource('line-of-sight')) { map.getSource('line-of-sight').setData( buildLineGeoJSON(lineOfSightData.line_coordinates) ); } }; if (map.isStyleLoaded()) { applyProjection(); } else { map.once('style.load', applyProjection); return () => map.off('style.load', applyProjection); } }, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps // --- Click handler (respects isLocked) --- useEffect(() => { if (!mapRef.current) return; const handleClick = (e) => { if (isLocked) return; const { lng, lat } = e.lngLat; // Update ref immediately (state update is async) selectedPointRef.current = { lat, lon: lng }; setSelectedPoint({ lat, lon: lng }); if (startMarkerRef.current) { startMarkerRef.current.setLngLat([lng, lat]); } clearCityMarkers(); const map = mapRef.current; if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'visible'); } }; mapRef.current.on('click', handleClick); return () => { if (mapRef.current) mapRef.current.off('click', handleClick); }; }, [isLocked]); // Update preview line when direction or point changes useEffect(() => { if (!isLocked) { updatePreviewLine(selectedPoint, direction); } }, [direction, selectedPoint, isLocked]); // eslint-disable-line react-hooks/exhaustive-deps // --- Map style change --- useEffect(() => { if (mapRef.current) { mapRef.current.setStyle(getMapStyle(mapStyle)); } }, [mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps // --- Handlers --- const handleStartAgain = () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); setIsPlaying(false); setFlightSpeed(1.0); } if (popupRef.current) { popupRef.current.remove(); popupRef.current = null; } setIsLocked(false); setLineOfSightData(null); setSelectedCity(null); clearCityMarkers(); if (mapRef.current) { const map = mapRef.current; if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'visible'); } map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 }); } }; const clearCityMarkers = () => { if (cityMarkersRef.current) { cityMarkersRef.current.forEach(marker => marker.remove()); cityMarkersRef.current = []; } }; const getCountryName = (code) => { try { const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); return regionNames.of(code.toUpperCase()) || code; } catch (e) { return code; } }; const showCityPopup = (city) => { if (!mapRef.current) return; if (popupRef.current) popupRef.current.remove(); const popupNode = document.createElement('div'); popupNode.className = 'map-info-popup'; const countryName = getCountryName(city.country); popupNode.innerHTML = ` `; popupRef.current = new maplibregl.Popup({ closeButton: true, closeOnClick: false, maxWidth: '300px', offset: [0, -20] }) .setLngLat([city.lon, city.lat]) .setDOMContent(popupNode) .addTo(mapRef.current); popupRef.current.on('close', () => setSelectedCity(null)); }; const startFlyOver = () => { if (!lineOfSightData || !mapRef.current) return; if (isPlaying) { if (animationRef.current) cancelAnimationFrame(animationRef.current); setIsPlaying(false); setFlightSpeed(1.0); setCurrentCityIndex(-1); currentCityIndexRef.current = -1; return; } setIsPlaying(true); setCurrentCityIndex(-1); currentCityIndexRef.current = -1; if (popupRef.current) popupRef.current.remove(); const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]); const route = turf.lineString(coordinates); const routeDistance = turf.length(route); let lastFetchedDist = flightProgressRef.current; let currentProgress = flightProgressRef.current; flightProgressRef.current = 0; let lastTimestamp = performance.now(); let currentSpeedMultiplier = 1.0; let frameCount = 0; async function animate(currentTime) { if (!mapRef.current) return; // Apply any pending seek if (seekRef.current !== null) { currentProgress = seekRef.current; lastFetchedDist = currentProgress; lastTimestamp = currentTime; seekRef.current = null; } const deltaTime = currentTime - lastTimestamp; lastTimestamp = currentTime; const baseKmPerMs = 0.1; const pathPoints = lineOfSightDataRef.current.line_coordinates; const pointIndex = Math.min( Math.floor((currentProgress / routeDistance) * pathPoints.length), pathPoints.length - 1 ); const isOverWater = pathPoints[pointIndex]?.is_over_water; let futureIsOverWater = true; const lookAheadDistance = 2000; const lookAheadSteps = 10; for (let i = 1; i <= lookAheadSteps; i++) { const checkDist = currentProgress + (lookAheadDistance * i) / lookAheadSteps; const checkIndex = Math.min( Math.floor((checkDist / routeDistance) * pathPoints.length), pathPoints.length - 1 ); if (pathPoints[checkIndex] && !pathPoints[checkIndex].is_over_water) { futureIsOverWater = false; break; } } const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0; const acceleration = 0.005; if (currentSpeedMultiplier < targetMultiplier) { currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier); } else if (currentSpeedMultiplier > targetMultiplier) { currentSpeedMultiplier = Math.max(currentSpeedMultiplier - acceleration * deltaTime, targetMultiplier); } frameCount++; if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier); currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier; const phase = Math.min(currentProgress / routeDistance, 1); if (phase >= 1) { setIsPlaying(false); setFlightSpeed(1.0); setCurrentCityIndex(-1); currentCityIndexRef.current = -1; return; } const eye = turf.along(route, currentProgress).geometry.coordinates; const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates; const bearing = turf.bearing(turf.point(eye), turf.point(target)); mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing }); // Find nearest city to current position and update sidebar highlight const cities = lineOfSightDataRef.current.conurbations; let nearestIdx = -1; let nearestDist = Infinity; cities.forEach((city, idx) => { const d = Math.abs(city.distance_km - currentProgress); if (d < nearestDist) { nearestDist = d; nearestIdx = idx; } }); if (nearestDist < 300 && nearestIdx !== currentCityIndexRef.current) { currentCityIndexRef.current = nearestIdx; setCurrentCityIndex(nearestIdx); showCityPopup(cities[nearestIdx]); } if (currentProgress - lastFetchedDist > 2000) { lastFetchedDist = currentProgress; try { const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance); if (response.data.success) { const newCities = response.data.data.conurbations; setLineOfSightData(prev => { const existingIds = new Set(prev.conurbations.map(c => c.id)); const uniqueNewCities = newCities.filter(c => !existingIds.has(c.id)); const adjustedCities = uniqueNewCities.map(c => ({ ...c, distance_km: Math.round(c.distance_km + currentProgress) })); const updatedData = { ...prev, conurbations: [...prev.conurbations, ...adjustedCities].sort((a, b) => a.distance_km - b.distance_km) }; addMarkersToMap(adjustedCities, prev.conurbations.length); return updatedData; }); } } catch (e) { console.error('Error fetching more cities during flight:', e); } } animationRef.current = requestAnimationFrame(animate); } animationRef.current = requestAnimationFrame(animate); }; const addMarkersToMap = (cities, startIndex = 0) => { const map = mapRef.current; if (!map) return; cities.forEach((city, index) => { const displayIndex = startIndex + index + 1; const el = document.createElement('div'); el.className = 'city-marker'; el.innerHTML = `
${displayIndex}
${city.name}
${(city.population / 1000).toFixed(0)}k
`; const marker = new maplibregl.Marker(el).setLngLat([city.lon, city.lat]).addTo(map); marker.getElement().addEventListener('click', (e) => { e.stopPropagation(); setSelectedCity(city); showCityPopup(city); }); cityMarkersRef.current.push(marker); }); }; const renderLineOnMap = (data) => { const map = mapRef.current; if (!map) return; clearCityMarkers(); // Remove existing layers/source (arrow layer must go before line layer) if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows'); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } const projection = mapProjectionRef.current; map.addSource('line-of-sight', { type: 'geojson', data: buildLineGeoJSON(data.line_coordinates) }); map.addLayer({ id: 'line-of-sight', type: 'line', source: 'line-of-sight', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#FF6B6B', 'line-width': 4, 'line-opacity': 0.8 } }); // Subtle periodic directional arrows along the line map.addLayer({ id: 'line-of-sight-arrows', type: 'symbol', source: 'line-of-sight', layout: { 'symbol-placement': 'line', 'symbol-spacing': 300, 'icon-image': 'arrow-icon', 'icon-size': 1.0, 'icon-allow-overlap': true, 'icon-ignore-placement': true } }); addMarkersToMap(data.conurbations); // Zoom to show the full path from the start point map.flyTo({ center: [data.line_coordinates[0].lon, data.line_coordinates[0].lat], zoom: 2, pitch: 0, bearing: 0 }); }; const updatePreviewLine = (point, brng) => { const map = mapRef.current; if (!map || !map.getSource('preview-line')) return; const path = []; const steps = 160; const totalDistance = 40074; // Full Earth circumference (km) for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; path.push(calculateDestination(point.lat, point.lon, brng, dist)); } map.getSource('preview-line').setData( buildLineGeoJSON(path) ); }; const calculateDestination = (lat, lon, bearing, distance) => { const R = 6371; const brng = (bearing * Math.PI) / 180; const φ1 = (lat * Math.PI) / 180; const λ1 = (lon * Math.PI) / 180; const δ = distance / R; const φ2 = Math.asin( Math.sin(φ1) * Math.cos(δ) + Math.cos(φ1) * Math.sin(δ) * Math.cos(brng) ); const λ2 = λ1 + Math.atan2( Math.sin(brng) * Math.sin(δ) * Math.cos(φ1), Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2) ); return { lat: (φ2 * 180) / Math.PI, lon: (((λ2 * 180) / Math.PI + 540) % 360) - 180 }; }; const getMapStyle = (style) => { if (style === 'satellite') { return { version: 8, sources: { 'esri-satellite': { type: 'raster', tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' } }, layers: [{ id: 'satellite-layer', type: 'raster', source: 'esri-satellite', minzoom: 0, maxzoom: 20 }] }; } if (style === 'map') { return 'https://tiles.openfreemap.org/styles/liberty'; } // 'basic' — clean MapLibre demo tiles, no labels return 'https://demotiles.maplibre.org/style.json'; }; const handleShowLineOfSight = async () => { setLoading(true); setSelectedCity(null); try { const response = await apiService.getLineOfSight( selectedPoint.lat, selectedPoint.lon, direction, tolerance ); setLineOfSightData(response.data.data); setIsLocked(true); if (mapRef.current && mapRef.current.getLayer('preview-line')) { mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none'); } renderLineOnMap(response.data.data); } catch (error) { console.error('Error fetching line of sight:', error); } finally { setLoading(false); } }; const PROJECTIONS = [ { id: 'globe', label: 'Globe' }, { id: 'mercator', label: 'Mercator' }, { id: 'vertical-perspective', label: 'Perspective' } ]; return (

Line of Sight Settings

{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}
setDirection(parseInt(e.target.value))} /> {direction}°
setTolerance(parseInt(e.target.value))} min="10" max="200" /> {tolerance} km
{PROJECTIONS.map(p => ( ))}
{!isLocked ? ( ) : ( <> )}
{lineOfSightData && (

Cities Along Route ({lineOfSightData.conurbations.length})

{isPlaying && currentCityIndex >= 0 && (
Now passing: {lineOfSightData.conurbations[currentCityIndex]?.name}
)}

{isPlaying ? 'Click a city to jump to it' : 'Click a city for details'}

{lineOfSightData.conurbations.map((city, index) => { let rowClass = ''; if (isPlaying && currentCityIndex >= 0) { if (index === currentCityIndex) rowClass = 'current-row'; else if (index < currentCityIndex) rowClass = 'passed-row'; else if (index <= currentCityIndex + 5) rowClass = 'upcoming-row'; } else if (selectedCity?.id === city.id) { rowClass = 'selected-row'; } return ( { setSelectedCity(city); showCityPopup(city); mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 }); if (isPlaying) { seekRef.current = city.distance_km; } }} className={rowClass} > ); })}
# City Pop. Dist.
{index + 1} {city.name} {(city.population / 1000).toFixed(0)}k {city.distance_km}km
)}

How to Use

  1. Click anywhere on the map to select a starting point
  2. Adjust the direction using the slider (0-360°)
  3. Set your fuzziness tolerance (how close cities must be to the line)
  4. Click "Show Line of Sight" to visualize the path and cities
); }; export default APP;