Implement 3D globe fly-over animation using MapLibre FreeCamera and Turf.js
This commit is contained in:
Generated
+2162
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@turf/turf": "^7.3.4",
|
||||
"axios": "^1.7.9",
|
||||
"maplibre-gl": "^5.20.1",
|
||||
"react": "^19.2.1",
|
||||
|
||||
+95
-26
@@ -16,20 +16,32 @@ const APP = () => {
|
||||
const [mapStyle, setMapStyle] = useState('light'); // 'light' or 'dark'
|
||||
const [tolerance, setTolerance] = useState(50);
|
||||
const [selectedCity, setSelectedCity] = useState(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize MapLibre map
|
||||
// Initialize MapLibre map - ONLY ONCE
|
||||
mapRef.current = new maplibregl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: getMapStyle(mapStyle),
|
||||
center: [-0.1278, 51.5074], // London
|
||||
zoom: 3,
|
||||
pitch: 0
|
||||
zoom: 2,
|
||||
pitch: 0,
|
||||
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])
|
||||
@@ -61,7 +73,19 @@ const APP = () => {
|
||||
updatePreviewLine(selectedPoint, direction);
|
||||
});
|
||||
|
||||
mapRef.current.on('click', (e) => {
|
||||
return () => {
|
||||
if (mapRef.current) mapRef.current.remove();
|
||||
};
|
||||
}, []); // Run only once
|
||||
|
||||
// Separate effect for the click listener that 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 });
|
||||
|
||||
@@ -75,12 +99,31 @@ const APP = () => {
|
||||
mapRef.current.removeLayer('line-of-sight');
|
||||
mapRef.current.removeSource('line-of-sight');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mapRef.current.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
mapRef.current.on('click', handleClick);
|
||||
return () => {
|
||||
if (mapRef.current) mapRef.current.off('click', handleClick);
|
||||
};
|
||||
}, [isLocked]);
|
||||
|
||||
const handleStartAgain = () => {
|
||||
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');
|
||||
}
|
||||
// Re-enable preview line if it was hidden
|
||||
if (mapRef.current.getLayer('preview-line')) {
|
||||
mapRef.current.setLayoutProperty('preview-line', 'visibility', 'visible');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearCityMarkers = () => {
|
||||
if (cityMarkersRef.current) {
|
||||
@@ -100,10 +143,10 @@ const APP = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.getSource('preview-line')) return;
|
||||
|
||||
// Generate 50 points along a 5000km great circle path for a smooth curve
|
||||
// Generate 50 points along a 20,000km great circle path for a smooth curve
|
||||
const path = [];
|
||||
const steps = 50;
|
||||
const totalDistance = 5000;
|
||||
const totalDistance = 20000;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const dist = (totalDistance * i) / steps;
|
||||
@@ -169,6 +212,13 @@ const APP = () => {
|
||||
);
|
||||
|
||||
setLineOfSightData(response.data.data);
|
||||
setIsLocked(true); // Lock the map after showing results
|
||||
|
||||
// Hide the preview line when showing final results
|
||||
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);
|
||||
@@ -234,11 +284,13 @@ const APP = () => {
|
||||
// Add marker to map
|
||||
const marker = new maplibregl.Marker(el)
|
||||
.setLngLat([city.lon, city.lat])
|
||||
.setPopup(new maplibregl.Popup().setHTML(
|
||||
`<strong>${city.name}</strong><br/>Population: ${city.population.toLocaleString()}<br/>Distance: ${city.distance_km} km`
|
||||
))
|
||||
.addTo(map);
|
||||
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent map click
|
||||
setSelectedCity(city);
|
||||
});
|
||||
|
||||
cityMarkersRef.current.push(marker);
|
||||
});
|
||||
|
||||
@@ -291,7 +343,7 @@ const APP = () => {
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<div className="control-group">
|
||||
<div className={`control-group ${isLocked ? 'disabled-controls' : ''}`}>
|
||||
<h3>Line of Sight Settings</h3>
|
||||
|
||||
<div className="setting-row">
|
||||
@@ -306,6 +358,7 @@ const APP = () => {
|
||||
min="0"
|
||||
max="360"
|
||||
value={direction}
|
||||
disabled={isLocked}
|
||||
onChange={(e) => setDirection(parseInt(e.target.value))}
|
||||
/>
|
||||
<span>{direction}°</span>
|
||||
@@ -316,6 +369,7 @@ const APP = () => {
|
||||
<input
|
||||
type="number"
|
||||
value={tolerance}
|
||||
disabled={isLocked}
|
||||
onChange={(e) => setTolerance(parseInt(e.target.value))}
|
||||
min="10"
|
||||
max="200"
|
||||
@@ -329,13 +383,31 @@ const APP = () => {
|
||||
<button onClick={() => setMapStyle('dark')}>Dark</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleShowLineOfSight}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||
</button>
|
||||
{!isLocked ? (
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleShowLineOfSight}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Calculating...' : '📡 Show Line of Sight'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={startFlyOver}
|
||||
style={{ backgroundColor: isPlaying ? '#e74c3c' : '#3498db' }}
|
||||
>
|
||||
{isPlaying ? '⏹ Stop Flight' : '✈️ Fly Over Route'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn-secondary"
|
||||
onClick={handleStartAgain}
|
||||
>
|
||||
🔄 Start Again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lineOfSightData && (
|
||||
@@ -352,7 +424,7 @@ const APP = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineOfSightData.conurbations.slice(0, 50).map((city, index) => (
|
||||
{lineOfSightData.conurbations.map((city, index) => (
|
||||
<tr
|
||||
key={city.id}
|
||||
onClick={() => setSelectedCity(city)}
|
||||
@@ -366,9 +438,6 @@ const APP = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{lineOfSightData.conurbations.length > 50 && (
|
||||
<p className="more-info">... and {lineOfSightData.conurbations.length - 50} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -104,11 +104,55 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn-secondary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #34495e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-btn-secondary:hover {
|
||||
background: #2c3e50;
|
||||
}
|
||||
|
||||
.disabled-controls {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.disabled-controls input, .disabled-controls .action-btn {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn-secondary {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.setting-row button {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.results-panel table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.results-panel h3 {
|
||||
|
||||
Reference in New Issue
Block a user