From 25f3a09374657e6adfbd08693ab9933b4d1667df Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Tue, 17 Mar 2026 00:08:50 +0000 Subject: [PATCH] Implement 20x warp speed over water with land prediction and speedometer UI --- backend/app/server.js | 28 +- frontend/src/App.jsx | 503 +++++++++++++++++++++++------------- frontend/src/styles/App.css | 40 +++ 3 files changed, 391 insertions(+), 180 deletions(-) diff --git a/backend/app/server.js b/backend/app/server.js index 867b87a..af1ed10 100644 --- a/backend/app/server.js +++ b/backend/app/server.js @@ -54,13 +54,32 @@ app.get('/api/line-of-sight', async (req, res) => { // Generate path points for visualization and spatial query const pathPoints = []; const totalDistance = 20000; - const steps = 40; + const steps = 80; // More steps for smoother speed transition for (let i = 0; i <= steps; i++) { const dist = (totalDistance * i) / steps; pathPoints.push(calculateDestination(startLat, startLon, bearing, dist)); } + // Batch check for 'over water' status for all path points + // We'll consider a point 'over water' if no city is within 500km + const waterChecks = await Promise.all(pathPoints.map(async (p) => { + const checkQuery = ` + SELECT EXISTS ( + SELECT 1 FROM cities + WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 500000) + LIMIT 1 + ) as has_land; + `; + const res = await pool.query(checkQuery, [p.lon, p.lat]); + return !res.rows[0].has_land; + })); + + const pathPointsWithWater = pathPoints.map((p, i) => ({ + ...p, + is_over_water: waterChecks[i] + })); + const lineWKT = `LINESTRING(${pathPoints.map(p => `${p.lon} ${p.lat}`).join(',')})`; const query = ` @@ -86,11 +105,6 @@ app.get('/api/line-of-sight', async (req, res) => { const result = await pool.query(query, [lineWKT, toleranceKm, startLon, startLat]); - console.log(`Found ${result.rows.length} conurbations near the line.`); - if (result.rows.length > 0) { - console.log('Sample result row:', result.rows[0]); - } - res.json({ success: true, data: { @@ -104,7 +118,7 @@ app.get('/api/line-of-sight', async (req, res) => { distance_km: Math.round(row.distance_from_start_km), off_line_km: Math.round(row.distance_off_line_km) })), - line_coordinates: pathPoints + line_coordinates: pathPointsWithWater } }); } catch (err) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 84567cf..18ec4dc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,11 +11,13 @@ const APP = () => { 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); @@ -32,26 +34,52 @@ const APP = () => { 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); + mapRef.current.on('style.load', () => { + console.log('Map style loaded or changed'); + setupMapLayers(); + }); - // Initialize preview line source and layer - mapRef.current.addSource('preview-line', { + 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', @@ -62,7 +90,7 @@ const APP = () => { } }); - mapRef.current.addLayer({ + map.addLayer({ id: 'preview-line', type: 'line', source: 'preview-line', @@ -72,15 +100,19 @@ const APP = () => { '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); - }); - - 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(() => { @@ -115,8 +147,14 @@ const APP = () => { 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); @@ -149,53 +187,181 @@ const APP = () => { } }; + 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 duration = 20000; // 20 seconds for the full flyover - const startTime = performance.now(); const routeDistance = turf.length(route); + const startTime = performance.now(); - function animate(currentTime) { + 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 elapsed = currentTime - startTime; - const phase = Math.min(elapsed / duration, 1); // 0 to 1 + 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); - // Stop when we reach the end if (phase >= 1) { setIsPlaying(false); + setFlightSpeed(1.0); return; } - const currentDist = routeDistance * phase; + const eye = turf.along(route, currentProgress).geometry.coordinates; + const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates; - // 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 bearing = turf.bearing(turf.point(eye), turf.point(target)); - 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 + 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 ); - - // Look at the target point - camera.lookAtPoint({ lng: target[0], lat: target[1] }); - mapRef.current.setFreeCameraOptions(camera); + if (nearestCity && nearestCity.id !== lastNearestCityId) { + lastNearestCityId = nearestCity.id; + setSelectedCity(nearestCity); + showCityPopup(nearestCity); + } animationRef.current = requestAnimationFrame(animate); } @@ -203,18 +369,86 @@ const APP = () => { animationRef.current = requestAnimationFrame(animate); }; - useEffect(() => { - // Update preview whenever point or direction changes - if (mapRef.current && mapRef.current.isStyleLoaded()) { - updatePreviewLine(selectedPoint, direction); + 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'); } - }, [selectedPoint, direction]); + + 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; - // Generate 50 points along a 20,000km great circle path for a smooth curve const path = []; const steps = 50; const totalDistance = 20000; @@ -233,9 +467,8 @@ const APP = () => { }); }; - // 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 R = 6371; const brng = (bearing * Math.PI) / 180; const φ1 = (lat * Math.PI) / 180; const λ1 = (lon * Math.PI) / 180; @@ -259,16 +492,37 @@ const APP = () => { }; useEffect(() => { - // Update map style when toggle changes 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'; // Using same for now, can be customized + : 'https://demotiles.maplibre.org/style.json'; }; const handleShowLineOfSight = async () => { @@ -283,9 +537,8 @@ const APP = () => { ); setLineOfSightData(response.data.data); - setIsLocked(true); // Lock the map after showing results + setIsLocked(true); - // Hide the preview line when showing final results if (mapRef.current && mapRef.current.getLayer('preview-line')) { mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none'); } @@ -298,119 +551,9 @@ const APP = () => { } }; - 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 = ` -
${displayIndex}
-
-
${city.name}
-
${(city.population / 1000).toFixed(0)}k
- `; - - // Add marker to map - const marker = new maplibregl.Marker(el) - .setLngLat([city.lon, city.lat]) - .addTo(map); - - el.addEventListener('click', (e) => { - e.stopPropagation(); // Prevent map click - setSelectedCity(city); - }); - - cityMarkersRef.current.push(marker); - }); - - // Fit bounds to show line - const bounds = new maplibregl.LngLatBounds(); - data.line_coordinates.forEach(coord => { - bounds.extend([coord.lon, coord.lat]); - }); - map.fitBounds(bounds, { padding: 50 }); - }; - return (
- {selectedCity && ( -
-
- -

{selectedCity.name}

-
-
- - {selectedCity.country} -
-
- - {selectedCity.population.toLocaleString()} -
-
- - {selectedCity.lat.toFixed(4)}, {selectedCity.lon.toFixed(4)} -
-
- - {selectedCity.distance_km} km -
-
- - {selectedCity.off_line_km} km -
-
- -
-
- )}
@@ -450,8 +593,18 @@ const APP = () => {
- - + + +
{!isLocked ? ( @@ -469,7 +622,7 @@ const APP = () => { onClick={startFlyOver} style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }} > - {isPlaying ? '⏹ Stop Flight' : '✈️ Fly Over Route'} + {isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'}