sc/full-world-wrap #6

Open
steve-admin wants to merge 17 commits from sc/full-world-wrap into main
5 changed files with 308 additions and 212 deletions
Showing only changes of commit 234ed8ae94 - Show all commits
+2 -2
View File
@@ -53,8 +53,8 @@ app.get('/api/line-of-sight', async (req, res) => {
try { try {
// Generate path points for visualization and spatial query // Generate path points for visualization and spatial query
const pathPoints = []; const pathPoints = [];
const totalDistance = 20000; const totalDistance = 40074; // Full Earth circumference (km)
const steps = 80; // More steps for smoother speed transition const steps = 160;
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps; const dist = (totalDistance * i) / steps;
+3
View File
@@ -33,6 +33,9 @@ class Map {
flyTo() {} flyTo() {}
jumpTo() {} jumpTo() {}
setStyle() { Promise.resolve().then(() => this._emit('style.load')); } setStyle() { Promise.resolve().then(() => this._emit('style.load')); }
setProjection() {}
hasImage() { return false; }
addImage() {}
getCanvas() { return document.createElement('canvas'); } getCanvas() { return document.createElement('canvas'); }
project() { return { x: 0, y: 0 }; } project() { return { x: 0, y: 0 }; }
unproject() { return { lng: 0, lat: 0 }; } unproject() { return { lng: 0, lat: 0 }; }
+213 -154
View File
@@ -12,30 +12,90 @@ const APP = () => {
const cityMarkersRef = useRef([]); const cityMarkersRef = useRef([]);
const animationRef = useRef(null); const animationRef = useRef(null);
const popupRef = 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 [selectedPoint, setSelectedPoint] = useState({ lat: 51.5074, lon: -0.1278 });
const [direction, setDirection] = useState(45); const [direction, setDirection] = useState(45);
const [lineOfSightData, setLineOfSightData] = useState(null); const [lineOfSightData, setLineOfSightData] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [flightSpeed, setFlightSpeed] = useState(1.0); 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 [tolerance, setTolerance] = useState(50);
const [selectedCity, setSelectedCity] = useState(null); const [selectedCity, setSelectedCity] = useState(null);
const [isLocked, setIsLocked] = useState(false); 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(() => { useEffect(() => {
// Initialize MapLibre map - ONLY ONCE
mapRef.current = new maplibregl.Map({ mapRef.current = new maplibregl.Map({
container: mapContainerRef.current, container: mapContainerRef.current,
style: getMapStyle(mapStyle), style: getMapStyle(mapStyle),
center: [-0.1278, 51.5074], // London center: [-0.1278, 51.5074],
zoom: 2, zoom: 2,
pitch: 0, pitch: 0,
projection: 'globe' // Enable 3D Globe projection: 'globe'
}); });
mapRef.current.on('style.load', () => { mapRef.current.on('style.load', () => {
console.log('Map style loaded or changed');
setupMapLayers(); setupMapLayers();
}); });
@@ -43,13 +103,19 @@ const APP = () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current); if (animationRef.current) cancelAnimationFrame(animationRef.current);
if (mapRef.current) mapRef.current.remove(); if (mapRef.current) mapRef.current.remove();
}; };
}, []); // Run only once }, []); // eslint-disable-line react-hooks/exhaustive-deps
const setupMapLayers = () => { const setupMapLayers = () => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
// 1. Add atmosphere/sky effects const isGlobe = mapProjectionRef.current === 'globe';
// 1. Register arrow image (cleared on style reload)
addArrowImage(map);
// 2. Sky and terrain — globe only
if (isGlobe) {
map.setSky({ map.setSky({
'sky-color': '#199EF3', 'sky-color': '#199EF3',
'sky-horizon-blend': 0.5, 'sky-horizon-blend': 0.5,
@@ -58,8 +124,6 @@ const APP = () => {
'fog-color': '#add8e6', 'fog-color': '#add8e6',
'fog-ground-blend': 0.5 'fog-ground-blend': 0.5
}); });
// 2. Add terrain source for 3D effect
if (!map.getSource('terrain')) { if (!map.getSource('terrain')) {
map.addSource('terrain', { map.addSource('terrain', {
type: 'raster-dem', type: 'raster-dem',
@@ -70,42 +134,31 @@ const APP = () => {
}); });
} }
map.setTerrain({ source: 'terrain', exaggeration: 1.5 }); map.setTerrain({ source: 'terrain', exaggeration: 1.5 });
}
// 3. Initialize start marker // 3. Start marker
if (startMarkerRef.current) startMarkerRef.current.remove(); if (startMarkerRef.current) startMarkerRef.current.remove();
startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' }) startMarkerRef.current = new maplibregl.Marker({ color: '#FF6B6B' })
.setLngLat([selectedPoint.lon, selectedPoint.lat]) .setLngLat([selectedPoint.lon, selectedPoint.lat])
.addTo(map); .addTo(map);
// 4. Initialize preview line source and layer // 4. Preview line source and layer
if (!map.getSource('preview-line')) { if (!map.getSource('preview-line')) {
map.addSource('preview-line', { map.addSource('preview-line', {
type: 'geojson', type: 'geojson',
data: { data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } }
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: []
}
}
}); });
map.addLayer({ map.addLayer({
id: 'preview-line', id: 'preview-line',
type: 'line', type: 'line',
source: 'preview-line', source: 'preview-line',
paint: { paint: { 'line-color': '#FF6B6B', 'line-width': 2, 'line-dasharray': [2, 2] }
'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) { if (lineOfSightData) {
renderLineOnMap(lineOfSightData); renderLineOnMap(lineOfSightData);
// Hide preview if results are shown
if (map.getLayer('preview-line')) { if (map.getLayer('preview-line')) {
map.setLayoutProperty('preview-line', 'visibility', 'none'); 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(() => { useEffect(() => {
if (!mapRef.current) return; if (!mapRef.current) return;
const handleClick = (e) => { const handleClick = (e) => {
// Don't allow moving start point if locked
if (isLocked) return; if (isLocked) return;
const { lng, lat } = e.lngLat; const { lng, lat } = e.lngLat;
setSelectedPoint({ lat, lon: lng }); setSelectedPoint({ lat, lon: lng });
if (startMarkerRef.current) { if (startMarkerRef.current) {
startMarkerRef.current.setLngLat([lng, lat]); startMarkerRef.current.setLngLat([lng, lat]);
} }
// Clear previous final results when moving start point
clearCityMarkers(); clearCityMarkers();
if (mapRef.current.getSource('line-of-sight')) { const map = mapRef.current;
mapRef.current.removeLayer('line-of-sight'); if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
mapRef.current.removeSource('line-of-sight'); if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight');
map.removeSource('line-of-sight');
} }
}; };
@@ -143,40 +226,48 @@ const APP = () => {
}; };
}, [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 = () => { const handleStartAgain = () => {
if (animationRef.current) { if (animationRef.current) {
cancelAnimationFrame(animationRef.current); cancelAnimationFrame(animationRef.current);
setIsPlaying(false); setIsPlaying(false);
setFlightSpeed(1.0); setFlightSpeed(1.0);
} }
if (popupRef.current) { if (popupRef.current) {
popupRef.current.remove(); popupRef.current.remove();
popupRef.current = null; popupRef.current = null;
} }
setIsLocked(false); setIsLocked(false);
setLineOfSightData(null); setLineOfSightData(null);
setSelectedCity(null); setSelectedCity(null);
clearCityMarkers(); clearCityMarkers();
if (mapRef.current) { if (mapRef.current) {
if (mapRef.current.getSource('line-of-sight')) { const map = mapRef.current;
mapRef.current.removeLayer('line-of-sight'); if (map.getLayer('line-of-sight-arrows')) map.removeLayer('line-of-sight-arrows');
mapRef.current.removeSource('line-of-sight'); 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 (map.getLayer('preview-line')) {
if (mapRef.current.getLayer('preview-line')) { map.setLayoutProperty('preview-line', 'visibility', 'visible');
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible');
} }
map.flyTo({ center: [selectedPoint.lon, selectedPoint.lat], zoom: 3, pitch: 0, bearing: 0 });
// Reset map pitch and zoom
mapRef.current.flyTo({
center: [selectedPoint.lon, selectedPoint.lat],
zoom: 3,
pitch: 0,
bearing: 0
});
} }
}; };
@@ -198,13 +289,10 @@ const APP = () => {
const showCityPopup = (city) => { const showCityPopup = (city) => {
if (!mapRef.current) return; if (!mapRef.current) return;
if (popupRef.current) popupRef.current.remove(); if (popupRef.current) popupRef.current.remove();
const popupNode = document.createElement('div'); const popupNode = document.createElement('div');
popupNode.className = 'map-info-popup'; popupNode.className = 'map-info-popup';
const countryName = getCountryName(city.country); const countryName = getCountryName(city.country);
popupNode.innerHTML = ` popupNode.innerHTML = `
<div class="popup-header"> <div class="popup-header">
<strong>${city.name}</strong>, ${countryName} <strong>${city.name}</strong>, ${countryName}
@@ -215,7 +303,6 @@ const APP = () => {
<p>Deviation: ${city.off_line_km} km</p> <p>Deviation: ${city.off_line_km} km</p>
</div> </div>
`; `;
popupRef.current = new maplibregl.Popup({ popupRef.current = new maplibregl.Popup({
closeButton: true, closeButton: true,
closeOnClick: false, closeOnClick: false,
@@ -225,10 +312,7 @@ const APP = () => {
.setLngLat([city.lon, city.lat]) .setLngLat([city.lon, city.lat])
.setDOMContent(popupNode) .setDOMContent(popupNode)
.addTo(mapRef.current); .addTo(mapRef.current);
popupRef.current.on('close', () => setSelectedCity(null));
popupRef.current.on('close', () => {
setSelectedCity(null);
});
}; };
const startFlyOver = () => { const startFlyOver = () => {
@@ -248,7 +332,6 @@ const APP = () => {
const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]); const coordinates = lineOfSightData.line_coordinates.map(c => [c.lon, c.lat]);
const route = turf.lineString(coordinates); const route = turf.lineString(coordinates);
const routeDistance = turf.length(route); const routeDistance = turf.length(route);
const startTime = performance.now();
let lastNearestCityId = null; let lastNearestCityId = null;
let lastFetchedDist = 0; let lastFetchedDist = 0;
@@ -263,8 +346,7 @@ const APP = () => {
const deltaTime = currentTime - lastTimestamp; const deltaTime = currentTime - lastTimestamp;
lastTimestamp = currentTime; lastTimestamp = currentTime;
const baseKmPerMs = 0.1; // 100km per second const baseKmPerMs = 0.1; // 100 km/s
const pathPoints = lineOfSightData.line_coordinates; const pathPoints = lineOfSightData.line_coordinates;
const pointIndex = Math.min( const pointIndex = Math.min(
Math.floor((currentProgress / routeDistance) * pathPoints.length), Math.floor((currentProgress / routeDistance) * pathPoints.length),
@@ -272,7 +354,7 @@ const APP = () => {
); );
const isOverWater = pathPoints[pointIndex]?.is_over_water; const isOverWater = pathPoints[pointIndex]?.is_over_water;
// Predictive land detection (look ahead 2000km) // Predictive land detection (look ahead 2000 km)
let futureIsOverWater = true; let futureIsOverWater = true;
const lookAheadDistance = 2000; const lookAheadDistance = 2000;
const lookAheadSteps = 10; const lookAheadSteps = 10;
@@ -290,7 +372,6 @@ const APP = () => {
const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0; const targetMultiplier = (isOverWater && futureIsOverWater) ? 20.0 : 1.0;
const acceleration = 0.005; const acceleration = 0.005;
if (currentSpeedMultiplier < targetMultiplier) { if (currentSpeedMultiplier < targetMultiplier) {
currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier); currentSpeedMultiplier = Math.min(currentSpeedMultiplier + acceleration * deltaTime, targetMultiplier);
} else if (currentSpeedMultiplier > targetMultiplier) { } else if (currentSpeedMultiplier > targetMultiplier) {
@@ -298,12 +379,9 @@ const APP = () => {
} }
frameCount++; frameCount++;
if (frameCount % 10 === 0) { if (frameCount % 10 === 0) setFlightSpeed(currentSpeedMultiplier);
setFlightSpeed(currentSpeedMultiplier);
}
currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier; currentProgress += baseKmPerMs * deltaTime * currentSpeedMultiplier;
const phase = Math.min(currentProgress / routeDistance, 1); const phase = Math.min(currentProgress / routeDistance, 1);
if (phase >= 1) { if (phase >= 1) {
@@ -314,23 +392,14 @@ const APP = () => {
const eye = turf.along(route, currentProgress).geometry.coordinates; const eye = turf.along(route, currentProgress).geometry.coordinates;
const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates; const target = turf.along(route, Math.min(currentProgress + 10, routeDistance)).geometry.coordinates;
const bearing = turf.bearing(turf.point(eye), turf.point(target)); const bearing = turf.bearing(turf.point(eye), turf.point(target));
mapRef.current.jumpTo({ mapRef.current.jumpTo({ center: eye, zoom: 6, pitch: 65, bearing });
center: eye,
zoom: 6,
pitch: 65,
bearing: bearing
});
if (currentProgress - lastFetchedDist > 2000) { if (currentProgress - lastFetchedDist > 2000) {
lastFetchedDist = currentProgress; lastFetchedDist = currentProgress;
try { try {
const response = await apiService.getLineOfSight( const response = await apiService.getLineOfSight(eye[1], eye[0], bearing, tolerance);
eye[1], eye[0], bearing, tolerance
);
if (response.data.success) { if (response.data.success) {
const newCities = response.data.data.conurbations; const newCities = response.data.data.conurbations;
setLineOfSightData(prev => { setLineOfSightData(prev => {
@@ -356,7 +425,6 @@ const APP = () => {
const nearestCity = lineOfSightData.conurbations.find(city => const nearestCity = lineOfSightData.conurbations.find(city =>
Math.abs(city.distance_km - currentProgress) < 100 Math.abs(city.distance_km - currentProgress) < 100
); );
if (nearestCity && nearestCity.id !== lastNearestCityId) { if (nearestCity && nearestCity.id !== lastNearestCityId) {
lastNearestCityId = nearestCity.id; lastNearestCityId = nearestCity.id;
setSelectedCity(nearestCity); setSelectedCity(nearestCity);
@@ -372,10 +440,8 @@ const APP = () => {
const addMarkersToMap = (cities, startIndex = 0) => { const addMarkersToMap = (cities, startIndex = 0) => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
cities.forEach((city, index) => { cities.forEach((city, index) => {
const displayIndex = startIndex + index + 1; const displayIndex = startIndex + index + 1;
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'city-marker'; el.className = 'city-marker';
el.innerHTML = ` el.innerHTML = `
@@ -384,17 +450,12 @@ const APP = () => {
<div class="marker-label">${city.name}</div> <div class="marker-label">${city.name}</div>
<div class="marker-pop">${(city.population / 1000).toFixed(0)}k</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);
const marker = new maplibregl.Marker(el)
.setLngLat([city.lon, city.lat])
.addTo(map);
marker.getElement().addEventListener('click', (e) => { marker.getElement().addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedCity(city); setSelectedCity(city);
showCityPopup(city); showCityPopup(city);
}); });
cityMarkersRef.current.push(marker); cityMarkersRef.current.push(marker);
}); });
}; };
@@ -404,66 +465,71 @@ const APP = () => {
if (!map) return; if (!map) return;
clearCityMarkers(); 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')) { if (map.getSource('line-of-sight')) {
map.removeLayer('line-of-sight'); map.removeLayer('line-of-sight');
map.removeSource('line-of-sight'); map.removeSource('line-of-sight');
} }
const projection = mapProjectionRef.current;
map.addSource('line-of-sight', { map.addSource('line-of-sight', {
type: 'geojson', type: 'geojson',
data: { data: buildLineGeoJSON(data.line_coordinates, projection)
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: data.line_coordinates.map(coord => [coord.lon, coord.lat])
}
}
}); });
map.addLayer({ map.addLayer({
id: 'line-of-sight', id: 'line-of-sight',
type: 'line', type: 'line',
source: 'line-of-sight', 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: { layout: {
'line-join': 'round', 'symbol-placement': 'line',
'line-cap': 'round' 'symbol-spacing': 300,
}, 'icon-image': 'arrow-icon',
paint: { 'icon-size': 1.0,
'line-color': '#FF6B6B', 'icon-allow-overlap': true,
'line-width': 4, 'icon-ignore-placement': true
'line-opacity': 0.8
} }
}); });
addMarkersToMap(data.conurbations); addMarkersToMap(data.conurbations);
const bounds = new maplibregl.LngLatBounds(); // Zoom to show the full path from the start point
data.line_coordinates.forEach(coord => { map.flyTo({
bounds.extend([coord.lon, coord.lat]); 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; const map = mapRef.current;
if (!map || !map.getSource('preview-line')) return; if (!map || !map.getSource('preview-line')) return;
const path = []; const path = [];
const steps = 50; const steps = 160;
const totalDistance = 20000; const totalDistance = 40074; // Full Earth circumference (km)
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const dist = (totalDistance * i) / steps; 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({ map.getSource('preview-line').setData({
type: 'Feature', type: 'Feature',
geometry: { geometry: { type: 'LineString', coordinates: path.map(p => [p.lon, p.lat]) }
type: 'LineString',
coordinates: path.map(p => [p.lon, p.lat])
}
}); });
}; };
@@ -491,12 +557,6 @@ const APP = () => {
}; };
}; };
useEffect(() => {
if (mapRef.current) {
mapRef.current.setStyle(getMapStyle(mapStyle));
}
}, [mapStyle]);
const getMapStyle = (style) => { const getMapStyle = (style) => {
if (style === 'satellite') { if (style === 'satellite') {
return { return {
@@ -509,20 +569,16 @@ const APP = () => {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
} }
}, },
layers: [ layers: [{
{
id: 'satellite-layer', id: 'satellite-layer',
type: 'raster', type: 'raster',
source: 'esri-satellite', source: 'esri-satellite',
minzoom: 0, minzoom: 0,
maxzoom: 20 maxzoom: 20
} }]
]
}; };
} }
return style === 'dark' return 'https://demotiles.maplibre.org/style.json';
? 'https://demotiles.maplibre.org/style.json'
: 'https://demotiles.maplibre.org/style.json';
}; };
const handleShowLineOfSight = async () => { const handleShowLineOfSight = async () => {
@@ -535,14 +591,11 @@ const APP = () => {
direction, direction,
tolerance tolerance
); );
setLineOfSightData(response.data.data); setLineOfSightData(response.data.data);
setIsLocked(true); setIsLocked(true);
if (mapRef.current && mapRef.current.getLayer('preview-line')) { if (mapRef.current && mapRef.current.getLayer('preview-line')) {
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none'); mapRef.current.setLayoutProperty('preview-line', 'visibility', 'none');
} }
renderLineOnMap(response.data.data); renderLineOnMap(response.data.data);
} catch (error) { } catch (error) {
console.error('Error fetching line of sight:', error); console.error('Error fetching line of sight:', error);
@@ -551,10 +604,17 @@ 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 ( return (
<div className="app-container"> <div className="app-container">
<div className="map-container" ref={mapContainerRef}> <div className="map-container" ref={mapContainerRef} />
</div>
<div className="controls"> <div className="controls">
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}> <div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
@@ -593,26 +653,28 @@ const APP = () => {
<div className="setting-row"> <div className="setting-row">
<label>Map Style:</label> <label>Map Style:</label>
<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>
<div className="setting-row projection-row">
<label>Projection:</label>
<div className="projection-buttons">
{PROJECTIONS.map(p => (
<button <button
className={mapStyle === 'light' ? 'active-style' : ''} key={p.id}
onClick={() => setMapStyle('light')} className={mapProjection === p.id ? 'active-style' : ''}
>Light</button> onClick={() => setMapProjection(p.id)}
<button >
className={mapStyle === 'dark' ? 'active-style' : ''} {p.label}
onClick={() => setMapStyle('dark')} </button>
>Dark</button> ))}
<button </div>
className={mapStyle === 'satellite' ? 'active-style' : ''}
onClick={() => setMapStyle('satellite')}
>Satellite</button>
</div> </div>
{!isLocked ? ( {!isLocked ? (
<button <button className="action-btn" onClick={handleShowLineOfSight} disabled={loading}>
className="action-btn"
onClick={handleShowLineOfSight}
disabled={loading}
>
{loading ? 'Calculating...' : '📡 Show Line of Sight'} {loading ? 'Calculating...' : '📡 Show Line of Sight'}
</button> </button>
) : ( ) : (
@@ -624,10 +686,7 @@ const APP = () => {
> >
{isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'} {isPlaying ? `⏹ Stop Flight (${flightSpeed.toFixed(1)}x)` : '✈️ Fly Over Route'}
</button> </button>
<button <button className="action-btn-secondary" onClick={handleStartAgain}>
className="action-btn-secondary"
onClick={handleStartAgain}
>
🔄 Start Again 🔄 Start Again
</button> </button>
</> </>
+3
View File
@@ -23,6 +23,9 @@ vi.mock('maplibre-gl', () => {
isStyleLoaded: vi.fn(() => true), isStyleLoaded: vi.fn(() => true),
setSky: vi.fn(), setSky: vi.fn(),
setTerrain: vi.fn(), setTerrain: vi.fn(),
setProjection: vi.fn(),
hasImage: vi.fn(() => false),
addImage: vi.fn(),
}; };
return { return {
+31
View File
@@ -411,6 +411,37 @@
padding: 15px !important; padding: 15px !important;
} }
.projection-row {
align-items: flex-start;
}
.projection-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 2;
}
.projection-buttons button {
padding: 4px 8px;
font-size: 11px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.projection-buttons button:hover {
background: #e9ecef;
}
.projection-buttons button.active-style {
background: #34495e;
color: white;
border-color: #2c3e50;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.app-container { .app-container {
flex-direction: column; flex-direction: column;