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); const [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 }); const [direction, setDirection] = useState(45); 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('light'); // 'light' or 'dark' const [tolerance, setTolerance] = useState(50); const [selectedCity, setSelectedCity] = useState(null); const [isLocked, setIsLocked] = useState(false); useEffect(() => { // Initialize MapLibre map - ONLY ONCE mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: getMapStyle(mapStyle), center: [-0.1278, 51.5074], // London zoom: 2, pitch: 0, projection: 'globe' // Enable 3D Globe }); mapRef.current.on('style.load', () => { console.log('Map style loaded or changed'); setupMapLayers(); }); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; }, []); // Run only once const setupMapLayers = () => { const map = mapRef.current; if (!map) return; // 1. Add atmosphere/sky effects 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 }); // 2. Add terrain source for 3D effect 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 }); // 3. Initialize start marker if (startMarkerRef.current) startMarkerRef.current.remove(); startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' }) .setLngLat([selectedPoint.lon, selectedPoint.lat]) .addTo(map); // 4. Initialize 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 city markers if they existed if (lineOfSightData) { renderLineOnMap(lineOfSightData); // Hide preview if results are shown if (map.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'none'); } } else { updatePreviewLine(selectedPoint, direction); } }; // Separate effect for the click listener that respects isLocked useEffect(() => { if (!mapRef.current) return; const handleClick = (e) => { // Don't allow moving start point if locked if (isLocked) return; const { lng, lat } = e.lngLat; setSelectedPoint({ lat, lon: lng }); if (startMarkerRef.current) { startMarkerRef.current.setLngLat([lng, lat]); } // Clear previous final results when moving start point clearCityMarkers(); if (mapRef.current.getSource('line-of-sight')) { mapRef.current.removeLayer('line-of-sight'); mapRef.current.removeSource('line-of-sight'); } }; mapRef.current.on('click', handleClick); return () => { if (mapRef.current) mapRef.current.off('click', handleClick); }; }, [isLocked]); 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) { if (mapRef.current.getSource('line-of-sight')) { mapRef.current.removeLayer('line-of-sight'); mapRef.current.removeSource('line-of-sight'); } // Re-enable preview line if it was hidden if (mapRef.current.getLayer('preview-line')) { mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible'); } // Reset map pitch and zoom mapRef.current.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); return; } setIsPlaying(true); setSelectedCity(null); 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); const startTime = performance.now(); let lastNearestCityId = null; let lastFetchedDist = 0; let currentProgress = 0; let lastTimestamp = performance.now(); let currentSpeedMultiplier = 1.0; let frameCount = 0; async function animate(currentTime) { if (!mapRef.current) return; const deltaTime = currentTime - lastTimestamp; lastTimestamp = currentTime; const baseKmPerMs = 0.1; // 100km per second const pathPoints = lineOfSightData.line_coordinates; const pointIndex = Math.min( Math.floor((currentProgress / routeDistance) * pathPoints.length), pathPoints.length - 1 ); const isOverWater = pathPoints[pointIndex]?.is_over_water; // Predictive land detection (look ahead 2000km) 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); 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: bearing }); 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); } } const nearestCity = lineOfSightData.conurbations.find(city => Math.abs(city.distance_km - currentProgress) < 100 ); if (nearestCity && nearestCity.id !== lastNearestCityId) { lastNearestCityId = nearestCity.id; setSelectedCity(nearestCity); showCityPopup(nearestCity); } 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(); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } map.addSource('line-of-sight', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat]) } } }); 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 } }); addMarkersToMap(data.conurbations); const bounds = new maplibregl.LngLatBounds(); data.line_coordinates.forEach(coord => { bounds.extend([coord.lon, coord.lat]); }); map.fitBounds(bounds, { padding: 50 }); }; const updatePreviewLine = (point, bearing) => { const map = mapRef.current; if (!map || !map.getSource('preview-line')) return; const path = []; const steps = 50; const totalDistance = 20000; for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; path.push(calculateDestination(point.lat, point.lon, bearing, dist)); } map.getSource('preview-line').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: path.map(p => [p.lon, p.lat]) } }); }; 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 }; }; useEffect(() => { if (mapRef.current) { mapRef.current.setStyle(getMapStyle(mapStyle)); } }, [mapStyle]); 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 } ] }; } return style === 'dark' ? 'https://demotiles.maplibre.org/style.json' : '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); } }; 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
{!isLocked ? ( ) : ( <> )}
{lineOfSightData && (

Conurbations Found ({lineOfSightData.conurbations.length})

Click a city for details

{lineOfSightData.conurbations.map((city, index) => ( { setSelectedCity(city); showCityPopup(city); mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 }); }} className={selectedCity?.id === city.id ? 'selected-row' : ''} > ))}
# City Population 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;