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 = `
+
+
+
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 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'}