Implement 20x warp speed over water with land prediction and speedometer UI
This commit is contained in:
+21
-7
@@ -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) {
|
||||
|
||||
+330
-173
@@ -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 = `
|
||||
<div class="popup-header">
|
||||
<strong>${city.name}</strong>, ${countryName}
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<p>Population: ${city.population.toLocaleString()}</p>
|
||||
<p>Dist. from start: ${city.distance_km} km</p>
|
||||
<p>Deviation: ${city.off_line_km} km</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="marker-number">${displayIndex}</div>
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-label">${city.name}</div>
|
||||
<div class="marker-pop">${(city.population / 1000).toFixed(0)}k</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="marker-number">${displayIndex}</div>
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-label">${city.name}</div>
|
||||
<div class="marker-pop">${(city.population / 1000).toFixed(0)}k</div>
|
||||
`;
|
||||
|
||||
// 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 (
|
||||
<div className="app-container">
|
||||
<div className="map-container" ref={mapContainerRef}>
|
||||
{selectedCity && (
|
||||
<div className="info-panel-modal">
|
||||
<div className="info-panel-content">
|
||||
<button className="close-btn" onClick={() => setSelectedCity(null)}>×</button>
|
||||
<h2>{selectedCity.name}</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Country:</label>
|
||||
<span>{selectedCity.country}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Population:</label>
|
||||
<span>{selectedCity.population.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Coordinates:</label>
|
||||
<span>{selectedCity.lat.toFixed(4)}, {selectedCity.lon.toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Distance from Start:</label>
|
||||
<span>{selectedCity.distance_km} km</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Deviation from Line:</label>
|
||||
<span>{selectedCity.off_line_km} km</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-btn-small" onClick={() => {
|
||||
mapRef.current.flyTo({ center: [selectedCity.lon, selectedCity.lat], zoom: 8 });
|
||||
}}>
|
||||
📍 Go to City
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
@@ -450,8 +593,18 @@ const APP = () => {
|
||||
|
||||
<div className="setting-row">
|
||||
<label>Map Style:</label>
|
||||
<button onClick={() => setMapStyle('light')}>Light</button>
|
||||
<button onClick={() => setMapStyle('dark')}>Dark</button>
|
||||
<button
|
||||
className={mapStyle === 'light' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('light')}
|
||||
>Light</button>
|
||||
<button
|
||||
className={mapStyle === 'dark' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('dark')}
|
||||
>Dark</button>
|
||||
<button
|
||||
className={mapStyle === 'satellite' ? 'active-style' : ''}
|
||||
onClick={() => setMapStyle('satellite')}
|
||||
>Satellite</button>
|
||||
</div>
|
||||
|
||||
{!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'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn-secondary"
|
||||
@@ -498,7 +651,11 @@ const APP = () => {
|
||||
{lineOfSightData.conurbations.map((city, index) => (
|
||||
<tr
|
||||
key={city.id}
|
||||
onClick={() => setSelectedCity(city)}
|
||||
onClick={() => {
|
||||
setSelectedCity(city);
|
||||
showCityPopup(city);
|
||||
mapRef.current.flyTo({ center: [city.lon, city.lat], zoom: 8 });
|
||||
}}
|
||||
className={selectedCity?.id === city.id ? 'selected-row' : ''}
|
||||
>
|
||||
<td>{index + 1}</td>
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.setting-row button.active-style {
|
||||
background: #34495e;
|
||||
color: white;
|
||||
border-color: #2c3e50;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
@@ -322,6 +328,8 @@
|
||||
align-items: center;
|
||||
font-family: sans-serif;
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.marker-number {
|
||||
@@ -371,6 +379,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-info-popup {
|
||||
padding: 5px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-body p {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.maplibregl-popup {
|
||||
z-index: 2000 !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3) !important;
|
||||
padding: 15px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user