From 234ed8ae947ad4866533627bbb0e13b6c5097299 Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Wed, 1 Apr 2026 12:06:32 +0100 Subject: [PATCH] Initial commit for new projection support. --- backend/app/server.js | 4 +- frontend/e2e/app.spec.js | 3 + frontend/src/App.jsx | 479 ++++++++++++++++------------ frontend/src/__tests__/App.test.jsx | 3 + frontend/src/styles/App.css | 31 ++ 5 files changed, 308 insertions(+), 212 deletions(-) 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 = `
Deviation: ${city.off_line_km} km