diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b230706..a934297 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,10 +9,12 @@ const APP = () => { 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); @@ -74,6 +76,7 @@ const APP = () => { }); return () => { + if (animationRef.current) cancelAnimationFrame(animationRef.current); if (mapRef.current) mapRef.current.remove(); }; }, []); // Run only once @@ -108,6 +111,11 @@ const APP = () => { }, [isLocked]); const handleStartAgain = () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + setIsPlaying(false); + } + setIsLocked(false); setLineOfSightData(null); setSelectedCity(null); @@ -122,6 +130,14 @@ const APP = () => { 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 + }); } }; @@ -132,6 +148,60 @@ const APP = () => { } }; + 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()) { diff --git a/frontend/src/styles/App.css b/frontend/src/styles/App.css index 893a0c2..5d4da50 100644 --- a/frontend/src/styles/App.css +++ b/frontend/src/styles/App.css @@ -126,16 +126,13 @@ opacity: 0.8; } -.disabled-controls input, .disabled-controls .action-btn { +.disabled-controls input { pointer-events: none; cursor: not-allowed; } -.action-btn-secondary { - pointer-events: auto !important; -} - -.setting-row button { +/* Allow action buttons and settings buttons to work even when locked */ +.action-btn, .action-btn-secondary, .setting-row button { pointer-events: auto !important; }