import React, { useState, useEffect, useRef } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; 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 [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 [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('load', () => { console.log('Map loaded successfully'); // Add atmosphere/sky effects (MapLibre v5 uses setSky) mapRef.current.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 }); // Initialize start marker startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' }) .setLngLat([selectedPoint.lon, selectedPoint.lat]) .addTo(mapRef.current); // Initialize preview line source and layer mapRef.current.addSource('preview-line', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); mapRef.current.addLayer({ id: 'preview-line', type: 'line', source: 'preview-line', paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] } }); updatePreviewLine(selectedPoint, direction); }); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; }, []); // Run only once // 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); } 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 startFlyOver = () => { if (!lineOfSightData || !mapRef.current) return; if (isPlaying) { if (animationRef.current) cancelAnimationFrame(animationRef.current); setIsPlaying(false); return; } setIsPlaying(true); const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]); const route = turf.lineString(coordinates); const duration = 20000; // 20 seconds for the full flyover const startTime = performance.now(); const routeDistance = turf.length(route); function animate(currentTime) { if (!mapRef.current) return; const elapsed = currentTime - startTime; const phase = Math.min(elapsed / duration, 1); // 0 to 1 // Stop when we reach the end if (phase >= 1) { setIsPlaying(false); return; } const currentDist = routeDistance * phase; // Look 100km ahead, camera is at the current distance const target = turf.along(route, Math.min(currentDist + 100, routeDistance)).geometry.coordinates; const eye = turf.along(route, currentDist).geometry.coordinates; const camera = mapRef.current.getFreeCameraOptions(); // Position camera 50,000m (50km) above the 'eye' point camera.position = maplibregl.MercatorCoordinate.fromLngLat( { lng: eye[0], lat: eye[1] }, 50000 ); // Look at the target point camera.lookAtPoint({ lng: target[0], lat: target[1] }); mapRef.current.setFreeCameraOptions(camera); animationRef.current = requestAnimationFrame(animate); } animationRef.current = requestAnimationFrame(animate); }; useEffect(() => { // Update preview whenever point or direction changes if (mapRef.current && mapRef.current.isStyleLoaded()) { updatePreviewLine(selectedPoint, direction); } }, [selectedPoint, direction]); const updatePreviewLine = (point, bearing) => { const map = mapRef.current; if (!map || !map.getSource('preview-line')) return; // Generate 50 points along a 20,000km great circle path for a smooth curve 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]) } }); }; // Helper to calculate destination point given start, bearing, and distance (km) const calculateDestination = (lat, lon, bearing, distance) => { const R = 6371; // Earth's radius in km 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(() => { // Update map style when toggle changes if (mapRef.current) { mapRef.current.setStyle(getMapStyle(mapStyle)); } }, [mapStyle]); const getMapStyle = (style) => { return style === 'dark' ? 'https://demotiles.maplibre.org/style.json' : 'https://demotiles.maplibre.org/style.json'; // Using same for now, can be customized }; 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); // Lock the map after showing results // Hide the preview line when showing final results 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 renderLineOnMap = (data) => { const map = mapRef.current; if (!map) return; // Clear existing results clearCityMarkers(); if (map.getSource('line-of-sight')) { map.removeLayer('line-of-sight'); map.removeSource('line-of-sight'); } // Add line source map.addSource('line-of-sight', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat]) } } }); // Add line layer 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 } }); // Add city markers data.conurbations.forEach((city, index) => { const displayIndex = index + 1; // Create marker element const el = document.createElement('div'); el.className = 'city-marker'; el.innerHTML = `
Click a city for details
| # | City | Population | Dist. |
|---|---|---|---|
| {index + 1} | {city.name} | {(city.population / 1000).toFixed(0)}k | {city.distance_km}km |