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 CityRow = React.memo(({ city, index, isPlaying, currentCityIndex, isSelected, onClick }) => {
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 (isSelected) {
rowClass = 'selected-row';
}
return (
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);
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);
}
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)
};
syncCitiesToMap(updatedData.conurbations);
return updatedData;
});
}
} catch (e) {
console.error('Error fetching more cities during flight:', e);
}
}
animationRef.current = requestAnimationFrame(animate);
}
animationRef.current = requestAnimationFrame(animate);
};
const renderLineOnMap = (data) => {
const map = mapRef.current;
if (!map) return;
// 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;
syncCitiesToMap([]);
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
}
});
addArrowImage(map);
syncCitiesToMap(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 handleLocateMe = () => {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude: lat, longitude: lon } = pos.coords;
selectedPointRef.current = { lat, lon };
setSelectedPoint({ lat, lon });
if (startMarkerRef.current) {
startMarkerRef.current.setLngLat([lon, lat]);
}
mapRef.current?.flyTo({ center: [lon, lat], zoom: 5 });
},
(err) => console.error('Geolocation error:', err)
);
};
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: 'perspective', label: 'Perspective' },
{ id: 'flat', label: 'Flat' }
];
return (