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 = `
Population: ${city.population.toLocaleString()}
Dist. from start: ${city.distance_km} km
Deviation: ${city.off_line_km} km
`;
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
| # |
City |
Population |
Dist. |
{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' : ''}
>
| {index + 1} |
{city.name} |
{(city.population / 1000).toFixed(0)}k |
{city.distance_km}km |
))}
)}
How to Use
- Click anywhere on the map to select a starting point
- Adjust the direction using the slider (0-360°)
- Set your fuzziness tolerance (how close cities must be to the line)
- Click "Show Line of Sight" to visualize the path and cities
);
};
export default APP;