diff --git a/backend/app/server.js b/backend/app/server.js
index 6845e24..903cd2b 100644
--- a/backend/app/server.js
+++ b/backend/app/server.js
@@ -53,8 +53,8 @@ app.get('/api/line-of-sight', async (req, res) => {
try {
// Generate path points for visualization and spatial query
const pathPoints = [];
- const totalDistance = 20000;
- const steps = 80; // More steps for smoother speed transition
+ const totalDistance = 40074; // Full Earth circumference (km)
+ const steps = 160;
for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps;
diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js
index cd14b93..526d282 100644
--- a/frontend/e2e/app.spec.js
+++ b/frontend/e2e/app.spec.js
@@ -33,6 +33,9 @@ class Map {
flyTo() {}
jumpTo() {}
setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
+ setProjection() {}
+ hasImage() { return false; }
+ addImage() {}
getCanvas() { return document.createElement('canvas'); }
project() { return { x: 0, y: 0 }; }
unproject() { return { lng: 0, lat: 0 }; }
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 18ec4dc..f7f24ad 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -12,30 +12,90 @@ const APP = () => {
const cityMarkersRef = useRef([]);
const animationRef = useRef(null);
const popupRef = useRef(null);
+ // Ref mirrors mapProjection state so stale closures (style.load) read the current value
+ const mapProjectionRef = useRef('globe');
+
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 [mapStyle, setMapStyle] = useState('light');
+ const [mapProjection, setMapProjection] = useState('globe');
const [tolerance, setTolerance] = useState(50);
const [selectedCity, setSelectedCity] = useState(null);
const [isLocked, setIsLocked] = useState(false);
+ // Keep ref in sync with state
+ useEffect(() => {
+ mapProjectionRef.current = mapProjection;
+ }, [mapProjection]);
+
+ // --- Helpers ---
+
+ // Build GeoJSON for the line, splitting at the antimeridian for flat projections
+ const buildLineGeoJSON = (lineCoords, projection) => {
+ const coords = lineCoords.map(c => [c.lon, c.lat]);
+ if (projection === 'globe') {
+ return {
+ type: 'Feature',
+ properties: {},
+ geometry: { type: 'LineString', coordinates: coords }
+ };
+ }
+ // Split segments wherever consecutive points cross the antimeridian (±180°)
+ const segments = [];
+ let current = [coords[0]];
+ for (let i = 1; i < coords.length; i++) {
+ const diff = coords[i][0] - coords[i - 1][0];
+ if (Math.abs(diff) > 180) {
+ segments.push(current);
+ current = [coords[i]];
+ } else {
+ current.push(coords[i]);
+ }
+ }
+ segments.push(current);
+ if (segments.length === 1) {
+ return { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: segments[0] } };
+ }
+ return { type: 'Feature', properties: {}, geometry: { type: 'MultiLineString', coordinates: segments } };
+ };
+
+ // Register a subtle chevron arrow image for the symbol layer
+ const addArrowImage = (map) => {
+ const size = 20;
+ const canvas = document.createElement('canvas');
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ ctx.strokeStyle = 'rgba(255, 107, 107, 0.75)';
+ ctx.lineWidth = 2.5;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ // Chevron pointing right; MapLibre rotates it to follow line direction
+ ctx.moveTo(size * 0.2, size * 0.22);
+ ctx.lineTo(size * 0.78, size * 0.5);
+ ctx.lineTo(size * 0.2, size * 0.78);
+ ctx.stroke();
+ map.addImage('arrow-icon', canvas);
+ };
+
+ // --- Map initialisation (runs once) ---
+
useEffect(() => {
- // Initialize MapLibre map - ONLY ONCE
mapRef.current = new maplibregl.Map({
container: mapContainerRef.current,
style: getMapStyle(mapStyle),
- center: [-0.1278, 51.5074], // London
+ center: [-0.1278, 51.5074],
zoom: 2,
pitch: 0,
- projection: 'globe' // Enable 3D Globe
+ projection: 'globe'
});
mapRef.current.on('style.load', () => {
- console.log('Map style loaded or changed');
setupMapLayers();
});
@@ -43,69 +103,62 @@ const APP = () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
if (mapRef.current) mapRef.current.remove();
};
- }, []); // Run only once
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
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 });
+ const isGlobe = mapProjectionRef.current === 'globe';
- // 3. Initialize start marker
+ // 1. Register arrow image (cleared on style reload)
+ addArrowImage(map);
+
+ // 2. Sky and terrain — globe only
+ if (isGlobe) {
+ 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
+ });
+ 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. 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
+ // 4. Preview line source and layer
if (!map.getSource('preview-line')) {
map.addSource('preview-line', {
type: 'geojson',
- data: {
- type: 'Feature',
- geometry: {
- type: 'LineString',
- coordinates: []
- }
- }
+ data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
});
-
map.addLayer({
id: 'preview-line',
type: 'line',
source: 'preview-line',
- paint: {
- 'line-color': '#FF6B6B',
- 'line-width': 2,
- 'line-dasharray': [2, 2]
- }
+ paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] }
});
}
- // 5. Restore results line and city markers if they existed
+ // 5. Restore results line and 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');
}
@@ -114,26 +167,56 @@ const APP = () => {
}
};
- // Separate effect for the click listener that respects isLocked
+ // --- Projection change effect ---
+
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map) return;
+
+ map.setProjection(mapProjection);
+
+ if (mapProjection === 'globe') {
+ 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
+ });
+ if (map.getSource('terrain')) {
+ map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
+ }
+ } else {
+ map.setTerrain(null);
+ }
+
+ // Re-render line with correct antimeridian handling for this projection
+ if (lineOfSightData && map.getSource('line-of-sight')) {
+ map.getSource('line-of-sight').setData(
+ buildLineGeoJSON(lineOfSightData.line_coordinates, mapProjection)
+ );
+ }
+ }, [mapProjection]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // --- Click handler (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');
+ const map = mapRef.current;
+ if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
+ if (map.getSource('line-of-sight')) {
+ map.removeLayer('line-of-sight');
+ map.removeSource('line-of-sight');
}
};
@@ -141,7 +224,24 @@ const APP = () => {
return () => {
if (mapRef.current) mapRef.current.off('click', handleClick);
};
- }, [isLocked]);
+ }, [isLocked]);
+
+ // Update preview line when direction or point changes
+ useEffect(() => {
+ if (!isLocked) {
+ updatePreviewLine(selectedPoint, direction);
+ }
+ }, [direction, selectedPoint, isLocked]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // --- Map style change ---
+
+ useEffect(() => {
+ if (mapRef.current) {
+ mapRef.current.setStyle(getMapStyle(mapStyle));
+ }
+ }, [mapStyle]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // --- Handlers ---
const handleStartAgain = () => {
if (animationRef.current) {
@@ -149,34 +249,25 @@ const APP = () => {
setIsPlaying(false);
setFlightSpeed(1.0);
}
-
if (popupRef.current) {
popupRef.current.remove();
popupRef.current = null;
}
-
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');
+ const map = mapRef.current;
+ if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
+ if (map.getSource('line-of-sight')) {
+ map.removeLayer('line-of-sight');
+ map.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');
+ if (map.getLayer('preview-line')) {
+ map.setLayoutProperty('preview-line', 'visibility', 'visible');
}
-
- // Reset map pitch and zoom
- mapRef.current.flyTo({
- center: [selectedPoint.lon, selectedPoint.lat],
- zoom: 3,
- pitch: 0,
- bearing: 0
- });
+ map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 });
}
};
@@ -198,13 +289,10 @@ const APP = () => {
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,
+ popupRef.current = new maplibregl.Popup({
+ closeButton: true,
closeOnClick: false,
maxWidth: '300px',
offset: [0, -20]
@@ -225,15 +312,12 @@ const APP = () => {
.setLngLat([city.lon, city.lat])
.setDOMContent(popupNode)
.addTo(mapRef.current);
-
- popupRef.current.on('close', () => {
- setSelectedCity(null);
- });
+ popupRef.current.on('close', () => setSelectedCity(null));
};
const startFlyOver = () => {
if (!lineOfSightData || !mapRef.current) return;
-
+
if (isPlaying) {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
setIsPlaying(false);
@@ -244,11 +328,10 @@ const APP = () => {
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 routeDistance = turf.length(route);
- const startTime = performance.now();
let lastNearestCityId = null;
let lastFetchedDist = 0;
@@ -259,12 +342,11 @@ const APP = () => {
async function animate(currentTime) {
if (!mapRef.current) return;
-
+
const deltaTime = currentTime - lastTimestamp;
lastTimestamp = currentTime;
- const baseKmPerMs = 0.1; // 100km per second
-
+ const baseKmPerMs = 0.1; // 100 km/s
const pathPoints = lineOfSightData.line_coordinates;
const pointIndex = Math.min(
Math.floor((currentProgress / routeDistance) * pathPoints.length),
@@ -272,7 +354,7 @@ const APP = () => {
);
const isOverWater = pathPoints[pointIndex]?.is_over_water;
- // Predictive land detection (look ahead 2000km)
+ // Predictive land detection (look ahead 2000 km)
let futureIsOverWater = true;
const lookAheadDistance = 2000;
const lookAheadSteps = 10;
@@ -290,7 +372,6 @@ const APP = () => {
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) {
@@ -298,39 +379,27 @@ const APP = () => {
}
frameCount++;
- if (frameCount % 10 === 0) {
- setFlightSpeed(currentSpeedMultiplier);
- }
+ if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier);
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
-
const phase = Math.min(currentProgress / routeDistance, 1);
-
+
if (phase >= 1) {
setIsPlaying(false);
setFlightSpeed(1.0);
return;
}
-
+
const eye = turf.along(route, currentProgress).geometry.coordinates;
const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates;
-
const bearing = turf.bearing(turf.point(eye), turf.point(target));
- mapRef.current.jumpTo({
- center: eye,
- zoom: 6,
- pitch: 65,
- bearing: bearing
- });
+ mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing });
if (currentProgress - lastFetchedDist > 2000) {
lastFetchedDist = currentProgress;
try {
- const response = await apiService.getLineOfSight(
- eye[1], eye[0], bearing, tolerance
- );
-
+ const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance);
if (response.data.success) {
const newCities = response.data.data.conurbations;
setLineOfSightData(prev => {
@@ -353,29 +422,26 @@ const APP = () => {
}
}
- const nearestCity = lineOfSightData.conurbations.find(city =>
- Math.abs(city.distance_km - currentProgress) < 100
+ const nearestCity = lineOfSightData.conurbations.find(city =>
+ Math.abs(city.distance_km - currentProgress) < 100
);
-
if (nearestCity && nearestCity.id !== lastNearestCityId) {
lastNearestCityId = nearestCity.id;
setSelectedCity(nearestCity);
showCityPopup(nearestCity);
}
-
+
animationRef.current = requestAnimationFrame(animate);
}
-
+
animationRef.current = requestAnimationFrame(animate);
};
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 = `
@@ -384,17 +450,12 @@ const APP = () => {
${city.name}
${(city.population / 1000).toFixed(0)}k
`;
-
- const marker = new maplibregl.Marker(el)
- .setLngLat([city.lon, city.lat])
- .addTo(map);
-
+ 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);
});
};
@@ -404,71 +465,76 @@ const APP = () => {
if (!map) return;
clearCityMarkers();
+
+ // Remove existing layers/source (arrow layer must go before line layer)
+ if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight');
map.removeSource('line-of-sight');
}
+ const projection = mapProjectionRef.current;
+
map.addSource('line-of-sight', {
type: 'geojson',
- data: {
- type: 'Feature',
- properties: {},
- geometry: {
- type: 'LineString',
- coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat])
- }
- }
+ data: buildLineGeoJSON(data.line_coordinates, projection)
});
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 }
+ });
+
+ // Subtle periodic directional arrows along the line
+ map.addLayer({
+ id: 'line-of-sight-arrows',
+ type: 'symbol',
+ source: 'line-of-sight',
layout: {
- 'line-join': 'round',
- 'line-cap': 'round'
- },
- paint: {
- 'line-color': '#FF6B6B',
- 'line-width': 4,
- 'line-opacity': 0.8
+ 'symbol-placement': 'line',
+ 'symbol-spacing': 300,
+ 'icon-image': 'arrow-icon',
+ 'icon-size': 1.0,
+ 'icon-allow-overlap': true,
+ 'icon-ignore-placement': true
}
});
addMarkersToMap(data.conurbations);
- const bounds = new maplibregl.LngLatBounds();
- data.line_coordinates.forEach(coord => {
- bounds.extend([coord.lon, coord.lat]);
+ // Zoom to show the full path from the start point
+ map.flyTo({
+ center: [data.line_coordinates[0].lon, data.line_coordinates[0].lat],
+ zoom: 2,
+ pitch: 0,
+ bearing: 0
});
- map.fitBounds(bounds, { padding: 50 });
};
- const updatePreviewLine = (point, bearing) => {
+ const updatePreviewLine = (point, brng) => {
const map = mapRef.current;
if (!map || !map.getSource('preview-line')) return;
const path = [];
- const steps = 50;
- const totalDistance = 20000;
+ const steps = 160;
+ const totalDistance = 40074; // Full Earth circumference (km)
for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps;
- path.push(calculateDestination(point.lat, point.lon, bearing, dist));
+ path.push(calculateDestination(point.lat, point.lon, brng, dist));
}
-
+
map.getSource('preview-line').setData({
type: 'Feature',
- geometry: {
- type: 'LineString',
- coordinates: path.map(p => [p.lon, p.lat])
- }
+ geometry: { type: 'LineString', coordinates: path.map(p => [p.lon, p.lat]) }
});
};
const calculateDestination = (lat, lon, bearing, distance) => {
- const R = 6371;
+ const R = 6371;
const brng = (bearing * Math.PI) / 180;
const φ1 = (lat * Math.PI) / 180;
const λ1 = (lon * Math.PI) / 180;
@@ -491,12 +557,6 @@ const APP = () => {
};
};
- useEffect(() => {
- if (mapRef.current) {
- mapRef.current.setStyle(getMapStyle(mapStyle));
- }
- }, [mapStyle]);
-
const getMapStyle = (style) => {
if (style === 'satellite') {
return {
@@ -509,20 +569,16 @@ const APP = () => {
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
- }
- ]
+ 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';
+ return 'https://demotiles.maplibre.org/style.json';
};
const handleShowLineOfSight = async () => {
@@ -530,19 +586,16 @@ const APP = () => {
setSelectedCity(null);
try {
const response = await apiService.getLineOfSight(
- selectedPoint.lat,
- selectedPoint.lon,
- direction,
+ selectedPoint.lat,
+ selectedPoint.lon,
+ direction,
tolerance
);
-
setLineOfSightData(response.data.data);
- setIsLocked(true);
-
+ setIsLocked(true);
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);
@@ -551,15 +604,22 @@ const APP = () => {
}
};
+ const PROJECTIONS = [
+ { id: 'globe', label: 'Globe' },
+ { id: 'mercator', label: 'Mercator' },
+ { id: 'equalEarth', label: 'Equal Earth' },
+ { id: 'naturalEarth', label: 'Natural Earth' },
+ { id: 'winkelTripel', label: 'Winkel Tripel' }
+ ];
+
return (
-
-
-
+
+
Line of Sight Settings
-
+
{selectedPoint.lat.toFixed(4)}, {selectedPoint.lon.toFixed(4)}
@@ -567,11 +627,11 @@ const APP = () => {
-
setDirection(parseInt(e.target.value))}
/>
@@ -580,9 +640,9 @@ const APP = () => {
-
setTolerance(parseInt(e.target.value))}
min="10"
@@ -593,41 +653,40 @@ const APP = () => {
-
-
-
+
+
+
+
+
+
+
+
+ {PROJECTIONS.map(p => (
+
+ ))}
+
{!isLocked ? (
-